syncova-backup/apps/web/src/api/client.test.ts
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

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 wuerde. */
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-Huelle zurueck', 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 liesse 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 verstaendlich', 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 ausserhalb 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 Fehlerkoerper 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 unveraendert durch', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
);
// Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall.
await expect(requestApi('/health')).rejects.toSatisfy(
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
);
});
});