Aller au contenu

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"