dsh-im-ops/test/channels/weixin/weixin-controller.test.mjs

445 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import test from 'node:test';
import { WeixinController } from '../../../src/channels/weixin/weixin-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function waitFor(read, predicate, attempts = 100) {
for (let index = 0; index < attempts; index += 1) {
const value = read();
if (predicate(value)) return value;
await flush();
}
throw new Error('condition was not reached');
}
function credentialsFixture() {
const values = new Map();
const calls = [];
return {
values,
calls,
provider: {
resolve: async (ref) => values.has(ref)
? { configured: true, source: 'settings', value: values.get(ref) }
: { configured: false },
set: async (ref, value) => { calls.push(['set', ref]); values.set(ref, value); },
unset: async (ref) => { calls.push(['unset', ref]); values.delete(ref); },
},
};
}
function configFixture() {
const accounts = new Map();
return {
accounts,
store: {
list: () => [...accounts.values()].map((account) => structuredClone(account)),
get: (botId) => accounts.has(botId) ? structuredClone(accounts.get(botId)) : null,
getByAccountId: (accountId) => {
const found = [...accounts.values()].find((account) => account.accountId === accountId);
return found ? structuredClone(found) : null;
},
save: async (account) => { accounts.set(account.botId, structuredClone(account)); return account; },
remove: async (botId) => accounts.delete(botId),
},
};
}
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
const runtimes = [];
const connectionTests = [];
const createRuntime = async ({ config, token }) => {
let ready = false;
const runtime = {
config,
token,
get status() {
return {
ready,
weixinConnectionState: ready ? 'connected' : 'idle',
harnessReachable: ready,
lastCheckedAt: ready ? 100 : null,
lastMessageError,
};
},
async start() {
if (startError) throw startError;
if (failStart) throw new Error('runtime start failed with host-only detail');
ready = true;
},
async stop() { ready = false; },
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
};
runtimes.push(runtime);
return runtime;
};
return { runtimes, connectionTests, createRuntime };
}
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({
lastMessageError: {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
providerDetail: 'must-not-cross-controller-boundary',
},
});
const controller = new WeixinController({
api: {
beginLogin: async ({ localTokens }) => {
assert.deepEqual(localTokens, []);
return { qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' };
},
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'private-bot-token',
ilink_bot_id: 'account@im.bot',
ilink_user_id: 'owner-user',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const begun = await controller.startProvisioning();
const completed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'connected',
);
assert.match(completed.botId, /^wx_[a-f0-9]{24}$/);
assert.equal(credentials.values.size, 1);
assert.equal([...credentials.values.values()][0], 'private-bot-token');
const stored = [...configs.accounts.values()][0];
assert.equal(stored.ownerUserId, 'owner-user');
assert.equal('token' in stored, false);
assert.equal(runtimes.runtimes[0].token, 'private-bot-token');
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
assert.equal(controller.status().totals.connected, 1);
assert.deepEqual(controller.status().bots[0].lastMessageError, {
code: 'attachment-error',
reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES',
message: '当前模型不支持图片。',
at: 123,
});
assert.doesNotMatch(publicJson, /must-not-cross-controller-boundary/);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
await controller.deleteBot(completed.botId);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
await controller.close();
});
test('verification-code state pauses polling and resumes with the submitted digits', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory();
const verifyCodes = [];
let polls = 0;
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async ({ verifyCode }) => {
polls += 1;
verifyCodes.push(verifyCode);
if (polls === 1) return { status: 'need_verifycode' };
return {
status: 'confirmed',
bot_token: 'token-after-code',
ilink_bot_id: 'verify@im.bot',
ilink_user_id: 'verify-owner',
baseurl: 'https://ilinkai.weixin.qq.com',
};
},
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const begun = await controller.startProvisioning();
await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'needs_verification',
);
assert.equal(polls, 1);
await controller.submitVerification(begun.attemptId, '123456');
await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'connected',
);
assert.deepEqual(verifyCodes, [null, '123456']);
await controller.close();
});
test('runtime activation failure is classified and rolls credentials and config back', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({ failStart: true });
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'rollback@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'connection-start-failed');
assert.match(failed.error.message, /消息连接初始化失败/);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
await controller.close();
});
test('credential write failure is classified and rolls back a post-commit error', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
credentials.provider.set = async (ref, value) => {
credentials.values.set(ref, value);
throw new Error('credential backend failed after commit with private detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'credential-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'credential-save-failed');
assert.match(failed.error.message, /DSH 凭据存储/);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /private detail|must-be-rolled-back/);
await controller.close();
});
test('credential read failure stops activation before any durable write', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
credentials.provider.resolve = async () => {
throw new Error('credential read host-only detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-never-be-written',
ilink_bot_id: 'credential-read-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'credential-read-failed');
assert.equal(credentials.calls.length, 0);
assert.equal(credentials.values.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-never-be-written/);
await controller.close();
});
test('account config write and runtime preparation failures have distinct safe codes', async () => {
for (const scenario of [
{
expectedCode: 'account-config-save-failed',
prepare: ({ configs }) => {
configs.store.save = async (account) => {
configs.accounts.set(account.botId, structuredClone(account));
throw new Error('config path host-only detail');
};
},
createRuntime: runtimeFactory().createRuntime,
},
{
expectedCode: 'runtime-prepare-failed',
prepare: () => {},
createRuntime: async () => { throw new Error('workspace path host-only detail'); },
},
]) {
const credentials = credentialsFixture();
const configs = configFixture();
scenario.prepare({ credentials, configs });
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: `${scenario.expectedCode}@im.bot`,
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: scenario.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, scenario.expectedCode);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
await controller.close();
}
});
test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({
startError: Object.assign(new Error('loopback transport host-only detail'), {
code: 'harness-unreachable',
}),
});
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'harness-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'harness-unreachable');
assert.match(failed.error.message, /无法连接本机 Harness/);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
await controller.close();
});
test('an unclassified activation error uses the explicit unknown fallback code', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
configs.store.getByAccountId = () => {
throw new Error('unexpected host-only activation detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-never-cross-the-browser-boundary',
ilink_bot_id: 'unknown-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'activation-unknown-failed');
assert.notEqual(failed.error.code, 'activation-failed');
assert.match(failed.error.message, /未知错误/);
assert.doesNotMatch(
JSON.stringify(failed),
/unexpected host-only activation detail|must-never-cross-the-browser-boundary/,
);
await controller.close();
});
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
});
const begun = await controller.startProvisioning();
const cancelled = await controller.cancelProvisioning(begun.attemptId);
assert.equal(cancelled.status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
await controller.close();
});