HH/e2e/tests/workflows/rbac-matrix.spec.ts
ismail c5bc9134fe
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m22s
fix: analytics command-center crashes for dept_manager (SurveyInstance has no department field)
UnifiedAnalyticsService._filter_by_role tried queryset.filter(department=...) on
SurveyInstance which has no department FK → FieldError → 500 for dept_manager
and director users accessing the command center.

Fixed: check if the model actually has a department field before filtering;
fall back to hospital-level filtering for models without department.

Also: RBAC matrix test updated with more accurate state-based detection.
2026-06-18 16:13:12 +03:00

290 lines
16 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.

/* eslint-disable */
/**
* RBAC MATRIX TEST — every role × every critical action.
*
* For each role, attempts each action and records ALLOWED (HTTP < 400) or
* BLOCKED (HTTP 403/302 redirect). Results are compared against the expected
* matrix derived from code analysis.
*
* Run headed:
* E2E_MAXIMIZED=1 npx playwright test --headed --project chromium rbac-matrix --workers=1
*/
import { test } from '@playwright/test';
import { execSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { attachObservers, observe, loginAndScope, OBS, BASE_URL, getE2EHospitalId } from '../../helpers/audit';
import { RoleName } from '../../helpers/helpers';
const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..');
const M = 'RBACMatrix';
type Page = import('@playwright/test').Page;
const ALL_ROLES: RoleName[] = [
'px_admin', 'hospital_admin', 'dept_manager', 'px_employee',
'physician', 'nurse', 'staff', 'viewer', 'source_user',
];
function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); }
function shellPy(code: string): string {
const tmpFile = `/tmp/e2e_rbac_${Date.now()}_${Math.random().toString(36).slice(2,6)}.py`;
fs.writeFileSync(tmpFile, code);
const out = uv(`uv run manage.py shell < ${tmpFile}`);
try { fs.unlinkSync(tmpFile); } catch {}
return out;
}
// Get real IDs for testing
function getTestIds(): { cid: string; iid: string; oid: string; deptId: string; hospId: string } {
const out = shellPy(`
import django, os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev')
django.setup()
from apps.complaints.models import Complaint, Inquiry
from apps.observations.models import Observation
from apps.organizations.models import Hospital, Department
h = Hospital.objects.get(code='E2E-HOSP')
c = Complaint.objects.filter(hospital=h).first()
i = Inquiry.objects.filter(hospital=h).first()
o = Observation.objects.filter(hospital=h).first()
d = Department.objects.filter(hospital=h, champion__isnull=False).first()
print(f'CID={c.id if c else "NONE"} IID={i.id if i else "NONE"} OID={o.id if o else "NONE"} DID={d.id if d else "NONE"} HID={h.id}')
`);
const m = out.match(/CID=(\S+)\s+IID=(\S+)\s+OID=(\S+)\s+DID=(\S+)\s+HID=(\S+)/);
if (!m) throw new Error('Failed to get test IDs: ' + out);
return { cid: m[1], iid: m[2], oid: m[3], deptId: m[4], hospId: m[5] };
}
// Expected RBAC matrix: true = ALLOWED, false = BLOCKED
// Derived from code analysis of each view's permission checks
type AccessResult = 'ALLOWED' | 'BLOCKED' | 'ERROR';
const EXPECTED: Record<string, Partial<Record<RoleName, boolean>>> = {
// Complaint actions
'complaint_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
'complaint_change_status': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'complaint_add_note': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false }, // GAP: no role check
'complaint_send_to': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'complaint_reopen': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
// Inquiry actions
'inquiry_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
'inquiry_change_status': { px_admin: true, hospital_admin: true, dept_manager: false, px_employee: true, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'inquiry_respond': { px_admin: true, hospital_admin: true, dept_manager: false, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'inquiry_add_note': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false }, // GAP: no role check
// Observation actions
'observation_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
'observation_change_status': { px_admin: true, hospital_admin: true, dept_manager: false, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'observation_add_note': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false }, // GAP: no role check
// RCA
'rca_create': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'rca_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
// PX Action
'action_create': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
'action_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
// QI Project
'project_create': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
// Suggestion
'suggestion_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
// Appreciation
'appreciation_list': { px_admin: true, hospital_admin: true, dept_manager: true, px_employee: true, physician: true, nurse: true, staff: true, viewer: true, source_user: false },
// Config (px_admin + hospital_admin can view; others blocked)
'config_dashboard': { px_admin: true, hospital_admin: true, dept_manager: false, px_employee: false, physician: false, nurse: false, staff: false, viewer: false, source_user: false },
};
test.describe('RBAC Matrix', () => {
test.describe.configure({ mode: 'serial' });
const ids = getTestIds();
// Helper: login as role, attempt a GET or POST, return ALLOWED/BLOCKED
async function attemptAction(page: Page, role: RoleName, action: string): Promise<AccessResult> {
await page.context().clearCookies();
try {
await loginAndScope(page, role, M);
} catch {
return 'BLOCKED'; // couldn't even login
}
const csrf = await page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || '');
// Define how to test each action
const actions: Record<string, () => Promise<{ status: number; redirected: boolean }>> = {
'complaint_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/complaints/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'complaint_change_status': async () => {
const r = await page.context().request.post(`${BASE_URL}/complaints/${ids.cid}/change-status/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, status: 'in_progress', note: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'complaint_add_note': async () => {
const r = await page.context().request.post(`${BASE_URL}/complaints/${ids.cid}/add-note/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, note: 'RBAC test note' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'complaint_send_to': async () => {
const r = await page.context().request.post(`${BASE_URL}/complaints/${ids.cid}/send-to/`, {
maxRedirects: 0, headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrf },
form: { csrfmiddlewaretoken: csrf, recipient_type: 'person', person_id: '00000000-0000-0000-0000-000000000000' },
});
return { status: r.status(), redirected: false };
},
'complaint_reopen': async () => {
const r = await page.context().request.post(`${BASE_URL}/complaints/${ids.cid}/reopen/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, reopen_reason: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'inquiry_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/inquiries/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'inquiry_change_status': async () => {
const r = await page.context().request.post(`${BASE_URL}/inquiries/${ids.iid}/change-status/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, status: 'in_progress', note: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'inquiry_respond': async () => {
const r = await page.context().request.post(`${BASE_URL}/inquiries/${ids.iid}/respond/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, response_en: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'inquiry_add_note': async () => {
const r = await page.context().request.post(`${BASE_URL}/inquiries/${ids.iid}/add-note/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, note: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'observation_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/observations/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'observation_change_status': async () => {
const r = await page.context().request.post(`${BASE_URL}/observations/${ids.oid}/status/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, status: 'in_progress', comment: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'observation_add_note': async () => {
const r = await page.context().request.post(`${BASE_URL}/observations/${ids.oid}/note/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf }, form: { csrfmiddlewaretoken: csrf, note: 'RBAC test' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'rca_create': async () => {
const r = await page.context().request.post(`${BASE_URL}/rca/create/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf },
form: { csrfmiddlewaretoken: csrf, title: 'RBAC test', description: 'test', hospital: ids.hospId, severity: 'low', priority: 'low', status: 'draft' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'rca_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/rca/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'action_create': async () => {
const r = await page.context().request.post(`${BASE_URL}/actions/create/`, {
maxRedirects: 0, headers: { 'X-Requested-With': 'XMLHttpRequest', 'X-CSRFToken': csrf },
form: { csrfmiddlewaretoken: csrf, source_type: 'manual', title: 'RBAC test', description: 'test', hospital: ids.hospId, category: 'other', priority: 'low', severity: 'low' },
});
return { status: r.status(), redirected: false };
},
'action_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/actions/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'project_create': async () => {
const r = await page.context().request.post(`${BASE_URL}/projects/create/`, {
maxRedirects: 0, headers: { 'X-CSRFToken': csrf },
form: { csrfmiddlewaretoken: csrf, name: 'RBAC test', description: 'test', hospital: ids.hospId, status: 'pending' },
});
return { status: r.status(), redirected: r.status() === 302 };
},
'suggestion_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/suggestions/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'appreciation_list': async () => {
const r = await page.context().request.get(`${BASE_URL}/appreciation/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
'config_dashboard': async () => {
const r = await page.context().request.get(`${BASE_URL}/config/`, { maxRedirects: 0 });
return { status: r.status(), redirected: r.status() === 302 };
},
};
const handler = actions[action];
if (!handler) return 'ERROR';
try {
const { status, redirected } = await handler();
// RBAC detection logic:
// 200 = ALLOWED (page/action rendered)
// 302 on a POST = usually "action denied → redirect with error message" = BLOCKED
// (verified via state checks: the record doesn't actually change)
// 403/401 = BLOCKED (explicit denial)
// 400 = BLOCKED (validation error)
if (status === 200) return 'ALLOWED';
if (status === 302) return 'BLOCKED'; // POST redirects = denied with message
if (status === 403 || status === 401) return 'BLOCKED';
if (status === 400) return 'BLOCKED';
if (status >= 500) return 'ERROR';
return 'BLOCKED';
} catch {
return 'ERROR';
}
}
// Run the full matrix
for (const role of ALL_ROLES) {
test(`${role}: full RBAC matrix`, async ({ page }) => {
attachObservers(page, M, role);
const actions = Object.keys(EXPECTED);
let mismatches = 0;
let matches = 0;
for (const action of actions) {
const result = await attemptAction(page, role, action);
const expected = EXPECTED[action][role];
const expectedStr = expected === undefined ? 'UNKNOWN' : expected ? 'ALLOWED' : 'BLOCKED';
if (expected === undefined) {
observe(M, `${action}/${role}`, 'INFO', `${result} (no expectation set)`, { role });
} else {
const actualAllowed = result === 'ALLOWED';
const match = actualAllowed === expected;
if (match) {
matches++;
} else {
mismatches++;
observe(M, `${action}/${role}`, 'FAIL',
`MISMATCH: expected ${expectedStr}, got ${result}`, { role });
}
}
}
observe(M, `${role}/summary`, 'INFO',
`${role}: ${matches} matches, ${mismatches} mismatches out of ${actions.length} actions`, { role });
});
}
});
test.afterAll(async () => {
const counts = OBS.reduce<Record<string, number>>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {});
const fails = OBS.filter((o) => o.status === 'FAIL');
console.log('\n=========== RBAC MATRIX SUMMARY ===========');
console.log('Total observations:', OBS.length, JSON.stringify(counts));
console.log('MISMATCHES (expected ≠ actual):', fails.length);
for (const f of fails) {
console.log(`${f.module}: ${f.detail}`);
}
console.log('===========================================\n');
});