import { readFile, rm } from 'fs/promises'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { testHome, testUserData, mockLoggerWarn, mockLoggerInfo, mockLoggerError } = vi.hoisted(() => { const suffix = Math.random().toString(36).slice(2); return { testHome: `/tmp/clawx-channel-config-${suffix}`, testUserData: `/tmp/clawx-channel-config-user-data-${suffix}`, mockLoggerWarn: vi.fn(), mockLoggerInfo: vi.fn(), mockLoggerError: vi.fn(), }; }); vi.mock('os', async () => { const actual = await vi.importActual('os'); const mocked = { ...actual, homedir: () => testHome, }; return { ...mocked, default: mocked, }; }); vi.mock('electron', () => ({ app: { isPackaged: false, getPath: () => testUserData, getVersion: () => '0.0.0-test', getAppPath: () => '/tmp', }, })); vi.mock('@electron/utils/logger', () => ({ warn: mockLoggerWarn, info: mockLoggerInfo, error: mockLoggerError, })); async function readOpenClawJson(): Promise> { const content = await readFile(join(testHome, '.openclaw', 'openclaw.json'), 'utf8'); return JSON.parse(content) as Record; } describe('channel credential normalization and duplicate checks', () => { beforeEach(async () => { vi.resetAllMocks(); vi.resetModules(); await rm(testHome, { recursive: true, force: true }); await rm(testUserData, { recursive: true, force: true }); }); it('assertNoDuplicateCredential detects duplicates with different whitespace', async () => { const { saveChannelConfig } = await import('@electron/utils/channel-config'); await saveChannelConfig('feishu', { appId: 'bot-123', appSecret: 'secret-a' }, 'agent-a'); await expect( saveChannelConfig('feishu', { appId: ' bot-123 ', appSecret: 'secret-b' }, 'agent-b'), ).rejects.toThrow('already bound to another agent'); }); it('assertNoDuplicateCredential does NOT detect duplicates with different case', async () => { // Case-sensitive credentials (like tokens) should NOT be normalized to lowercase // to avoid false positives where different tokens become the same after lowercasing const { saveChannelConfig } = await import('@electron/utils/channel-config'); await saveChannelConfig('feishu', { appId: 'Bot-ABC', appSecret: 'secret-a' }, 'agent-a'); // Should NOT throw - different case is considered a different credential await expect( saveChannelConfig('feishu', { appId: 'bot-abc', appSecret: 'secret-b' }, 'agent-b'), ).resolves.not.toThrow(); }); it('normalizes credential values when saving (trim only, preserve case)', async () => { const { saveChannelConfig } = await import('@electron/utils/channel-config'); await saveChannelConfig('feishu', { appId: ' BoT-XyZ ', appSecret: 'secret' }, 'agent-a'); const config = await readOpenClawJson(); const channels = config.channels as Record }>; // Should trim whitespace but preserve original case expect(channels.feishu.accounts['agent-a'].appId).toBe('BoT-XyZ'); }); it('emits warning logs when credential normalization (trim) occurs', async () => { const { saveChannelConfig } = await import('@electron/utils/channel-config'); await saveChannelConfig('feishu', { appId: ' BoT-Log ', appSecret: 'secret' }, 'agent-a'); expect(mockLoggerWarn).toHaveBeenCalledWith( 'Normalized channel credential value for duplicate check', expect.objectContaining({ channelType: 'feishu', accountId: 'agent-a', key: 'appId' }), ); expect(mockLoggerWarn).toHaveBeenCalledWith( 'Normalizing channel credential value before save', expect.objectContaining({ channelType: 'feishu', accountId: 'agent-a', key: 'appId' }), ); }); });