mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
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();
|
||
});
|