Files
gongxue-base/apps/server/src/integration/config/integration-config.service.spec.ts
wangziqi 99ea931409 fix(security): 认证/越权/注入/上传/凭据全链路加固
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- JWT 生产必填、密码 8-72 字节、防枚举;全局 ValidationPipe
- classes/dashboard/schedules/students/attendance/archive/exams 越权与 IDOR 修复
- LIKE 通配符转义(14 处);上传 10MB 上限 + MIME 白名单 + 附件 XSS
- 集成配置 appSecret AES 加密 + 回填脚本;审计 best-effort;IP 来源防伪造

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:22 +08:00

194 lines
6.0 KiB
TypeScript

import { IntegrationConfigService } from './integration-config.service';
import { decryptSecret, encryptSecret, isEncryptedSecret } from './secret-crypto';
describe('IntegrationConfigService.testConnection', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('uses the saved AppSecret when testing an existing configuration with a blank secret', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue({
configId: 1,
type: 'DINGTALK_SYNC',
content: JSON.stringify({
config: {
corpId: 'ding-corp',
agentId: 'saved-key',
appSecret: 'saved-secret',
},
}),
}),
};
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
}) as never;
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(
service.testConnection('DINGTALK', {
corpId: 'ding-corp',
agentId: 'saved-key',
appSecret: '',
}),
).resolves.toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.dingtalk.com/v1.0/oauth2/accessToken',
expect.objectContaining({
body: JSON.stringify({ appKey: 'saved-key', appSecret: 'saved-secret' }),
}),
);
});
});
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);
});
});
describe('IntegrationConfigService appSecret encryption', () => {
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
jest.restoreAllMocks();
});
it('encrypts AppSecret before persisting content', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
create: jest.fn(),
save: jest.fn(),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn((data) => data),
save: jest.fn().mockResolvedValue(undefined),
};
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({ accessToken: 'token' }),
}) as never;
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await service.saveConfig({
type: 'DINGTALK',
config: { corpId: 'corp', agentId: 'agent', appSecret: 'plain-secret' },
} as never);
expect(detailRepo.create).toHaveBeenCalled();
const saved = detailRepo.create.mock.calls[0][0];
const parsed = JSON.parse(saved.content) as {
config: { appSecret: string };
};
expect(parsed.config.appSecret).not.toBe('plain-secret');
expect(isEncryptedSecret(parsed.config.appSecret)).toBe(true);
expect(decryptSecret(parsed.config.appSecret)).toBe('plain-secret');
});
it('decrypts an encrypted stored AppSecret when reading raw config', async () => {
const configRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }),
};
const detailRepo = {
findOne: jest.fn().mockResolvedValue({
configId: 1,
type: 'DINGTALK_SYNC',
content: JSON.stringify({
config: {
corpId: 'corp',
agentId: 'agent',
appSecret: encryptSecret('saved-secret'),
},
}),
}),
};
const service = new IntegrationConfigService(configRepo as never, detailRepo as never);
await expect(service.getRawConfig('DINGTALK')).resolves.toEqual({
corpId: 'corp',
agentId: 'agent',
appSecret: 'saved-secret',
});
});
it('keeps masking AppSecret even when the stored value is encrypted', async () => {
const content = JSON.stringify({
config: {
corpId: 'corp',
agentId: 'agent',
appSecret: encryptSecret('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' },
},
]);
});
});