test: harden business boundary conditions
This commit is contained in:
@@ -26,6 +26,7 @@ describe('IntegrationConfigService.testConnection', () => {
|
||||
}),
|
||||
};
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
|
||||
}) as never;
|
||||
|
||||
@@ -47,3 +48,52 @@ describe('IntegrationConfigService.testConnection', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IntegrationConfigService security boundaries', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('masks AppSecret without mutating the parsed source object', async () => {
|
||||
const content = JSON.stringify({
|
||||
config: { corpId: 'corp', agentId: 'agent', appSecret: 'top-secret' },
|
||||
});
|
||||
const configRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
|
||||
};
|
||||
const detailRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ type: 'DINGTALK_SYNC', enable: true, content }]),
|
||||
};
|
||||
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
|
||||
|
||||
await expect(service.getThirdConfig()).resolves.toEqual([
|
||||
{
|
||||
type: 'DINGTALK',
|
||||
verify: true,
|
||||
config: { corpId: 'corp', agentId: 'agent' },
|
||||
},
|
||||
]);
|
||||
expect(JSON.parse(content).config.appSecret).toBe('top-secret');
|
||||
});
|
||||
|
||||
it('treats a non-2xx DingTalk token response as a failed connection even if it contains a token field', async () => {
|
||||
const configRepo = { findOne: jest.fn() };
|
||||
const detailRepo = { findOne: jest.fn() };
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: jest.fn().mockResolvedValue({ accessToken: 'must-not-be-used' }),
|
||||
}) as never;
|
||||
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
|
||||
|
||||
await expect(
|
||||
service.testConnection('DINGTALK' as never, {
|
||||
corpId: 'corp',
|
||||
agentId: 'agent',
|
||||
appSecret: 'secret',
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -205,13 +205,21 @@ export class IntegrationConfigService {
|
||||
|
||||
/** 调钉钉新版接口拿 access_token */
|
||||
private async fetchDingTalkToken(appKey: string, appSecret: string): Promise<string | null> {
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
});
|
||||
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
|
||||
return body.accessToken || null;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const res = await fetch('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appKey, appSecret }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { accessToken?: string; expireIn?: number };
|
||||
return body.accessToken || null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析并脱敏:删掉 appSecret 后返回 config 对象 */
|
||||
@@ -219,9 +227,10 @@ export class IntegrationConfigService {
|
||||
if (!content) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
const cfg = parsed.config || parsed;
|
||||
if (cfg.appSecret) delete cfg.appSecret;
|
||||
return cfg;
|
||||
const source = parsed.config || parsed;
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
||||
const { appSecret: _appSecret, ...masked } = source as Record<string, unknown>;
|
||||
return masked;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -174,3 +174,41 @@ describe('DingTalkService — attendance machine only group', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingTalkService — department user pagination boundaries', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it('stops when DingTalk says there is another page but omits the next cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
list: [{ userid: 'u1', name: 'Alice', mobile: '', dept_id_list: [1] }],
|
||||
has_more: true,
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toHaveLength(1);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops when the next cursor repeats the current cursor', async () => {
|
||||
const service = new DingTalkService({} as never, {} as never);
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: { list: [], has_more: true, next_cursor: 0 },
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect((service as any).getDeptUsers('token', 1)).resolves.toEqual([]);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,8 +279,13 @@ export class DingTalkService {
|
||||
if (body.errcode === 0 && body.result) {
|
||||
all.push(...body.result.list);
|
||||
hasMore = body.result.has_more;
|
||||
if (hasMore && body.result.next_cursor !== undefined) {
|
||||
cursor = body.result.next_cursor;
|
||||
if (hasMore) {
|
||||
if (body.result.next_cursor === undefined || body.result.next_cursor === cursor) {
|
||||
this.logger.error(`获取部门 ${deptId} 用户失败: 分页游标未前进`);
|
||||
hasMore = false;
|
||||
} else {
|
||||
cursor = body.result.next_cursor;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasMore = false;
|
||||
|
||||
Reference in New Issue
Block a user