修复管理员班级可见范围

This commit is contained in:
2026-07-24 09:41:37 +08:00
parent 106c30683f
commit a9c6578569
2 changed files with 110 additions and 7 deletions

View File

@@ -0,0 +1,96 @@
import { ClassesController } from './classes.controller';
import { ClassesService } from './classes.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { NotificationsService } from '../notifications/notifications.service';
import { CaslAction, SubjectName } from '../authorization';
describe('ClassesController - class data scope', () => {
const service = {
getAccessibleClassIds: jest.fn(),
assertClassAccess: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
getSchedule: jest.fn(),
getAttendanceSummary: jest.fn(),
getStudents: jest.fn(),
getTeachers: jest.fn(),
};
const authzService = { can: jest.fn() };
const logService = {};
const notificationsService = {};
let controller: ClassesController;
const request = (permissions: string[] = [], isSuperAdmin = false) => ({
user: {
id: 21,
username: 'user',
permissions,
isSuperAdmin,
roles: [],
},
});
beforeEach(() => {
jest.clearAllMocks();
authzService.can.mockImplementation((req, action, subject) => {
if (subject !== SubjectName.Class) return false;
if (req.user.isSuperAdmin && action === CaslAction.Manage) return true;
if (action === CaslAction.Create) return req.user.permissions.includes('class:create');
if (action === CaslAction.Update) return req.user.permissions.includes('class:edit');
return false;
});
service.getAccessibleClassIds.mockResolvedValue(undefined);
service.assertClassAccess.mockResolvedValue(undefined);
service.findAll.mockResolvedValue([]);
service.findOne.mockResolvedValue({ id: 8 });
service.getSchedule.mockResolvedValue([]);
service.getAttendanceSummary.mockResolvedValue({});
service.getStudents.mockResolvedValue([]);
service.getTeachers.mockResolvedValue([]);
controller = new ClassesController(
service as unknown as ClassesService,
logService as unknown as OperationLogsService,
notificationsService as unknown as NotificationsService,
authzService as never,
);
});
it.each([
['class creator', request(['class:view', 'class:create'])],
['class editor', request(['class:view', 'class:edit'])],
['super admin', request([], true)],
])('allows %s to list all classes', async (_label, req) => {
await controller.findAll({}, req);
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, true);
expect(service.findAll).toHaveBeenCalledWith({}, undefined);
});
it('keeps view-only users scoped to their assigned classes', async () => {
service.getAccessibleClassIds.mockResolvedValue([8]);
await controller.findAll({}, request(['class:view']));
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, false);
expect(service.findAll).toHaveBeenCalledWith({}, [8]);
});
it('allows a class creator to read an unassigned class through every detail endpoint', async () => {
const req = request(['class:view', 'class:create']);
await controller.findOne('8', req);
await controller.getSchedule('8', {}, req);
await controller.getAttendanceSummary('8', {}, req);
await controller.getStudents('8', req);
await controller.getTeachers('8', req);
expect(service.assertClassAccess).toHaveBeenCalledTimes(5);
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, true);
});
it('requires a view-only user to be assigned before reading class details', async () => {
await controller.findOne('8', request(['class:view']));
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
});

View File

@@ -54,12 +54,20 @@ export class ClassesController {
private readonly authz: AuthorizationService,
) {}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
const canManageAll =
private canManageAllClasses(req: AuthenticatedRequest): boolean {
return (
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
this.authz.can(req, CaslAction.Create, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
);
}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
return this.service.assertClassAccess(
req.user.id,
classId,
this.canManageAllClasses(req),
);
}
@Get()
@@ -67,8 +75,7 @@ export class ClassesController {
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
this.canManageAllClasses(req),
);
return this.service.findAll(query, classIds);
}