fix(260923-ku6): Verbindungstest prueft Formularwerte statt gespeicherten Stand

Nachbesserung aus dem Browser-Rundgang zu 260923-dhh (Befund 1): "Verbindung
testen" pruefte bislang immer den in der Datenbank gespeicherten Server, nicht
das ungespeicherte Formular. Eine im Formular abgeschaltete Zertifikatspruefung
oder ein neu eingetipptes Token-Geheimnis wurden dadurch beim Test ignoriert
und erst nach "Speichern" wirksam — eine Falle fuer genau den Ablauf, den
Nutzer instinktiv waehlen (eintippen, testen, dann erst speichern).

Neues `TestProxmoxServerDto` plus Merge-Baustein `resolveEffectiveTestServer`
in `ProxmoxService`: normale Felder folgen dem Formular (auch wenn absichtlich
geleert), Geheimnisfelder folgen der bestehenden "leer -> gespeicherten Wert
behalten"-Regel, weil `ServerForm` sie beim Laden nie aus der Datenbank
vorbefuellt. Neue Route `POST servers/test` (ohne `:id`) deckt die Neuanlage
ab, wo es noch keinen gespeicherten Server gibt. Der Testen-Knopf steht jetzt
immer zur Verfuegung, nicht mehr nur nach dem ersten Speichern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:11:56 +02:00
parent 3091b04673
commit 710034c80a
7 changed files with 372 additions and 32 deletions
@@ -4,11 +4,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
const mockCreateServer = vi.fn();
const mockUpdateServer = vi.fn();
const mockTestServer = vi.fn();
const mockTestDraftServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
createServer: (...args: unknown[]) => mockCreateServer(...args),
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
testServer: (...args: unknown[]) => mockTestServer(...args),
testDraftServer: (...args: unknown[]) => mockTestDraftServer(...args),
}));
vi.mock('next-intl', () => ({
@@ -62,6 +64,7 @@ afterEach(() => {
mockCreateServer.mockReset();
mockUpdateServer.mockReset();
mockTestServer.mockReset();
mockTestDraftServer.mockReset();
});
const EXISTING_SERVER = {
@@ -178,10 +181,64 @@ describe('ServerForm', () => {
);
});
it('ohne gespeicherten Server (Neuanlage) gibt es keinen Verbindung-testen-Knopf', async () => {
it('Nachbesserung Befund 1: bei der Neuanlage (kein gespeicherter Server) steht der Testen-Knopf zur Verfuegung und ruft den Neuanlage-Testweg auf', async () => {
mockTestDraftServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByText('Verbindung testen')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Adresse'), {
target: { value: 'https://pve.neu:8006' },
});
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestDraftServer).toHaveBeenCalled());
expect(mockTestServer).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByText('Verbindung erfolgreich.')).toBeInTheDocument());
});
it('Nachbesserung Befund 1: der Test prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
const [, payload] = mockTestServer.mock.calls[0];
// Ohne die Korrektur wuerde `testServer` ganz ohne Formularstand aufgerufen — roter Test.
expect(payload.tlsRejectUnauthorized).toBe(false);
});
it('Nachbesserung Befund 1: ein leer gelassenes Geheimnisfeld sendet beim Testen kein tokenSecret (gespeicherter Wert bleibt massgeblich)', async () => {
mockTestServer.mockResolvedValue({
reachable: true,
errorKind: null,
errorDetail: null,
metrics: null,
rawSample: null,
});
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen'));
await waitFor(() => expect(mockTestServer).toHaveBeenCalled());
const [, payload] = mockTestServer.mock.calls[0];
expect(payload.tokenSecret).toBeUndefined();
});
});
@@ -4,6 +4,7 @@ import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
createServer,
testDraftServer,
testServer,
updateServer,
type ProxmoxAuthMethod,
@@ -114,6 +115,17 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
isActive: form.isActive,
});
/**
* Wie `buildPayload()`, aber OHNE `name` (Nachbesserung Befund 1): der
* Verbindungstest braucht den Namen nicht, und ein waehrend der Neuanlage
* noch leer gelassenes Namensfeld wuerde sonst die serverseitige
* `@IsNotEmpty()`-Pruefung auf `name` bei jedem Testklick blockieren.
*/
const buildTestPayload = () => {
const { name: _name, ...rest } = buildPayload();
return rest;
};
const handleSave = async () => {
setIsSaving(true);
setSaveError(null);
@@ -132,11 +144,17 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
};
const handleTest = async () => {
if (!savedServer) return;
setIsTesting(true);
setTestResult(null);
try {
const result = await testServer(savedServer.id);
// Nachbesserung Befund 1: der Test prueft immer den aktuellen
// Formularstand (`buildPayload()`), nie nur den gespeicherten Stand.
// Ein leer gelassenes Geheimnisfeld wird dabei als `undefined`
// gesendet — der Server faellt dann auf den gespeicherten Wert
// zurueck (siehe `ProxmoxService.resolveEffectiveTestServer`).
const result = savedServer
? await testServer(savedServer.id, buildTestPayload())
: await testDraftServer(buildTestPayload());
setTestResult(result);
} catch (err) {
setTestResult({
@@ -355,16 +373,20 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
{isSaving ? t('settings.saving') : t('settings.save')}
</button>
{savedServer && (
<button
type="button"
onClick={handleTest}
disabled={isTesting}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
)}
{/*
Nachbesserung Befund 1: der Testen-Knopf steht IMMER zur
Verfuegung, auch waehrend der Neuanlage vor dem ersten Speichern
(vorher: nur bei `savedServer`) — `handleTest` waehlt selbst den
passenden Endpunkt (`testServer` vs. `testDraftServer`).
*/}
<button
type="button"
onClick={handleTest}
disabled={isTesting || !form.baseUrl}
className="rounded border border-border px-4 py-2 text-sm text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
</button>
<button
type="button"