forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers — simulate TypeORM QueryBuilder with raw-column naming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QbMock extends Record<string, jest.Mock> {
|
||||
select: jest.Mock;
|
||||
distinct: jest.Mock;
|
||||
leftJoin: jest.Mock;
|
||||
innerJoin: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
take: jest.Mock;
|
||||
getRawMany: jest.Mock;
|
||||
getRawOne: jest.Mock;
|
||||
setParameter: jest.Mock;
|
||||
}
|
||||
|
||||
function makeQb(rawMany: unknown[] = [], rawOne: unknown | null = null): QbMock {
|
||||
const qb: QbMock = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
setParameter: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue(rawMany),
|
||||
getRawOne: jest.fn().mockResolvedValue(rawOne),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** StudentsService constructor args in order. */
|
||||
function makeService(
|
||||
studentRawMany: unknown[] = [],
|
||||
studentRawOne: unknown | null = null,
|
||||
classStudentRawMany: unknown[] = [],
|
||||
innerJoinOnStudentQb?: (qb: QbMock) => void,
|
||||
): {
|
||||
service: StudentsService;
|
||||
studentQb: QbMock;
|
||||
classStudentQb: QbMock;
|
||||
} {
|
||||
const studentQb = makeQb(studentRawMany, studentRawOne ?? studentRawMany[0] ?? null);
|
||||
const classStudentQb = makeQb(classStudentRawMany);
|
||||
|
||||
if (innerJoinOnStudentQb) innerJoinOnStudentQb(studentQb);
|
||||
|
||||
const studentRepo = { createQueryBuilder: jest.fn().mockReturnValue(studentQb) };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn().mockReturnValue(classStudentQb) };
|
||||
|
||||
const service = new StudentsService(
|
||||
studentRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never, // classRepo
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // organizationRepo
|
||||
);
|
||||
|
||||
return { service, studentQb, classStudentQb };
|
||||
}
|
||||
|
||||
const manageAll: StudentAccessScope = { type: 'manageAll' };
|
||||
const teacher: StudentAccessScope = { type: 'teacher', userId: 42 };
|
||||
|
||||
// TypeORM getRawMany/getRawOne uses snake_case column aliases
|
||||
const sampleRaw = {
|
||||
student_id: 1,
|
||||
student_name: '张三',
|
||||
student_student_no: 'S001',
|
||||
student_gender: '男',
|
||||
student_status: 'active',
|
||||
student_organization_id: 10,
|
||||
organization_name: '杭州校区',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
id: 1,
|
||||
name: '张三',
|
||||
studentNo: 'S001',
|
||||
gender: '男',
|
||||
status: 'active',
|
||||
organizationId: 10,
|
||||
organizationName: '杭州校区',
|
||||
classIds: [5],
|
||||
};
|
||||
|
||||
// classStudentRepo raw aliases: cs.studentId → cs_student_id, cs.classId → cs_class_id
|
||||
const classStudentRaw = [
|
||||
{ cs_student_id: 1, cs_class_id: 5 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StudentsService — agent-safe query APIs', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — scope enforcement in SQL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — scope enforcement in SQL', () => {
|
||||
it('manageAll scope does NOT add teacher/class restrictions', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).not.toContain('class_teacher');
|
||||
expect(innerJoinArgs).not.toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope adds class_student join with class_teacher subquery', async () => {
|
||||
const { service, studentQb } = makeService([], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope returns empty array when no students match', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentSearchStudents(teacher, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — classId intersection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — classId intersection', () => {
|
||||
it('classId triggers class_student join for manageAll', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { classId: 5 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('scopeClassId');
|
||||
});
|
||||
|
||||
it('classId ANDs with teacher scope (intersection, not widening)', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, { classId: 3 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
|
||||
// classId adds an andWhere, not replacing the teacher join
|
||||
const andWhereArgs = studentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereArgs).toContain('scopeClassId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — field whitelist
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — field whitelist', () => {
|
||||
it('select list excludes sensitive fields', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const selectCalls = studentQb.select.mock.calls.flat().join(' ');
|
||||
const forbidden = ['phone', 'idNumber', 'emergencyContact', 'emergencyPhone'];
|
||||
for (const field of forbidden) {
|
||||
expect(selectCalls).not.toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps raw columns to formatted output', async () => {
|
||||
const { service } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
expect(result).toEqual([expectedOutput]);
|
||||
});
|
||||
|
||||
it('output never contains phone/idNumber', async () => {
|
||||
const { service } = makeService([{ ...sampleRaw, student_phone: '13800138000' }], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
if (result.length > 0) {
|
||||
const keys = Object.keys(result[0]);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — limit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — limit', () => {
|
||||
it('clamps limit to max 50', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 200 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('clamps limit to min 1', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 0 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('defaults limit to 20 when not specified', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
expect(studentQb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — filters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — filters', () => {
|
||||
it('keyword goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { keyword: '张三' });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('LIKE');
|
||||
expect(andWhereStr).toContain('keyword');
|
||||
});
|
||||
|
||||
it('organizationId goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { organizationId: 10 });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('orgId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentGetStudentBasic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentGetStudentBasic', () => {
|
||||
it('returns formatted result for student in scope', async () => {
|
||||
// getRawOne returns first element
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('returns null when student not in scope', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentGetStudentBasic(teacher, 999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces teacher scope at SQL level via subquery', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], sampleRaw, []);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('output whitelist excludes sensitive fields', async () => {
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
const keys = Object.keys(result!);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
expect(keys).not.toContain('emergencyContact');
|
||||
expect(keys).not.toContain('emergencyPhone');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P1-3: classIds scope enforcement (second query re-applies teacher filter)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P1-3: classIds second query enforces teacher scope', () => {
|
||||
it('agentSearchStudents teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic manageAll: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(manageAll, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-3: DISTINCT to prevent duplicate students from teacher multi-class join
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-3: DISTINCT in main student query', () => {
|
||||
it('agentSearchStudents teacher scope calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope also calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user