182 lines
6.4 KiB
TypeScript
182 lines
6.4 KiB
TypeScript
import { SearchStudentsTool } from './search-students.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 noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
|
|
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?: { agentSearchStudents: jest.Mock }): SearchStudentsTool {
|
|
const svc = svcOverride ?? { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
|
return new SearchStudentsTool(svc as never, scopeFactory);
|
|
}
|
|
|
|
describe('SearchStudentsTool', () => {
|
|
// -----------------------------------------------------------------------
|
|
// Tool metadata
|
|
// -----------------------------------------------------------------------
|
|
|
|
it('has name "search_students"', () => {
|
|
const tool = makeTool();
|
|
expect(tool.name).toBe('search_students');
|
|
});
|
|
|
|
it('requires permission "student:view"', () => {
|
|
const tool = makeTool();
|
|
expect(tool.requiredPermission).toBe('student:view');
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Input validation
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('validate', () => {
|
|
it('accepts valid input with keyword', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ keyword: '张三' });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value.keyword).toBe('张三');
|
|
});
|
|
|
|
it('accepts valid input with classId', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ classId: 5 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value.classId).toBe(5);
|
|
});
|
|
|
|
it('accepts valid input with organizationId', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ organizationId: 10 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value.organizationId).toBe(10);
|
|
});
|
|
|
|
it('accepts valid input with limit', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ limit: 30 });
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.value.limit).toBe(30);
|
|
});
|
|
|
|
it('rejects userId', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ userId: 999 });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('userId');
|
|
});
|
|
|
|
it('rejects isSuperAdmin', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ isSuperAdmin: true });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('isSuperAdmin');
|
|
});
|
|
|
|
it('rejects permissions', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ permissions: ['student:delete'] });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('permissions');
|
|
});
|
|
|
|
it('rejects roles', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ roles: ['admin'] });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('roles');
|
|
});
|
|
|
|
it('rejects ability', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ ability: {} });
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toContain('ability');
|
|
});
|
|
|
|
it('rejects unknown fields to match additionalProperties false', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ debug: true });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('rejects limit above the advertised maximum', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ limit: 51 });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('rejects non-integer classId', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ classId: 'abc' });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it('rejects non-integer organizationId', () => {
|
|
const tool = makeTool();
|
|
const result = tool.validate({ organizationId: 1.5 });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
// -----------------------------------------------------------------------
|
|
// P2-2: Scope construction via StudentAccessScopeFactory
|
|
// -----------------------------------------------------------------------
|
|
|
|
describe('P2-2: scope construction', () => {
|
|
it('super admin uses manageAll scope', async () => {
|
|
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
|
const tool = makeTool(mockSvc);
|
|
|
|
await tool.execute({}, superAdminCtx);
|
|
|
|
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
|
|
});
|
|
|
|
it('non-admin uses teacher scope with userId', async () => {
|
|
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
|
const tool = makeTool(mockSvc);
|
|
|
|
await tool.execute({ keyword: 'test' }, studentViewerCtx);
|
|
|
|
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith(
|
|
{ type: 'teacher', userId: 2 },
|
|
{ keyword: 'test' },
|
|
);
|
|
});
|
|
|
|
it('class:edit permission grants manageAll scope (not teacher)', async () => {
|
|
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
|
const tool = makeTool(mockSvc);
|
|
|
|
await tool.execute({}, classEditorCtx);
|
|
|
|
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
|
|
});
|
|
});
|
|
});
|