Assessor_2.0/tests/server/gpo-backup-http.test.ts

163 lines
9.0 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FileSystemBackendArtifactStore } from '../../server/artifacts/backend-artifact-store';
import { createAssessorHttpServer } from '../../server/assessor-http-server';
import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service';
import { directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { createGpoFolderTransport } from '../../src/gpo/gpo-folder-transport';
import { syntheticGpoBackupZip } from './gpo-synthetic-fixture';
const now = new Date('2026-07-21T09:00:00.000Z');
const project = { id: 'http-gpo-project', name: 'Synthetic HTTP project', createdAt: now.toISOString(), updatedAt: now.toISOString(), attributes: {} };
describe('GPO backup submission HTTP boundary', () => {
let directory: string;
let server: Server;
let baseUrl: string;
let repository: LocalAssessorRepository;
let artifactStore: FileSystemBackendArtifactStore;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-gpo-http-'));
const staticRoot = join(directory, 'dist');
await writeFile(join(directory, 'placeholder'), 'synthetic');
repository = new LocalAssessorRepository();
await repository.saveProject(project);
artifactStore = new FileSystemBackendArtifactStore(join(directory, 'artifacts'));
const service = new GpoBackupSubmissionService(
repository, artifactStore, directGpoInventoryExecutor, 1024 * 1024
);
server = createAssessorHttpServer({ artifactStore, repository, staticRoot, now: () => now, gpoBackupSubmissionService: service });
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
await rm(directory, { recursive: true, force: true });
});
it('accepts a same-origin project upload and returns only opaque IDs and technical receipt data', async () => {
const bytes = syntheticGpoBackupZip();
const response = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({ fileName: 'backup.zip', sizeBytes: bytes.byteLength, bytesBase64: Buffer.from(bytes).toString('base64') })
});
expect(response.status).toBe(202);
const body = await response.json();
expect(body).toMatchObject({ status: 'queued', deduplicated: false, sizeBytes: bytes.byteLength });
expect(JSON.stringify(body)).not.toContain('storageKey');
expect(JSON.stringify(body)).not.toContain('synthetic.example');
});
it('returns the persisted project GPO status after leaving and reopening the tenant view', async () => {
const bytes = syntheticGpoBackupZip();
const submitted = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({ fileName: 'persistent-status.zip', sizeBytes: bytes.byteLength, bytesBase64: Buffer.from(bytes).toString('base64') })
});
expect(submitted.status).toBe(202);
const response = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups/status`);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
version: 1,
projectId: project.id,
records: [{ fileName: 'persistent-status.zip', state: 'queued', attempts: 0, gpoCount: 0, backupInstanceCount: 0 }]
});
});
it('binds the uploaded GPO snapshot to the selected assessment timeslot', async () => {
const timeslot = {
id: 'gpo-timeslot', projectId: project.id, name: '22.07.2026',
effectiveAt: '2026-07-22T12:00:00.000Z', createdAt: now.toISOString(), updatedAt: now.toISOString()
};
const saved = await fetch(`${baseUrl}/api/v1/assessments`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1' }, body: JSON.stringify(timeslot)
});
expect(saved.status).toBe(200);
const bytes = syntheticGpoBackupZip();
const submitted = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({
fileName: 'timeslot-gpo.zip', sizeBytes: bytes.byteLength,
bytesBase64: Buffer.from(bytes).toString('base64'), assessmentId: timeslot.id
})
});
expect(submitted.status).toBe(202);
await expect((await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups/status`)).json()).resolves.toMatchObject({
records: [expect.objectContaining({ fileName: 'timeslot-gpo.zip', assessmentId: timeslot.id })]
});
});
it('rejects size mismatches without exposing or storing content', async () => {
const bytes = syntheticGpoBackupZip();
const response = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({ fileName: 'backup.zip', sizeBytes: bytes.byteLength + 1, bytesBase64: Buffer.from(bytes).toString('base64') })
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({ error: { code: 'file_size_mismatch' } });
});
it('offers live missing-source status and restores only the exact project artifact through the upload boundary', async () => {
const bytes = syntheticGpoBackupZip();
const upload = (restoreArtifactId?: string, content = bytes) => fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({ fileName: 'original.zip', sizeBytes: content.byteLength,
bytesBase64: Buffer.from(content).toString('base64'), restoreArtifactId })
});
const first = await upload();
expect(first.status).toBe(202);
const { artifactId } = await first.json();
const original = (await repository.listImportArtifacts(project.id))[0];
if (original.storage.kind !== 'backend-controlled') throw new Error('Synthetic storage missing');
await artifactStore.remove(original.storage.storageKey);
const status = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups/status`)).json();
expect(status.records).toMatchObject([{ artifactId, state: 'upload-required', attempts: 0 }]);
expect(JSON.stringify(status)).not.toContain(original.storage.storageKey);
const wrong = Uint8Array.from(bytes);
wrong[0] ^= 1;
expect((await upload(artifactId, wrong)).status).toBe(400);
const restored = await upload(artifactId);
expect(restored.status).toBe(202);
expect(await restored.json()).toMatchObject({ artifactId, sourceRestored: true, deduplicated: true });
await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([original]);
const current = await (await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups/status`)).json();
expect(current.records).toMatchObject([{ artifactId, state: 'queued', attempts: 0 }]);
});
it('accepts a complete unzipped browser folder through the deterministic transport envelope', async () => {
const encoder = new TextEncoder();
const folderEntries = [
['SyntheticFolder/instance/Backup.xml', '<?xml version="1.0"?><BackupInst><ID>{22222222-2222-4222-8222-222222222222}</ID><GPOGuid>{11111111-1111-4111-8111-111111111111}</GPOGuid><GPODomain>synthetic.example</GPODomain><BackupTime>2026-07-21T09:00:00Z</BackupTime></BackupInst>'],
['SyntheticFolder/instance/DomainSysvol/GPO/gpt.ini', '[General]']
].map(([relativePath, content]) => {
const bytes = encoder.encode(content);
return { relativePath, sizeBytes: bytes.byteLength, readBytes: async () => bytes };
});
const transport = await createGpoFolderTransport(folderEntries);
const response = await fetch(`${baseUrl}/api/v1/projects/${project.id}/gpo-backups`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Assessor-Request': '1', Origin: baseUrl },
body: JSON.stringify({
fileName: transport.fileName,
sizeBytes: transport.bytes.byteLength,
bytesBase64: Buffer.from(transport.bytes).toString('base64')
})
});
expect(response.status).toBe(202);
await expect(response.json()).resolves.toMatchObject({ status: 'queued', deduplicated: false });
});});