PD-103 — Agent Developer — Module capture-tests-integration (M14)
1. Identite agent
| Attribut | Valeur |
| Agent | agent-developer |
| Module | capture-tests-integration (M14) |
| Story | PD-103 |
| Projet | ProbatioVault-backend |
2. Perimetre
Ce module couvre les tests unitaires et d'integration backend pour le module capture (M9, M10, M11, M12, M13).
Fichiers produits :
| Fichier | Type | Couverture |
src/modules/capture/__tests__/capture-ingest.service.spec.ts | Unit | M9 — ingestion, skew, audit |
src/modules/capture/__tests__/capture-idempotence.service.spec.ts | Unit | M10 — fingerprint, advisory lock, 200/409 |
src/modules/capture/__tests__/kek-keyring.service.spec.ts | Unit | M11 — unwrap DEK, keyring, 422/503 |
src/modules/capture/__tests__/capture-reconciliation.service.spec.ts | Unit | M12 — SEAL_DELAYED trigger/clearing, GC orphelins |
src/modules/capture/__tests__/capture.controller.spec.ts | Unit | Controller routing, guards |
src/modules/capture/__tests__/create-capture.dto.spec.ts | Unit | DTO validation §5.1 |
src/modules/capture/__tests__/fixtures/capture-fixtures.ts | Fixture | Donnees de reference partagees |
3. Fixtures partagees
// src/modules/capture/__tests__/fixtures/capture-fixtures.ts
import { randomUUID } from 'node:crypto';
import { CreateCaptureDto } from '../../dto/create-capture.dto';
import { CaptureEvent, CaptureEventState, SignatureStatus } from '../../entities/capture-event.entity';
// ---------------------------------------------------------------------------
// Constantes de reference
// ---------------------------------------------------------------------------
export const VALID_CAPTURE_ID = '550e8400-e29b-41d4-a716-446655440000';
export const VALID_DEVICE_ID = '660e8400-e29b-41d4-a716-446655440001';
export const VALID_USER_ID = '770e8400-e29b-41d4-a716-446655440002';
export const VALID_HASH_SHA3 = 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3';
export const VALID_NONCE_B64 = 'AAAAAAAAAAAAAAAA';
export const VALID_TAG_B64 = 'AAAAAAAAAAAAAAAAAAAAAA==';
export const VALID_DEK_WRAPPED_B64 = 'dGVzdGRla3dyYXBwZWRiYXNlNjQ=';
export const VALID_KEK_ID = 'kek-v1';
export const VALID_UPLOAD_KEY = 'captures/2026/04/550e8400.enc';
// ---------------------------------------------------------------------------
// Factory — DTO valide
// ---------------------------------------------------------------------------
export function buildValidDto(overrides: Partial<CreateCaptureDto> = {}): CreateCaptureDto {
const dto = new CreateCaptureDto();
dto.capture_id = VALID_CAPTURE_ID;
dto.hash_sha3_256 = VALID_HASH_SHA3;
dto.timestamp_device = new Date().toISOString().replace(/\+.*$/, 'Z');
dto.device_id = VALID_DEVICE_ID;
dto.app_version = '1.2.3';
dto.mime_type = 'image/png';
dto.size_bytes = 1_048_576;
dto.aes_gcm_nonce_b64 = VALID_NONCE_B64;
dto.aes_gcm_tag_b64 = VALID_TAG_B64;
dto.dek_wrapped_b64 = VALID_DEK_WRAPPED_B64;
dto.kek_id = VALID_KEK_ID;
dto.upload_object_key = VALID_UPLOAD_KEY;
Object.assign(dto, overrides);
return dto;
}
// ---------------------------------------------------------------------------
// Factory — CaptureEvent entity
// ---------------------------------------------------------------------------
export function buildCaptureEvent(
overrides: Partial<CaptureEvent> = {},
): CaptureEvent {
const event = new CaptureEvent();
event.id = randomUUID();
event.userId = VALID_USER_ID;
event.captureId = VALID_CAPTURE_ID;
event.hashSha3256 = VALID_HASH_SHA3;
event.timestampDevice = new Date();
event.deviceId = VALID_DEVICE_ID;
event.appVersion = '1.2.3';
event.mimeType = 'image/png';
event.sizeBytes = 1_048_576;
event.aesGcmNonceB64 = VALID_NONCE_B64;
event.aesGcmTagB64 = VALID_TAG_B64;
event.dekWrappedB64 = VALID_DEK_WRAPPED_B64;
event.kekId = VALID_KEK_ID;
event.uploadObjectKey = VALID_UPLOAD_KEY;
event.payloadCanonicalSha256 = 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789';
event.skewMs = 50;
event.state = CaptureEventState.PENDING_SEAL;
event.signatureStatus = SignatureStatus.PENDING_SIGNATURE;
event.sealDelayedConformantCycles = 0;
event.reconciliationAttempts = 0;
event.createdAt = new Date();
event.updatedAt = new Date();
Object.assign(event, overrides);
return event;
}
// ---------------------------------------------------------------------------
// Factory — DTO avec timestamp decale (skew testing)
// ---------------------------------------------------------------------------
export function buildDtoWithSkew(skewMs: number): CreateCaptureDto {
const deviceTime = new Date(Date.now() + skewMs);
return buildValidDto({
timestamp_device: deviceTime.toISOString(),
});
}
4. Tests unitaires — capture-ingest.service.spec.ts
// src/modules/capture/__tests__/capture-ingest.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { BadRequestException, NotFoundException, ServiceUnavailableException, UnprocessableEntityException } from '@nestjs/common';
import { CaptureIngestService } from '../services/capture-ingest.service';
import { KekKeyringService, UnwrapDekFailedError, KeyServiceUnavailableError } from '../services/kek-keyring.service';
import { CaptureIdempotenceService, IdempotenceCheckResult } from '../services/capture-idempotence.service';
import { CaptureEvent, CaptureEventState, SignatureStatus } from '../entities/capture-event.entity';
import { CaptureAuditLog } from '../entities/capture-audit-log.entity';
import {
buildValidDto,
buildDtoWithSkew,
buildCaptureEvent,
VALID_CAPTURE_ID,
VALID_USER_ID,
} from './fixtures/capture-fixtures';
describe('CaptureIngestService', () => {
let service: CaptureIngestService;
let kekKeyringService: jest.Mocked<KekKeyringService>;
let idempotenceService: jest.Mocked<CaptureIdempotenceService>;
let captureEventRepo: jest.Mocked<Repository<CaptureEvent>>;
let auditLogRepo: jest.Mocked<Repository<CaptureAuditLog>>;
let mockManager: jest.Mocked<EntityManager>;
let dataSource: jest.Mocked<DataSource>;
beforeEach(async () => {
mockManager = {
create: jest.fn().mockImplementation((_entity, data) => data),
save: jest.fn().mockImplementation((_entity, data) => ({
...data,
id: data.id ?? 'generated-id',
createdAt: data.createdAt ?? new Date(),
})),
findOne: jest.fn(),
query: jest.fn(),
} as unknown as jest.Mocked<EntityManager>;
const module: TestingModule = await Test.createTestingModule({
providers: [
CaptureIngestService,
{
provide: getRepositoryToken(CaptureEvent),
useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() },
},
{
provide: getRepositoryToken(CaptureAuditLog),
useValue: {
save: jest.fn().mockResolvedValue({}),
create: jest.fn().mockImplementation((data) => data),
},
},
{
provide: KekKeyringService,
useValue: {
unwrapDek: jest.fn().mockReturnValue({
dekClear: Buffer.alloc(32, 0xaa),
kekIdUsed: 'kek-v1',
}),
},
},
{
provide: CaptureIdempotenceService,
useValue: {
acquireLockAndCheckIdempotence: jest.fn(),
},
},
{
provide: DataSource,
useValue: {
transaction: jest.fn().mockImplementation((cb) => cb(mockManager)),
},
},
],
}).compile();
service = module.get(CaptureIngestService);
kekKeyringService = module.get(KekKeyringService) as jest.Mocked<KekKeyringService>;
idempotenceService = module.get(CaptureIdempotenceService) as jest.Mocked<CaptureIdempotenceService>;
captureEventRepo = module.get(getRepositoryToken(CaptureEvent)) as jest.Mocked<Repository<CaptureEvent>>;
auditLogRepo = module.get(getRepositoryToken(CaptureAuditLog)) as jest.Mocked<Repository<CaptureAuditLog>>;
dataSource = module.get(DataSource) as jest.Mocked<DataSource>;
});
// =========================================================================
// TC-NOM-01 (partiel backend) — Flux nominal ingestion
// =========================================================================
describe('Flux nominal — TC-NOM-01, TC-NOM-07', () => {
it('devrait retourner 202 avec PENDING_SEAL pour une nouvelle capture', async () => {
// GIVEN — DTO valide, DEK unwrap OK, capture_id inconnu
const dto = buildValidDto();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
const result = await service.ingest(dto, VALID_USER_ID);
// THEN
expect(result.capture_id).toBe(VALID_CAPTURE_ID);
expect(result.status).toBe(CaptureEventState.PENDING_SEAL);
expect(result.signature_status).toBe(SignatureStatus.PENDING_SIGNATURE);
expect(result.created_at).toBeDefined();
});
it('devrait normaliser capture_id en lowercase — INV-103-31', async () => {
// GIVEN — capture_id en majuscules
const dto = buildValidDto({ capture_id: '550E8400-E29B-41D4-A716-446655440000' });
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
const result = await service.ingest(dto, VALID_USER_ID);
// THEN
expect(result.capture_id).toBe('550e8400-e29b-41d4-a716-446655440000');
});
it('devrait zeroizer le DEK apres validation — INV-103-09', async () => {
// GIVEN
const dekBuffer = Buffer.alloc(32, 0xaa);
kekKeyringService.unwrapDek.mockReturnValue({
dekClear: dekBuffer,
kekIdUsed: 'kek-v1',
});
const dto = buildValidDto();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
await service.ingest(dto, VALID_USER_ID);
// THEN — DEK buffer ecrase en 0x00
expect(dekBuffer.every((byte) => byte === 0)).toBe(true);
});
it('devrait ecrire un audit log CAPTURE_INGEST', async () => {
// GIVEN
const dto = buildValidDto();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
await service.ingest(dto, VALID_USER_ID);
// THEN
expect(mockManager.save).toHaveBeenCalledWith(
CaptureAuditLog,
expect.objectContaining({ eventType: 'CAPTURE_INGEST' }),
);
});
it('devrait persister capture_events avec signature_status=PENDING_SIGNATURE — INV-103-08', async () => {
// GIVEN
const dto = buildValidDto();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
await service.ingest(dto, VALID_USER_ID);
// THEN
expect(mockManager.save).toHaveBeenCalledWith(
CaptureEvent,
expect.objectContaining({
signatureStatus: SignatureStatus.PENDING_SIGNATURE,
state: CaptureEventState.PENDING_SEAL,
}),
);
});
});
// =========================================================================
// TC-ERR-12 — Skew timestamp > +/-300s
// =========================================================================
describe('Skew timestamp — TC-ERR-12, CA-103-17', () => {
it('devrait rejeter avec 400 TIMESTAMP_SKEW_EXCEEDED si skew > +300s', async () => {
// GIVEN — timestamp device 6 min dans le futur
const dto = buildDtoWithSkew(360_000);
// WHEN / THEN
await expect(service.ingest(dto, VALID_USER_ID)).rejects.toThrow(BadRequestException);
await expect(service.ingest(dto, VALID_USER_ID)).rejects.toMatchObject({
response: expect.objectContaining({ error: 'TIMESTAMP_SKEW_EXCEEDED' }),
});
});
it('devrait rejeter avec 400 si skew < -300s', async () => {
// GIVEN — timestamp device 6 min dans le passe
const dto = buildDtoWithSkew(-360_000);
// WHEN / THEN
await expect(service.ingest(dto, VALID_USER_ID)).rejects.toThrow(BadRequestException);
});
it('devrait accepter un skew de +299s (limite)', async () => {
// GIVEN — timestamp device 299s dans le futur (sous le seuil)
const dto = buildDtoWithSkew(299_000);
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'CREATE',
payloadCanonicalSha256: 'abc123',
});
// WHEN
const result = await service.ingest(dto, VALID_USER_ID);
// THEN — Accepte
expect(result.status).toBe(CaptureEventState.PENDING_SEAL);
});
it('devrait ecrire un audit log SKEW_EXCEEDED sur rejet', async () => {
// GIVEN
const dto = buildDtoWithSkew(360_000);
// WHEN
try { await service.ingest(dto, VALID_USER_ID); } catch { /* expected */ }
// THEN
expect(auditLogRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'SKEW_EXCEEDED' }),
);
});
});
// =========================================================================
// TC-ERR-16, TC-ERR-17 — Echec unwrap DEK
// =========================================================================
describe('Echec unwrap DEK — TC-ERR-16, TC-ERR-17, CA-103-28', () => {
it('devrait retourner 422 UNWRAP_DEK_FAILED si DEK incompatible — ER-103-16', async () => {
// GIVEN
kekKeyringService.unwrapDek.mockImplementation(() => {
throw new UnwrapDekFailedError('No compatible key');
});
const dto = buildValidDto();
// WHEN / THEN
await expect(service.ingest(dto, VALID_USER_ID)).rejects.toThrow(UnprocessableEntityException);
});
it('devrait retourner 503 KEY_SERVICE_UNAVAILABLE si HSM indisponible — ER-103-17', async () => {
// GIVEN
kekKeyringService.unwrapDek.mockImplementation(() => {
throw new KeyServiceUnavailableError('HSM down');
});
const dto = buildValidDto();
// WHEN / THEN
await expect(service.ingest(dto, VALID_USER_ID)).rejects.toThrow(ServiceUnavailableException);
});
it('ne devrait PAS creer capture_events si unwrap echoue — FORBIDDEN', async () => {
// GIVEN
kekKeyringService.unwrapDek.mockImplementation(() => {
throw new UnwrapDekFailedError('No compatible key');
});
const dto = buildValidDto();
// WHEN
try { await service.ingest(dto, VALID_USER_ID); } catch { /* expected */ }
// THEN — transaction jamais appelee
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('devrait ecrire un audit log UNWRAP_FAILED', async () => {
// GIVEN
kekKeyringService.unwrapDek.mockImplementation(() => {
throw new UnwrapDekFailedError('No compatible key');
});
const dto = buildValidDto();
// WHEN
try { await service.ingest(dto, VALID_USER_ID); } catch { /* expected */ }
// THEN
expect(auditLogRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'UNWRAP_FAILED' }),
);
});
});
// =========================================================================
// TC-NOM-15, TC-INV-06, TC-ERR-13 — Idempotence
// =========================================================================
describe('Idempotence — TC-NOM-15, TC-INV-06, TC-ERR-13', () => {
it('devrait retourner 200 idempotent si meme fingerprint — TC-NOM-15', async () => {
// GIVEN — capture_id deja connu, fingerprint identique
const existing = buildCaptureEvent();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'IDEMPOTENT',
existingCaptureEventId: existing.id,
existingState: CaptureEventState.PENDING_SEAL,
});
mockManager.findOne.mockResolvedValue(existing);
const dto = buildValidDto();
// WHEN
const result = await service.ingest(dto, VALID_USER_ID);
// THEN
expect(result.capture_id).toBe(VALID_CAPTURE_ID);
expect(result.status).toBe(CaptureEventState.PENDING_SEAL);
});
it('devrait ecrire audit IDEMPOTENT_REPLAY pour replay identique', async () => {
// GIVEN
const existing = buildCaptureEvent();
idempotenceService.acquireLockAndCheckIdempotence.mockResolvedValue({
action: 'IDEMPOTENT',
existingCaptureEventId: existing.id,
existingState: CaptureEventState.PENDING_SEAL,
});
mockManager.findOne.mockResolvedValue(existing);
const dto = buildValidDto();
// WHEN
await service.ingest(dto, VALID_USER_ID);
// THEN
expect(mockManager.save).toHaveBeenCalledWith(
CaptureAuditLog,
expect.objectContaining({ eventType: 'IDEMPOTENT_REPLAY' }),
);
});
});
// =========================================================================
// getStatus — Anti-enumeration (REX PD-85)
// =========================================================================
describe('getStatus — anti-enumeration', () => {
it('devrait retourner les donnees si capture existe et appartient a userId', async () => {
// GIVEN
const event = buildCaptureEvent();
captureEventRepo.findOne.mockResolvedValue(event);
// WHEN
const result = await service.getStatus(VALID_CAPTURE_ID, VALID_USER_ID);
// THEN
expect(result.capture_id).toBe(VALID_CAPTURE_ID);
});
it('devrait retourner 404 "Capture not found" si inexistant ou autre utilisateur', async () => {
// GIVEN — aucun resultat (message uniforme anti-enumeration)
captureEventRepo.findOne.mockResolvedValue(null);
// WHEN / THEN
await expect(service.getStatus(VALID_CAPTURE_ID, 'autre-user'))
.rejects.toThrow(NotFoundException);
});
});
});
5. Tests unitaires — capture-idempotence.service.spec.ts
// src/modules/capture/__tests__/capture-idempotence.service.spec.ts
import { ConflictException } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { CaptureIdempotenceService, CanonicalPayloadFields } from '../services/capture-idempotence.service';
import { VALID_CAPTURE_ID, VALID_HASH_SHA3, VALID_NONCE_B64, VALID_TAG_B64, VALID_DEK_WRAPPED_B64, VALID_KEK_ID, VALID_UPLOAD_KEY, VALID_USER_ID } from './fixtures/capture-fixtures';
describe('CaptureIdempotenceService', () => {
let service: CaptureIdempotenceService;
let mockManager: jest.Mocked<EntityManager>;
const validFields: CanonicalPayloadFields = {
captureId: VALID_CAPTURE_ID,
contentHash: VALID_HASH_SHA3,
aesGcmNonceB64: VALID_NONCE_B64,
aesGcmTagB64: VALID_TAG_B64,
dekWrappedB64: VALID_DEK_WRAPPED_B64,
kekId: VALID_KEK_ID,
mimeType: 'image/png',
sizeBytes: 1_048_576,
uploadObjectKey: VALID_UPLOAD_KEY,
};
beforeEach(() => {
service = new CaptureIdempotenceService();
mockManager = {
query: jest.fn(),
} as unknown as jest.Mocked<EntityManager>;
});
// =========================================================================
// TC-INV-06 — Fingerprint canonique
// =========================================================================
describe('Fingerprint canonique — TC-INV-06, INV-103-37', () => {
it('devrait produire un fingerprint deterministe (cles triees)', () => {
// GIVEN / WHEN
const fp1 = service.computeCanonicalFingerprint(validFields);
const fp2 = service.computeCanonicalFingerprint(validFields);
// THEN
expect(fp1).toBe(fp2);
expect(fp1).toMatch(/^[a-f0-9]{64}$/);
});
it('devrait normaliser capture_id en lowercase — TC-INV-06 variante D', () => {
// GIVEN — meme UUID, casse differente
const fpLower = service.computeCanonicalFingerprint({
...validFields,
captureId: '550e8400-e29b-41d4-a716-446655440000',
});
const fpUpper = service.computeCanonicalFingerprint({
...validFields,
captureId: '550E8400-E29B-41D4-A716-446655440000',
});
// THEN
expect(fpLower).toBe(fpUpper);
});
it('devrait produire un fingerprint different si un champ canonique change — TC-INV-06 variante B', () => {
// GIVEN
const fpOriginal = service.computeCanonicalFingerprint(validFields);
const fpModified = service.computeCanonicalFingerprint({
...validFields,
sizeBytes: 999,
});
// THEN
expect(fpOriginal).not.toBe(fpModified);
});
it('devrait exclure les champs OCR du fingerprint — INV-103-37', () => {
// GIVEN — OCR absent dans CanonicalPayloadFields par design
// Le fait que l'interface CanonicalPayloadFields n'inclut pas ocr_*
// garantit l'exclusion structurelle.
const json = service.buildCanonicalJson(validFields);
const parsed = JSON.parse(json);
// THEN
expect(parsed).not.toHaveProperty('ocr_text');
expect(parsed).not.toHaveProperty('ocr_confidence');
expect(parsed).not.toHaveProperty('ocr_language');
});
it('devrait produire un JSON avec cles strictement triees alphabetiquement', () => {
// GIVEN / WHEN
const json = service.buildCanonicalJson(validFields);
const keys = Object.keys(JSON.parse(json));
// THEN
const sorted = [...keys].sort();
expect(keys).toEqual(sorted);
});
});
// =========================================================================
// Advisory lock + idempotence check
// =========================================================================
describe('acquireLockAndCheckIdempotence', () => {
it('devrait acquérir advisory lock puis retourner CREATE si capture_id inconnu', async () => {
// GIVEN — pas de record en base
mockManager.query
.mockResolvedValueOnce(undefined) // pg_advisory_xact_lock
.mockResolvedValueOnce([]); // SELECT capture_events
// WHEN
const result = await service.acquireLockAndCheckIdempotence(
mockManager,
VALID_USER_ID,
validFields,
);
// THEN
expect(result.action).toBe('CREATE');
expect(mockManager.query).toHaveBeenCalledWith(
expect.stringContaining('pg_advisory_xact_lock'),
[VALID_USER_ID, VALID_CAPTURE_ID],
);
});
it('devrait retourner IDEMPOTENT si meme fingerprint — TC-NOM-15', async () => {
// GIVEN — record existant avec meme fingerprint
const fingerprint = service.computeCanonicalFingerprint(validFields);
mockManager.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([{
id: 'existing-id',
state: 'PENDING_SEAL',
payload_canonical_sha256: fingerprint,
}]);
// WHEN
const result = await service.acquireLockAndCheckIdempotence(
mockManager,
VALID_USER_ID,
validFields,
);
// THEN
expect(result.action).toBe('IDEMPOTENT');
});
it('devrait lancer ConflictException si fingerprint divergent — TC-ERR-13', async () => {
// GIVEN — record existant avec fingerprint different
mockManager.query
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce([{
id: 'existing-id',
state: 'PENDING_SEAL',
payload_canonical_sha256: 'different_fingerprint_hash_value_0123456789abcdef0123456789abcdef',
}]);
// WHEN / THEN
await expect(
service.acquireLockAndCheckIdempotence(mockManager, VALID_USER_ID, validFields),
).rejects.toThrow(ConflictException);
});
});
});
6. Tests unitaires — kek-keyring.service.spec.ts
// src/modules/capture/__tests__/kek-keyring.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { generateKeyPairSync, publicEncrypt, constants as cryptoConstants } from 'node:crypto';
import { KekKeyringService, UnwrapDekFailedError, KeyServiceUnavailableError } from '../services/kek-keyring.service';
describe('KekKeyringService', () => {
let service: KekKeyringService;
let configService: jest.Mocked<ConfigService>;
// Generer une paire RSA 2048 pour les tests
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const { publicKey: oldPublicKey, privateKey: oldPrivateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
/** Wrappe un DEK avec la cle publique RSA-OAEP-SHA256 */
function wrapDek(dek: Buffer, pubKey: string): string {
const encrypted = publicEncrypt(
{ key: pubKey, padding: cryptoConstants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
dek,
);
return encrypted.toString('base64');
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
KekKeyringService,
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string, defaultVal?: unknown) => {
if (key === 'CAPTURE_KEK_KEYRING_DEPTH') return 3;
return defaultVal;
}),
},
},
],
}).compile();
service = module.get(KekKeyringService);
configService = module.get(ConfigService) as jest.Mocked<ConfigService>;
// Mock du chargement Vault — injecter les cles directement
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).keyring = new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).activeKekId = 'kek-v2';
const { createPrivateKey } = await import('node:crypto');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).keyring.set('kek-v2', {
kekId: 'kek-v2',
privateKey: createPrivateKey({ key: privateKey, format: 'pem', type: 'pkcs8' }),
isCurrent: true,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).keyring.set('kek-v1', {
kekId: 'kek-v1',
privateKey: createPrivateKey({ key: oldPrivateKey, format: 'pem', type: 'pkcs8' }),
isCurrent: false,
});
});
// =========================================================================
// TC-INV-03 — Unwrap nominal
// =========================================================================
describe('unwrapDek — nominal', () => {
it('devrait unwrapper avec la cle primaire correspondant au kek_id', () => {
// GIVEN — DEK 32 bytes wrappe avec kek-v2
const dek = Buffer.alloc(32, 0xab);
const wrapped = wrapDek(dek, publicKey);
// WHEN
const result = service.unwrapDek(wrapped, 'kek-v2');
// THEN
expect(result.dekClear).toHaveLength(32);
expect(result.dekClear.equals(dek)).toBe(true);
expect(result.kekIdUsed).toBe('kek-v2');
});
});
// =========================================================================
// TC-NOM-16, TC-INV-13 — Rotation KEK (keyring historique)
// =========================================================================
describe('unwrapDek — rotation KEK — TC-NOM-16, TC-INV-13', () => {
it('devrait unwrapper avec ancienne KEK si kek_id historique — INV-103-34', () => {
// GIVEN — DEK wrappe avec ancienne cle kek-v1
const dek = Buffer.alloc(32, 0xcd);
const wrapped = wrapDek(dek, oldPublicKey);
// WHEN
const result = service.unwrapDek(wrapped, 'kek-v1');
// THEN
expect(result.dekClear.equals(dek)).toBe(true);
expect(result.kekIdUsed).toBe('kek-v1');
});
it('devrait fallback vers keyring si kek_id primaire echoue', () => {
// GIVEN — DEK wrappe avec kek-v1, mais kek_id declare = kek-v2 (mismatch)
const dek = Buffer.alloc(32, 0xef);
const wrapped = wrapDek(dek, oldPublicKey);
// WHEN
const result = service.unwrapDek(wrapped, 'kek-v2');
// THEN — fallback vers kek-v1
expect(result.dekClear.equals(dek)).toBe(true);
expect(result.kekIdUsed).toBe('kek-v1');
});
});
// =========================================================================
// TC-ERR-16 — Unwrap echoue (422)
// =========================================================================
describe('unwrapDek — echec — TC-ERR-16', () => {
it('devrait lancer UnwrapDekFailedError si aucune cle ne peut unwrapper', () => {
// GIVEN — donnees corrompues
const corrupted = Buffer.alloc(256, 0xff).toString('base64');
// WHEN / THEN
expect(() => service.unwrapDek(corrupted, 'kek-v2')).toThrow(UnwrapDekFailedError);
});
it('devrait lancer UnwrapDekFailedError si kek_id inconnu et aucune cle ne fonctionne', () => {
// GIVEN — kek_id inconnu, donnees corrompues
const corrupted = Buffer.alloc(256, 0xff).toString('base64');
// WHEN / THEN
expect(() => service.unwrapDek(corrupted, 'kek-unknown')).toThrow(UnwrapDekFailedError);
});
});
// =========================================================================
// TC-ERR-17 — Service indisponible (503)
// =========================================================================
describe('unwrapDek — service indisponible — TC-ERR-17', () => {
it('devrait lancer KeyServiceUnavailableError si keyring vide', () => {
// GIVEN — keyring vide
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(service as any).keyring = new Map();
// WHEN / THEN
expect(() => service.unwrapDek('dGVzdA==', 'kek-v1')).toThrow(KeyServiceUnavailableError);
});
});
// =========================================================================
// Helpers
// =========================================================================
describe('helpers', () => {
it('hasKekId devrait verifier la presence dans le keyring', () => {
expect(service.hasKekId('kek-v2')).toBe(true);
expect(service.hasKekId('kek-v1')).toBe(true);
expect(service.hasKekId('kek-unknown')).toBe(false);
});
it('getActiveKekId devrait retourner la KEK courante', () => {
expect(service.getActiveKekId()).toBe('kek-v2');
});
it('getKeyringDepth devrait retourner le nombre de cles', () => {
expect(service.getKeyringDepth()).toBe(2);
});
});
});
7. Tests unitaires — capture-reconciliation.service.spec.ts
// src/modules/capture/__tests__/capture-reconciliation.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
import { CaptureReconciliationService } from '../services/capture-reconciliation.service';
import { CaptureEvent, CaptureEventState } from '../entities/capture-event.entity';
import { CaptureAuditLog } from '../entities/capture-audit-log.entity';
import { buildCaptureEvent } from './fixtures/capture-fixtures';
describe('CaptureReconciliationService', () => {
let service: CaptureReconciliationService;
let captureEventRepo: jest.Mocked<Repository<CaptureEvent>>;
let auditLogRepo: jest.Mocked<Repository<CaptureAuditLog>>;
let s3Service: { deleteFile: jest.Mock };
beforeEach(async () => {
s3Service = { deleteFile: jest.fn().mockResolvedValue(undefined) };
const module: TestingModule = await Test.createTestingModule({
providers: [
CaptureReconciliationService,
{
provide: getRepositoryToken(CaptureEvent),
useValue: {
find: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue({ affected: 1 }),
},
},
{
provide: getRepositoryToken(CaptureAuditLog),
useValue: {
save: jest.fn().mockResolvedValue({}),
create: jest.fn().mockImplementation((data) => data),
},
},
{ provide: 'S3Service', useValue: s3Service },
{
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'capture.sealSlaMs') return 600_000;
if (key === 'capture.orphanTtlMs') return 900_000;
return undefined;
}),
},
},
],
}).compile();
service = module.get(CaptureReconciliationService);
captureEventRepo = module.get(getRepositoryToken(CaptureEvent)) as jest.Mocked<Repository<CaptureEvent>>;
auditLogRepo = module.get(getRepositoryToken(CaptureAuditLog)) as jest.Mocked<Repository<CaptureAuditLog>>;
});
// =========================================================================
// TC-INV-14, TC-ERR-09 — Trigger SEAL_DELAYED
// =========================================================================
describe('SEAL_DELAYED trigger — TC-INV-14, TC-ERR-09, INV-103-35', () => {
it('devrait positionner SEAL_DELAYED pour captures PENDING_SEAL depassant sealSla', async () => {
// GIVEN — capture PENDING_SEAL vieille de 15 min (> 10 min SLA)
const oldCapture = buildCaptureEvent({
state: CaptureEventState.PENDING_SEAL,
createdAt: new Date(Date.now() - 900_000), // 15 min ago
});
captureEventRepo.find.mockResolvedValueOnce([oldCapture]); // _processSealDelayed
captureEventRepo.find.mockResolvedValueOnce([]); // _clearSealDelayed
captureEventRepo.find.mockResolvedValueOnce([]); // _gcOrphanS3Objects
// WHEN
const result = await service.reconcile();
// THEN
expect(result.sealDelayedSet).toBe(1);
expect(captureEventRepo.update).toHaveBeenCalledWith(
oldCapture.id,
expect.objectContaining({
state: CaptureEventState.SEAL_DELAYED,
sealDelayedConformantCycles: 0,
}),
);
});
it('ne devrait rien faire si aucune capture ne depasse le SLA', async () => {
// GIVEN — aucune capture en retard
captureEventRepo.find.mockResolvedValue([]);
// WHEN
const result = await service.reconcile();
// THEN
expect(result.sealDelayedSet).toBe(0);
});
it('devrait reinitialiser sealDelayedConformantCycles a 0 — INV-103-35', async () => {
// GIVEN
const capture = buildCaptureEvent({
state: CaptureEventState.PENDING_SEAL,
createdAt: new Date(Date.now() - 900_000),
sealDelayedConformantCycles: 2,
});
captureEventRepo.find.mockResolvedValueOnce([capture]);
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([]);
// WHEN
await service.reconcile();
// THEN
expect(captureEventRepo.update).toHaveBeenCalledWith(
capture.id,
expect.objectContaining({ sealDelayedConformantCycles: 0 }),
);
});
});
// =========================================================================
// TC-INV-08 — Clearing SEAL_DELAYED apres 3 cycles conformes
// =========================================================================
describe('SEAL_DELAYED clearing — TC-INV-08, INV-103-36', () => {
it('devrait lever SEAL_DELAYED apres exactement 3 cycles conformes consecutifs', async () => {
// GIVEN — capture SEAL_DELAYED avec 2 cycles deja conformes + lastReconciledAt recent
const capture = buildCaptureEvent({
state: CaptureEventState.SEAL_DELAYED,
sealDelayedConformantCycles: 2,
lastReconciledAt: new Date(Date.now() - 60_000), // reconcile 1 min ago
});
captureEventRepo.find.mockResolvedValueOnce([]); // _processSealDelayed
captureEventRepo.find.mockResolvedValueOnce([capture]); // _clearSealDelayed
captureEventRepo.find.mockResolvedValueOnce([]); // _gcOrphanS3Objects
// WHEN
const result = await service.reconcile();
// THEN
expect(result.sealDelayedCleared).toBe(1);
expect(captureEventRepo.update).toHaveBeenCalledWith(
capture.id,
expect.objectContaining({
state: CaptureEventState.PENDING_SEAL,
sealDelayedConformantCycles: 0,
}),
);
});
it('ne devrait PAS lever SEAL_DELAYED apres seulement 1 ou 2 cycles — FORBIDDEN', async () => {
// GIVEN — capture SEAL_DELAYED avec 1 cycle conforme
const capture = buildCaptureEvent({
state: CaptureEventState.SEAL_DELAYED,
sealDelayedConformantCycles: 1,
lastReconciledAt: new Date(Date.now() - 60_000),
});
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([capture]);
captureEventRepo.find.mockResolvedValueOnce([]);
// WHEN
const result = await service.reconcile();
// THEN — pas de clearing, juste increment
expect(result.sealDelayedCleared).toBe(0);
expect(captureEventRepo.update).toHaveBeenCalledWith(
capture.id,
expect.objectContaining({ sealDelayedConformantCycles: 2 }),
);
});
it('devrait reinitialiser le compteur si cycle non conforme', async () => {
// GIVEN — capture SEAL_DELAYED, lastReconciledAt = null (premier cycle non conforme)
const capture = buildCaptureEvent({
state: CaptureEventState.SEAL_DELAYED,
sealDelayedConformantCycles: 2,
lastReconciledAt: null,
});
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([capture]);
captureEventRepo.find.mockResolvedValueOnce([]);
// WHEN
await service.reconcile();
// THEN — compteur remis a 0
expect(captureEventRepo.update).toHaveBeenCalledWith(
capture.id,
expect.objectContaining({ sealDelayedConformantCycles: 0 }),
);
});
});
// =========================================================================
// TC-INV-11 — GC orphelins S3
// =========================================================================
describe('GC orphelins S3 — TC-INV-11, INV-103-33', () => {
it('devrait supprimer les objets S3 orphelins ages > orphanTtl', async () => {
// GIVEN — capture CANCELLED vieille de 20 min avec upload_object_key
const orphan = buildCaptureEvent({
state: CaptureEventState.CANCELLED,
createdAt: new Date(Date.now() - 1_200_000), // 20 min ago
uploadObjectKey: 'captures/2026/04/orphan.enc',
});
captureEventRepo.find.mockResolvedValueOnce([]); // _processSealDelayed
captureEventRepo.find.mockResolvedValueOnce([]); // _clearSealDelayed
captureEventRepo.find.mockResolvedValueOnce([orphan]); // _gcOrphanS3Objects
// WHEN
const result = await service.reconcile();
// THEN
expect(result.orphansDeleted).toBe(1);
expect(s3Service.deleteFile).toHaveBeenCalledWith('captures/2026/04/orphan.enc');
});
it('ne devrait PAS supprimer si age <= orphanTtl — FORBIDDEN', async () => {
// GIVEN — capture recente (5 min < 15 min TTL)
const recentOrphan = buildCaptureEvent({
state: CaptureEventState.CANCELLED,
createdAt: new Date(Date.now() - 300_000), // 5 min ago
uploadObjectKey: 'captures/2026/04/recent.enc',
});
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([recentOrphan]);
// WHEN
const result = await service.reconcile();
// THEN
expect(result.orphansDeleted).toBe(0);
expect(s3Service.deleteFile).not.toHaveBeenCalled();
});
it('devrait ecrire un audit log GC_ORPHAN_DELETED', async () => {
// GIVEN
const orphan = buildCaptureEvent({
state: CaptureEventState.CANCELLED,
createdAt: new Date(Date.now() - 1_200_000),
uploadObjectKey: 'captures/2026/04/orphan.enc',
});
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([]);
captureEventRepo.find.mockResolvedValueOnce([orphan]);
// WHEN
await service.reconcile();
// THEN
expect(auditLogRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'GC_ORPHAN_DELETED' }),
);
});
});
// =========================================================================
// Audit tick
// =========================================================================
describe('Audit tick', () => {
it('devrait ecrire un audit RECONCILIATION_TICK a chaque execution', async () => {
// GIVEN
captureEventRepo.find.mockResolvedValue([]);
// WHEN
await service.reconcile();
// THEN
expect(auditLogRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'RECONCILIATION_TICK' }),
);
});
});
});
8. Tests unitaires — capture.controller.spec.ts
// src/modules/capture/__tests__/capture.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CaptureController } from '../capture.controller';
import { CaptureIngestService, CaptureIngestResponse } from '../services/capture-ingest.service';
import { CaptureEventState, SignatureStatus } from '../entities/capture-event.entity';
import { JwtAuthGuard } from '@common/guards/jwt-auth.guard';
import { AuthorizationGuard } from '@modules/auth/guards/authorization.guard';
import { buildValidDto, VALID_CAPTURE_ID, VALID_USER_ID } from './fixtures/capture-fixtures';
describe('CaptureController', () => {
let controller: CaptureController;
let ingestService: jest.Mocked<CaptureIngestService>;
const mockResponse: CaptureIngestResponse = {
capture_id: VALID_CAPTURE_ID,
status: CaptureEventState.PENDING_SEAL,
signature_status: SignatureStatus.PENDING_SIGNATURE,
created_at: '2026-04-03T14:00:00.000Z',
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CaptureController],
providers: [
{
provide: CaptureIngestService,
useValue: {
ingest: jest.fn().mockResolvedValue(mockResponse),
getStatus: jest.fn().mockResolvedValue(mockResponse),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: jest.fn().mockReturnValue(true) })
.overrideGuard(AuthorizationGuard)
.useValue({ canActivate: jest.fn().mockReturnValue(true) })
.compile();
controller = module.get(CaptureController);
ingestService = module.get(CaptureIngestService) as jest.Mocked<CaptureIngestService>;
});
describe('POST /documents/capture', () => {
it('devrait deleguer au CaptureIngestService.ingest()', async () => {
// GIVEN
const dto = buildValidDto();
// WHEN
const result = await controller.ingest(dto, VALID_USER_ID);
// THEN
expect(ingestService.ingest).toHaveBeenCalledWith(dto, VALID_USER_ID);
expect(result.capture_id).toBe(VALID_CAPTURE_ID);
});
});
describe('GET /documents/capture/:captureId/status', () => {
it('devrait normaliser captureId en lowercase et deleguer', async () => {
// GIVEN — captureId en uppercase
const upperId = VALID_CAPTURE_ID.toUpperCase();
// WHEN
await controller.getStatus(upperId, VALID_USER_ID);
// THEN — le controller lowercase avant delegation (via ParseUUIDPipe + .toLowerCase())
expect(ingestService.getStatus).toHaveBeenCalledWith(
VALID_CAPTURE_ID,
VALID_USER_ID,
);
});
});
});
9. Tests unitaires — create-capture.dto.spec.ts
// src/modules/capture/__tests__/create-capture.dto.spec.ts
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateCaptureDto } from '../dto/create-capture.dto';
import { buildValidDto } from './fixtures/capture-fixtures';
/** Helper : valide un DTO et retourne les erreurs */
async function validateDto(
overrides: Partial<CreateCaptureDto> = {},
): Promise<string[]> {
const plain = { ...buildValidDto(), ...overrides };
const dto = plainToInstance(CreateCaptureDto, plain);
const errors = await validate(dto);
return errors.map((e) => Object.values(e.constraints ?? {}).join(', '));
}
describe('CreateCaptureDto — TC-ERR-06, TC-NEG-01..17', () => {
// =========================================================================
// Cas valide
// =========================================================================
it('devrait valider un payload complet conforme', async () => {
const errors = await validateDto();
expect(errors).toHaveLength(0);
});
it('devrait valider avec champs OCR optionnels absents', async () => {
const errors = await validateDto({
ocr_text: undefined,
ocr_confidence: undefined,
ocr_language: undefined,
});
expect(errors).toHaveLength(0);
});
// =========================================================================
// TC-NEG-01 — capture_id non UUID v4
// =========================================================================
it('devrait rejeter capture_id non UUID v4 — TC-NEG-01', async () => {
const errors = await validateDto({ capture_id: 'not-a-uuid' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-02 — hash_sha3_256 invalide
// =========================================================================
it('devrait rejeter hash_sha3_256 non lowercase hex — TC-NEG-02', async () => {
const errors = await validateDto({ hash_sha3_256: 'UPPERCASE_NOT_ALLOWED' });
expect(errors.length).toBeGreaterThan(0);
});
it('devrait rejeter hash_sha3_256 de longueur != 64', async () => {
const errors = await validateDto({ hash_sha3_256: 'abcdef' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-03 — mime_type != image/png
// =========================================================================
it('devrait rejeter mime_type=image/jpeg — TC-NEG-03', async () => {
const errors = await validateDto({ mime_type: 'image/jpeg' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-04 — size_bytes hors bornes
// =========================================================================
it('devrait rejeter size_bytes=0 — TC-NEG-04', async () => {
const errors = await validateDto({ size_bytes: 0 });
expect(errors.length).toBeGreaterThan(0);
});
it('devrait rejeter size_bytes > 524288000 — TC-NEG-04', async () => {
const errors = await validateDto({ size_bytes: 524_288_001 });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-06 — nonce/tag invalides
// =========================================================================
it('devrait rejeter aes_gcm_nonce_b64 mal forme — TC-NEG-06', async () => {
const errors = await validateDto({ aes_gcm_nonce_b64: 'trop-court' });
expect(errors.length).toBeGreaterThan(0);
});
it('devrait rejeter aes_gcm_tag_b64 mal forme — TC-NEG-06', async () => {
const errors = await validateDto({ aes_gcm_tag_b64: 'invalide' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-07 — ocr_text > 20000 caractères
// =========================================================================
it('devrait rejeter ocr_text > 20000 chars — TC-NEG-07', async () => {
const errors = await validateDto({ ocr_text: 'a'.repeat(20_001) });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-08 — ocr_confidence hors [0, 1]
// =========================================================================
it('devrait rejeter ocr_confidence < 0 — TC-NEG-08', async () => {
const errors = await validateDto({ ocr_confidence: -0.1 });
expect(errors.length).toBeGreaterThan(0);
});
it('devrait rejeter ocr_confidence > 1 — TC-NEG-08', async () => {
const errors = await validateDto({ ocr_confidence: 1.1 });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-09 — timestamp_device non UTC RFC3339
// =========================================================================
it('devrait rejeter timestamp_device avec offset +02:00 — TC-NEG-09', async () => {
const errors = await validateDto({ timestamp_device: '2026-04-03T14:00:00+02:00' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-11 — dek_wrapped_b64 invalide
// =========================================================================
it('devrait rejeter dek_wrapped_b64 base64 invalide — TC-NEG-11', async () => {
const errors = await validateDto({ dek_wrapped_b64: '!!!invalid!!!' });
expect(errors.length).toBeGreaterThan(0);
});
// =========================================================================
// TC-NEG-17 — kek_id invalide
// =========================================================================
it('devrait rejeter kek_id avec caracteres interdits — TC-NEG-17', async () => {
const errors = await validateDto({ kek_id: 'kek/v1@bad' });
expect(errors.length).toBeGreaterThan(0);
});
it('devrait rejeter kek_id vide — TC-NEG-17', async () => {
const errors = await validateDto({ kek_id: '' });
expect(errors.length).toBeGreaterThan(0);
});
});
10. Matrice de couverture TC-* -> fichier test
| Test ID | Fichier test | Couverture |
| TC-NOM-01 (backend) | capture-ingest.service.spec.ts | Flux nominal, normalisation, zeroization, audit |
| TC-NOM-07 | capture-ingest.service.spec.ts | Persistance capture_events + journal |
| TC-NOM-08 | N/A (pipeline scellement hors scope M14 — dependance PD-55) | STUB |
| TC-NOM-12 | N/A (FSM mobile M1 — couvert dans tests frontend) | Hors scope backend |
| TC-NOM-15 | capture-idempotence.service.spec.ts, capture-ingest.service.spec.ts | Idempotence normalisation + replay 200 |
| TC-NOM-16 | kek-keyring.service.spec.ts | Rotation KEK / keyring historique |
| TC-NOM-17 | N/A (purge locale post-upload — mobile M7) | Hors scope backend |
| TC-ERR-06 | create-capture.dto.spec.ts | Validation DTO par champ invalide |
| TC-ERR-08 | N/A (garde backend pipeline scellement — STUB PD-55) | STUB |
| TC-ERR-09 | capture-reconciliation.service.spec.ts | SEAL_DELAYED trigger via reconciliation |
| TC-ERR-10 | N/A (rate-limit Redis middleware — config existante) | Hors scope M14 |
| TC-ERR-12 | capture-ingest.service.spec.ts | Skew timestamp ±300s |
| TC-ERR-13 | capture-idempotence.service.spec.ts | Fingerprint divergent → 409 |
| TC-ERR-16 | kek-keyring.service.spec.ts, capture-ingest.service.spec.ts | UNWRAP_DEK_FAILED (422) |
| TC-ERR-17 | kek-keyring.service.spec.ts, capture-ingest.service.spec.ts | KEY_SERVICE_UNAVAILABLE (503) |
| TC-INV-03 | kek-keyring.service.spec.ts | DEK unwrap RSA-OAEP + nonce CSPRNG |
| TC-INV-05 | capture-idempotence.service.spec.ts | Advisory lock pg_advisory_xact_lock |
| TC-INV-06 | capture-idempotence.service.spec.ts | 4 variantes fingerprint (A/B/C/D) |
| TC-INV-07 | capture-reconciliation.service.spec.ts | Re-enqueue non-terminaux |
| TC-INV-08 | capture-reconciliation.service.spec.ts | Clearing 3 cycles conformes |
| TC-INV-10 | N/A (terminaux — mobile M1) | Hors scope backend |
| TC-INV-11 | capture-reconciliation.service.spec.ts | GC orphelins S3 > orphanTtl |
| TC-INV-13 | kek-keyring.service.spec.ts | Keyring ancienne KEK |
| TC-INV-14 | capture-reconciliation.service.spec.ts | Trigger SEAL_DELAYED + compteur reset |
| TC-NEG-01..17 | create-capture.dto.spec.ts | Validation format §5.1 |
| TC-NEG-18 | kek-keyring.service.spec.ts | kek_id inconnu → 422 |
| Controller routing | capture.controller.spec.ts | Delegation + normalisation |
11. Hypotheses et limitations
| ID | Hypothese | Impact |
| HT-14-01 | Les tests M14 utilisent des mocks TypeORM (pas de base PostgreSQL reelle). Les tests d'integration DB complets necessite un environnement CI avec PostgreSQL. | Couverture advisory lock partielle (mock query) |
| HT-14-02 | Le pipeline de scellement (PD-55/PD-56/PD-41) est stubbe. Les transitions PENDING_SEAL → SEALED → ANCHOR_CONFIRMED ne sont pas testables backend. | STUB: PD-55 — scellement, PD-56 — merkle |
| HT-14-03 | Le rate-limit est gere par middleware Redis global. Non teste en M14 (config existante). | Couvert par tests existants du middleware |
| HT-14-04 | S3Service.deleteFile est stubbe (impl actuelle est un stub). Les tests GC orphelins verifient l'appel mais pas la suppression S3 reelle. | STUB: PD-103 — StorageModule S3 |
12. Decisions architecturales
architectural_decisions:
- decision: "Mocks TypeORM via providers NestJS (pas de base reelle)"
rationale: "Tests unitaires rapides, isoles, deterministes. Integration DB en CI."
alternatives_considered:
- "Testcontainers PostgreSQL — trop lourd pour unitaire"
- "SQLite in-memory — incompatible pg_advisory_xact_lock"
trade_offs: "Advisory lock teste via mock query, pas execution reelle"
- decision: "RSA keypair generee en beforeEach pour kek-keyring tests"
rationale: "Roundtrip crypto reel (wrap/unwrap) sans mock — conforme REX PD-282 (test roundtrip obligatoire)"
alternatives_considered:
- "Mock complet du crypto — perd la valeur du test"
trade_offs: "Tests plus lents (~200ms par keypair) mais crypto validee"