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' }, }, ]); }); });