224 lines
7.7 KiB
TypeScript
224 lines
7.7 KiB
TypeScript
import { PermissionGuard } from './permission.guard';
|
|
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
|
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
|
|
|
|
describe('PermissionGuard', () => {
|
|
const abilityFactory = new CaslAbilityFactory();
|
|
|
|
/** Mock NestJS ExecutionContext with reflector overrides */
|
|
function createContext(
|
|
user: unknown,
|
|
overrides: {
|
|
isPublic?: boolean;
|
|
authenticatedOnly?: boolean;
|
|
permissions?: string[];
|
|
checkPolicies?: unknown[];
|
|
} = {},
|
|
) {
|
|
const meta = new Map<string, unknown>();
|
|
if (overrides.isPublic !== undefined) meta.set('isPublic', overrides.isPublic);
|
|
if (overrides.authenticatedOnly !== undefined)
|
|
meta.set('authenticatedOnly', overrides.authenticatedOnly);
|
|
if (overrides.permissions) meta.set('permissions', overrides.permissions);
|
|
if (overrides.checkPolicies) meta.set('check_policies', overrides.checkPolicies);
|
|
|
|
const reflector = {
|
|
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
|
|
getAllAndMerge: jest.fn((key: string) => meta.get(key) ?? []),
|
|
};
|
|
const guard = new PermissionGuard(reflector as never, abilityFactory);
|
|
return guard.canActivate({
|
|
getHandler: () => function handler() {},
|
|
getClass: () => class Controller {},
|
|
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
|
} as never);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// @Public / @Authenticated / undeclared
|
|
// -----------------------------------------------------------------------
|
|
|
|
it('denies routes that forgot to declare permissions', () => {
|
|
expect(createContext(undefined)).toBe(false);
|
|
expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
|
|
});
|
|
|
|
it('allows explicitly public routes without a user', () => {
|
|
expect(createContext(undefined, { isPublic: true })).toBe(true);
|
|
});
|
|
|
|
it('allows authenticated-only routes for logged-in users', () => {
|
|
expect(
|
|
createContext({ permissions: [], isSuperAdmin: false }, { authenticatedOnly: true }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('denies authenticated-only routes when no user is present', () => {
|
|
expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
|
|
});
|
|
|
|
it('does not let controller-level @Authenticated bypass handler permissions', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: [], isSuperAdmin: false },
|
|
{ authenticatedOnly: true, permissions: ['user:edit'] },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('still enforces policies when @Authenticated and @CheckPolicies coexist', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: [], isSuperAdmin: false },
|
|
{
|
|
authenticatedOnly: true,
|
|
checkPolicies: [(ability: any) => ability.can('read', 'Student')],
|
|
},
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// @CheckPolicies passthrough
|
|
// -----------------------------------------------------------------------
|
|
|
|
it('allows pass-through for @CheckPolicies routes with user present', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: [], isSuperAdmin: false },
|
|
{ checkPolicies: [(ab: any) => ab.can('read', 'Student')] },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('denies pass-through for @CheckPolicies routes without user', () => {
|
|
expect(
|
|
createContext(undefined, {
|
|
checkPolicies: [(ab: any) => ab.can('read', 'Student')],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// CASL exact-code authorization (collision-free)
|
|
// -----------------------------------------------------------------------
|
|
|
|
it('grants access to super admin for any permission', () => {
|
|
expect(
|
|
createContext({ permissions: [], isSuperAdmin: true }, { permissions: ['student:view'] }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('grants access when user has the exact required permission', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: ['student:view'], isSuperAdmin: false },
|
|
{ permissions: ['student:view'] },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('denies access when user lacks the required permission', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: ['student:view'], isSuperAdmin: false },
|
|
{ permissions: ['class:delete'] },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('denies access when user has no permissions', () => {
|
|
expect(
|
|
createContext({ permissions: [], isSuperAdmin: false }, { permissions: ['student:view'] }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('denies unknown permission codes (no user holds them)', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: ['unknown:action'], isSuperAdmin: false },
|
|
{ permissions: ['other:thing'] },
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('grants exact-code access for custom permissions', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: ['custom:special'], isSuperAdmin: false },
|
|
{ permissions: ['custom:special'] },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('grants with OR matching: one of multiple required permissions', () => {
|
|
expect(
|
|
createContext(
|
|
{ permissions: ['class:view'], isSuperAdmin: false },
|
|
{ permissions: ['student:delete', 'class:view'] },
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// ── Collision regression tests ──
|
|
|
|
it('denies bill:export-excel when user only has bill:view', () => {
|
|
const ability = abilityFactory.createForUser({
|
|
permissions: ['bill:view'],
|
|
isSuperAdmin: false,
|
|
});
|
|
|
|
// Domain layer: both would give read Bill — but exact-code check must discriminate
|
|
expect(ability.can(CaslAction.Read, 'Bill')).toBe(true);
|
|
// Exact-code check: bill:view user must NOT have bill:export-excel
|
|
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
|
|
});
|
|
|
|
it('denies bill:confirm when user only has bill:view', () => {
|
|
const ability = abilityFactory.createForUser({
|
|
permissions: ['bill:view'],
|
|
isSuperAdmin: false,
|
|
});
|
|
|
|
// Domain layer: confirm → update, view → read — already distinct at domain level
|
|
expect(ability.can(CaslAction.Update, 'Bill')).toBe(false);
|
|
// Exact-code: must also fail
|
|
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:confirm'))).toBe(false);
|
|
});
|
|
|
|
it('denies deposit:approve when user only has deposit:edit', () => {
|
|
const ability = abilityFactory.createForUser({
|
|
permissions: ['deposit:edit'],
|
|
isSuperAdmin: false,
|
|
});
|
|
|
|
// Domain layer: both map to update — would pass domain check
|
|
expect(ability.can(CaslAction.Update, 'Deposit')).toBe(true);
|
|
// Exact-code: must fail — edit is not approve
|
|
expect(ability.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
|
|
});
|
|
|
|
it('denies attendance:export when user only has attendance:view', () => {
|
|
const ability = abilityFactory.createForUser({
|
|
permissions: ['attendance:view'],
|
|
isSuperAdmin: false,
|
|
});
|
|
|
|
// Domain layer: both map to read
|
|
expect(ability.can(CaslAction.Read, 'Attendance')).toBe(true);
|
|
// Exact-code: must fail
|
|
expect(ability.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
|
|
});
|
|
|
|
it('unknown code student:nuke does not create domain ability', () => {
|
|
const ability = abilityFactory.createForUser({
|
|
permissions: ['student:nuke'],
|
|
isSuperAdmin: false,
|
|
});
|
|
|
|
expect(ability.can(CaslAction.Manage, 'Student')).toBe(false);
|
|
expect(ability.can(CaslAction.Read, 'Student')).toBe(false);
|
|
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
|
|
});
|
|
});
|