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,9 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
],
}).compile();
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
classStudentRepo as never,
mappingRepo as never,
classTeacherRepo as never,
{} as never,
{} as never,
);
beforeEach(() => {
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
classStudentRepo as never,
mappingRepo as never,
{} as never,
{} as never,
{} as never,
);
await expect(
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
expect(qb.take).toHaveBeenCalledWith(10);
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('AttendanceService — session serialization', () => {
const sessionId = 100;
const recordId = 200;
const sessionInProgress = { id: sessionId, status: 'in_progress' };
const sessionCompleted = { id: sessionId, status: 'completed' };
const record: Record<string, unknown> = {
id: recordId,
attendanceSessionId: sessionId,
studentId: 1,
status: 'present',
source: 'dingtalk',
attendanceDate: '2026-07-01',
session: 'morning',
};
function makeService(
dataSourceMock: { transaction: jest.Mock },
attendanceRepoOverrides?: Record<string, jest.Mock>,
) {
const defaultAttendanceRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
create: jest.fn(),
createQueryBuilder: jest.fn(),
};
const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };
return new AttendanceService(
attendanceRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
dataSourceMock as never,
);
}
function makeTxManager(sessionStatus: string) {
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const sessionRepo = {
findOne: jest.fn().mockResolvedValue(session),
save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
};
const recordRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
count: jest.fn().mockResolvedValue(0),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
};
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === AttendanceSession) return sessionRepo;
if (entity === AttendanceRecord) return recordRepo;
throw new Error('Unexpected entity');
}),
sessionRepo,
recordRepo,
};
}
it('complete holds lock; queued update is rejected after session becomes completed', async () => {
// For this test, the manager has a completed session
// The complete callback is stalled, update queues and then finds completed
const manager = makeTxManager('completed');
const completeStall = deferred<unknown>();
const txMock = jest
.fn()
.mockImplementationOnce(() => completeStall.promise)
.mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const completeP = svc.completeLessonAttendance(sessionId, 1);
const updateP = svc.update(recordId, { status: 'absent' });
completeStall.resolve({ session: sessionCompleted, records: [record] });
await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
await expect(updateP).rejects.toThrow(BadRequestException);
expect(manager.sessionRepo.findOne).toHaveBeenCalled();
});
it('update holds lock; complete reads current state after update finishes', async () => {
const manager = makeTxManager('in_progress');
const updateStall = deferred<unknown>();
// Track call order
const callOrder: string[] = [];
let transactionsRun = 0;
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
transactionsRun++;
if (transactionsRun === 1) {
// update: stall
callOrder.push('update-tx-started');
return updateStall.promise.then((v) => {
callOrder.push('update-tx-resolved');
return v;
});
}
// complete: actually run the callback
callOrder.push('complete-tx-started');
return cb(manager);
});
const svc = makeService({ transaction: txMock });
// Start update but DON'T await — it will stall
const updateP = svc.update(recordId, { status: 'absent' });
// Give update time to enter the mutex and transaction
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Start complete while update is stalled
const completeP = svc.completeLessonAttendance(sessionId, 1);
// Another tick
await Promise.resolve();
// Resolve update's stalled transaction
updateStall.resolve(record);
const updateResult = await updateP;
expect(updateResult).toEqual(record);
const completeResult = await completeP;
// Verify the callback was actually run correctly
expect(callOrder).toContain('update-tx-started');
expect(callOrder).toContain('complete-tx-started');
expect(completeResult).toHaveProperty('session');
expect(completeResult).toHaveProperty('records');
});
it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
const noSessionRecord = { ...record, attendanceSessionId: null };
const overrides = {
findOne: jest.fn().mockResolvedValue(noSessionRecord),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
};
const txMock = jest.fn();
const svc = makeService({ transaction: txMock }, overrides);
const result = await svc.update(recordId, { status: 'absent' });
expect(result).toEqual(noSessionRecord);
expect(txMock).not.toHaveBeenCalled();
const removeResult = await svc.remove(recordId);
expect(removeResult).toEqual({ deleted: true });
});
it('complete is idempotent: returns current state when session already completed', async () => {
const manager = makeTxManager('completed');
const txMock = jest
.fn()
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const result = await svc.completeLessonAttendance(sessionId, 1);
expect(result).toHaveProperty('session');
expect(result).toHaveProperty('records');
expect(manager.recordRepo.count).not.toHaveBeenCalled();
});
});