cc-haha/src/server/__tests__/adapters.test.ts
程序员阿江(Relakkes) ddcfa5faae feat(adapters): add WhatsApp linked-device support (#573)
Add a WhatsApp adapter backed by Baileys linked-device auth, plus desktop QR binding UI, server config endpoints, sidecar startup wiring, tests, and documentation.

Constraint: Uses WhatsApp Web linked-device auth, not Meta WhatsApp Business Cloud API.

Tested:
- cd adapters && bun run check:adapters
- bun test src/server/__tests__/adapters.test.ts
- cd desktop && bun run check:desktop
- bun run check:native
- bun run check:persistence-upgrade
- bun run check:docs

Not-tested:
- Live WhatsApp QR pairing, because no WhatsApp account/device was exercised here.
- bun run check:server, because the existing src/server/__tests__/conversations.test.ts timeout still fails independently.

Confidence: medium
Scope-risk: moderate
2026-06-09 21:31:46 +08:00

186 lines
7.1 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)
if (process.platform === 'win32') {
expect(stat.isFile()).toBe(true)
return
}
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([])
})
it('stores and clears WhatsApp account binding', async () => {
const authDir = path.join(tmpDir, 'whatsapp-auth', 'default')
await fs.mkdir(authDir, { recursive: true })
await fs.writeFile(path.join(authDir, 'creds.json'), '{}')
const put = makeRequest('PUT', '/api/adapters', {
whatsapp: {
accountJid: '15551234567@s.whatsapp.net',
authDir,
allowedUsers: ['15550000000@s.whatsapp.net'],
pairedUsers: [{ userId: '15551234567@s.whatsapp.net', displayName: 'WhatsApp User', pairedAt: 1 }],
},
})
await handleAdaptersApi(put.req, put.url, put.segments)
const get = makeRequest('GET', '/api/adapters')
const getRes = await handleAdaptersApi(get.req, get.url, get.segments)
const before = await getRes.json() as any
expect(before.whatsapp.accountJid).toBe('15551234567@s.whatsapp.net')
expect(before.whatsapp.authDir).toBe(authDir)
const unbind = makeRequest('POST', '/api/adapters/whatsapp/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.whatsapp.accountJid).toBeUndefined()
expect(json.whatsapp.allowedUsers).toEqual([])
expect(json.whatsapp.pairedUsers).toEqual([])
await expect(fs.stat(path.join(authDir, 'creds.json'))).rejects.toThrow()
})
})