130 lines
6.0 KiB
TypeScript
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([]);
|
|
});
|
|
});
|