Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user