mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
127 lines
4.4 KiB
JavaScript
127 lines
4.4 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
DingtalkConfigStore,
|
|
deriveDingtalkBotIdentity,
|
|
deriveDingtalkSenderKey,
|
|
} from '../../../src/channels/dingtalk/config-store.mjs';
|
|
|
|
async function temporaryConfig(t) {
|
|
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-config-'));
|
|
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
return join(directory, 'nested', 'bots.json');
|
|
}
|
|
|
|
test('bot identity is deterministic while sender keys are random opaque values', () => {
|
|
const clientId = 'ding-client-one';
|
|
const clientDigest = createHash('sha256').update(clientId).digest('hex').slice(0, 24);
|
|
assert.deepEqual(deriveDingtalkBotIdentity(clientId), {
|
|
botId: `dt_${clientDigest}`,
|
|
secretRef: `DSH_DINGTALK_BOT_SECRET_${clientDigest.toUpperCase()}`,
|
|
});
|
|
|
|
const firstSenderKey = deriveDingtalkSenderKey();
|
|
const secondSenderKey = deriveDingtalkSenderKey();
|
|
assert.match(firstSenderKey, /^dt_sender_[a-f0-9]{32}$/);
|
|
assert.notEqual(firstSenderKey, secondSenderKey);
|
|
});
|
|
|
|
test('config persists only clientId, derived secretRef, and approvedSenders with mode 0600', async (t) => {
|
|
const path = await temporaryConfig(t);
|
|
const store = await new DingtalkConfigStore(path).load();
|
|
const identity = deriveDingtalkBotIdentity('ding-client-one');
|
|
await store.save({
|
|
...identity,
|
|
clientId: 'ding-client-one',
|
|
approvedSenders: [{
|
|
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
staffId: 'staff-private-one',
|
|
displayName: '测试用户',
|
|
approvedAt: '2026-08-15T00:00:00.000Z',
|
|
}],
|
|
ignoredMetadata: 'not-persisted',
|
|
});
|
|
|
|
const document = JSON.parse(await readFile(path, 'utf8'));
|
|
assert.deepEqual(Object.keys(document.bots[0]).sort(), [
|
|
'approvedSenders',
|
|
'clientId',
|
|
'secretRef',
|
|
]);
|
|
assert.equal('botId' in document.bots[0], false);
|
|
assert.equal('clientSecret' in document.bots[0], false);
|
|
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
|
|
|
const reloaded = await new DingtalkConfigStore(path).load();
|
|
assert.deepEqual(reloaded.get(identity.botId), {
|
|
botId: identity.botId,
|
|
clientId: 'ding-client-one',
|
|
secretRef: identity.secretRef,
|
|
approvedSenders: [{
|
|
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
|
staffId: 'staff-private-one',
|
|
displayName: '测试用户',
|
|
approvedAt: '2026-08-15T00:00:00.000Z',
|
|
}],
|
|
});
|
|
});
|
|
|
|
test('config rejects a persisted secret or a non-derived secret reference', async (t) => {
|
|
const path = await temporaryConfig(t);
|
|
const identity = deriveDingtalkBotIdentity('ding-client-one');
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, JSON.stringify({
|
|
version: 1,
|
|
bots: [{
|
|
clientId: 'ding-client-one',
|
|
secretRef: identity.secretRef,
|
|
clientSecret: 'must-not-be-here',
|
|
approvedSenders: [],
|
|
}],
|
|
}));
|
|
await assert.rejects(
|
|
new DingtalkConfigStore(path).load(),
|
|
/invalid bot data/,
|
|
);
|
|
|
|
const store = await new DingtalkConfigStore(`${path}.other`).load();
|
|
await assert.rejects(
|
|
store.save({
|
|
...identity,
|
|
clientId: 'ding-client-one',
|
|
clientSecret: 'must-not-be-here',
|
|
approvedSenders: [],
|
|
}),
|
|
/invalid dsh-dingtalk bot data/,
|
|
);
|
|
await assert.rejects(
|
|
store.save({
|
|
botId: identity.botId,
|
|
clientId: 'ding-client-one',
|
|
secretRef: 'DSH_DINGTALK_BOT_SECRET_000000000000000000000000',
|
|
approvedSenders: [],
|
|
}),
|
|
/invalid dsh-dingtalk bot data/,
|
|
);
|
|
});
|
|
|
|
test('queued concurrent saves retain every bot and remove by derived bot ID', async (t) => {
|
|
const path = await temporaryConfig(t);
|
|
const store = await new DingtalkConfigStore(path).load();
|
|
const first = deriveDingtalkBotIdentity('ding-client-one');
|
|
const second = deriveDingtalkBotIdentity('ding-client-two');
|
|
|
|
await Promise.all([
|
|
store.save({ ...first, clientId: 'ding-client-one', approvedSenders: [] }),
|
|
store.save({ ...second, clientId: 'ding-client-two', approvedSenders: [] }),
|
|
]);
|
|
assert.equal(store.list().length, 2);
|
|
assert.equal(store.getByClientId('ding-client-two').botId, second.botId);
|
|
assert.equal((await store.remove(first.botId)).clientId, 'ding-client-one');
|
|
assert.deepEqual(store.list().map((bot) => bot.botId), [second.botId]);
|
|
});
|