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:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Subject } from 'rxjs';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
@@ -11,6 +12,7 @@ describe('AttendanceController — DingTalk import scope', () => {
};
const importService = {
importFromDingTalk: jest.fn(),
progress$: undefined as unknown,
};
const logService = {
log: jest.fn(),
@@ -52,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => {
endDate: '2026-07-10',
userIds: ['ding-today'],
autoMatch: true,
userId: 21,
});
jest.useRealTimers();
});
@@ -64,12 +67,12 @@ describe('AttendanceController — DingTalk import scope', () => {
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 21,
});
});
@@ -144,12 +147,223 @@ describe('AttendanceController — DingTalk import scope', () => {
},
} as never,
);
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 7,
});
});
});
describe('AttendanceController — write data scope', () => {
const attendanceService = {
assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(),
batchCreate: jest.fn(),
generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
getLessonAttendance: jest.fn(),
createLessonAttendanceFromDingTalk: jest.fn(),
findAttendanceSession: jest.fn(),
completeLessonAttendance: jest.fn(),
};
const importService = { importFromDingTalk: jest.fn() };
const logService = { log: jest.fn().mockResolvedValue(undefined) };
const authzService = { can: jest.fn().mockReturnValue(false) };
const req = {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] },
headers: {},
};
let controller: AttendanceController;
beforeEach(() => {
jest.clearAllMocks();
authzService.can.mockReturnValue(false);
controller = new AttendanceController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
});
it('checks every distinct class in a manual attendance batch', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] });
const dto = {
records: [
{
studentId: 1,
classId: 8,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
{
studentId: 2,
classId: 9,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await controller.batchCreate(dto, req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
});
it('rejects classless manual attendance records for a class-scoped teacher', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] });
const dto = {
records: [
{
studentId: 1,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await expect(controller.batchCreate(dto, req as never)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
});
it('checks the schedule class before starting and completing lesson attendance', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
importService.importFromDingTalk.mockResolvedValue({
success: true,
imported: 1,
skipped: 0,
matched: 1,
errors: [],
duration: 10,
});
attendanceService.getLessonAttendance.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: null,
records: [],
});
attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: { id: 90, classId: 8 },
records: [],
});
attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 });
attendanceService.completeLessonAttendance.mockResolvedValue({
session: { id: 90, classId: 8, status: 'completed' },
records: [],
});
await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req as never);
await controller.completeLessonAttendance('90', req as never);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
it('checks class access before generating attendance from schedules', async () => {
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
await controller.generateFromSchedules({ classId: 8 }, req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
it('checks the attendance record owning class before update and delete', async () => {
attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 });
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
attendanceService.remove.mockResolvedValue({ deleted: true });
await controller.update('4', { status: 'late' }, req as never);
await controller.remove('4', req as never);
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
});
describe('AttendanceController — SSE progress scoping', () => {
let progressSubject: Subject<{ phase: string; userId?: number }>;
const importService = {
importFromDingTalk: jest.fn(),
get progress$() { return progressSubject.asObservable(); },
};
const attendanceService = {} as unknown as AttendanceService;
const logService = {} as unknown as OperationLogsService;
const authzService = {} as never;
let controller: AttendanceController;
beforeEach(() => {
progressSubject = new Subject<{ phase: string; userId?: number }>();
controller = new AttendanceController(
attendanceService,
importService as unknown as AttendanceImportService,
logService,
authzService,
);
});
afterEach(() => {
progressSubject.complete();
});
it('delivers events matching the requesting user id', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 42 });
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
expect(received).toHaveLength(2);
});
it('excludes events from a different user', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 99 }); // different user
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the matching event should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
it('excludes events with undefined userId (non-HTTP callers)', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
} as never).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching' }); // no userId
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the event with matching userId should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
});