forked from wangziqi/gongxue-base
328 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|