mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 21:30:49 +08:00
fix(feishu): honor proxy settings for REST and WebSocket
This commit is contained in:
parent
b9ceac0779
commit
f7d2fe7047
14 changed files with 467 additions and 188 deletions
|
|
@ -2,24 +2,23 @@ import assert from 'node:assert/strict';
|
|||
import test from 'node:test';
|
||||
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||
|
||||
function response(body, ok = true, status = 200) {
|
||||
return { ok, status, async json() { return body; } };
|
||||
}
|
||||
|
||||
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
|
||||
const requests = [];
|
||||
const result = await verifyFeishuApp({
|
||||
appId: 'cli_test',
|
||||
appSecret: 'never-return-this',
|
||||
fetchImpl: async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (requests.length === 1) {
|
||||
return response({ code: 0, tenant_access_token: 'tenant-token' });
|
||||
}
|
||||
return response({
|
||||
code: 0,
|
||||
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
||||
});
|
||||
timeoutMs: 1234,
|
||||
httpInstance: {
|
||||
async request(options) {
|
||||
requests.push(options);
|
||||
if (requests.length === 1) {
|
||||
return { code: 0, tenant_access_token: 'tenant-token' };
|
||||
}
|
||||
return {
|
||||
code: 0,
|
||||
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -30,8 +29,16 @@ test('verifyFeishuApp validates credentials and returns a safe bot identity', as
|
|||
activated: 1,
|
||||
});
|
||||
assert.equal('appSecret' in result, false);
|
||||
assert.match(requests[0].options.body, /never-return-this/);
|
||||
assert.equal(requests[1].options.headers.authorization, 'Bearer tenant-token');
|
||||
assert.equal(requests[0].method, 'POST');
|
||||
assert.equal(requests[0].url, 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal');
|
||||
assert.deepEqual(requests[0].data, { app_id: 'cli_test', app_secret: 'never-return-this' });
|
||||
assert.equal(requests[0].timeout, 1234);
|
||||
assert.ok(requests[0].signal instanceof AbortSignal);
|
||||
assert.equal(requests[1].method, 'GET');
|
||||
assert.equal(requests[1].url, 'https://open.feishu.cn/open-apis/bot/v3/info/');
|
||||
assert.equal(requests[1].headers.authorization, 'Bearer tenant-token');
|
||||
assert.equal(requests[1].timeout, 1234);
|
||||
assert.ok(requests[1].signal instanceof AbortSignal);
|
||||
});
|
||||
|
||||
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
|
||||
|
|
@ -39,10 +46,19 @@ test('verifyFeishuApp rejects invalid credentials before reading bot info', asyn
|
|||
await assert.rejects(verifyFeishuApp({
|
||||
appId: 'cli_bad',
|
||||
appSecret: 'bad',
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return response({ code: 10003, msg: 'invalid app secret' });
|
||||
httpInstance: {
|
||||
async request() {
|
||||
calls += 1;
|
||||
return { code: 10003, msg: 'invalid app secret' };
|
||||
},
|
||||
},
|
||||
}), /invalid app secret/);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('verifyFeishuApp requires the shared SDK HTTP instance', async () => {
|
||||
await assert.rejects(verifyFeishuApp({
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
}), /requires an HTTP instance/);
|
||||
});
|
||||
|
|
|
|||
199
test/channels/feishu/feishu-proxy.test.mjs
Normal file
199
test/channels/feishu/feishu-proxy.test.mjs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import { createServer, request } from 'node:http';
|
||||
import { connect } from 'node:net';
|
||||
import test from 'node:test';
|
||||
import * as Lark from '@larksuiteoapi/node-sdk';
|
||||
import { createFeishuWebSocketAgent } from '../../../plugin-src/host/channels/feishu/production.mjs';
|
||||
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
|
||||
|
||||
async function listen(server) {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return server.address().port;
|
||||
}
|
||||
|
||||
function close(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function restoreEnvironment(snapshot) {
|
||||
for (const [key, value] of snapshot) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
test('Feishu verification uses the SDK HTTP client through HTTPS_PROXY', {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const proxyHits = [];
|
||||
const proxy = createServer((request, response) => {
|
||||
proxyHits.push(request.url);
|
||||
response.writeHead(502).end();
|
||||
});
|
||||
proxy.on('connect', (request, socket) => {
|
||||
proxyHits.push(request.url);
|
||||
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
const environmentKeys = ['https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY'];
|
||||
const environment = new Map(environmentKeys.map((key) => [key, process.env[key]]));
|
||||
|
||||
try {
|
||||
const proxyUrl = `http://127.0.0.1:${proxyPort}`;
|
||||
process.env.https_proxy = proxyUrl;
|
||||
process.env.HTTPS_PROXY = proxyUrl;
|
||||
delete process.env.no_proxy;
|
||||
delete process.env.NO_PROXY;
|
||||
|
||||
await assert.rejects(verifyFeishuApp({
|
||||
appId: 'cli_0000000000000000',
|
||||
appSecret: 'local-proxy-test-only',
|
||||
httpInstance: Lark.defaultHttpInstance,
|
||||
timeoutMs: 1000,
|
||||
}));
|
||||
} finally {
|
||||
restoreEnvironment(environment);
|
||||
await close(proxy);
|
||||
}
|
||||
|
||||
assert.equal(proxyHits.length, 1);
|
||||
assert.match(proxyHits[0], /open\.feishu\.cn/);
|
||||
});
|
||||
|
||||
test('Lark WSClient sends its WSS handshake through the Feishu proxy agent', {
|
||||
timeout: 5000,
|
||||
}, async (t) => {
|
||||
const connectTargets = [];
|
||||
const proxy = createServer();
|
||||
proxy.on('connect', (request, socket) => {
|
||||
connectTargets.push(request.url);
|
||||
socket.end('HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n');
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
const agent = createFeishuWebSocketAgent({
|
||||
HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
|
||||
});
|
||||
const quietLogger = {
|
||||
debug() {},
|
||||
error() {},
|
||||
info() {},
|
||||
trace() {},
|
||||
warn() {},
|
||||
};
|
||||
let failed;
|
||||
const failure = new Promise((resolve, reject) => {
|
||||
failed = { resolve, reject };
|
||||
});
|
||||
const wsClient = new Lark.WSClient({
|
||||
agent,
|
||||
appId: 'cli_0000000000000000',
|
||||
appSecret: 'local-proxy-test-only',
|
||||
autoReconnect: false,
|
||||
handshakeTimeoutMs: 1000,
|
||||
httpInstance: {
|
||||
async request() {
|
||||
return {
|
||||
code: 0,
|
||||
data: {
|
||||
ClientConfig: {
|
||||
PingInterval: 120,
|
||||
ReconnectCount: 0,
|
||||
ReconnectInterval: 1,
|
||||
ReconnectNonce: 0,
|
||||
},
|
||||
URL: 'wss://msg-frontier.feishu.cn/ws/v2?device_id=local&service_id=1',
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
logger: quietLogger,
|
||||
loggerLevel: Lark.LoggerLevel.error,
|
||||
onError: () => failed.resolve(),
|
||||
onReady: () => failed.reject(new Error('The synthetic 502 proxy unexpectedly connected')),
|
||||
});
|
||||
t.after(() => {
|
||||
wsClient.close({ force: true });
|
||||
agent.destroy();
|
||||
});
|
||||
t.after(() => close(proxy));
|
||||
|
||||
await wsClient.start({ eventDispatcher: {} });
|
||||
await failure;
|
||||
|
||||
assert.deepEqual(connectTargets, ['msg-frontier.feishu.cn:443']);
|
||||
});
|
||||
|
||||
test('Feishu WebSocket agent sends an upgrade through an HTTPS_PROXY CONNECT tunnel', {
|
||||
timeout: 5000,
|
||||
}, async (t) => {
|
||||
let originSawUpgrade = false;
|
||||
const origin = createServer();
|
||||
origin.on('upgrade', (_request, socket) => {
|
||||
originSawUpgrade = true;
|
||||
socket.end([
|
||||
'HTTP/1.1 101 Switching Protocols',
|
||||
'Connection: Upgrade',
|
||||
'Upgrade: websocket',
|
||||
'',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
});
|
||||
const originPort = await listen(origin);
|
||||
|
||||
const connectTargets = [];
|
||||
const proxy = createServer();
|
||||
proxy.on('connect', (proxyRequest, clientSocket, head) => {
|
||||
connectTargets.push(proxyRequest.url);
|
||||
const [hostname, port] = proxyRequest.url.split(':');
|
||||
const upstream = connect({ host: hostname, port: Number(port) }, () => {
|
||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
if (head.length > 0) upstream.write(head);
|
||||
upstream.pipe(clientSocket);
|
||||
clientSocket.pipe(upstream);
|
||||
});
|
||||
upstream.on('error', (error) => clientSocket.destroy(error));
|
||||
clientSocket.on('error', () => upstream.destroy());
|
||||
});
|
||||
const proxyPort = await listen(proxy);
|
||||
|
||||
const agent = createFeishuWebSocketAgent({
|
||||
HTTPS_PROXY: `http://127.0.0.1:${proxyPort}`,
|
||||
});
|
||||
t.after(() => agent.destroy());
|
||||
t.after(async () => {
|
||||
await Promise.all([close(proxy), close(origin)]);
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const upgradeRequest = request({
|
||||
agent,
|
||||
headers: {
|
||||
connection: 'Upgrade',
|
||||
upgrade: 'websocket',
|
||||
},
|
||||
hostname: '127.0.0.1',
|
||||
path: '/',
|
||||
port: originPort,
|
||||
});
|
||||
upgradeRequest.once('upgrade', (_response, socket) => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
upgradeRequest.once('response', (response) => {
|
||||
reject(new Error(`Expected an upgrade, received HTTP ${response.statusCode}`));
|
||||
});
|
||||
upgradeRequest.once('error', reject);
|
||||
upgradeRequest.end();
|
||||
});
|
||||
|
||||
assert.equal(originSawUpgrade, true);
|
||||
assert.deepEqual(connectTargets, [`127.0.0.1:${originPort}`]);
|
||||
});
|
||||
|
||||
test('Feishu WebSocket agent is absent when no proxy is configured', () => {
|
||||
assert.equal(createFeishuWebSocketAgent({}), undefined);
|
||||
});
|
||||
|
|
@ -84,10 +84,12 @@ function fakeLark() {
|
|||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
let harnessSignal;
|
||||
const wsAgent = { addRequest() {} };
|
||||
const runtime = new FeishuRuntime({
|
||||
lark: fakeLark(),
|
||||
appId: 'cli_test',
|
||||
appSecret: 'secret',
|
||||
wsAgent,
|
||||
ownerOpenIds: ['*', 'ou_owner'],
|
||||
harness: {
|
||||
async ensureRunning(options) {
|
||||
|
|
@ -107,6 +109,8 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false);
|
||||
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
|
||||
assert.equal(FakeWSClient.instances[0].options.agent, wsAgent);
|
||||
assert.equal('agent' in FakeClient.instances[0].options, false);
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
const status = await starting;
|
||||
assert.equal(harnessChecks, 1);
|
||||
|
|
|
|||
|
|
@ -1126,6 +1126,11 @@ test('DSH credential adapter stores refs off the browser plane and clears them',
|
|||
|
||||
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
|
||||
const constructed = {};
|
||||
const httpInstance = { request: async () => ({}) };
|
||||
const wsAgent = {
|
||||
addRequest() {},
|
||||
destroy() { constructed.wsAgentDestroyed = true; },
|
||||
};
|
||||
class FakeConfigStore {
|
||||
constructor(path) { constructed.configPath = path; }
|
||||
async load() { return this; }
|
||||
|
|
@ -1160,13 +1165,22 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
dshHome: '/tmp/dsh-feishu-host-test',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
}, {
|
||||
lark: { registerApp: async () => ({}) },
|
||||
lark: { registerApp: async () => ({}), defaultHttpInstance: httpInstance },
|
||||
Controller: FakeController,
|
||||
ConfigStore: FakeConfigStore,
|
||||
StateStore: FakeStateStore,
|
||||
HarnessClient: FakeHarness,
|
||||
FeishuRuntime: FakeRuntime,
|
||||
verifyFeishuApp: async () => ({}),
|
||||
verifyFeishuApp: async (options) => {
|
||||
constructed.verifyOptions = options;
|
||||
return {};
|
||||
},
|
||||
proxyEnv: { HTTPS_PROXY: 'http://proxy.test:8080' },
|
||||
createProxyAgent: (proxyUrl) => {
|
||||
constructed.wsAgentCreated = (constructed.wsAgentCreated ?? 0) + 1;
|
||||
constructed.wsProxyUrl = proxyUrl;
|
||||
return wsAgent;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(constructed.initialized, undefined);
|
||||
|
|
@ -1174,6 +1188,10 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
assert.equal(constructed.initialized, true);
|
||||
assert.equal(constructed.harnessReadyChecks, 1);
|
||||
assert.equal(constructed.controller.credentials, credentials);
|
||||
await constructed.controller.verifyApp({ appId: 'cli_verify', appSecret: 'verify-secret' });
|
||||
assert.equal(constructed.verifyOptions.httpInstance, httpInstance);
|
||||
assert.equal(constructed.wsAgentCreated, 1);
|
||||
assert.equal(constructed.wsProxyUrl, 'http://proxy.test:8080');
|
||||
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
|
||||
assert.equal(constructed.harness.autostart, false);
|
||||
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
|
||||
|
|
@ -1188,6 +1206,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
});
|
||||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||
assert.equal(constructed.runtime.wsAgent, wsAgent);
|
||||
const repair = { start() {}, status() {}, cancel() {} };
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_alpha',
|
||||
|
|
@ -1222,6 +1241,7 @@ test('production assembly needs only ctx credentials and the active DSH webServe
|
|||
await production.close();
|
||||
assert.equal(constructed.closed, true);
|
||||
assert.equal(constructed.harnessStopped, true);
|
||||
assert.equal(constructed.wsAgentDestroyed, true);
|
||||
});
|
||||
|
||||
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
|
||||
|
|
@ -1276,6 +1296,7 @@ test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting'
|
|||
HarnessClient: FakeHarness,
|
||||
FeishuRuntime: FakeRuntime,
|
||||
verifyFeishuApp: async () => ({}),
|
||||
proxyEnv: {},
|
||||
});
|
||||
|
||||
await production.ready;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue