Files
gongxue-base/apps/server/src/auth/guards/permission.guard.spec.ts

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);
});
});