fix(feishu): feishu connector name validate (#797)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
94
tests/e2e/channels-account-id-validation.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const testConfigResponses = {
|
||||
channelsAccounts: {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [
|
||||
{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
agents: {
|
||||
success: true,
|
||||
agents: [],
|
||||
},
|
||||
credentialsValidate: {
|
||||
success: true,
|
||||
valid: true,
|
||||
warnings: [],
|
||||
},
|
||||
channelConfig: {
|
||||
success: true,
|
||||
},
|
||||
};
|
||||
|
||||
test.describe('Channels account ID validation', () => {
|
||||
test('rejects non-canonical custom account ID before save', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, responses) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxE2eChannelConfigSaveCount = 0;
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => {
|
||||
const method = request?.method ?? 'GET';
|
||||
const path = request?.path ?? '';
|
||||
|
||||
if (path === '/api/channels/accounts' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.channelsAccounts } };
|
||||
}
|
||||
if (path === '/api/agents' && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.agents } };
|
||||
}
|
||||
if (path === '/api/channels/credentials/validate' && method === 'POST') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.credentialsValidate } };
|
||||
}
|
||||
if (path === '/api/channels/config' && method === 'POST') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxE2eChannelConfigSaveCount += 1;
|
||||
return { ok: true, data: { status: 200, ok: true, json: responses.channelConfig } };
|
||||
}
|
||||
if (path.startsWith('/api/channels/config/') && method === 'GET') {
|
||||
return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` },
|
||||
};
|
||||
});
|
||||
}, testConfigResponses);
|
||||
|
||||
await completeSetup(page);
|
||||
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||
await expect(page.getByText('Feishu / Lark')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Add Account|account\.add/i }).click();
|
||||
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible();
|
||||
|
||||
await page.locator('#account-id').fill('测试账号');
|
||||
await page.locator('#appId').fill('cli_test');
|
||||
await page.locator('#appSecret').fill('secret_test');
|
||||
|
||||
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click();
|
||||
await expect(page.getByText(/account\.invalidCanonicalId|must use lowercase letters/i).first()).toBeVisible();
|
||||
|
||||
const saveCalls = await electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const count = Number((globalThis as any).__clawxE2eChannelConfigSaveCount || 0);
|
||||
return { count };
|
||||
});
|
||||
expect(saveCalls.count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,11 @@ const readOpenClawConfigMock = vi.fn();
|
||||
const listAgentsSnapshotMock = vi.fn();
|
||||
const sendJsonMock = vi.fn();
|
||||
const proxyAwareFetchMock = vi.fn();
|
||||
const saveChannelConfigMock = vi.fn();
|
||||
const setChannelDefaultAccountMock = vi.fn();
|
||||
const assignChannelAccountToAgentMock = vi.fn();
|
||||
const clearChannelBindingMock = vi.fn();
|
||||
const parseJsonBodyMock = vi.fn();
|
||||
const testOpenClawConfigDir = join(tmpdir(), 'clawx-tests', 'channel-routes-openclaw');
|
||||
|
||||
vi.mock('@electron/utils/channel-config', () => ({
|
||||
@@ -22,17 +27,17 @@ vi.mock('@electron/utils/channel-config', () => ({
|
||||
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
||||
listConfiguredChannelsFromConfig: (...args: unknown[]) => listConfiguredChannelsMock(...args),
|
||||
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
|
||||
saveChannelConfig: vi.fn(),
|
||||
setChannelDefaultAccount: vi.fn(),
|
||||
saveChannelConfig: (...args: unknown[]) => saveChannelConfigMock(...args),
|
||||
setChannelDefaultAccount: (...args: unknown[]) => setChannelDefaultAccountMock(...args),
|
||||
setChannelEnabled: vi.fn(),
|
||||
validateChannelConfig: vi.fn(),
|
||||
validateChannelCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/agent-config', () => ({
|
||||
assignChannelAccountToAgent: vi.fn(),
|
||||
assignChannelAccountToAgent: (...args: unknown[]) => assignChannelAccountToAgentMock(...args),
|
||||
clearAllBindingsForChannel: vi.fn(),
|
||||
clearChannelBinding: vi.fn(),
|
||||
clearChannelBinding: (...args: unknown[]) => clearChannelBindingMock(...args),
|
||||
listAgentsSnapshot: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
||||
listAgentsSnapshotFromConfig: (...args: unknown[]) => listAgentsSnapshotMock(...args),
|
||||
}));
|
||||
@@ -59,7 +64,7 @@ vi.mock('@electron/utils/whatsapp-login', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@electron/api/route-utils', () => ({
|
||||
parseJsonBody: vi.fn().mockResolvedValue({}),
|
||||
parseJsonBody: (...args: unknown[]) => parseJsonBodyMock(...args),
|
||||
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||
}));
|
||||
|
||||
@@ -93,6 +98,8 @@ describe('handleChannelRoutes', () => {
|
||||
vi.resetAllMocks();
|
||||
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
|
||||
proxyAwareFetchMock.mockReset();
|
||||
parseJsonBodyMock.mockResolvedValue({});
|
||||
listConfiguredChannelAccountsMock.mockReturnValue({});
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [],
|
||||
channelAccountOwners: {},
|
||||
@@ -194,6 +201,214 @@ describe('handleChannelRoutes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-canonical account ID on channel config save', async () => {
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: '测试账号',
|
||||
config: { appId: 'cli_xxx', appSecret: 'secret' },
|
||||
});
|
||||
|
||||
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||
const handled = await handleChannelRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/config'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.stringContaining('Invalid accountId format'),
|
||||
}),
|
||||
);
|
||||
expect(saveChannelConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows legacy non-canonical account ID on channel config save when account already exists', async () => {
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'telegram',
|
||||
accountId: 'Legacy_Account',
|
||||
config: { botToken: 'token', allowedUsers: '123456' },
|
||||
});
|
||||
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||
telegram: {
|
||||
defaultAccountId: 'default',
|
||||
accountIds: ['default', 'Legacy_Account'],
|
||||
},
|
||||
});
|
||||
|
||||
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||
const handled = await handleChannelRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/config'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
'telegram',
|
||||
{ botToken: 'token', allowedUsers: '123456' },
|
||||
'Legacy_Account',
|
||||
);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
200,
|
||||
expect.objectContaining({ success: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-canonical account ID on default-account route', async () => {
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: 'ABC',
|
||||
});
|
||||
|
||||
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||
await handleChannelRoutes(
|
||||
{ method: 'PUT' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/default-account'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.stringContaining('Invalid accountId format'),
|
||||
}),
|
||||
);
|
||||
expect(setChannelDefaultAccountMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-canonical account ID on binding routes', async () => {
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: 'Account-Upper',
|
||||
agentId: 'main',
|
||||
});
|
||||
|
||||
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||
await handleChannelRoutes(
|
||||
{ method: 'PUT' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
400,
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
error: expect.stringContaining('Invalid accountId format'),
|
||||
}),
|
||||
);
|
||||
expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
|
||||
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: 'INVALID VALUE',
|
||||
});
|
||||
await handleChannelRoutes(
|
||||
{ method: 'DELETE' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(clearChannelBindingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows legacy non-canonical account ID on default-account and binding routes', async () => {
|
||||
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||
feishu: {
|
||||
defaultAccountId: 'default',
|
||||
accountIds: ['default', 'Legacy_Account'],
|
||||
},
|
||||
});
|
||||
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: 'Legacy_Account',
|
||||
});
|
||||
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||
await handleChannelRoutes(
|
||||
{ method: 'PUT' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/default-account'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'Legacy_Account');
|
||||
|
||||
parseJsonBodyMock.mockResolvedValue({
|
||||
channelType: 'feishu',
|
||||
accountId: 'Legacy_Account',
|
||||
agentId: 'main',
|
||||
});
|
||||
await handleChannelRoutes(
|
||||
{ method: 'PUT' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||
{
|
||||
gatewayManager: {
|
||||
rpc: vi.fn(),
|
||||
getStatus: () => ({ state: 'running' }),
|
||||
debouncedReload: vi.fn(),
|
||||
debouncedRestart: vi.fn(),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account');
|
||||
});
|
||||
|
||||
it('keeps channel connected when one account is healthy and another errors', async () => {
|
||||
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
||||
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||
|
||||
@@ -4,6 +4,9 @@ import { Channels } from '@/pages/Channels/index';
|
||||
|
||||
const hostApiFetchMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
const toastSuccessMock = vi.fn();
|
||||
const toastErrorMock = vi.fn();
|
||||
const toastWarningMock = vi.fn();
|
||||
|
||||
const { gatewayState } = vi.hoisted(() => ({
|
||||
gatewayState: {
|
||||
@@ -31,9 +34,9 @@ vi.mock('react-i18next', () => ({
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
success: (...args: unknown[]) => toastSuccessMock(...args),
|
||||
error: (...args: unknown[]) => toastErrorMock(...args),
|
||||
warning: (...args: unknown[]) => toastWarningMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -83,6 +86,94 @@ describe('Channels page status refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks saving when custom account ID is non-canonical', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/channels/accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [
|
||||
{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/agents') {
|
||||
return {
|
||||
success: true,
|
||||
agents: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/channels/credentials/validate') {
|
||||
return {
|
||||
success: true,
|
||||
valid: true,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (path === '/api/channels/config') {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feishu / Lark')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'account.add' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('dialog.configureTitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('account.customIdLabel'), {
|
||||
target: { value: '测试账号' },
|
||||
});
|
||||
const appIdInput = document.getElementById('appId') as HTMLInputElement | null;
|
||||
const appSecretInput = document.getElementById('appSecret') as HTMLInputElement | null;
|
||||
expect(appIdInput).not.toBeNull();
|
||||
expect(appSecretInput).not.toBeNull();
|
||||
fireEvent.change(appIdInput!, { target: { value: 'cli_test' } });
|
||||
fireEvent.change(appSecretInput!, { target: { value: 'secret_test' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dialog.saveAndConnect' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('account.invalidCanonicalId')).toBeInTheDocument();
|
||||
});
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('account.invalidCanonicalId');
|
||||
|
||||
const saveCalls = hostApiFetchMock.mock.calls.filter(([path, init]) => (
|
||||
path === '/api/channels/config'
|
||||
&& typeof init === 'object'
|
||||
&& init != null
|
||||
&& 'method' in init
|
||||
&& (init as { method?: string }).method === 'POST'
|
||||
));
|
||||
expect(saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||
let channelStatusHandler: (() => void) | undefined;
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||
|
||||
Reference in New Issue
Block a user