fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
const teacherRequest = {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const scheduleDto = {
|
||||
classId: 8,
|
||||
classroomId: 3,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '数学',
|
||||
};
|
||||
|
||||
describe('SchedulesController — class data scope', () => {
|
||||
const service = {
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
assertClassAccess: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getClassroomOccupancy: jest.fn(),
|
||||
maskScheduleOccupancy: jest.fn((schedule) => ({
|
||||
...schedule,
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
})),
|
||||
checkConflict: jest.fn(),
|
||||
};
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationsService = { create: jest.fn() };
|
||||
const ability = { can: jest.fn().mockReturnValue(false) };
|
||||
const authzService = { abilityForRequest: jest.fn().mockReturnValue(ability) };
|
||||
let controller: SchedulesController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
ability.can.mockReturnValue(false);
|
||||
service.getAccessibleClassIds.mockResolvedValue([8]);
|
||||
controller = new SchedulesController(
|
||||
service as unknown as SchedulesService,
|
||||
logService as unknown as OperationLogsService,
|
||||
notificationsService as unknown as NotificationsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the requested class before creating a schedule', async () => {
|
||||
service.create.mockResolvedValue({ id: 1, ...scheduleDto });
|
||||
|
||||
await controller.create(scheduleDto, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.create).toHaveBeenCalledWith(scheduleDto);
|
||||
});
|
||||
|
||||
it('checks both the current and destination class before moving a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.update.mockResolvedValue({ id: 4, ...scheduleDto, classId: 9 });
|
||||
|
||||
await controller.update('4', { classId: 9 }, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 9, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before returning full schedule details', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
|
||||
await controller.findOne('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before deleting a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.remove.mockResolvedValue({ success: true });
|
||||
|
||||
await controller.remove('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.remove).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('returns only masked occupancy blocks from the classroom occupancy endpoint', async () => {
|
||||
service.getClassroomOccupancy.mockResolvedValue([
|
||||
{ id: 2, classId: 99, subject: '英语', teacherId: 7, notes: '隐私', classroomId: 3 },
|
||||
]);
|
||||
|
||||
const result = await controller.getClassroomOccupancy('3', undefined, teacherRequest as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('英语');
|
||||
expect(JSON.stringify(result)).not.toContain('隐私');
|
||||
});
|
||||
|
||||
it('rejects records without a class instead of exposing full details to a scoped teacher', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto, classId: null });
|
||||
|
||||
await expect(controller.findOne('4', teacherRequest as never)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user