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:
@@ -147,3 +147,17 @@ export class UpdateProxmoxServerDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO fuer den Verbindungstest (Nachbesserung Befund 1, Rundgang zu Aufgabe 4):
|
||||||
|
* derselbe Feldsatz wie `UpdateProxmoxServerDto` — der Test soll auf JEDEM
|
||||||
|
* dieser Felder den ungespeicherten Formularwert pruefen koennen, nicht den
|
||||||
|
* gespeicherten Stand. Ein NICHT gesendetes oder leeres Geheimnisfeld heisst
|
||||||
|
* "gespeicherten Wert weiterverwenden" (Merge-Logik in
|
||||||
|
* `ProxmoxService.resolveEffectiveTestServer`), genau wie beim Bearbeiten.
|
||||||
|
* Fuer die Neuanlage (noch kein gespeicherter Server) bleiben alle Felder
|
||||||
|
* optional, weil es dort keinen gespeicherten Fallback gibt — ein fehlendes
|
||||||
|
* Pflichtfeld fuehrt dort einfach zum selben Fehlerschluessel wie ein leer
|
||||||
|
* gelassenes Feld beim Anlegen selbst (z. B. `zugang` ohne Geheimnis).
|
||||||
|
*/
|
||||||
|
export class TestProxmoxServerDto extends UpdateProxmoxServerDto {}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { Role } from '@prisma/client';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
import {
|
||||||
|
CreateProxmoxServerDto,
|
||||||
|
TestProxmoxServerDto,
|
||||||
|
UpdateProxmoxServerDto,
|
||||||
|
} from './dto/proxmox-server.dto';
|
||||||
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||||
import { ProxmoxService } from './proxmox.service';
|
import { ProxmoxService } from './proxmox.service';
|
||||||
|
|
||||||
@@ -22,8 +26,12 @@ import { ProxmoxService } from './proxmox.service';
|
|||||||
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
|
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
|
||||||
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
|
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
|
||||||
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
|
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
|
||||||
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/:id/poll`,
|
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`,
|
||||||
* `POST servers/:id/test`) zusaetzlich `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05).
|
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich
|
||||||
|
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei
|
||||||
|
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
|
||||||
|
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
|
||||||
|
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
|
||||||
*/
|
*/
|
||||||
@Controller('modules/proxmox')
|
@Controller('modules/proxmox')
|
||||||
@UseModule('proxmox')
|
@UseModule('proxmox')
|
||||||
@@ -85,13 +93,31 @@ export class ProxmoxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbindungstest (Aufgabe 4, `<behavior>`): liefert bei Erfolg eine
|
* Verbindungstest fuer einen gespeicherten Server (Aufgabe 4, `<behavior>`;
|
||||||
* Erfolgsmeldung und bei Misserfolg einen der sieben Fehlerschluessel
|
* Nachbesserung Befund 1: `dto` traegt den aktuellen Formularstand,
|
||||||
* samt kurzer Ergaenzung, OHNE den Zwischenlagerstand zu ueberschreiben.
|
* `ProxmoxService.testConnection` prueft diesen statt blind des
|
||||||
|
* gespeicherten Stands). Liefert bei Erfolg eine Erfolgsmeldung und bei
|
||||||
|
* Misserfolg einen der sieben Fehlerschluessel samt kurzer Ergaenzung,
|
||||||
|
* OHNE den Zwischenlagerstand zu ueberschreiben.
|
||||||
*/
|
*/
|
||||||
@Post('servers/:id/test')
|
@Post('servers/:id/test')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
async test(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async test(
|
||||||
return this.proxmoxService.testConnection(this.requireTenantId(req), id);
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: TestProxmoxServerDto,
|
||||||
|
) {
|
||||||
|
return this.proxmoxService.testConnection(this.requireTenantId(req), id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1): es gibt
|
||||||
|
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
|
||||||
|
*/
|
||||||
|
@Post('servers/test')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
|
||||||
|
this.requireTenantId(req);
|
||||||
|
return this.proxmoxService.testDraftConnection(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -402,6 +402,115 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
|
|||||||
expect(status.reachable).toBe(false);
|
expect(status.reachable).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Nachbesserung Befund 1: testConnection prueft die im Formular abgeschaltete Zertifikatspruefung, nicht den gespeicherten Stand (Server wurde MIT tlsRejectUnauthorized:true angelegt)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
// Gespeichert: Zertifikatspruefung AN (Vorgabe).
|
||||||
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||||
|
|
||||||
|
const dispatchers: unknown[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, options: RequestInit & { dispatcher?: unknown }) => {
|
||||||
|
dispatchers.push(options.dispatcher);
|
||||||
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Formular: Zertifikatspruefung wurde vom Nutzer AUSGESCHALTET, aber noch nicht gespeichert.
|
||||||
|
await service.testConnection('tenant-a', (created as any).id, {
|
||||||
|
tlsRejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vor der Korrektur wurde ausschliesslich der gespeicherte Server (Zertifikatspruefung AN)
|
||||||
|
// getestet — dieser Test waere ohne die Korrektur rot, weil dispatchers[0] dann `undefined` waere.
|
||||||
|
expect(dispatchers[0]).toBeInstanceOf(Agent);
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Test-Attrappe traegt `options` nicht im echten undici-Typ
|
||||||
|
expect((dispatchers[0] as any).options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nachbesserung Befund 1: ein im Formular NEU eingetipptes Token-Geheimnis wird getestet, nicht das gespeicherte', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||||
|
|
||||||
|
let capturedAuth: string | null = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, options: RequestInit) => {
|
||||||
|
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||||
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.testConnection('tenant-a', (created as any).id, {
|
||||||
|
tokenSecret: 'ein-anderes-geheimnis',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne die Korrektur wuerde hier weiterhin TOKEN_DTO.tokenSecret gesendet — roter Test.
|
||||||
|
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=ein-anderes-geheimnis`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nachbesserung Befund 1: leer gelassenes Geheimnisfeld im Formular nutzt weiterhin das gespeicherte Token-Geheimnis', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||||
|
|
||||||
|
let capturedAuth: string | null = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (_url: string, options: RequestInit) => {
|
||||||
|
capturedAuth = (options.headers as Record<string, string>).Authorization;
|
||||||
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Formular sendet kein tokenSecret (Feld leer gelassen) — wie `ServerForm.buildPayload()`.
|
||||||
|
await service.testConnection('tenant-a', (created as any).id, {});
|
||||||
|
|
||||||
|
expect(capturedAuth).toBe(`PVEAPIToken=${TOKEN_DTO.tokenId}=${TOKEN_DTO.tokenSecret}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nachbesserung Befund 1: testDraftConnection testet einen noch nicht gespeicherten Server ausschliesslich mit den Formularwerten', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
let capturedUrl: string | null = null;
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async (url: string) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
return new Response(JSON.stringify(pveResourcesBody()), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.testDraftConnection({
|
||||||
|
productType: 'pve',
|
||||||
|
baseUrl: 'https://neu.intern:8006',
|
||||||
|
authMethod: 'token',
|
||||||
|
tokenId: 'root@pam!neu',
|
||||||
|
tokenSecret: 'frisches-geheimnis',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reachable).toBe(true);
|
||||||
|
expect(capturedUrl).toBe('https://neu.intern:8006/api2/json/cluster/resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nachbesserung Befund 1: testDraftConnection ohne Geheimnis liefert den Fehlerschluessel "zugang", statt zu werfen', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
|
||||||
|
const result = await service.testDraftConnection({
|
||||||
|
productType: 'pve',
|
||||||
|
baseUrl: 'https://neu.intern:8006',
|
||||||
|
authMethod: 'token',
|
||||||
|
tokenId: 'root@pam!neu',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.reachable).toBe(false);
|
||||||
|
expect(result.errorKind).toBe('zugang');
|
||||||
|
});
|
||||||
|
|
||||||
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
|
it('POST servers/:id/poll verweigert einen zweiten Durchlauf innerhalb von zehn Sekunden und liefert den vorhandenen Stand', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||||
|
|||||||
@@ -6,9 +6,33 @@ import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
|||||||
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||||
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
||||||
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
||||||
import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
import type {
|
||||||
|
CreateProxmoxServerDto,
|
||||||
|
TestProxmoxServerDto,
|
||||||
|
UpdateProxmoxServerDto,
|
||||||
|
} from './dto/proxmox-server.dto';
|
||||||
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
|
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur die Felder, die eine Abfrage tatsaechlich braucht (Nachbesserung
|
||||||
|
* Befund 1) — `pollOne`/`buildAuthHeaders`/`getWithRetry` nehmen diesen
|
||||||
|
* schmalen Ausschnitt statt der vollen `ProxmoxServer`-Zeile entgegen, damit
|
||||||
|
* `resolveEffectiveTestServer` unten eine rein im Speicher gebaute Mischung
|
||||||
|
* aus Formular- und gespeicherten Werten uebergeben kann, ohne eine
|
||||||
|
* vollstaendige Datenbankzeile vorzutaeuschen.
|
||||||
|
*/
|
||||||
|
type ProxmoxCredentialSource = Pick<
|
||||||
|
ProxmoxServer,
|
||||||
|
| 'productType'
|
||||||
|
| 'baseUrl'
|
||||||
|
| 'authMethod'
|
||||||
|
| 'tokenId'
|
||||||
|
| 'encryptedTokenSecret'
|
||||||
|
| 'username'
|
||||||
|
| 'encryptedPassword'
|
||||||
|
| 'tlsRejectUnauthorized'
|
||||||
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
|
* Erkennungsform fuer "schon verschluesselt" — woertlich aus
|
||||||
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
|
* `ldap-config.service.ts:17` uebernommen (Format `iv:authTag:ciphertext`,
|
||||||
@@ -217,7 +241,7 @@ export class ProxmoxService {
|
|||||||
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
|
* Ticket-Anmeldung aus (die einzige nicht-lesende Anfrage des Moduls,
|
||||||
* D-01) — deshalb `async`.
|
* D-01) — deshalb `async`.
|
||||||
*/
|
*/
|
||||||
private async buildAuthHeaders(server: ProxmoxServer): Promise<AuthHeaderResult> {
|
private async buildAuthHeaders(server: ProxmoxCredentialSource): Promise<AuthHeaderResult> {
|
||||||
if (server.authMethod === 'token') {
|
if (server.authMethod === 'token') {
|
||||||
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
|
const tokenSecret = this.decryptSecret(server.encryptedTokenSecret);
|
||||||
if (!server.tokenId || !tokenSecret) {
|
if (!server.tokenId || !tokenSecret) {
|
||||||
@@ -273,7 +297,7 @@ export class ProxmoxService {
|
|||||||
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
|
* zwei Stunden einen Fehlalarm. Ein zweites 401 bleibt `'zugang'`.
|
||||||
*/
|
*/
|
||||||
private async getWithRetry(
|
private async getWithRetry(
|
||||||
server: ProxmoxServer,
|
server: ProxmoxCredentialSource,
|
||||||
session: { headers: Record<string, string>; retried: boolean },
|
session: { headers: Record<string, string>; retried: boolean },
|
||||||
path: string,
|
path: string,
|
||||||
): Promise<ProxmoxGetResult> {
|
): Promise<ProxmoxGetResult> {
|
||||||
@@ -306,7 +330,7 @@ export class ProxmoxService {
|
|||||||
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
|
* `pbs` und `pmg` (Aufgabe 3), jeweils mit Token ODER Benutzer/Passwort
|
||||||
* (Aufgabe 2).
|
* (Aufgabe 2).
|
||||||
*/
|
*/
|
||||||
private async pollOne(server: ProxmoxServer): Promise<ProxmoxPollResult> {
|
private async pollOne(server: ProxmoxCredentialSource): Promise<ProxmoxPollResult> {
|
||||||
const authHeaders = await this.buildAuthHeaders(server);
|
const authHeaders = await this.buildAuthHeaders(server);
|
||||||
if (!authHeaders.ok) {
|
if (!authHeaders.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -491,21 +515,79 @@ export class ProxmoxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbindungstest (Aufgabe 4, `<behavior>`, `POST servers/:id/test`):
|
* Mischt Formularwerte (`dto`, ungespeichert) mit dem gespeicherten Server
|
||||||
* benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der
|
* (`existing`, `null` bei der Neuanlage) zu genau den Feldern, die eine
|
||||||
|
* Abfrage braucht (Nachbesserung Befund 1). Zwei Regeln, je nachdem, ob
|
||||||
|
* das Formular das Feld beim Laden vorbefuellt (Vorbild `serverToForm`):
|
||||||
|
*
|
||||||
|
* - Normale Felder (`productType`, `baseUrl`, `authMethod`, `tokenId`,
|
||||||
|
* `username`, `tlsRejectUnauthorized`): das Formular zeigt immer den
|
||||||
|
* zuletzt gespeicherten Wert an, bis der Nutzer ihn aendert — ein vom
|
||||||
|
* Aufrufer GESENDETES Feld gilt also als Formularwert, auch wenn es
|
||||||
|
* absichtlich geleert wurde (`tokenId: ''` -> `null`). Nur ein NICHT
|
||||||
|
* gesendetes Feld (Aufrufer ohne diesen Schluessel im Body) faellt auf
|
||||||
|
* den gespeicherten Wert zurueck.
|
||||||
|
* - Geheimnisfelder (`tokenSecret`/`password`): `ServerForm` befuellt
|
||||||
|
* diese beim Laden bewusst NIE aus der Datenbank (Geheimnis nie im
|
||||||
|
* Klartext anzeigen). Ein leeres Feld bedeutet hier deshalb NICHT
|
||||||
|
* "Nutzer will loeschen", sondern "Nutzer hat nichts eingetippt" ->
|
||||||
|
* gespeicherten (verschluesselten) Wert weiterverwenden. Ein gefuelltes
|
||||||
|
* Feld ist der eingetippte Klartext und wird unveraendert durchgereicht;
|
||||||
|
* `decryptSecret()` erkennt anhand der Form `iv:authTag:ciphertext`
|
||||||
|
* automatisch, ob entschluesselt werden muss, und laesst Klartext sonst
|
||||||
|
* unangetastet.
|
||||||
|
*/
|
||||||
|
private resolveEffectiveTestServer(
|
||||||
|
existing: ProxmoxServer | null,
|
||||||
|
dto: TestProxmoxServerDto,
|
||||||
|
): ProxmoxCredentialSource {
|
||||||
|
return {
|
||||||
|
productType: dto.productType ?? existing?.productType ?? 'pve',
|
||||||
|
baseUrl: dto.baseUrl ?? existing?.baseUrl ?? '',
|
||||||
|
authMethod: dto.authMethod ?? existing?.authMethod ?? 'token',
|
||||||
|
tokenId: dto.tokenId !== undefined ? dto.tokenId || null : (existing?.tokenId ?? null),
|
||||||
|
encryptedTokenSecret: dto.tokenSecret
|
||||||
|
? dto.tokenSecret
|
||||||
|
: (existing?.encryptedTokenSecret ?? null),
|
||||||
|
username: dto.username !== undefined ? dto.username || null : (existing?.username ?? null),
|
||||||
|
encryptedPassword: dto.password ? dto.password : (existing?.encryptedPassword ?? null),
|
||||||
|
tlsRejectUnauthorized: dto.tlsRejectUnauthorized ?? existing?.tlsRejectUnauthorized ?? true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungstest fuer einen GESPEICHERTEN Server (Aufgabe 4, `<behavior>`,
|
||||||
|
* `POST servers/:id/test`; Nachbesserung Befund 1: prueft jetzt die
|
||||||
|
* Formularwerte aus `dto`, nicht mehr blind den gespeicherten Stand).
|
||||||
|
* Benutzt denselben Klienten und dieselbe Fehleruebersetzung wie der
|
||||||
* Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den
|
* Planer, schreibt aber NICHT ins Zwischenlager — ein Testklick darf den
|
||||||
* zuletzt gemessenen Stand nicht ueberschreiben (Vorbild
|
* zuletzt gemessenen Stand nicht ueberschreiben (Vorbild
|
||||||
* `TenderEmailConfigService.testConnection`/LDAP-Test). Keine
|
* `TenderEmailConfigService.testConnection`/LDAP-Test). Keine
|
||||||
* Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein
|
* Zehn-Sekunden-Sperre: ein Test ist ein bewusster Einzelklick, kein
|
||||||
* automatisierter Auffrischungsweg.
|
* automatisierter Auffrischungsweg.
|
||||||
*/
|
*/
|
||||||
async testConnection(tenantId: string, serverId: string): Promise<ProxmoxPollResult | null> {
|
async testConnection(
|
||||||
|
tenantId: string,
|
||||||
|
serverId: string,
|
||||||
|
dto: TestProxmoxServerDto = {},
|
||||||
|
): Promise<ProxmoxPollResult | null> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
const server = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||||
if (!server || server.tenantId !== tenantId) {
|
if (!server || server.tenantId !== tenantId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.pollOne(server);
|
return this.pollOne(this.resolveEffectiveTestServer(server, dto));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungstest waehrend der Neuanlage (Nachbesserung Befund 1,
|
||||||
|
* `POST servers/test`, ohne `:id`) — es gibt noch keinen gespeicherten
|
||||||
|
* Server, also ausschliesslich die Formularwerte aus `dto`. Fehlende
|
||||||
|
* Pflichtangaben (z. B. kein Geheimnis) fuehren zum selben Fehlerschluessel
|
||||||
|
* wie beim Abfragen eines gespeicherten Servers ohne Zugang (`zugang`).
|
||||||
|
*/
|
||||||
|
async testDraftConnection(dto: TestProxmoxServerDto): Promise<ProxmoxPollResult> {
|
||||||
|
return this.pollOne(this.resolveEffectiveTestServer(null, dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
const mockCreateServer = vi.fn();
|
const mockCreateServer = vi.fn();
|
||||||
const mockUpdateServer = vi.fn();
|
const mockUpdateServer = vi.fn();
|
||||||
const mockTestServer = vi.fn();
|
const mockTestServer = vi.fn();
|
||||||
|
const mockTestDraftServer = vi.fn();
|
||||||
|
|
||||||
vi.mock('@/lib/proxmox-api', () => ({
|
vi.mock('@/lib/proxmox-api', () => ({
|
||||||
createServer: (...args: unknown[]) => mockCreateServer(...args),
|
createServer: (...args: unknown[]) => mockCreateServer(...args),
|
||||||
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
|
updateServer: (...args: unknown[]) => mockUpdateServer(...args),
|
||||||
testServer: (...args: unknown[]) => mockTestServer(...args),
|
testServer: (...args: unknown[]) => mockTestServer(...args),
|
||||||
|
testDraftServer: (...args: unknown[]) => mockTestDraftServer(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
@@ -62,6 +64,7 @@ afterEach(() => {
|
|||||||
mockCreateServer.mockReset();
|
mockCreateServer.mockReset();
|
||||||
mockUpdateServer.mockReset();
|
mockUpdateServer.mockReset();
|
||||||
mockTestServer.mockReset();
|
mockTestServer.mockReset();
|
||||||
|
mockTestDraftServer.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
const EXISTING_SERVER = {
|
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');
|
const { ServerForm } = await import('./ServerForm');
|
||||||
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
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 { useTranslations } from 'next-intl';
|
||||||
import {
|
import {
|
||||||
createServer,
|
createServer,
|
||||||
|
testDraftServer,
|
||||||
testServer,
|
testServer,
|
||||||
updateServer,
|
updateServer,
|
||||||
type ProxmoxAuthMethod,
|
type ProxmoxAuthMethod,
|
||||||
@@ -114,6 +115,17 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
|||||||
isActive: form.isActive,
|
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 () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
@@ -132,11 +144,17 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
if (!savedServer) return;
|
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
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);
|
setTestResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setTestResult({
|
setTestResult({
|
||||||
@@ -355,16 +373,20 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
|||||||
{isSaving ? t('settings.saving') : t('settings.save')}
|
{isSaving ? t('settings.saving') : t('settings.save')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{savedServer && (
|
{/*
|
||||||
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
disabled={isTesting}
|
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"
|
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')}
|
{isTesting ? t('settings.testTesting') : t('settings.testConnection')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -193,11 +193,41 @@ export async function pollServer(id: string): Promise<ProxmoxTestResult> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /modules/proxmox/servers/:id/test — Verbindungstest, schreibt NICHT ins Zwischenlager. */
|
/**
|
||||||
export async function testServer(id: string): Promise<ProxmoxTestResult> {
|
* POST /modules/proxmox/servers/:id/test — Verbindungstest fuer einen
|
||||||
|
* gespeicherten Server, schreibt NICHT ins Zwischenlager. `payload` traegt
|
||||||
|
* den aktuellen Formularstand (Nachbesserung Befund 1): der Test prueft
|
||||||
|
* damit, was im Formular steht, statt blind den gespeicherten Stand — ein
|
||||||
|
* leer gelassenes Geheimnisfeld (`tokenSecret`/`password: undefined`) laesst
|
||||||
|
* den Server serverseitig auf den gespeicherten Wert zurueckfallen.
|
||||||
|
*/
|
||||||
|
export async function testServer(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateProxmoxServerPayload,
|
||||||
|
): Promise<ProxmoxTestResult> {
|
||||||
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/test`, {
|
const res = await fetch(`${API_URL}/modules/proxmox/servers/${id}/test`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/proxmox/servers/test — Verbindungstest waehrend der
|
||||||
|
* Neuanlage (Nachbesserung Befund 1): es gibt noch keinen gespeicherten
|
||||||
|
* Server, `payload` ist deshalb die einzige Quelle.
|
||||||
|
*/
|
||||||
|
export async function testDraftServer(
|
||||||
|
payload: UpdateProxmoxServerPayload,
|
||||||
|
): Promise<ProxmoxTestResult> {
|
||||||
|
const res = await fetch(`${API_URL}/modules/proxmox/servers/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
|
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to test proxmox server'));
|
||||||
return res.json();
|
return res.json();
|
||||||
|
|||||||
Reference in New Issue
Block a user