**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:
- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.
Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.
Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).
**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.
**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.
Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.
82 Tests gruen, tsc sauber, eslint ohne Warnung.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
/**
|
|
* Tests des Backup-Assistenten.
|
|
*
|
|
* Geprüft wird der Ablauf durch die Maske: dass unvollständige Schritte
|
|
* aufhalten, dass Rückwärtsgehen keine Eingaben verliert und dass eine
|
|
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
|
|
*/
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { BackupWizard } from './BackupWizard';
|
|
import { ApiError } from '../../api/client';
|
|
import * as jobsApi from './jobsApi';
|
|
|
|
/** Ein Sicherungsziel, das Sicherungen annimmt. */
|
|
const writableRepository: jobsApi.BackupRepository = {
|
|
id: '11111111-1111-1111-1111-111111111111',
|
|
name: 'Produktiv',
|
|
repository_type: 'local',
|
|
location: '/backup/produktiv',
|
|
status: 'active',
|
|
accepts_backups: true,
|
|
hardened: false,
|
|
};
|
|
|
|
/** Ein Sicherungsziel im Nur-Lese-Zustand. */
|
|
const readOnlyRepository: jobsApi.BackupRepository = {
|
|
id: '22222222-2222-2222-2222-222222222222',
|
|
name: 'Archiv',
|
|
repository_type: 'local',
|
|
location: '/backup/archiv',
|
|
status: 'read_only',
|
|
accepts_backups: false,
|
|
hardened: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
|
|
});
|
|
|
|
/** Führt den Assistenten bis zu einem Schritt und füllt das Nötige aus. */
|
|
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
|
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
// Zeitplan: der Standard "täglich 02:00" genügt.
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
}
|
|
|
|
describe('Backup-Assistent', () => {
|
|
it('zeigt alle zehn Schritte', async () => {
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
|
|
// erst nach dem Test, und React meldet eine Aktualisierung außerhalb von act().
|
|
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
|
|
|
|
for (const stepTitle of [
|
|
'Name',
|
|
'Quelle',
|
|
'Zeitplan',
|
|
'Repository',
|
|
'Aufbewahrung',
|
|
'Sicherheit',
|
|
'Prüfung',
|
|
'Benachrichtigung',
|
|
'Übersicht',
|
|
'Anlegen',
|
|
]) {
|
|
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
|
|
// zu zeigen hieße, ein leeres Formular als fehlerhaft zu markieren.
|
|
it('hält bei einem leeren Namen auf und erklärt warum', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent('braucht einen Namen');
|
|
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('hält ohne Quelle auf', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
expect(screen.getByText(/Schritt 2 von 10/)).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
|
|
});
|
|
|
|
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst wäre
|
|
// jeder Blick zurück ein Datenverlust.
|
|
it('bewahrt die Eingaben beim Zurückgehen', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Nächtliche Sicherung');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
await user.click(screen.getByRole('button', { name: 'Zurück' }));
|
|
|
|
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Nächtliche Sicherung');
|
|
});
|
|
|
|
it('zeigt eine Vorschau des Zeitplans', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
expect(screen.getByText(/Ergibt: täglich um 02:00 Uhr/)).toBeInTheDocument();
|
|
});
|
|
|
|
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
|
|
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
|
|
it('zeigt gesperrte Repositories, lässt sie aber nicht wählen', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await fillUntilRepository(user);
|
|
|
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
|
|
|
expect(screen.getByText('Archiv')).toBeInTheDocument();
|
|
expect(screen.getByText(/nimmt derzeit keine Sicherungen an/)).toBeInTheDocument();
|
|
|
|
const radioButtons = screen.getAllByRole('radio');
|
|
expect(radioButtons[0]).toBeEnabled();
|
|
expect(radioButtons[1]).toBeDisabled();
|
|
});
|
|
|
|
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
|
|
// vorgetäuschtes Funktionsversprechen (PROMPT.md §138).
|
|
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await fillUntilRepository(user);
|
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
|
|
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
// Schritt 5: Aufbewahrung.
|
|
expect(screen.getByText('Noch nicht verfügbar')).toBeInTheDocument();
|
|
expect(screen.getByText(/nicht automatisch gelöscht/)).toBeInTheDocument();
|
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('legt den Auftrag an und meldet ihn nach oben', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
const createdJob = { id: 'abc', name: 'Nächtliche Sicherung' } as jobsApi.BackupJob;
|
|
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
|
|
const handleCreated = vi.fn();
|
|
|
|
render(<BackupWizard onJobCreated={handleCreated} onCancel={vi.fn()} />);
|
|
|
|
await fillUntilRepository(user);
|
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
|
|
|
// Von Repository bis Anlegen sind es sechs Schritte.
|
|
for (let stepCounter = 0; stepCounter < 6; stepCounter++) {
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
}
|
|
|
|
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' }));
|
|
|
|
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
|
|
|
|
const sentRequest = createSpy.mock.calls[0]?.[0];
|
|
expect(sentRequest?.name).toBe('Nächtliche Sicherung');
|
|
expect(sentRequest?.sources[0]?.id).toBe('/daten');
|
|
expect(sentRequest?.repository_id).toBe(writableRepository.id);
|
|
});
|
|
|
|
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
|
|
// eigentlichen Grund, den die Oberfläche nicht kennen kann.
|
|
it('zeigt eine Fehlermeldung des Servers', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
|
|
new ApiError({
|
|
code: 'VALIDATION_FAILED',
|
|
message: 'der zeitplan läuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
|
|
statusCode: 422,
|
|
requestId: 'req-1',
|
|
}),
|
|
);
|
|
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await fillUntilRepository(user);
|
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
|
|
|
for (let stepCounter = 0; stepCounter < 6; stepCounter++) {
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
}
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' }));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
|
|
);
|
|
|
|
// Der Assistent bleibt stehen, damit der Anwender zurückgehen und den
|
|
// Zeitplan ändern kann.
|
|
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('meldet einen Fehler beim Laden der Ziele', async () => {
|
|
const user = userEvent.setup();
|
|
|
|
vi.spyOn(jobsApi, 'listRepositories').mockRejectedValue(
|
|
new ApiError({
|
|
code: 'PERMISSION_DENIED',
|
|
message: 'Die Berechtigung repositories.read fehlt.',
|
|
statusCode: 403,
|
|
requestId: 'req-2',
|
|
}),
|
|
);
|
|
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await fillUntilRepository(user);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText('Die Berechtigung repositories.read fehlt.')).toBeInTheDocument(),
|
|
);
|
|
});
|
|
|
|
it('erlaubt den Sprung zurück über die Schrittleiste, nicht nach vorn', async () => {
|
|
const user = userEvent.setup();
|
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
|
|
|
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er würde eine
|
|
// Prüfung überspringen, die der Assistent gerade führen soll.
|
|
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
|
|
|
|
await user.click(screen.getByRole('button', { name: /1\s*Name/ }));
|
|
|
|
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
|
});
|
|
});
|