Files
gongxue-base/apps/server/src/authorization/guards/policies.guard.spec.ts

202 lines
6.3 KiB
TypeScript

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