feat: add CASL authorization and AI configuration
This commit is contained in:
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { PoliciesGuard } from './policies.guard';
|
||||
import { CaslAbilityFactory } from '../casl-ability.factory';
|
||||
import { AppAbility, IPolicyHandler } from '../interfaces';
|
||||
|
||||
describe('PoliciesGuard', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
|
||||
/** Build a mock NestJS ExecutionContext for PoliciesGuard */
|
||||
function createContext(
|
||||
user: unknown,
|
||||
opts: {
|
||||
policyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler> | null;
|
||||
controllerPolicyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler>;
|
||||
isPublic?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const meta = new Map<string, unknown>();
|
||||
if (opts.policyHandlers !== undefined) meta.set('check_policies', opts.policyHandlers);
|
||||
if (opts.isPublic !== undefined) meta.set('isPublic', opts.isPublic);
|
||||
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
|
||||
getAllAndMerge: jest.fn((key: string) => {
|
||||
if (key !== 'check_policies') return [];
|
||||
return [...(opts.policyHandlers ?? []), ...(opts.controllerPolicyHandlers ?? [])];
|
||||
}),
|
||||
};
|
||||
const guard = new PoliciesGuard(reflector as never, factory);
|
||||
return guard.canActivate({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No @CheckPolicies → pass-through
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('passes through when no @CheckPolicies is declared', () => {
|
||||
expect(createContext(undefined)).toBe(true);
|
||||
expect(createContext(null)).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @Public interaction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('skips when @Public is declared, even with @CheckPolicies', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
isPublic: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// User absent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('denies when @CheckPolicies is declared but no user present', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Callback handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants when all policies pass for super admin', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: true },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('delete', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants when all policies pass for user with correct permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('read', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when any policy fails (AND semantics)', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'), // passes
|
||||
(ability) => ability.can('delete', 'Student'), // fails
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('empty handlers array passes (no policies to check)', () => {
|
||||
expect(
|
||||
createContext({ permissions: ['student:view'], isSuperAdmin: false }, { policyHandlers: [] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('merges controller and handler policies with AND semantics', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
controllerPolicyHandlers: [(ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Class-based handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('supports class-based policy handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new ReadStudentPolicy()] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when class-based handler fails', () => {
|
||||
class DeleteStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('delete', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new DeleteStudentPolicy()] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('mixes callback and class-based handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [new ReadStudentPolicy(), (ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Instance-level policy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('custom instance-level policy: checks specific resource conditions', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
const ownsResource = (ab: typeof ability) => ab.can('update', 'Student');
|
||||
|
||||
expect(ownsResource(ability)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user