**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>
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client';
|
|
|
|
/** Baut eine Antwort, wie sie das Backend liefern würde. */
|
|
function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
|
|
return new Response(JSON.stringify(responseBody), {
|
|
status: statusCode,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('requestApi', () => {
|
|
it('gibt die Nutzlast aus der Standard-Hülle zurück', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildJsonResponse({ data: { status: 'healthy' }, meta: { request_id: 'abc' } }, 200),
|
|
),
|
|
);
|
|
|
|
const loadedPayload = await requestApi<{ status: string }>('/health');
|
|
|
|
expect(loadedPayload).toEqual({ status: 'healthy' });
|
|
});
|
|
|
|
it('sendet eine Correlation ID mit', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(buildJsonResponse({ data: {}, meta: { request_id: 'abc' } }, 200));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await requestApi('/health');
|
|
|
|
// Ohne Correlation ID ließe sich eine Operation nicht Ende-zu-Ende verfolgen.
|
|
const firstCall = fetchMock.mock.calls[0];
|
|
expect(firstCall).toBeDefined();
|
|
|
|
const sentHeaders = (firstCall?.[1] as RequestInit).headers as Record<string, string>;
|
|
expect(sentHeaders['X-Correlation-ID']).toMatch(
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
);
|
|
});
|
|
|
|
it('wandelt eine Fehlerantwort in einen ApiError um', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue(
|
|
buildJsonResponse(
|
|
{
|
|
error: {
|
|
code: 'REPOSITORY_UNAVAILABLE',
|
|
message: 'Das Repository ist nicht erreichbar.',
|
|
request_id: 'req-42',
|
|
},
|
|
},
|
|
503,
|
|
),
|
|
),
|
|
);
|
|
|
|
await expect(requestApi('/repositories')).rejects.toMatchObject({
|
|
code: 'REPOSITORY_UNAVAILABLE',
|
|
statusCode: 503,
|
|
requestId: 'req-42',
|
|
});
|
|
});
|
|
|
|
it('meldet eine nicht erreichbare API verständlich', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
|
|
|
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
|
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: NETWORK_ERROR_CODE });
|
|
});
|
|
|
|
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
|
|
|
|
// Eine Antwort außerhalb des Vertrags darf nicht stillschweigend
|
|
// als leeres Ergebnis interpretiert werden (PROMPT.md §140).
|
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
|
});
|
|
|
|
it('lehnt eine Fehlerantwort ohne Fehlerkörper ab', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
|
|
|
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
|
});
|
|
|
|
it('reicht einen Abbruch unverändert durch', async () => {
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
|
|
);
|
|
|
|
// Ein Abbruch ist Folge des Aufräumens und kein Fehlerfall.
|
|
await expect(requestApi('/health')).rejects.toSatisfy(
|
|
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
|
|
);
|
|
});
|
|
});
|