mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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
171 lines
5.4 KiB
TypeScript
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',
|
|
},
|
|
})
|
|
})
|
|
})
|