mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-11 00:32:43 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
280
test/channels/feishu/registration-manager.test.mjs
Normal file
280
test/channels/feishu/registration-manager.test.mjs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RegistrationManager } from '../../../src/channels/feishu/registration-manager.mjs';
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function fakeClock() {
|
||||
let time = 0;
|
||||
let sequence = 0;
|
||||
const timers = new Map();
|
||||
|
||||
return {
|
||||
now: () => time,
|
||||
setTimeout(fn, delay) {
|
||||
const id = ++sequence;
|
||||
timers.set(id, { at: time + delay, fn });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
advance(milliseconds) {
|
||||
const target = time + milliseconds;
|
||||
for (;;) {
|
||||
const due = [...timers.entries()]
|
||||
.filter(([, timer]) => timer.at <= target)
|
||||
.sort((left, right) => left[1].at - right[1].at)[0];
|
||||
if (!due) break;
|
||||
const [id, timer] = due;
|
||||
timers.delete(id);
|
||||
time = timer.at;
|
||||
timer.fn();
|
||||
}
|
||||
time = target;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
test('RegistrationManager captures QR countdown and SDK polling states', async () => {
|
||||
const clock = fakeClock();
|
||||
const registration = deferred();
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
now: clock.now,
|
||||
setTimeout: clock.setTimeout,
|
||||
clearTimeout: clock.clearTimeout,
|
||||
});
|
||||
|
||||
assert.deepEqual(manager.start({ source: 'dsh-feishu' }), {
|
||||
state: 'starting',
|
||||
attempt: 1,
|
||||
updatedAt: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
assert.equal(sdkOptions.source, 'dsh-feishu');
|
||||
assert.ok(sdkOptions.signal instanceof AbortSignal);
|
||||
|
||||
sdkOptions.onQRCodeReady({ url: 'https://accounts.feishu.cn/device', expireIn: 5 });
|
||||
assert.deepEqual(manager.status(), {
|
||||
state: 'qr_ready',
|
||||
attempt: 1,
|
||||
updatedAt: 0,
|
||||
qrCodeUrl: 'https://accounts.feishu.cn/device',
|
||||
expiresAt: 5000,
|
||||
remainingSeconds: 5,
|
||||
});
|
||||
|
||||
clock.advance(1200);
|
||||
assert.equal(manager.status().remainingSeconds, 4);
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'polling' });
|
||||
assert.equal(manager.status().state, 'polling');
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'slow_down', interval: 10 });
|
||||
assert.equal(manager.status().state, 'slow_down');
|
||||
assert.equal(manager.status().pollIntervalSeconds, 10);
|
||||
|
||||
sdkOptions.onStatusChange({ status: 'domain_switched' });
|
||||
assert.equal(manager.status().state, 'domain_switched');
|
||||
assert.equal(manager.status().remainingSeconds, 4);
|
||||
|
||||
manager.cancel();
|
||||
});
|
||||
|
||||
test('RegistrationManager only sends credentials to callback and never exposes the secret', async () => {
|
||||
const registration = deferred();
|
||||
const persistence = deferred();
|
||||
const received = [];
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: (credentials) => {
|
||||
received.push(credentials);
|
||||
return persistence.promise;
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
sdkOptions.onQRCodeReady({ url: 'https://example.test/qr', expireIn: 60 });
|
||||
registration.resolve({
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'super-secret-value',
|
||||
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||
unexpected: 'must-not-be-forwarded',
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
assert.deepEqual(received, [{
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'super-secret-value',
|
||||
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
|
||||
}]);
|
||||
assert.equal(manager.status().state, 'saving');
|
||||
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||
assert.equal('remainingSeconds' in manager.status(), false);
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret/);
|
||||
|
||||
persistence.resolve();
|
||||
await flushPromises();
|
||||
assert.equal(manager.status().state, 'succeeded');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret|cli_test|ou_test/);
|
||||
});
|
||||
|
||||
test('a concurrent start aborts and ignores the previous attempt', async () => {
|
||||
const registrations = [deferred(), deferred()];
|
||||
const sdkCalls = [];
|
||||
const received = [];
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
const index = sdkCalls.length;
|
||||
sdkCalls.push(options);
|
||||
return registrations[index].promise;
|
||||
},
|
||||
onCredentials: async (credentials) => received.push(credentials.client_id),
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
manager.start();
|
||||
assert.equal(sdkCalls[0].signal.aborted, true);
|
||||
assert.equal(manager.status().attempt, 2);
|
||||
await flushPromises();
|
||||
|
||||
sdkCalls[0].onQRCodeReady({ url: 'https://stale.test', expireIn: 30 });
|
||||
registrations[0].resolve({ client_id: 'cli_stale', client_secret: 'stale-secret' });
|
||||
registrations[1].resolve({ client_id: 'cli_current', client_secret: 'current-secret' });
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
assert.deepEqual(received, ['cli_current']);
|
||||
assert.equal(manager.status().state, 'succeeded');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /stale-secret|current-secret/);
|
||||
});
|
||||
|
||||
test('cancel is terminal and ignores a late SDK result', async () => {
|
||||
const registration = deferred();
|
||||
const received = [];
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async (credentials) => received.push(credentials),
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
const status = manager.cancel();
|
||||
|
||||
assert.equal(status.state, 'cancelled');
|
||||
assert.equal(status.error.code, 'abort');
|
||||
assert.equal(sdkOptions.signal.aborted, true);
|
||||
|
||||
registration.resolve({ client_id: 'cli_late', client_secret: 'late-secret' });
|
||||
await flushPromises();
|
||||
assert.deepEqual(received, []);
|
||||
assert.equal(manager.status().state, 'cancelled');
|
||||
assert.doesNotMatch(JSON.stringify(manager.status()), /late-secret/);
|
||||
});
|
||||
|
||||
test('QR expiry aborts polling and reports an explicit expired state', async () => {
|
||||
const clock = fakeClock();
|
||||
const registration = deferred();
|
||||
let sdkOptions;
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: (options) => {
|
||||
sdkOptions = options;
|
||||
return registration.promise;
|
||||
},
|
||||
onCredentials: async () => assert.fail('expired credentials must not be stored'),
|
||||
now: clock.now,
|
||||
setTimeout: clock.setTimeout,
|
||||
clearTimeout: clock.clearTimeout,
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
sdkOptions.onQRCodeReady({ url: 'https://example.test/expiring', expireIn: 3 });
|
||||
clock.advance(2999);
|
||||
assert.equal(manager.status().remainingSeconds, 1);
|
||||
clock.advance(1);
|
||||
|
||||
assert.equal(manager.status().state, 'expired');
|
||||
assert.equal(manager.status().error.code, 'expired_token');
|
||||
assert.equal('qrCodeUrl' in manager.status(), false);
|
||||
assert.equal(sdkOptions.signal.aborted, true);
|
||||
});
|
||||
|
||||
test('SDK expiration and errors become distinct safe terminal states', async (t) => {
|
||||
await t.test('expired_token is expired', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => {
|
||||
throw { code: 'expired_token', description: 'Polling timed out' };
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
assert.equal(manager.status().state, 'expired');
|
||||
assert.equal(manager.status().error.code, 'expired_token');
|
||||
});
|
||||
|
||||
await t.test('unknown error does not reflect its message', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => {
|
||||
throw new Error('request failed with client_secret=do-not-leak');
|
||||
},
|
||||
onCredentials: async () => {},
|
||||
});
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
const status = manager.status();
|
||||
assert.equal(status.state, 'error');
|
||||
assert.equal(status.error.code, 'registration_failed');
|
||||
assert.doesNotMatch(JSON.stringify(status), /do-not-leak|client_secret/);
|
||||
});
|
||||
});
|
||||
|
||||
test('credential persistence failure is safe and does not expose the secret', async () => {
|
||||
const manager = new RegistrationManager({
|
||||
registerApp: async () => ({
|
||||
client_id: 'cli_test',
|
||||
client_secret: 'secret-mentioned-by-callback',
|
||||
user_info: { open_id: 'ou_test' },
|
||||
}),
|
||||
onCredentials: async ({ client_secret: secret }) => {
|
||||
throw new Error(`failed to persist ${secret}`);
|
||||
},
|
||||
});
|
||||
|
||||
manager.start();
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
const status = manager.status();
|
||||
assert.equal(status.state, 'error');
|
||||
assert.equal(status.error.code, 'credentials_callback_failed');
|
||||
assert.doesNotMatch(JSON.stringify(status), /secret-mentioned-by-callback|client_secret/);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue