133 lines
7.8 KiB
TypeScript
133 lines
7.8 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 { 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([]);
|
|
});
|
|
});
|