Files
digital_reception_ra/apps/api/test/enrollment-consent.e2e-spec.ts
T

230 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});