Pre-production security fixes to prevent cross-hospital data leaks: - Standards API: add get_queryset() filtering by department__hospital - Reports service: add user param with hospital filtering to all querysets - RCA views: replace is_superuser with tenant_hospital pattern, add access checks to all 11 mutation views - Notifications views: replace is_superuser patterns with _get_notification_hospital helper across all 5 settings functions - Appreciation API: add tenant_hospital fallback to AppreciationViewSet, AppreciationStatsViewSet, and LeaderboardView - AI Analytics: add tenant_hospital fallback in ExecutiveSummaryGenerator and ActionRecommendationEngine - SourceUserRestrictionMiddleware: remove None from ALLOWED_URL_NAMES - Complaint export: fix nullable patient/due_at/description crashes in CSV and Excel export, fix invalid get_category_display/get_source_display calls E2E test updates: - Update isolation gap tests to actively assert hospital filtering - Fix CSV export test to use API context for download handling - Switch clinical-staff tests to serial mode to prevent race conditions
203 lines
6.7 KiB
TypeScript
203 lines
6.7 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { ApiHelper, HOSPITAL_ID } from '../../helpers/api-helper';
|
|
|
|
test.describe('Hospital Data Isolation', () => {
|
|
test('Hospital Admin can only see own hospital complaints via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/?page_size=50');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
|
|
for (const complaint of results) {
|
|
if (complaint.hospital) {
|
|
expect(complaint.hospital).toBe(HOSPITAL_ID);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Hospital Admin can only see own hospital inquiries via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/complaints/api/inquiries/?page_size=50');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
|
|
for (const inquiry of results) {
|
|
if (inquiry.hospital) {
|
|
expect(inquiry.hospital).toBe(HOSPITAL_ID);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('PX Admin without hospital selection sees all complaints via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('px_admin');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
expect(results.length).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
test('Dept Manager gets filtered complaint data via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('dept_manager');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/?page_size=50');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
expect(body.results || body).toBeTruthy();
|
|
});
|
|
|
|
test('Source user can access own source data', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('source_user');
|
|
|
|
const resp = await api.get('/px-sources/api/sources/');
|
|
expect([200, 403]).toContain(resp.status());
|
|
});
|
|
|
|
test('Staff user gets limited data via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('staff');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
expect(body.results || body).toBeTruthy();
|
|
});
|
|
|
|
test('Viewer gets read-only data via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('viewer');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/?page_size=5');
|
|
expect(resp.status()).toBe(200);
|
|
|
|
const createResp = await api.post('/complaints/api/complaints/', {
|
|
patient_name: 'blocked',
|
|
national_id: 'blocked',
|
|
description: 'blocked',
|
|
hospital: HOSPITAL_ID,
|
|
});
|
|
expect([400, 403, 405]).toContain(createResp.status());
|
|
});
|
|
|
|
test('survey instances filtered by hospital for Hospital Admin', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/surveys/api/instances/?page_size=50');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
|
|
for (const instance of results) {
|
|
if (instance.survey_template?.hospital) {
|
|
expect(instance.survey_template.hospital).toBe(HOSPITAL_ID);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe('Cross-Tenant Write Protection', () => {
|
|
test('Hospital Admin cannot POST complaint to different hospital', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.post('/complaints/api/complaints/', {
|
|
patient_name: 'Cross Tenant Test',
|
|
national_id: `CROSS${Date.now()}`,
|
|
relation_to_patient: 'patient',
|
|
incident_date: '2026-01-15',
|
|
description: 'Cross tenant test',
|
|
hospital: '00000000-0000-0000-0000-000000000000',
|
|
complaint_type: 'complaint',
|
|
});
|
|
|
|
expect([400, 403, 404]).toContain(resp.status());
|
|
});
|
|
|
|
test('Hospital Admin cannot update user from different hospital', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const userResp = await api.get('/accounts/users/?page_size=50');
|
|
const userBody = await userResp.json();
|
|
const users = userBody.results || userBody;
|
|
|
|
const otherHospitalUser = users.find((u: any) => u.hospital && u.hospital !== HOSPITAL_ID);
|
|
if (!otherHospitalUser) {
|
|
test.skip();
|
|
return;
|
|
}
|
|
|
|
const resp = await api.patch(`/accounts/users/${otherHospitalUser.id}/`, {
|
|
first_name: 'Hacked',
|
|
});
|
|
expect([403, 404]).toContain(resp.status());
|
|
});
|
|
|
|
test('Source user cannot access complaints API directly', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('source_user');
|
|
|
|
const resp = await api.get('/complaints/api/complaints/');
|
|
expect([200, 403, 500]).toContain(resp.status());
|
|
});
|
|
|
|
test('non-PX-Admin cannot access config API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/actions/api/sla-configs/');
|
|
expect([403, 404]).toContain(resp.status());
|
|
});
|
|
});
|
|
|
|
test.describe('Known Isolation Gaps', () => {
|
|
test('Standards API filters by hospital after fix', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/standards/api/standards/');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
for (const standard of results) {
|
|
if (standard.hospital) {
|
|
expect(standard.hospital).toBe(HOSPITAL_ID);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Appreciation API filters by hospital after fix', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/api/v1/appreciation/api/appreciations/');
|
|
expect(resp.status()).toBe(200);
|
|
const body = await resp.json();
|
|
const results = body.results || body;
|
|
for (const item of results) {
|
|
if (item.hospital) {
|
|
expect(item.hospital).toBe(HOSPITAL_ID);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('Physician ratings accessible via API', async ({ page }) => {
|
|
const api = new ApiHelper(page);
|
|
await api.authenticate('hospital_admin');
|
|
|
|
const resp = await api.get('/physicians/api/physicians/');
|
|
expect(resp.status()).toBe(200);
|
|
});
|
|
});
|