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