Files
gongxue-base/apps/server/src/authorization/casl-ability.factory.spec.ts

328 lines
12 KiB
TypeScript

import { subject } from '@casl/ability';
import { CaslAbilityFactory } from './casl-ability.factory';
import {
CaslAction,
SubjectName,
permissionCodeSubject,
mapPermissionCode,
isKnownPermissionCode,
} from './casl.constants';
describe('CaslAbilityFactory', () => {
const factory = new CaslAbilityFactory();
// -----------------------------------------------------------------------
it('grants manage all for super admin regardless of permissions list', () => {
const ability = factory.createForUser({
permissions: [],
isSuperAdmin: true,
});
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Delete, SubjectName.Class)).toBe(true);
});
it('super admin ability can manage arbitrary subject strings', () => {
const ability = factory.createForUser({ permissions: [], isSuperAdmin: true });
expect(ability.can(CaslAction.Manage, 'FictionalEntity')).toBe(true);
});
// -----------------------------------------------------------------------
// Exact-code permissions (layer 1 — collision-free)
// -----------------------------------------------------------------------
it('grants exact-code access for specific permission codes', () => {
const ability = factory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(true);
});
it('does NOT grant exact-code access for a different code in same domain', () => {
const ability = factory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// bill:view user should NOT have bill:export-excel exact code
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
});
it('does NOT allow export to satisfy view or vice versa', () => {
const viewUser = factory.createForUser({
permissions: ['attendance:view'],
isSuperAdmin: false,
});
const exportUser = factory.createForUser({
permissions: ['attendance:export'],
isSuperAdmin: false,
});
expect(viewUser.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
expect(exportUser.can(CaslAction.Access, permissionCodeSubject('attendance:view'))).toBe(false);
});
it('does NOT allow edit to satisfy approve on same resource', () => {
const editor = factory.createForUser({
permissions: ['deposit:edit'],
isSuperAdmin: false,
});
expect(editor.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
});
it('custom/unknown codes get exact-code ability but NO domain ability', () => {
const ability = factory.createForUser({
permissions: ['student:nuke'],
isSuperAdmin: false,
});
// Exact code should be granted
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
// But no domain ability should exist
expect(ability.can(CaslAction.Manage, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
});
it('unknown resource custom code gets exact-code but no domain', () => {
const ability = factory.createForUser({
permissions: ['custom:action'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Access, permissionCodeSubject('custom:action'))).toBe(true);
// No domain ability for unknown resource
const hasAnyDomain = [SubjectName.Student, SubjectName.Bill, SubjectName.Class].some((s) =>
ability.can(CaslAction.Read, s),
);
expect(hasAnyDomain).toBe(false);
});
// -----------------------------------------------------------------------
// Domain-level permissions (layer 2 — for service scoping)
// -----------------------------------------------------------------------
it('maps student:view to domain read Student', () => {
const ability = factory.createForUser({
permissions: ['student:view'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
});
it('maps student:edit to domain update Student', () => {
const ability = factory.createForUser({
permissions: ['student:edit'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
});
it('maps student:create to domain create Student', () => {
const ability = factory.createForUser({
permissions: ['student:create'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(true);
});
it('maps student:delete to domain delete Student', () => {
const ability = factory.createForUser({
permissions: ['student:delete'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(true);
});
it('does not broaden occupancy:checkin into generic create ability', () => {
const ability = factory.createForUser({
permissions: ['occupancy:checkin'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Occupancy)).toBe(false);
});
it('does not broaden bill:export-excel into generic read ability', () => {
const ability = factory.createForUser({
permissions: ['bill:export-excel'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Bill)).toBe(false);
// But exact code is separate
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(false);
});
it('cumulative: multiple permissions all apply at both layers', () => {
const ability = factory.createForUser({
permissions: ['student:view', 'room:create', 'bill:delete'],
isSuperAdmin: false,
});
// Domain layer
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Create, SubjectName.Room)).toBe(true);
expect(ability.can(CaslAction.Delete, SubjectName.Bill)).toBe(true);
// Exact-code layer
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('room:create'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:delete'))).toBe(true);
});
it('recognizes CASL subject instances instead of treating them as all', () => {
const ability = factory.createForUser({
permissions: ['student:view'],
isSuperAdmin: false,
});
const student = subject(SubjectName.Student, { id: 1, classId: 7 });
expect(ability.can(CaslAction.Read, student)).toBe(true);
expect(ability.can(CaslAction.Update, student)).toBe(false);
});
it('does not let special exact-code permissions grant generic CRUD policies', () => {
const ability = factory.createForUser({
permissions: ['student:import', 'deposit:approve', 'bill:confirm'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Deposit)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Bill)).toBe(false);
});
it('does not broaden archive into generic delete ability', () => {
const ability = factory.createForUser({
permissions: ['student:archive'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:archive'))).toBe(true);
});
// -----------------------------------------------------------------------
// Unknown permission codes
// -----------------------------------------------------------------------
it('silently ignores unknown codes for domain but grants exact-code access', () => {
const ability = factory.createForUser({
permissions: ['unknown:stuff', 'student:view'],
isSuperAdmin: false,
});
// Domain: only known code applies
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
// Exact: both codes get access
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('unknown:stuff'))).toBe(true);
});
// -----------------------------------------------------------------------
// Profile auto-grant
// -----------------------------------------------------------------------
it('does not grant broad Profile read without an explicit permission', () => {
const ability = factory.createForUser({
permissions: [],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Profile)).toBe(false);
// No exact-code access either
expect(ability.can(CaslAction.Access, permissionCodeSubject('profile:view'))).toBe(false);
});
// -----------------------------------------------------------------------
// createFromPermissions helper
// -----------------------------------------------------------------------
it('createFromPermissions helper works for tests', () => {
const ability = factory.createFromPermissions(['student:view']);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
});
it('createFromPermissions helper supports isSuperAdmin flag', () => {
const ability = factory.createFromPermissions([], true);
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Standalone mapping function tests
// ---------------------------------------------------------------------------
describe('mapPermissionCode', () => {
it('maps known codes', () => {
expect(mapPermissionCode('student:view')).toEqual({
action: CaslAction.Read,
subject: SubjectName.Student,
});
});
it('returns null for unknown resource', () => {
expect(mapPermissionCode('ghost:action')).toBeNull();
});
it('returns null for empty string', () => {
expect(mapPermissionCode('')).toBeNull();
});
it('does not map occupancy:checkin to a generic domain action', () => {
expect(mapPermissionCode('occupancy:checkin')).toBeNull();
});
it('does not map bill:export-excel to generic read', () => {
expect(mapPermissionCode('bill:export-excel')).toBeNull();
});
it('does not map bill:generate to generic update', () => {
expect(mapPermissionCode('bill:generate')).toBeNull();
});
it('does not map deposit:approve to generic update', () => {
expect(mapPermissionCode('deposit:approve')).toBeNull();
});
it('does not map attendance:export to generic read', () => {
expect(mapPermissionCode('attendance:export')).toBeNull();
});
it('does not map archive to generic delete', () => {
expect(mapPermissionCode('student:archive')).toBeNull();
});
it('returns null for unknown action on known resource (student:nuke)', () => {
expect(mapPermissionCode('student:nuke')).toBeNull();
});
});
describe('isKnownPermissionCode', () => {
it('recognizes known codes', () => {
expect(isKnownPermissionCode('student:view')).toBe(true);
});
it('rejects unknown resources', () => {
expect(isKnownPermissionCode('ghost:action')).toBe(false);
});
});