feat: add CASL authorization and AI configuration
This commit is contained in:
@@ -1,50 +1,223 @@
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
|
||||
|
||||
describe('PermissionGuard', () => {
|
||||
const createContext = (user: unknown) =>
|
||||
({
|
||||
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;
|
||||
} as never);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @Public / @Authenticated / undeclared
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('denies routes that forgot to declare permissions', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(false),
|
||||
getAllAndMerge: jest.fn().mockReturnValue(undefined),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
|
||||
expect(createContext(undefined)).toBe(false);
|
||||
expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('allows explicitly public routes without a user', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(true);
|
||||
expect(createContext(undefined, { isPublic: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: [] }))).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 authenticated user is present', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
it('denies authenticated-only routes when no user is present', () => {
|
||||
expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
|
||||
});
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user