230 lines
8.4 KiB
TypeScript
230 lines
8.4 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|