fix(260923-le6): Aktualisieren-Knopf der Proxmox-Seite nur fuer Admins

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 15:33:32 +02:00
parent 2f8dd14bfb
commit e1b191bf0b
2 changed files with 183 additions and 3 deletions
@@ -11,7 +11,9 @@ import { ServerCard } from './components/ServerCard';
* Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das * Modulseite (Aufgabe 6) — liest ausschliesslich aus dem Zwischenlager, das
* `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05). * `GET servers` liefert; kein Live-Zugriff bei Proxmox von hier aus (D-05).
* "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die * "Jetzt aktualisieren" loest je Server eine Abfrage aus und laedt die
* Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. * Liste danach neu — waehrend des Laufs ist der Knopf gesperrt. Der Knopf
* erscheint nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN,
* SUPER_ADMIN)` verlangt; fuer andere waere er wirkungslos (260923-le6).
*/ */
export default function ProxmoxPage() { export default function ProxmoxPage() {
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
@@ -51,7 +53,7 @@ export default function ProxmoxPage() {
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{servers !== null && servers.length > 0 && ( {isAdmin && servers !== null && servers.length > 0 && (
<button <button
type="button" type="button"
onClick={handleRefresh} onClick={handleRefresh}
@@ -96,7 +98,7 @@ export default function ProxmoxPage() {
<ul className="space-y-3"> <ul className="space-y-3">
{servers.map((server) => ( {servers.map((server) => (
<li key={server.id}> <li key={server.id}>
<ServerCard server={server} /> <ServerCard server={server} isAdmin={isAdmin} />
</li> </li>
))} ))}
</ul> </ul>
@@ -0,0 +1,178 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api';
// Mock @/lib/proxmox-api — die Seite ruft `listServers` beim Laden auf,
// "Jetzt aktualisieren" ruft `pollServer` je Server.
const mockListServers = vi.fn();
const mockPollServer = vi.fn();
vi.mock('@/lib/proxmox-api', () => ({
listServers: (...args: unknown[]) => mockListServers(...args),
pollServer: (...args: unknown[]) => mockPollServer(...args),
}));
// Mock the auth store — mirrors settings-roles.test.tsx (selector-passthrough).
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
vi.mock('next/link', () => ({
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
title: 'Proxmox',
description: 'Zustand Ihrer Proxmox-Server (PVE/PBS/PMG) auf einen Blick.',
loading: 'Lade Serverliste...',
loadError: 'Die Serverliste konnte nicht geladen werden.',
emptyState:
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
'card.refresh': 'Jetzt aktualisieren',
'card.refreshing': 'Wird aktualisiert...',
'card.settingsLink': 'Zu den Einstellungen',
'card.unknownValue': 'unbekannt',
'card.lastPolledLabel': 'Letzte Abfrage',
'card.notPolledYet': 'Noch keine Abfrage gelaufen. Klicken Sie oben auf „{refreshLabel}“.',
'card.notPolledYetAutomatic':
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
};
let result = translations[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
result = result.replace(`{${k}}`, String(v));
}
}
return result;
},
}));
function mockUser(user: { role: 'SUPER_ADMIN' | 'ADMIN' | 'USER' } | null) {
mockAuthStore.mockImplementation((selector: (state: { user: typeof user }) => unknown) =>
selector({ user }),
);
}
// Ein nie abgefragter Server (wie im Befund-2-Test von ServerCard.test.tsx):
// leere Zwischenlagerzeile direkt nach `createServer`.
function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
return {
id: 'srv-1',
tenantId: 't1',
name: 'pve-1',
productType: 'pve',
baseUrl: 'https://pve.intern',
authMethod: 'token',
tokenId: 'root@pam!x',
username: null,
tlsRejectUnauthorized: true,
isActive: true,
pollIntervalMin: 5,
position: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
status: {
id: 's1',
serverId: 'srv-1',
lastPolledAt: null,
lastOkAt: null,
reachable: false,
errorKind: null,
errorDetail: null,
rawSample: null,
updatedAt: '2026-01-01T00:00:00.000Z',
metrics: null,
},
...overrides,
} as ProxmoxServer;
}
afterEach(() => {
cleanup();
mockListServers.mockReset();
mockPollServer.mockReset();
mockAuthStore.mockReset();
});
describe('ProxmoxPage role gating (260923-le6)', () => {
it('Rolle USER: kein Knopf "Jetzt aktualisieren", Karten-Hinweis ist der automatische Text', async () => {
mockUser({ role: 'USER' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
expect(
screen.getByText(
'Noch keine Abfrage gelaufen. Die Werte erscheinen nach der nächsten automatischen Abfrage.',
),
).toBeInTheDocument();
});
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
mockUser(null);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
});
it('Rolle ADMIN: Knopf sichtbar, Karten-Hinweis ist der Admin-Text mit Knopfverweis', async () => {
mockUser({ role: 'ADMIN' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
expect(
screen.getByText('Noch keine Abfrage gelaufen. Klicken Sie oben auf „Jetzt aktualisieren“.'),
).toBeInTheDocument();
});
it('Rolle SUPER_ADMIN: Knopf sichtbar', async () => {
mockUser({ role: 'SUPER_ADMIN' });
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(screen.getByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
});
it('leere Serverliste bei ADMIN: weiterhin kein Knopf', async () => {
mockUser({ role: 'ADMIN' });
mockListServers.mockResolvedValue([]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await waitFor(() => {
expect(
screen.getByText(
'Noch kein Server eingetragen. Legen Sie in den Moduleinstellungen einen Server an.',
),
).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
});
});