cc-haha/src/server/__tests__/adapters.test.ts
程序员阿江(Relakkes) 71f851d60a fix: clear DingTalk bot credentials through explicit unbind
DingTalk bot unbind was routed through the generic adapter config update,
but undefined credential fields are dropped during JSON serialization. That
left stored Client ID and Secret values intact after clicking the unbind
button. Match the working WeChat path with a dedicated server endpoint and
frontend API call so credential removal is explicit.

Constraint: JSON request bodies cannot preserve undefined fields for generic config clearing
Rejected: Keep using updateConfig with undefined fields | serialized requests omit the fields before the server can clear them
Confidence: high
Scope-risk: narrow
Directive: Keep credential-clearing flows on explicit unbind endpoints rather than relying on partial config merges
Tested: bun test src/server/__tests__/adapters.test.ts
Tested: cd desktop && bun run test src/stores/adapterStore.test.ts
Tested: bun run check:desktop
Tested: bun run check:server
Not-tested: bun run quality:pr is blocked by repository policy requiring allow-cli-core-change approval for the broader branch impact
2026-05-05 19:30:43 +08:00

152 lines
5.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { handleAdaptersApi } from '../api/adapters.js'
let tmpDir: string
let originalConfigDir: string | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-adapters-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
}
async function teardown() {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
function makeRequest(method: string, pathName: string, body?: Record<string, unknown>) {
const url = new URL(pathName, 'http://localhost:3456')
const req = new Request(url.toString(), {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
const segments = url.pathname.split('/').filter(Boolean)
return { req, url, segments }
}
describe('Adapters API', () => {
beforeEach(setup)
afterEach(teardown)
it('masks WeChat bot tokens in GET responses', async () => {
const put = makeRequest('PUT', '/api/adapters', {
wechat: {
accountId: 'bot-1',
botToken: 'wechat-secret-token',
baseUrl: 'https://ilinkai.weixin.qq.com',
userId: 'wx-user',
pairedUsers: [{ userId: 'wx-user', displayName: 'WeChat User', pairedAt: 1 }],
},
})
expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200)
const get = makeRequest('GET', '/api/adapters')
const res = await handleAdaptersApi(get.req, get.url, get.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.wechat.botToken).toBe('****oken')
expect(json.wechat.accountId).toBe('bot-1')
})
it('writes adapter credentials with owner-only permissions', async () => {
const put = makeRequest('PUT', '/api/adapters', {
telegram: {
botToken: 'telegram-secret-token',
},
})
expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200)
const configPath = path.join(tmpDir, 'adapters.json')
const stat = await fs.stat(configPath)
expect(stat.mode & 0o777).toBe(0o600)
})
it('masks and preserves DingTalk client secrets', async () => {
const put = makeRequest('PUT', '/api/adapters', {
dingtalk: {
clientId: 'ding-client-1',
clientSecret: 'dingtalk-client-secret',
permissionCardTemplateId: 'permission-template',
pairedUsers: [{ userId: 'ding-user', displayName: 'DingTalk User', pairedAt: 1 }],
},
})
expect((await handleAdaptersApi(put.req, put.url, put.segments)).status).toBe(200)
const get = makeRequest('GET', '/api/adapters')
const res = await handleAdaptersApi(get.req, get.url, get.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.dingtalk.clientSecret).toBe('****cret')
expect(json.dingtalk.clientId).toBe('ding-client-1')
expect(json.dingtalk.permissionCardTemplateId).toBe('permission-template')
const maskedPut = makeRequest('PUT', '/api/adapters', {
dingtalk: {
clientSecret: json.dingtalk.clientSecret,
allowedUsers: ['ding-user'],
},
})
expect((await handleAdaptersApi(maskedPut.req, maskedPut.url, maskedPut.segments)).status).toBe(200)
const raw = JSON.parse(await fs.readFile(path.join(tmpDir, 'adapters.json'), 'utf-8')) as any
expect(raw.dingtalk.clientSecret).toBe('dingtalk-client-secret')
expect(raw.dingtalk.allowedUsers).toEqual(['ding-user'])
expect(raw.dingtalk.permissionCardTemplateId).toBe('permission-template')
})
it('clears WeChat credentials on unbind', async () => {
const put = makeRequest('PUT', '/api/adapters', {
wechat: {
accountId: 'bot-1',
botToken: 'wechat-secret-token',
userId: 'wx-user',
allowedUsers: ['wx-allowed-user'],
pairedUsers: [{ userId: 'wx-user', displayName: 'WeChat User', pairedAt: 1 }],
},
})
await handleAdaptersApi(put.req, put.url, put.segments)
const unbind = makeRequest('POST', '/api/adapters/wechat/unbind')
const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.wechat.botToken).toBeUndefined()
expect(json.wechat.accountId).toBeUndefined()
expect(json.wechat.userId).toBeUndefined()
expect(json.wechat.allowedUsers).toEqual([])
expect(json.wechat.pairedUsers).toEqual([])
})
it('clears DingTalk credentials on unbind', async () => {
const put = makeRequest('PUT', '/api/adapters', {
dingtalk: {
clientId: 'ding-client-1',
clientSecret: 'dingtalk-client-secret',
allowedUsers: ['ding-allowed-user'],
permissionCardTemplateId: 'permission-template',
pairedUsers: [{ userId: 'ding-user', displayName: 'DingTalk User', pairedAt: 1 }],
},
})
await handleAdaptersApi(put.req, put.url, put.segments)
const unbind = makeRequest('POST', '/api/adapters/dingtalk/unbind')
const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.dingtalk.clientId).toBeUndefined()
expect(json.dingtalk.clientSecret).toBeUndefined()
expect(json.dingtalk.allowedUsers).toEqual([])
expect(json.dingtalk.permissionCardTemplateId).toBeUndefined()
expect(json.dingtalk.pairedUsers).toEqual([])
})
})