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

@@ -125,4 +125,101 @@ describe('AttendanceImportService', () => {
);
});
it('auto-matches previously imported duplicate records', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
},
]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1);
});
it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '李四',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T09:00:00.000Z',
checkId: 'check-2',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
userId: 42,
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBe(42);
}
});
it('emits userId undefined when import has no HTTP user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-2',
userName: '王五',
workDate: '2026-07-02',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-02T10:00:00.000Z',
checkId: 'check-3',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-02',
endDate: '2026-07-02',
userIds: ['ding-2'],
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBeUndefined();
}
});
});