/opt/mawid/apps/api/src/tenancy
Edit: /opt/mawid/apps/api/src/tenancy/tenancy.spec.ts (3571B)
/**
* Cross-tenant isolation test (PROJECT_PLAN §4 Phase 1 / §7.2):
* a user from clinic A must never read or mutate clinic B data.
*
* Integration test — requires the docker-compose postgres on localhost:5432.
*/
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StaffService } from '../staff/staff.service';
import { ServicesService } from '../services/services.service';
import { ClinicService } from '../clinic/clinic.service';
process.env.DATABASE_URL ??= 'postgresql://mawid:mawid@localhost:5432/mawid';
describe('tenancy isolation', () => {
const prisma = new PrismaService();
const staffService = new StaffService(prisma);
const servicesService = new ServicesService(prisma);
const clinicService = new ClinicService(prisma);
let clinicA: string;
let clinicB: string;
let staffB: string;
let serviceB: string;
beforeAll(async () => {
const a = await prisma.clinic.create({
data: { name: `tenancy-test-A-${Date.now()}`, phone: '+900000000001' },
});
const b = await prisma.clinic.create({
data: { name: `tenancy-test-B-${Date.now()}`, phone: '+900000000002' },
});
clinicA = a.id;
clinicB = b.id;
staffB = (
await prisma.staff.create({ data: { clinicId: clinicB, name: 'B Staff' } })
).id;
serviceB = (
await prisma.service.create({
data: {
clinicId: clinicB,
name: { ar: 'خدمة', tr: 'Hizmet', en: 'Service' },
durationMinutes: 30,
},
})
).id;
});
afterAll(async () => {
// Cascade deletes remove staff/services created above.
await prisma.clinic.deleteMany({ where: { id: { in: [clinicA, clinicB] } } });
await prisma.$disconnect();
});
it('listing staff for clinic A excludes clinic B rows', async () => {
const staff = await staffService.list(clinicA);
expect(staff.map((s) => s.id)).not.toContain(staffB);
});
it('clinic A cannot read clinic B staff by id', async () => {
await expect(staffService.get(clinicA, staffB)).rejects.toThrow(NotFoundException);
});
it('clinic A cannot update or delete clinic B staff', async () => {
await expect(staffService.update(clinicA, staffB, { name: 'hijacked' })).rejects.toThrow(
NotFoundException,
);
await expect(staffService.remove(clinicA, staffB)).rejects.toThrow(NotFoundException);
const untouched = await prisma.staff.findUnique({ where: { id: staffB } });
expect(untouched?.name).toBe('B Staff');
});
it('clinic A cannot read, update or delete clinic B services', async () => {
await expect(servicesService.get(clinicA, serviceB)).rejects.toThrow(NotFoundException);
await expect(
servicesService.update(clinicA, serviceB, { durationMinutes: 5 }),
).rejects.toThrow(NotFoundException);
await expect(servicesService.remove(clinicA, serviceB)).rejects.toThrow(NotFoundException);
});
it('clinic A staff cannot be linked to clinic B services', async () => {
await expect(
staffService.create(clinicA, {
name: 'A Staff',
role: 'practitioner',
workingHours: {},
serviceIds: [serviceB],
active: true,
}),
).rejects.toThrow(BadRequestException);
});
it('clinic settings reads are scoped to the caller clinic', async () => {
const clinic = await clinicService.get(clinicA);
expect(clinic.id).toBe(clinicA);
const other = await clinicService.get(clinicB);
expect(other.id).toBe(clinicB);
});
});