Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { Test, type TestingModule } from '@nestjs/testing';
import type { INestApplication } from '@nestjs/common';
import { ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Auth (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
const SENIOR_EMAIL = 'senior@local';
const SENIOR_PASSWORD = process.env.SEED_PASSWORD_SENIOR ?? 'senior123';
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
prisma = app.get(PrismaService);
});
afterAll(async () => {
await app.close();
});
it('GET /health is public', async () => {
const res = await request(app.getHttpServer()).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('GET /auth/me requires auth', async () => {
const res = await request(app.getHttpServer()).get('/auth/me');
expect(res.status).toBe(401);
});
it('POST /auth/login returns access cookie and userId', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
expect(res.status).toBe(200);
expect(res.body.userId).toBeDefined();
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
expect(cookies.some((c) => c.startsWith('access_token='))).toBe(true);
expect(cookies.some((c) => c.startsWith('refresh_token='))).toBe(true);
});
it('GET /auth/me with cookie returns user + role', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const meRes = await request(app.getHttpServer())
.get('/auth/me')
.set('Cookie', cookies);
expect(meRes.status).toBe(200);
expect(meRes.body.email).toBe(SENIOR_EMAIL);
expect(meRes.body.role).toBe('SENIOR_ADMIN');
});
it('POST /auth/login with bad password → 401', async () => {
const res = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: 'wrong-password' });
expect(res.status).toBe(401);
});
it('POST /auth/logout clears tokens', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const logoutRes = await request(app.getHttpServer())
.post('/auth/logout')
.set('Cookie', cookies);
expect(logoutRes.status).toBe(200);
// Используем тот же refresh — должен быть отозван.
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='));
expect(refreshCookie).toBeDefined();
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie!);
expect(refreshRes.status).toBe(401);
});
describe('refresh token rotation', () => {
it('issues new tokens with valid refresh', async () => {
const loginRes = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: SENIOR_EMAIL, password: SENIOR_PASSWORD });
const cookies = (loginRes.headers['set-cookie'] as unknown as string[]) ?? [];
const refreshCookie = cookies.find((c) => c.startsWith('refresh_token='))!;
const refreshRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie);
expect(refreshRes.status).toBe(200);
const newCookies = (refreshRes.headers['set-cookie'] as unknown as string[]) ?? [];
expect(newCookies.some((c) => c.startsWith('access_token='))).toBe(true);
// Старый refresh теперь невалиден — должен быть отозван.
const reuseRes = await request(app.getHttpServer())
.post('/auth/refresh')
.set('Cookie', refreshCookie);
expect(reuseRes.status).toBe(401);
});
});
});
@@ -0,0 +1,229 @@
/**
* 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);
});
+9
View File
@@ -0,0 +1,9 @@
import { config as dotenv } from 'dotenv';
import { join } from 'node:path';
// Загружаем корневой .env для e2e-тестов.
dotenv({ path: join(__dirname, '..', '..', '..', '.env') });
// В e2e ускоряем delay очереди до 300мс (иначе тест M6 не дождётся 24 ч).
// override=true потому что .env может уже содержать прод-значение.
process.env.CONSENT_REVOKE_DELAY_MS = '300';