forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { GetStudentBasicTool } from './get-student-basic.tool';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
|
||||
import { AgentToolContextFactory } from '../agent-tool.types';
|
||||
import type { AgentToolContext } from '../agent-tool.types';
|
||||
import type { AuthenticatedUser } from '../../authorization';
|
||||
|
||||
const abilityFactory = new CaslAbilityFactory();
|
||||
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
|
||||
|
||||
function makeCtx(overrides: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: overrides.id,
|
||||
username: overrides.username,
|
||||
permissions: overrides.permissions ?? [],
|
||||
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
||||
roles: overrides.roles ?? [],
|
||||
};
|
||||
return AgentToolContextFactory.fromAuthenticatedUser(user);
|
||||
}
|
||||
|
||||
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
|
||||
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
|
||||
const classEditorCtx = makeCtx({
|
||||
id: 4,
|
||||
username: 'class_editor',
|
||||
permissions: ['student:view', 'class:edit'],
|
||||
});
|
||||
|
||||
function makeTool(svcOverride?: { agentGetStudentBasic: jest.Mock }): GetStudentBasicTool {
|
||||
const svc = svcOverride ?? { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
return new GetStudentBasicTool(svc as never, scopeFactory);
|
||||
}
|
||||
|
||||
const basicOutput = {
|
||||
id: 1,
|
||||
name: '张三',
|
||||
studentNo: 'S001',
|
||||
gender: '男',
|
||||
status: 'active',
|
||||
organizationId: 10,
|
||||
organizationName: '杭州校区',
|
||||
classIds: [5],
|
||||
};
|
||||
|
||||
describe('GetStudentBasicTool', () => {
|
||||
it('has name "get_student_basic"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.name).toBe('get_student_basic');
|
||||
});
|
||||
|
||||
it('requires permission "student:view"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.requiredPermission).toBe('student:view');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('validate', () => {
|
||||
it('accepts valid studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.studentId).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects missing studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('studentId');
|
||||
});
|
||||
|
||||
it('rejects non-integer studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 'abc' });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra unknown fields', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1, extraField: 'hack' });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('extraField');
|
||||
});
|
||||
|
||||
it('rejects userId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1, userId: 999 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('userId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-2: Scope construction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-2: scope', () => {
|
||||
it('super admin uses manageAll scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, superAdminCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'manageAll' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('non-admin uses teacher scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, studentViewerCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'teacher', userId: 2 },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('class:edit uses manageAll scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, classEditorCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'manageAll' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-1: NotFoundException for null result
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-1: NotFoundException', () => {
|
||||
it('null from service throws NotFoundException (not returned as success)', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('service NotFound message is "记录不存在或无权访问"', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
|
||||
'记录不存在或无权访问',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Execute — happy path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns formatted student data', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
const result = await tool.execute({ studentId: 1 }, superAdminCtx);
|
||||
expect(result).toEqual(basicOutput);
|
||||
const keys = Object.keys(result as Record<string, unknown>);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user