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