syncova-backup/apps/web/src/features/auth/LoginPage.test.tsx
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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();
});
});
});