feat(260923-dhh): Proxmox-Modul Aufgabe 5 - Einstellungsseite (anlegen, bearbeiten, loeschen, testen)
- proxmox.service.ts: updateServer (Muster LdapConfigService.updateConfig - nicht gesendet laesst unveraendert, leer loescht, gefuellt verschluesselt neu; PMG+Token auch beim Bearbeiten abgelehnt, geprueft gegen den EFFEKTIVEN Stand nach Zusammenfuehren), deleteServer - proxmox.controller.ts: PUT/DELETE servers/:id, beide zusaetzlich mit scheduler.refreshTenant() nach dem Schreiben - Frontend: proxmox-api.ts (updateServer/deleteServer/testServer), settings/page.tsx (Rollenpruefung nur Anzeige, Serverliste, Loeschen mit Rueckfrage), ServerForm.tsx (PMG bietet Token gar nicht an, Geheimnisfelder nie vorbefuellt, Zertifikatspruefung-Schalter Standard "pruefen", Verbindungstest mit Klartext-Fehlertext) - umlaut-dictionary.ts: zwei neue, bereits korrekte Woerter (bewusst/gemessene) auf die Positivliste des Regressions-Waechters Tore: api 1311/1311 (>=1240), web 701/701 (>=693), type-check 4/4, lint 5/5, Biome apps/web 53 Warnungen (unveraendert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import { ProxmoxSchedulerService } from './proxmox-scheduler.service';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
|
||||
@@ -46,6 +56,28 @@ export class ProxmoxController {
|
||||
return created;
|
||||
}
|
||||
|
||||
@Put('servers/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProxmoxServerDto,
|
||||
) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
const updated = await this.proxmoxService.updateServer(tenantId, id, dto);
|
||||
await this.scheduler.refreshTenant(tenantId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@Delete('servers/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const tenantId = this.requireTenantId(req);
|
||||
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
|
||||
await this.scheduler.refreshTenant(tenantId);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
@Post('servers/:id/poll')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
|
||||
@@ -24,7 +24,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
import { Agent } from 'undici';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { ProxmoxService } from './proxmox.service';
|
||||
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
|
||||
/** Durchschaubarer Ersatz fuer AES-256-GCM — Zusammenspiel unter Test, nicht die Bibliothek. */
|
||||
const crypto = {
|
||||
@@ -81,6 +81,28 @@ function makeFakePrisma() {
|
||||
return { ...row };
|
||||
},
|
||||
),
|
||||
update: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
data,
|
||||
select,
|
||||
}: {
|
||||
where: { id: string };
|
||||
data: Record<string, unknown>;
|
||||
select?: any;
|
||||
}) => {
|
||||
const existing = servers.get(where.id);
|
||||
const updated = { ...existing, ...data, updatedAt: new Date() };
|
||||
servers.set(where.id, updated);
|
||||
return applySelect(updated, select);
|
||||
},
|
||||
),
|
||||
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||
const row = servers.get(where.id);
|
||||
servers.delete(where.id);
|
||||
statuses.delete(where.id); // Fremdschluessel mit Loeschweitergabe (onDelete: Cascade)
|
||||
return row ? { ...row } : null;
|
||||
}),
|
||||
};
|
||||
|
||||
const proxmoxServerStatus = {
|
||||
@@ -434,3 +456,71 @@ describe('ProxmoxService — Aufgabe 4 (Verbindungstest, Zehn-Sekunden-Sperre)',
|
||||
expect(servers[0]).toMatchObject({ tenantId: 'tenant-a', pollIntervalMin: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProxmoxService — Aufgabe 5 (Bearbeiten, Loeschen)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ein NICHT gesendetes Geheimnisfeld laesst den gespeicherten Wert unveraendert', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
const storedBefore = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { name: 'neuer-name' });
|
||||
|
||||
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBe(storedBefore);
|
||||
expect(prisma.__servers.get((created as any).id).name).toBe('neuer-name');
|
||||
});
|
||||
|
||||
it('eine LEERE Zeichenkette loescht das Geheimnis, ein gefuellter Wert verschluesselt neu', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: '' });
|
||||
expect(prisma.__servers.get((created as any).id).encryptedTokenSecret).toBeNull();
|
||||
|
||||
await service.updateServer('tenant-a', (created as any).id, { tokenSecret: 'neues-geheimnis' });
|
||||
const stored = prisma.__servers.get((created as any).id).encryptedTokenSecret;
|
||||
expect(stored).not.toBe('neues-geheimnis');
|
||||
expect(stored).toMatch(/^[0-9a-f]+:[0-9a-f]+:[0-9a-f]*$/i);
|
||||
});
|
||||
|
||||
it('PMG plus Token wird auch beim Bearbeiten abgelehnt — auch wenn nur authMethod gesendet wird', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', {
|
||||
name: 'pmg-1',
|
||||
productType: 'pmg',
|
||||
baseUrl: 'https://pmg.intern',
|
||||
authMethod: 'password',
|
||||
username: 'admin@pmg',
|
||||
password: 'geheim',
|
||||
});
|
||||
|
||||
const dto: UpdateProxmoxServerDto = { authMethod: 'token', tokenId: 'x', tokenSecret: 'y' };
|
||||
await expect(service.updateServer('tenant-a', (created as any).id, dto)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loescht einen Server samt Zwischenlagerzeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
const created = await service.createServer('tenant-a', TOKEN_DTO);
|
||||
expect(prisma.__statuses.has((created as any).id)).toBe(true);
|
||||
|
||||
const deleted = await service.deleteServer('tenant-a', (created as any).id);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(prisma.__servers.has((created as any).id)).toBe(false);
|
||||
expect(prisma.__statuses.has((created as any).id)).toBe(false);
|
||||
});
|
||||
|
||||
it('deleteServer liefert false fuer einen unbekannten Server', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = new ProxmoxService(prisma as any, crypto as any);
|
||||
expect(await service.deleteServer('tenant-a', 'unbekannt')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { ProxmoxServer } from '@prisma/client';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -6,7 +6,7 @@ import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { buildTicketCookieHeader, buildTokenAuthHeader, loginTicket } from './proxmox-auth';
|
||||
import { proxmoxGet, type ProxmoxGetResult } from './proxmox-client.service';
|
||||
import { listPbsDatastoreNames, normalizePbs, normalizePmg, normalizePve } from './proxmox-normalize';
|
||||
import type { CreateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import type { CreateProxmoxServerDto, UpdateProxmoxServerDto } from './dto/proxmox-server.dto';
|
||||
import type { ProxmoxErrorKind, ProxmoxPollResult, ProxmoxProductType } from './proxmox.types';
|
||||
|
||||
/**
|
||||
@@ -144,6 +144,73 @@ export class ProxmoxService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten (Aufgabe 5). Dieselbe Regel wie
|
||||
* `LdapConfigService.updateConfig`: ein NICHT gesendetes Geheimnisfeld
|
||||
* laesst den gespeicherten Wert unveraendert, eine LEERE Zeichenkette
|
||||
* bedeutet "loeschen", ein gefuellter Wert wird neu verschluesselt. Die
|
||||
* Ablehnung "PMG plus Token" gilt auch hier — geprueft gegen den
|
||||
* EFFEKTIVEN Stand nach dem Zusammenfuehren mit der vorhandenen Zeile,
|
||||
* nicht nur gegen die gesendeten Felder (ein Teil-Update, das nur
|
||||
* `authMethod` aendert, wuerde die DTO-eigene Pruefung sonst umgehen,
|
||||
* weil `productType` in diesem Aufruf gar nicht gesendet wird).
|
||||
*/
|
||||
async updateServer(tenantId: string, serverId: string, dto: UpdateProxmoxServerDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||
if (!existing || existing.tenantId !== tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveProductType = dto.productType ?? existing.productType;
|
||||
const effectiveAuthMethod = dto.authMethod ?? existing.authMethod;
|
||||
if (effectiveProductType === 'pmg' && effectiveAuthMethod === 'token') {
|
||||
throw new BadRequestException(
|
||||
'PMG unterstuetzt keinen API-Token-Zugang. Bitte Benutzer und Passwort waehlen.',
|
||||
);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.productType !== undefined) data.productType = dto.productType;
|
||||
if (dto.baseUrl !== undefined) data.baseUrl = dto.baseUrl;
|
||||
if (dto.authMethod !== undefined) data.authMethod = dto.authMethod;
|
||||
if (dto.tokenId !== undefined) data.tokenId = dto.tokenId || null;
|
||||
if (dto.tokenSecret !== undefined) {
|
||||
data.encryptedTokenSecret = dto.tokenSecret ? this.crypto.encrypt(dto.tokenSecret) : null;
|
||||
}
|
||||
if (dto.username !== undefined) data.username = dto.username || null;
|
||||
if (dto.password !== undefined) {
|
||||
data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null;
|
||||
}
|
||||
if (dto.tlsRejectUnauthorized !== undefined) {
|
||||
data.tlsRejectUnauthorized = dto.tlsRejectUnauthorized;
|
||||
}
|
||||
if (dto.pollIntervalMin !== undefined) data.pollIntervalMin = dto.pollIntervalMin;
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
|
||||
return tenantPrisma.proxmoxServer.update({
|
||||
where: { id: serverId },
|
||||
data,
|
||||
select: SAFE_SERVER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschen (Aufgabe 5) — entfernt den Server samt Zwischenlagerzeile
|
||||
* (Fremdschluessel mit Loeschweitergabe, `onDelete: Cascade`). Liefert
|
||||
* `false`, wenn der Server unter diesem Mandanten nicht existiert.
|
||||
*/
|
||||
async deleteServer(tenantId: string, serverId: string): Promise<boolean> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.proxmoxServer.findUnique({ where: { id: serverId } });
|
||||
if (!existing || existing.tenantId !== tenantId) {
|
||||
return false;
|
||||
}
|
||||
await tenantPrisma.proxmoxServer.delete({ where: { id: serverId } });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Anmeldekopfzeile fuer GENAU diesen Server ueber
|
||||
* `proxmox-auth.ts` (D-03). Beim Passwort-Zweig loest das eine
|
||||
|
||||
Reference in New Issue
Block a user