Add manual IM bot credential access

This commit is contained in:
xmanrui 2026-08-16 01:54:06 +08:00
parent d5d2c18ce0
commit 7123fce47a
42 changed files with 2458 additions and 889 deletions

View file

@ -19,6 +19,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});

View file

@ -148,6 +148,30 @@ test('successful QR poll stores secret then config then starts runtime without p
await controller.close();
});
test('manual DingTalk Client ID and Client Secret binding stores credentials off the public plane', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const status = await controller.bindCredentials({
clientId: 'manual-ding-client',
clientSecret: 'manual-ding-secret',
});
assert.equal(status.totals.connected, 1);
assert.equal([...credentials.values.values()][0], 'manual-ding-secret');
assert.equal(runtimes.runtimes[0].clientSecret, 'manual-ding-secret');
assert.doesNotMatch(JSON.stringify(status), /manual-ding-client|manual-ding-secret|secretRef/);
await controller.close();
});
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
const events = [];
const credentials = credentialsFixture(events);

View file

@ -9,6 +9,7 @@ function controller() {
startProvisioning() {},
registrationStatus() {},
cancelProvisioning() {},
bindCredentials() {},
reconnectBot() {},
deleteBot() {},
approveSender() {},

View file

@ -19,6 +19,7 @@ function controller(overrides = {}) {
}),
registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }),
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
bindCredentials: async () => ({ bots: [] }),
reconnectBot: async () => ({ bots: [] }),
deleteBot: async () => ({ bots: [] }),
approveSender: async () => ({ bots: [] }),
@ -76,6 +77,24 @@ test('RPC validates mutating requests before invoking the controller', async ()
assert.equal(calls, 0);
});
test('credential RPC accepts Client ID fields while keeping Client Secret host-only', async () => {
let received;
const handler = createDingtalkRpcHandler(controller({
bindCredentials: async (payload) => {
received = payload;
return { bots: [], clientSecret: payload.clientSecret };
},
}));
const result = await handler(DINGTALK_ENDPOINTS.bindCredentials, {
clientId: 'manual-client', clientSecret: 'manual-secret',
});
assert.equal(result.ok, true);
assert.deepEqual(received, { clientId: 'manual-client', clientSecret: 'manual-secret' });
assert.doesNotMatch(JSON.stringify(result), /manual-secret|clientSecret/);
assert.equal((await handler(DINGTALK_ENDPOINTS.bindCredentials, { clientId: 'manual-client' })).ok, false);
});
test('RPC is registered for loopback clients only', () => {
const registrations = [];
const dispose = () => {};