Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
6.9 KiB
TypeScript
216 lines
6.9 KiB
TypeScript
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { LoginPage } from './LoginPage';
|
|
import { clearTokens } from './authApi';
|
|
|
|
/** Baut eine Antwort, wie sie die API liefert. */
|
|
function buildResponse(responseBody: unknown, statusCode = 200): Response {
|
|
return new Response(JSON.stringify(responseBody), {
|
|
status: statusCode,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */
|
|
const testUser = {
|
|
id: '11111111-1111-4111-8111-111111111111',
|
|
username: 'admin',
|
|
status: 'active',
|
|
mfa_enabled: false,
|
|
roles: ['super_administrator'],
|
|
permissions: ['users.read'],
|
|
};
|
|
|
|
/** Ein Tokenpaar, wie es die API zurueckgibt. */
|
|
const testTokens = {
|
|
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
|
|
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
|
|
access_expires_at: '2026-08-10T16:00:00Z',
|
|
refresh_expires_at: '2026-08-11T04:00:00Z',
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
clearTokens();
|
|
});
|
|
|
|
describe('LoginPage', () => {
|
|
it('meldet einen Benutzer ohne zweiten Faktor direkt an', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildResponse({
|
|
data: { mfa_required: false, tokens: testTokens, user: testUser },
|
|
meta: { request_id: 'req-1' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const handleAuthenticated = vi.fn();
|
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
await waitFor(() => {
|
|
expect(handleAuthenticated).toHaveBeenCalledWith(testUser);
|
|
});
|
|
});
|
|
|
|
it('fordert bei aktivem zweiten Faktor den Code an', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildResponse({
|
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
|
meta: { request_id: 'req-2' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
const handleAuthenticated = vi.fn();
|
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
// Der zweite Schritt erscheint, und noch ist niemand angemeldet.
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText('Code')).toBeInTheDocument();
|
|
});
|
|
|
|
expect(handleAuthenticated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('schliesst die Anmeldung nach gueltigem Code ab', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
buildResponse({
|
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
|
meta: { request_id: 'req-3' },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
buildResponse({
|
|
data: { mfa_required: false, tokens: testTokens, user: { ...testUser, mfa_enabled: true } },
|
|
meta: { request_id: 'req-4' },
|
|
}),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const handleAuthenticated = vi.fn();
|
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
|
|
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
|
|
|
await waitFor(() => {
|
|
expect(handleAuthenticated).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildResponse(
|
|
{
|
|
error: {
|
|
code: 'UNAUTHENTICATED',
|
|
message: 'Benutzername oder Passwort ist falsch.',
|
|
request_id: 'req-5',
|
|
},
|
|
},
|
|
401,
|
|
),
|
|
),
|
|
);
|
|
|
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(screen.getByLabelText('Passwort'), 'falsch');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('alert')).toHaveTextContent('Benutzername oder Passwort ist falsch.');
|
|
});
|
|
});
|
|
|
|
it('leert das Passwortfeld nach einem Fehlversuch', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildResponse(
|
|
{ error: { code: 'UNAUTHENTICATED', message: 'Falsch.', request_id: 'req-6' } },
|
|
401,
|
|
),
|
|
),
|
|
);
|
|
|
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
|
|
|
const passwordField = screen.getByLabelText('Passwort') as HTMLInputElement;
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(passwordField, 'falsch');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
// Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten
|
|
// Bildschirm sichtbar.
|
|
await waitFor(() => {
|
|
expect(passwordField.value).toBe('');
|
|
});
|
|
});
|
|
|
|
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
buildResponse({
|
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
|
meta: { request_id: 'req-7' },
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
buildResponse(
|
|
{
|
|
error: {
|
|
code: 'MFA_CHALLENGE_EXPIRED',
|
|
message: 'Die Anmeldung ist abgelaufen. Bitte erneut anmelden.',
|
|
request_id: 'req-8',
|
|
},
|
|
},
|
|
401,
|
|
),
|
|
);
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
|
|
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
|
|
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
|
|
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
|
|
|
// Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|