dsh-im-ops/test/channels/qq/controller-and-rpc.test.mjs
2026-08-30 11:24:53 +08:00

254 lines
10 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 { maskQqAppId } from '../../../src/channels/qq/config-store.mjs';
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
import { connectionTestMessage } from '../../../src/channels/shared/connection-test.mjs';
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
test('QQ QR success stores the secret off-config and starts a scanner-owned runtime', async () => {
const qr = deferred();
let callbacks;
const values = new Map();
const configs = [];
let runtimeArgs;
const controller = new QqController({
qrAuth: {
start(next) {
callbacks = next;
queueMicrotask(() => next.onQrDisplayed('https://q.qq.com/connect?task=opaque'));
return () => {};
},
},
credentials: {
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
set: async (ref, value) => values.set(ref, value),
unset: async (ref) => values.delete(ref),
},
configStore: {
list: () => [...configs],
get: (id) => configs.find((value) => value.botId === id) ?? null,
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
save: async (value) => { configs.splice(0, configs.length, value); },
remove: async () => null,
},
createRuntime: async (args) => {
runtimeArgs = args;
return { status: { ready: true, qqConnectionState: 'connected', harnessReachable: true }, start: async () => {}, stop: async () => {} };
},
});
const started = await controller.startProvisioning();
assert.equal(started.status, 'pending');
callbacks.onSuccess([{ appId: 'app-id', appSecret: 'private-secret', userOpenid: 'scanner-openid' }]);
while (controller.registrationStatus(started.attemptId).status !== 'connected') await new Promise((resolve) => setImmediate(resolve));
const status = controller.status();
assert.equal(status.bots[0].connected, true);
assert.equal(configs[0].ownerUserOpenid, 'scanner-openid');
assert.equal(values.get(configs[0].secretRef), 'private-secret');
assert.equal(runtimeArgs.appSecret, 'private-secret');
assert.doesNotMatch(JSON.stringify(status), /private-secret|scanner-openid|secretRef/);
qr.resolve();
});
test('QQ AppID and AppSecret binding stores the secret and accepts the platform visibility scope', async () => {
const values = new Map();
const configs = [];
let runtimeArgs;
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
resolve: async (ref) => values.has(ref) ? { value: values.get(ref) } : undefined,
set: async (ref, value) => values.set(ref, value),
unset: async (ref) => values.delete(ref),
},
configStore: {
list: () => [...configs],
get: (id) => configs.find((value) => value.botId === id) ?? null,
getByAppId: (id) => configs.find((value) => value.appId === id) ?? null,
save: async (value) => { configs.splice(0, configs.length, value); },
remove: async () => null,
},
createRuntime: async (args) => {
runtimeArgs = args;
return {
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
start: async () => {}, stop: async () => {},
};
},
});
const status = await controller.bindCredentials({ appId: 'manual-app', appSecret: 'manual-secret' });
assert.equal(status.totals.connected, 1);
assert.equal(configs[0].ownerUserOpenid, '*');
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
assert.equal(runtimeArgs.appSecret, 'manual-secret');
assert.doesNotMatch(JSON.stringify(status), /manual-secret|ownerUserOpenid|secretRef/);
await controller.close();
});
test('QQ controller delegates the shared connection-test message to the selected runtime', async () => {
const config = {
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
};
const sent = [];
const proactiveSends = [];
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
resolve: async () => ({ value: 'secret' }),
set: async () => {},
unset: async () => {},
},
configStore: {
list: () => [config],
get: (botId) => botId === config.botId ? config : null,
getByAppId: () => config,
save: async () => {},
remove: async () => null,
},
createRuntime: async () => ({
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => sent.push(text),
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
}),
});
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
]);
const target = { kind: 'user', route: { userOpenId: 'user-openid' } };
assert.deepEqual(await controller.sendProactiveText(config.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
await controller.close();
});
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({
attemptId: 'attempt_1', status: 'pending', verificationUrl: 'https://q.qq.com/opaque',
appSecret: 'never-public', ownerUserOpenid: 'never-public', expiresAt: Date.now() + 1_000,
}),
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async (botId) => ({
bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 },
}),
deleteBot: async () => ({}),
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
const result = await handler(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
assert.equal(result.ok, true);
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
const legacyReconnect = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(legacyReconnect.ok, true);
assert.deepEqual(legacyReconnect.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
});
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
let received;
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({}), registrationStatus: () => null,
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
bindCredentials: async (payload) => {
received = payload;
return { bots: [], totals: { configured: 0, connected: 0 }, appSecret: payload.appSecret };
},
});
const result = await handler(QQ_ENDPOINTS.bindCredentials, {
appId: 'manual-app', appSecret: 'manual-secret',
});
assert.equal(result.ok, true);
assert.deepEqual(received, { appId: 'manual-app', appSecret: 'manual-secret' });
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
});
test('QQ reconnect RPC reports test-message outcomes without discarding the snapshot', async () => {
const calls = [];
let sendError = null;
let connected = true;
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({}),
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({}),
reconnectBot: async (botId) => {
calls.push(['reconnect', botId]);
return {
revision: 7,
bots: [{ botId, connected, bot: { name: 'QQ机器人' } }],
totals: { configured: 1, connected: connected ? 1 : 0 },
};
},
sendConnectionTest: async (botId) => {
calls.push(['test', botId]);
if (sendError) throw sendError;
return { sent: true };
},
deleteBot: async () => ({}),
});
const success = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(success.ok, true);
assert.equal(success.value.revision, 7);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(calls, [['reconnect', 'qq_bot'], ['test', 'qq_bot']]);
sendError = Object.assign(new Error('no recent target'), { code: 'test-target-unavailable' });
const unavailable = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(unavailable.ok, true);
assert.equal(unavailable.value.bots[0].connected, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
sendError = new Error('QQ API rejected the message');
const failed = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(failed.ok, true);
assert.deepEqual(failed.value.testMessage, { sent: false, code: 'test-message-failed' });
calls.length = 0;
connected = false;
sendError = null;
const offline = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(offline.ok, true);
assert.deepEqual(offline.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
calls.length = 0;
connected = true;
const reconnectOnly = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot' });
assert.equal(reconnectOnly.ok, true);
assert.equal(reconnectOnly.value.testMessage, undefined);
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
assert.equal((await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: false,
})).ok, false);
});