test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -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);
});
});

View File

@@ -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 {};
}