/** * M6 acceptance test: * создаём фикстуру (track + embedding) → * POST /enrollment → * POST /consents/.../revoke с CONSENT_REVOKE_DELAY_MS=300 → * через ~600мс эмбеддинги удалены, трек ANONYMIZED, ФИО null, * в biometry_access_log полный след. * * FaceClient и PolimedClient замоканы. Redis/Postgres — реальные. */ // CONSENT_REVOKE_DELAY_MS=300 устанавливается в test/setup-env.ts ДО imports. import { Test, type TestingModule } from '@nestjs/testing'; import type { INestApplication } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common'; import { TrackStatus } from '@reception/db'; import cookieParser from 'cookie-parser'; import request from 'supertest'; import { randomUUID } from 'node:crypto'; import { AppModule } from '../src/app.module'; import { PrismaService } from '../src/prisma/prisma.service'; import { FaceClient } from '../src/face/face.client'; import { PolimedClient } from '../src/polimed/polimed.client'; const SENIOR_EMAIL = 'senior@local'; const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123'; class FaceClientMock { enrolledTracks: string[] = []; deletedPatients: string[] = []; countCalls = 0; async recognize() { return null; } async enrollTrack(trackId: string, _patientId: string): Promise { this.enrolledTracks.push(trackId); return 1; } async deletePatientEmbeddings(patientId: string): Promise { this.deletedPatients.push(patientId); return 1; } async countPatientEmbeddings(_patientId: string): Promise { this.countCalls += 1; return 0; } } class PolimedClientMock { async searchPatients(q: string) { return [ { id: q, fullName: 'Иванов Иван Иванович', birthDate: '1980-01-01', phone: '+7900', cardNumber: 'K-MOCK' }, ]; } async getAppointments() { return []; } async getAppointment(id: string) { return { id, patientId: 'pol-p-test', patientFullName: 'Иванов Иван Иванович', doctorFullName: 'Доктор Тест', specialty: 'Терапевт', scheduledFor: new Date().toISOString(), status: 'scheduled' as const, }; } async pushVisitEvent() { return; } } describe('Enrollment + Consent revoke (e2e, M6)', () => { let app: INestApplication; let prisma: PrismaService; let face: FaceClientMock; let cameraId: string; let zoneId: string; let trackId: string; let embeddingId: string; const polimedPatientId = `pol-p-test-${Date.now()}`; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(FaceClient) .useClass(FaceClientMock) .overrideProvider(PolimedClient) .useClass(PolimedClientMock) .compile(); app = module.createNestApplication(); app.use(cookieParser()); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); await app.init(); prisma = app.get(PrismaService); face = app.get(FaceClient) as unknown as FaceClientMock; // Готовим зону, камеру, трек, фейковый эмбеддинг. const zone = await prisma.zone.findUniqueOrThrow({ where: { code: 'A' } }); zoneId = zone.id; const camera = await prisma.camera.create({ data: { name: `test-cam-${Date.now()}`, zoneId }, }); cameraId = camera.id; trackId = randomUUID(); embeddingId = randomUUID(); await prisma.track.create({ data: { id: trackId, firstSeenAt: new Date(Date.now() - 60_000), lastSeenAt: new Date(), }, }); // Прямой INSERT эмбеддинга через $executeRawUnsafe (синтетический 512-d). const embeddingLiteral = '[' + Array.from({ length: 512 }, () => Math.random()).join(',') + ']'; await prisma.$executeRawUnsafe( `INSERT INTO face_embeddings (id, embedding, track_id, camera_id, quality, captured_at, created_at) VALUES ($1::uuid, $2::vector, $3::uuid, $4::uuid, $5, NOW(), NOW())`, embeddingId, embeddingLiteral, trackId, cameraId, 0.9, ); }); afterAll(async () => { await prisma.$executeRawUnsafe(`DELETE FROM face_embeddings WHERE track_id = $1::uuid`, trackId); await prisma.visit.deleteMany({ where: { patient: { polimedPatientId } } }); await prisma.patientConsent.deleteMany({ where: { patient: { polimedPatientId } } }); await prisma.consentRevocationJob.deleteMany({ where: { patient: { polimedPatientId } } }); await prisma.biometryAccessLog.deleteMany({ where: { subjectPatientId: { not: null } } }); await prisma.track.deleteMany({ where: { id: trackId } }); await prisma.patient.deleteMany({ where: { polimedPatientId } }); await prisma.camera.deleteMany({ where: { id: cameraId } }); await app.close(); }); async function loginSenior(): Promise { const res = await request(app.getHttpServer()) .post('/auth/login') .send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD }); expect(res.status).toBe(200); return (res.headers['set-cookie'] as unknown as string[]) ?? []; } it('full enrollment + revoke flow', async () => { const cookies = await loginSenior(); // 1. Enrollment. const enrollRes = await request(app.getHttpServer()) .post('/enrollment') .set('Cookie', cookies) .send({ trackId, polimedPatientId, polimedAppointmentId: 'pol-a-test', paperConsentRef: 'paper-ref-001', }); expect(enrollRes.status).toBe(201); expect(enrollRes.body.patientId).toBeDefined(); expect(enrollRes.body.visitId).toBeDefined(); const patientId: string = enrollRes.body.patientId; // FaceClient.enrollTrack был вызван. expect(face.enrolledTracks).toContain(trackId); // Проверяем DB-состояние после enrollment. const patientAfterEnroll = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } }); expect(patientAfterEnroll.polimedPatientId).toBe(polimedPatientId); expect(patientAfterEnroll.consentReceivedAt).not.toBeNull(); const trackAfterEnroll = await prisma.track.findUniqueOrThrow({ where: { id: trackId } }); expect(trackAfterEnroll.patientId).toBe(patientId); expect(trackAfterEnroll.status).toBe(TrackStatus.MATCHED); const grantedConsent = await prisma.patientConsent.findFirst({ where: { patientId, action: 'GRANTED' }, }); expect(grantedConsent).not.toBeNull(); expect(grantedConsent?.paperRef).toBe('paper-ref-001'); // 2. Revoke consent. const revokeRes = await request(app.getHttpServer()) .post(`/consents/${patientId}/revoke`) .set('Cookie', cookies); expect(revokeRes.status).toBe(202); expect(revokeRes.body.jobId).toBeDefined(); // Сразу после revoke — consentRevokedAt и pendingDeletionAt установлены. const patientAfterRevoke = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } }); expect(patientAfterRevoke.consentRevokedAt).not.toBeNull(); expect(patientAfterRevoke.pendingDeletionAt).not.toBeNull(); // 3. Ждём срабатывания BullMQ (delay=300мс + обработка). await new Promise((r) => setTimeout(r, 1500)); // 4. Проверяем результат отложенной задачи. expect(face.deletedPatients).toContain(patientId); const patientFinal = await prisma.patient.findUniqueOrThrow({ where: { id: patientId } }); expect(patientFinal.fullName).toBeNull(); expect(patientFinal.pendingDeletionAt).toBeNull(); const trackFinal = await prisma.track.findUniqueOrThrow({ where: { id: trackId } }); expect(trackFinal.status).toBe(TrackStatus.ANONYMIZED); const job = await prisma.consentRevocationJob.findFirstOrThrow({ where: { patientId } }); expect(job.status).toBe('DONE'); expect(job.completedAt).not.toBeNull(); // 5. Полный аудит-след в biometry_access_log. const logs = await prisma.biometryAccessLog.findMany({ where: { subjectPatientId: patientId }, orderBy: { occurredAt: 'asc' }, }); const actions = logs.map((l) => l.action); expect(actions).toContain('enroll'); expect(actions).toContain('consent_revoke'); expect(actions).toContain('consent_revocation_completed'); }, 30_000); });