cc-haha/src/server/__tests__/h5-access-api.test.ts
程序员阿江(Relakkes) e357c978fe Add a durable H5 access control surface for remote browser setup
Persist H5 access state in cc-haha managed settings, expose a narrow server API
for enable/disable/regenerate/verify flows, and keep token responses sanitized
so the raw secret is only returned at generation time.

Constraint: H5 config must live in ~/.claude/cc-haha/settings.json and preserve unknown fields
Rejected: Store raw token for later display | violates hash-only persistence requirement
Confidence: high
Scope-risk: narrow
Directive: Do not move H5 settings into ~/.claude/settings.json or expose tokenHash through the API
Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts
Not-tested: Full repo typecheck via tsc is blocked locally by missing bun-types and a TS 6 baseUrl deprecation in the current config
2026-05-09 23:22:55 +08:00

171 lines
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { handleApiRequest } from '../router.js'
let tmpDir: string
let originalConfigDir: string | undefined
async function api(
method: string,
pathname: string,
options: {
body?: Record<string, unknown>
bearerToken?: string
} = {},
): Promise<Response> {
const url = new URL(pathname, 'http://localhost:3456')
const headers: Record<string, string> = {}
if (options.body !== undefined) {
headers['Content-Type'] = 'application/json'
}
if (options.bearerToken) {
headers.Authorization = `Bearer ${options.bearerToken}`
}
return handleApiRequest(
new Request(url.toString(), {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
}),
url,
)
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-api-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('/api/h5-access', () => {
test('GET returns sanitized disabled status by default', async () => {
const response = await api('GET', '/api/h5-access')
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
},
})
})
test('enable returns raw token once and status stays sanitized', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
expect(enableResponse.status).toBe(200)
const enablePayload = await enableResponse.json() as {
settings: {
enabled: boolean
tokenPreview: string
}
token: string
}
expect(enablePayload.settings.enabled).toBe(true)
expect(enablePayload.token).toMatch(/^h5_/)
const statusResponse = await api('GET', '/api/h5-access')
expect(statusResponse.status).toBe(200)
const statusPayload = await statusResponse.json() as {
settings: {
enabled: boolean
tokenPreview: string | null
}
token?: string
}
expect(statusPayload.settings.enabled).toBe(true)
expect(statusPayload.settings.tokenPreview).toBe(enablePayload.settings.tokenPreview)
expect(statusPayload.token).toBeUndefined()
})
test('verify accepts a good bearer token and rejects missing or bad tokens', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
const enablePayload = await enableResponse.json() as { token: string }
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
).toMatchObject({ status: 200 })
expect(await api('POST', '/api/h5-access/verify')).toMatchObject({ status: 401 })
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: 'bad-token' }),
).toMatchObject({ status: 401 })
})
test('regenerate returns a new token and invalidates the previous one', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
const enablePayload = await enableResponse.json() as { token: string }
const regenerateResponse = await api('POST', '/api/h5-access/regenerate')
expect(regenerateResponse.status).toBe(200)
const regeneratePayload = await regenerateResponse.json() as {
settings: {
enabled: boolean
}
token: string
}
expect(regeneratePayload.settings.enabled).toBe(true)
expect(regeneratePayload.token).toMatch(/^h5_/)
expect(regeneratePayload.token).not.toBe(enablePayload.token)
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
).toMatchObject({ status: 401 })
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: regeneratePayload.token }),
).toMatchObject({ status: 200 })
})
test('disable clears access and causes the old token to fail verification', async () => {
const enableResponse = await api('POST', '/api/h5-access/enable')
const enablePayload = await enableResponse.json() as { token: string }
const disableResponse = await api('POST', '/api/h5-access/disable')
expect(disableResponse.status).toBe(200)
await expect(disableResponse.json()).resolves.toEqual({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
},
})
expect(
await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }),
).toMatchObject({ status: 401 })
})
test('PUT updates sanitized settings', async () => {
const response = await api('PUT', '/api/h5-access', {
body: {
allowedOrigins: ['https://example.com/path'],
publicBaseUrl: 'https://public.example.com/app/',
},
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
settings: {
enabled: false,
tokenPreview: null,
allowedOrigins: ['https://example.com'],
publicBaseUrl: 'https://public.example.com/app',
},
})
})
})