dsh-im-ops/test/channels/dingtalk/device-auth.test.mjs
2026-08-15 15:40:53 +08:00

120 lines
3.9 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
DingtalkDeviceAuth,
DingtalkDeviceAuthError,
} from '../../../src/channels/dingtalk/device-auth.mjs';
function fetchFixture(responses) {
const calls = [];
return {
calls,
fetch: async (url, init) => {
calls.push({ url, init });
return {
ok: true,
json: async () => responses.shift(),
};
},
};
}
test('device auth performs init then begin with the fixed DingTalk registration source', async () => {
const fixture = fetchFixture([
{ errcode: 0, nonce: 'nonce-private' },
{
errcode: 0,
device_code: 'device-private',
user_code: 'user-code',
verification_uri: 'https://oapi.dingtalk.com/verify',
verification_uri_complete: 'https://oapi.dingtalk.com/verify?user_code=user-code',
expires_in: 120,
interval: 3,
},
]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch, clock: () => 10_000 });
const result = await auth.start();
assert.deepEqual(fixture.calls.map((call) => call.url), [
'https://oapi.dingtalk.com/app/registration/init',
'https://oapi.dingtalk.com/app/registration/begin',
]);
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { source: 'DING_DWS_CLAW' });
assert.deepEqual(JSON.parse(fixture.calls[1].init.body), { nonce: 'nonce-private' });
assert.equal(fixture.calls[0].init.redirect, 'error');
assert.equal(result.deviceCode, 'device-private');
assert.equal(result.verificationUrl, 'https://oapi.dingtalk.com/verify?user_code=user-code');
assert.equal(result.expiresAt, 130_000);
assert.equal(result.pollIntervalMs, 3_000);
});
test('device auth polls with a host-only device code and normalizes credentials', async () => {
const fixture = fetchFixture([{
errcode: 0,
status: 'success',
client_id: 'ding-client',
client_secret: 'private-secret',
}]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
const result = await auth.poll({ deviceCode: 'device-private' });
assert.equal(fixture.calls[0].url, 'https://oapi.dingtalk.com/app/registration/poll');
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { device_code: 'device-private' });
assert.deepEqual(result, {
status: 'SUCCESS',
clientId: 'ding-client',
clientSecret: 'private-secret',
failReason: null,
});
});
test('device auth accepts only default-port HTTPS DingTalk registration hosts', () => {
const fetch = async () => assert.fail('fetch must not run');
for (const baseUrl of [
'http://oapi.dingtalk.com',
'https://oapi.dingtalk.com:8443',
'https://example.com',
'https://dingtalk.com@example.com',
]) {
assert.throws(
() => new DingtalkDeviceAuth({ fetch, baseUrl }),
/valid HTTPS URL/,
);
}
assert.doesNotThrow(
() => new DingtalkDeviceAuth({ fetch, baseUrl: 'https://staging.dingtalk.com/root/' }),
);
});
test('device auth API failures do not echo remote response details', async () => {
const fixture = fetchFixture([{
errcode: 400,
errmsg: 'do not leak device-private or client-secret-private',
}]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
await assert.rejects(
auth.start(),
(error) => {
assert.ok(error instanceof DingtalkDeviceAuthError);
assert.equal(error.code, 'api-error');
assert.doesNotMatch(error.message, /device-private|client-secret-private/);
return true;
},
);
});
test('device auth bounds a stalled registration request with a local timeout', async () => {
const fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
const auth = new DingtalkDeviceAuth({ fetch, timeoutMs: 5 });
await assert.rejects(
auth.start(),
(error) => error instanceof DingtalkDeviceAuthError && error.code === 'timeout',
);
});