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:
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal file
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
|
||||
describe('DingTalkService attendance group deletion', () => {
|
||||
const originalAppKey = process.env.DINGTALK_APP_KEY;
|
||||
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
|
||||
let service: DingTalkService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DINGTALK_APP_KEY = 'test-app-key';
|
||||
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
|
||||
service = new DingTalkService({} as never, {} as never);
|
||||
Object.assign(service, {
|
||||
accessToken: 'test-token',
|
||||
tokenExpiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
|
||||
else process.env.DINGTALK_APP_KEY = originalAppKey;
|
||||
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
|
||||
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
|
||||
});
|
||||
|
||||
it('converts groupId to groupKey before deleting the group', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', result: 'group-key-1' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', success: true }),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.deleteAttendanceGroup(123, 'manager');
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('/topapi/attendance/groups/idtokey'),
|
||||
expect.objectContaining({ body: JSON.stringify({ op_user_id: 'manager', group_id: 123 }) }),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('/topapi/attendance/group/delete'),
|
||||
expect.objectContaining({ body: JSON.stringify({ op_userid: 'manager', group_key: 'group-key-1' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -480,7 +480,7 @@ export class DingTalkService {
|
||||
return records.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: '',
|
||||
workDate: new Date(r.workDate).toISOString().slice(0, 10),
|
||||
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
timeResult: r.timeResult ?? r.sourceType ?? '',
|
||||
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
|
||||
planCheckTime: '',
|
||||
@@ -728,6 +728,47 @@ export class DingTalkService {
|
||||
return all;
|
||||
}
|
||||
|
||||
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
await this.rateLimit();
|
||||
const keyResponse = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
|
||||
},
|
||||
);
|
||||
const keyData = await keyResponse.json() as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result?: string;
|
||||
};
|
||||
if (keyData.errcode !== 0 || !keyData.result) {
|
||||
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
|
||||
}
|
||||
|
||||
await this.rateLimit();
|
||||
const deleteResponse = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
|
||||
},
|
||||
);
|
||||
const deleteData = await deleteResponse.json() as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
success?: boolean;
|
||||
};
|
||||
if (deleteData.errcode !== 0 || deleteData.success !== true) {
|
||||
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 考勤排班 — 排班分配
|
||||
|
||||
Reference in New Issue
Block a user