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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user