You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
229 lines
8.4 KiB
229 lines
8.4 KiB
/** |
|
* 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<number> { |
|
this.enrolledTracks.push(trackId); |
|
return 1; |
|
} |
|
async deletePatientEmbeddings(patientId: string): Promise<number> { |
|
this.deletedPatients.push(patientId); |
|
return 1; |
|
} |
|
async countPatientEmbeddings(_patientId: string): Promise<number> { |
|
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<string[]> { |
|
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); |
|
});
|
|
|