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 { GpoBackupJobProcessor } from '../../server/gpo/gpo-backup-job-processor'; import { GpoBackupManagementService } from '../../server/gpo/gpo-backup-management-service'; import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service'; import { GpoPolicySnapshotService } from '../../server/gpo/gpo-policy-snapshot-service'; import { directGpoBackupWorkerExecutor, directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor'; import type { ArtifactDeletionRequest } from '../../src/persistence/assessor-repository'; import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository'; import { syntheticGpoBackupZip } from './gpo-synthetic-fixture'; const initial = Date.parse('2026-07-21T16:00:00.000Z'); const project = { id: 'gpo-management-project', name: 'Synthetic GPO management', createdAt: new Date(initial).toISOString(), updatedAt: new Date(initial).toISOString(), attributes: {} }; describe('GPO backup management', () => { let directory: string; let repository: LocalAssessorRepository; let store: FileSystemBackendArtifactStore; let artifactId: string; let jobId: string; let currentTime: number; beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'assessor-gpo-management-')); repository = new LocalAssessorRepository(); store = new FileSystemBackendArtifactStore(directory); currentTime = initial; await repository.saveProject(project); const submitted = await new GpoBackupSubmissionService(repository, store, directGpoInventoryExecutor, 1024 * 1024).submit({ projectId: project.id, fileName: 'failed.zip', bytes: syntheticGpoBackupZip(), receivedAt: new Date(currentTime).toISOString() }); artifactId = submitted.artifactId; jobId = submitted.jobId; for (let attempt = 0; attempt < 3; attempt += 1) { const timestamp = new Date(currentTime).toISOString(); await repository.claimNextIngestionJob({ workerId: 'synthetic-worker', now: timestamp, leaseDurationMs: 60_000, kinds: ['analyze-gpo-backup'] }); currentTime += 1_000; const failedAt = new Date(currentTime).toISOString(); await repository.failIngestionJob(jobId, 'synthetic-worker', failedAt, 'synthetic-memory-limit', failedAt); } }); afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); it('requeues only a dead-letter GPO job and resets its attempt state', async () => { const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000)); await expect(management.retry(project.id, artifactId)).resolves.toMatchObject({ status: 'queued', attempts: 0, jobId }); const jobs = await repository.listIngestionJobs(project.id); expect(jobs).toMatchObject([{ id: jobId, status: 'queued', attempts: 0 }]); expect(jobs[0]).not.toHaveProperty('lastErrorCode'); await expect(management.retry(project.id, artifactId)).rejects.toThrow('Nur ein endgültig fehlgeschlagener'); }); it('deletes database history and the retained package through the persistent outbox', async () => { const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000)); await expect(management.delete(project.id, artifactId)).resolves.toMatchObject({ artifactId, deleted: true, storageRemoval: 'complete' }); await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([]); await expect(repository.listIngestionJobs(project.id)).resolves.toEqual([]); await expect(repository.listArtifactDeletionRequests(new Date(currentTime + 2_000).toISOString(), 10)).resolves.toEqual([]); await expect(readdir(join(directory, 'objects'))).resolves.toEqual([]); }); it('deletes a completed GPO import and invalidates only its derived policy snapshot', async () => { const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000)); await management.retry(project.id, artifactId); const processor = new GpoBackupJobProcessor(repository, store, directGpoBackupWorkerExecutor, { maximumArtifactBytes: 1024 * 1024, now: () => new Date(currentTime + 2_000) }); await expect(processor.processNext('synthetic-success-worker')).resolves.toMatchObject({ status: 'succeeded' }); const analyses = await repository.listGpoPolicyAnalyses(project.id); expect(analyses).toHaveLength(1); const snapshots = new GpoPolicySnapshotService(repository, { now: () => new Date(currentTime + 3_000) }); const profile = await snapshots.createEnvironmentProfile(project.id, { name: 'Synthetic environment', contexts: [{ id: 'synthetic-os', kind: 'operating-system', productFamily: 'Windows Server', productVersion: '2022', role: 'Member Server', scope: 'computer' }] }); await snapshots.aggregate(project.id, { environmentProfileId: profile.id, sourceAnalysisRunIds: [analyses[0].run.id] }); const firstProcessingRun = (await repository.listProcessingRuns(artifactId))[0]; await management.reprocess(project.id, artifactId, firstProcessingRun.id); await expect(processor.processNext('synthetic-reprocessing-worker')).resolves.toMatchObject({ status: 'succeeded' }); await expect(repository.listProcessingRuns(artifactId)).resolves.toHaveLength(2); await expect(management.delete(project.id, artifactId)).resolves.toMatchObject({ deleted: true, storageRemoval: 'complete' }); await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([]); await expect(repository.listGpoPolicyAnalyses(project.id)).resolves.toEqual([]); await expect(repository.listPolicySnapshotAggregations(project.id)).resolves.toEqual([]); await expect(repository.listEnvironmentProfiles(project.id)).resolves.toEqual([profile]); }); it('refuses deletion while the GPO import is queued', async () => { const management = new GpoBackupManagementService(repository, store, () => new Date(currentTime + 1_000)); await management.retry(project.id, artifactId); await expect(management.delete(project.id, artifactId)).rejects.toThrow('Ein laufender GPO-Import kann nicht gelöscht werden.'); await expect(repository.listImportArtifacts(project.id)).resolves.toHaveLength(1); }); it('finishes a deletion left between the database commit and storage removal after restart', async () => { const artifact = (await repository.listImportArtifacts(project.id))[0]; if (artifact.storage.kind !== 'backend-controlled') throw new Error('Synthetic artifact storage missing.'); const timestamp = new Date(currentTime + 1_000).toISOString(); const request: ArtifactDeletionRequest = { version: 1, id: `artifact-delete-${createHash('sha256').update(artifact.storage.storageKey).digest('hex').slice(0, 32)}`, projectId: project.id, artifactId, storageKey: artifact.storage.storageKey, attempts: 0, nextAttemptAt: timestamp, createdAt: timestamp }; await repository.deleteGpoArtifact(project.id, artifactId, request); await expect(readdir(join(directory, 'objects'))).resolves.toHaveLength(1); const result = await new GpoArtifactReconciler(repository, store, { gracePeriodMs: 60_000, maximumRemovals: 10, now: () => new Date(currentTime + 2_000) }).run(); expect(result).toMatchObject({ completedDeletionRequests: 1, pendingDeletionRequests: 0 }); await expect(readdir(join(directory, 'objects'))).resolves.toEqual([]); }); });