Assessor_2.0/tests/server/gpo-artifact-reconciler.tes...

130 lines
6.0 KiB
TypeScript

import { createHash } from 'node:crypto';
import { mkdtemp, readdir, rm } from 'node:fs/promises';
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 { GpoArtifactReconciler } from '../../server/gpo/gpo-artifact-reconciler';
import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service';
import { directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor';
import type { ImportArtifact, IngestionJob } from '../../src/domain/ingestion-persistence';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { syntheticGpoBackupZip } from './gpo-synthetic-fixture';
const submittedAt = '2026-07-21T08:00:00.000Z';
// Relativ zur realen Uhr, weil die Waisendatei real geschrieben wird und ihre echte mtime trägt:
// ein festes Datum lässt den Alterungs-Cutoff der Reconciliation irgendwann dauerhaft ins Leere laufen.
const reconciliationTime = new Date(Date.now() + 24 * 60 * 60 * 1_000);
const project = { id: 'reconcile-project', name: 'Synthetic reconciliation', createdAt: submittedAt, updatedAt: submittedAt, attributes: {} };
class AtomicCommitFailsRepository extends LocalAssessorRepository {
override async saveImportArtifactAndEnqueueJob(_artifact: ImportArtifact, _job: IngestionJob): Promise<never> {
throw new Error('synthetic atomic interruption');
}
}
describe('GPO artifact orphan reconciliation', () => {
let directory: string;
let repository: LocalAssessorRepository;
let store: FileSystemBackendArtifactStore;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-gpo-reconcile-'));
repository = new LocalAssessorRepository();
store = new FileSystemBackendArtifactStore(directory);
await repository.saveProject(project);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('repairs a referenced GPO artifact without a job and removes only aged unreferenced objects', async () => {
const bytes = syntheticGpoBackupZip();
const sha256 = createHash('sha256').update(bytes).digest('hex');
const referenced = await store.put({ bytes, expectedSha256: sha256, maximumBytes: 1024 * 1024 });
await repository.saveImportArtifact({
version: 1,
id: 'legacy-gpo-artifact',
projectId: project.id,
kind: 'source-file',
relativePath: 'gpo-backups/legacy.zip',
sha256,
sizeBytes: bytes.byteLength,
sensitivity: 'secret',
parseStatus: 'pending',
storage: { kind: 'backend-controlled', storageKey: referenced.storageKey },
createdAt: submittedAt
});
await store.put({ bytes: new TextEncoder().encode('synthetic orphan'), maximumBytes: 1024 });
const result = await new GpoArtifactReconciler(repository, store, {
gracePeriodMs: 60_000,
maximumRemovals: 10,
now: () => reconciliationTime
}).run();
expect(result).toMatchObject({ repairedMissingJobs: 1, failedJobRepairs: 0, removedUnreferencedObjects: 1 });
await expect(repository.listIngestionJobs(project.id)).resolves.toMatchObject([{
artifactId: 'legacy-gpo-artifact', kind: 'analyze-gpo-backup', status: 'queued'
}]);
await expect(store.read({ storageKey: referenced.storageKey, expectedSha256: sha256, maximumBytes: 1024 * 1024 })).resolves.toEqual(new Uint8Array(bytes));
await expect(readdir(join(directory, 'objects'))).resolves.toHaveLength(1);
});
it('never removes stored objects when the database exposes no trusted backend references', async () => {
await store.put({ bytes: new TextEncoder().encode('synthetic unbound object'), maximumBytes: 1024 });
const result = await new GpoArtifactReconciler(repository, store, {
gracePeriodMs: 60_000,
maximumRemovals: 10,
now: () => reconciliationTime
}).run();
expect(result).toMatchObject({ referencedObjects: 0, removedUnreferencedObjects: 0, retainedUnreferencedObjects: 1 });
await expect(readdir(join(directory, 'objects'))).resolves.toHaveLength(1);
});
it('retains all regular objects when any persisted storage reference uses an unsupported legacy key', async () => {
const retained = await store.put({ bytes: new TextEncoder().encode('synthetic retained object'), maximumBytes: 1024 });
await repository.saveImportArtifact({
version: 1,
id: 'legacy-storage-reference',
projectId: project.id,
kind: 'derived-document',
relativePath: 'legacy/synthetic.bin',
sha256: 'a'.repeat(64),
sizeBytes: 1,
sensitivity: 'confidential',
parseStatus: 'failed',
storage: { kind: 'backend-controlled', storageKey: 'unsupported-legacy-key' },
createdAt: submittedAt
});
const result = await new GpoArtifactReconciler(repository, store, {
gracePeriodMs: 60_000,
maximumRemovals: 10,
now: () => reconciliationTime
}).run();
expect(result).toMatchObject({ invalidStorageReferences: 1, removedUnreferencedObjects: 0, retainedUnreferencedObjects: 1 });
await expect(readdir(join(directory, 'objects'))).resolves.toEqual([`${retained.storageKey}.bin`]);
});
it('immediately removes a newly written object when the atomic database commit fails', async () => {
const failingRepository = new AtomicCommitFailsRepository();
await failingRepository.saveProject(project);
const service = new GpoBackupSubmissionService(failingRepository, store, directGpoInventoryExecutor, 1024 * 1024);
await expect(service.submit({
projectId: project.id,
fileName: 'interrupted.zip',
bytes: syntheticGpoBackupZip(),
receivedAt: submittedAt
})).rejects.toThrow('synthetic atomic interruption');
await expect(readdir(join(directory, 'objects'))).resolves.toEqual([]);
await expect(failingRepository.listImportArtifacts(project.id)).resolves.toEqual([]);
await expect(failingRepository.listIngestionJobs(project.id)).resolves.toEqual([]);
});
});