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 { 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([]); }); });