Assessor_2.0/tests/server/gpo-backup-submission-servi...

75 lines
4.4 KiB
TypeScript

import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { assertArtifactStoreOutsidePublicRoot, FileSystemBackendArtifactStore } from '../../server/artifacts/backend-artifact-store';
import { GpoBackupSubmissionService } from '../../server/gpo/gpo-backup-submission-service';
import { directGpoInventoryExecutor } from '../../server/gpo/gpo-inventory-executor';
import { LocalAssessorRepository } from '../../src/persistence/local-assessor-repository';
import { RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
import { syntheticGpoBackupZip } from './gpo-synthetic-fixture';
const receivedAt = '2026-07-21T08:00:00.000Z';
const project = { id: 'gpo-project', name: 'Synthetic GPO project', createdAt: receivedAt, updatedAt: receivedAt, attributes: {} };
describe('project-bound GPO backup submission', () => {
let directory: string;
let repository: LocalAssessorRepository;
let service: GpoBackupSubmissionService;
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'assessor-gpo-submit-'));
repository = new LocalAssessorRepository();
await repository.saveProject(project);
service = new GpoBackupSubmissionService(repository, new FileSystemBackendArtifactStore(directory), directGpoInventoryExecutor, 1024 * 1024);
});
afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('preflights, stores and queues a confidential-by-default GPO original within its project', async () => {
const bytes = syntheticGpoBackupZip();
const result = await service.submit({ projectId: project.id, fileName: 'backup.zip', bytes, receivedAt });
expect(result).toMatchObject({ version: 1, status: 'queued', deduplicated: false, sizeBytes: bytes.byteLength });
await expect(repository.listImportArtifacts(project.id)).resolves.toMatchObject([{
id: result.artifactId, projectId: project.id, sensitivity: 'secret', parseStatus: 'pending',
storage: { kind: 'backend-controlled' }
}]);
await expect(repository.listIngestionJobs(project.id)).resolves.toMatchObject([{
id: result.jobId, artifactId: result.artifactId, kind: 'analyze-gpo-backup', status: 'queued'
}]);
expect(await readdir(join(directory, 'objects'))).toHaveLength(1);
});
it('reuses the project artifact and idempotent job for the same bytes', async () => {
const bytes = syntheticGpoBackupZip();
const first = await service.submit({ projectId: project.id, fileName: 'first.zip', bytes, receivedAt });
const second = await service.submit({ projectId: project.id, fileName: 'first.zip', bytes, receivedAt: '2026-07-21T08:01:00.000Z' });
expect(second).toMatchObject({ artifactId: first.artifactId, jobId: first.jobId, deduplicated: true });
await expect(repository.listImportArtifacts(project.id)).resolves.toHaveLength(1);
await expect(repository.listIngestionJobs(project.id)).resolves.toHaveLength(1);
expect(await readdir(join(directory, 'objects'))).toHaveLength(1);
});
it('rejects non-GPO bytes during worker preflight before creating repository state', async () => {
await expect(service.submit({
projectId: project.id, fileName: 'not-a-backup.zip', bytes: new Uint8Array([1, 2, 3, 4]), receivedAt
})).rejects.toThrow();
await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([]);
await expect(repository.listIngestionJobs(project.id)).resolves.toEqual([]);
});
it('rejects unknown projects and unsafe names before retaining bytes', async () => {
const bytes = syntheticGpoBackupZip();
await expect(service.submit({ projectId: 'missing', fileName: 'backup.zip', bytes, receivedAt })).rejects.toBeInstanceOf(RepositoryNotFoundError);
await expect(service.submit({ projectId: project.id, fileName: '../backup.zip', bytes, receivedAt })).rejects.toBeInstanceOf(RepositoryValidationError);
await expect(repository.listImportArtifacts(project.id)).resolves.toEqual([]);
});
it('requires the artifact root to be outside the published webroot', () => {
const publicRoot = resolve(directory, 'dist');
expect(() => assertArtifactStoreOutsidePublicRoot(resolve(publicRoot, 'uploads'), publicRoot)).toThrow();
expect(() => assertArtifactStoreOutsidePublicRoot(resolve(directory, 'artifacts'), publicRoot)).not.toThrow();
});
});