cc-haha/src/server/__tests__/h5-access-service.test.ts
程序员阿江(Relakkes) 775ddb9c51 fix(desktop): make H5 access reachable from LAN
Packaged desktop builds started the sidecar on loopback only, so phones on the same WiFi could not connect to the H5 URL. The server now binds to LAN interfaces while keeping the desktop control URL on loopback, serves the bundled H5 frontend, and reports a LAN public URL when H5 access is enabled.

Constraint: Packaged Tauri resources place ../dist under Contents/Resources/_up_/dist.
Rejected: Bind the desktop control URL to 0.0.0.0 | desktop clients should keep using loopback for local control.
Confidence: high
Scope-risk: moderate
Directive: Keep packaged H5 resource lookup aligned with Tauri resource mapping when changing bundle resources.
Tested: bun run check:server
Tested: cd desktop/src-tauri && cargo test --lib
Tested: cd desktop && bun run test -- desktopRuntime.test.ts generalSettings.test.tsx
Tested: SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh
Tested: Packaged claude-sidecar smoke served H5 HTML while listening on *:39876
Not-tested: Physical phone browser test on the local WiFi network
2026-05-10 19:41:25 +08:00

231 lines
7.3 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 { H5AccessService } from '../services/h5AccessService.js'
import { ProviderService } from '../services/providerService.js'
let tmpDir: string
let originalConfigDir: string | undefined
let originalH5PublicBaseUrl: string | undefined
let originalH5AutoPublicUrl: string | undefined
function getManagedSettingsPath(): string {
return path.join(tmpDir, 'cc-haha', 'settings.json')
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-service-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalH5PublicBaseUrl = process.env.CLAUDE_H5_PUBLIC_BASE_URL
originalH5AutoPublicUrl = process.env.CLAUDE_H5_AUTO_PUBLIC_URL
process.env.CLAUDE_CONFIG_DIR = tmpDir
})
afterEach(async () => {
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
if (originalH5PublicBaseUrl === undefined) delete process.env.CLAUDE_H5_PUBLIC_BASE_URL
else process.env.CLAUDE_H5_PUBLIC_BASE_URL = originalH5PublicBaseUrl
if (originalH5AutoPublicUrl === undefined) delete process.env.CLAUDE_H5_AUTO_PUBLIC_URL
else process.env.CLAUDE_H5_AUTO_PUBLIC_URL = originalH5AutoPublicUrl
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('H5AccessService', () => {
test('defaults to disabled state with sanitized settings', async () => {
const service = new H5AccessService()
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
})
await expect(service.validateToken('missing-token')).resolves.toBe(false)
})
test('enable generates a token and persists only hash plus preview', async () => {
const service = new H5AccessService()
const result = await service.enable()
const raw = await fs.readFile(getManagedSettingsPath(), 'utf-8')
const saved = JSON.parse(raw) as {
h5Access: {
enabled: boolean
tokenHash: string
tokenPreview: string
}
}
expect(result.token).toMatch(/^h5_[A-Za-z0-9_-]{43}$/)
expect(result.settings).toEqual({
enabled: true,
tokenPreview: saved.h5Access.tokenPreview,
allowedOrigins: [],
publicBaseUrl: null,
})
expect(saved.h5Access.enabled).toBe(true)
expect(saved.h5Access.tokenHash).toHaveLength(64)
expect(saved.h5Access.tokenPreview).toBe(
`${result.token.slice(0, 7)}...${result.token.slice(-4)}`,
)
expect(raw).not.toContain(result.token)
expect(await service.validateToken(result.token)).toBe(true)
})
test('enabled public settings use the packaged app LAN URL when provided', async () => {
process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'http://192.168.1.20:28670/'
process.env.CLAUDE_H5_AUTO_PUBLIC_URL = '1'
const service = new H5AccessService()
const result = await service.enable()
expect(result.settings.publicBaseUrl).toBe('http://192.168.1.20:28670')
})
test('regenerateToken invalidates the previous token', async () => {
const service = new H5AccessService()
const first = await service.enable()
const second = await service.regenerateToken()
expect(second.token).toMatch(/^h5_/)
expect(second.token).not.toBe(first.token)
expect(await service.validateToken(first.token)).toBe(false)
expect(await service.validateToken(second.token)).toBe(true)
})
test('preserves unknown managed settings fields when updating h5Access', async () => {
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
await fs.writeFile(
getManagedSettingsPath(),
JSON.stringify(
{
env: {
ANTHROPIC_MODEL: 'keep-me',
},
futureField: {
keep: true,
},
},
null,
2,
),
'utf-8',
)
const service = new H5AccessService()
await service.enable()
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
env: {
ANTHROPIC_MODEL: string
}
futureField: {
keep: boolean
}
h5Access: unknown
}
expect(saved.env.ANTHROPIC_MODEL).toBe('keep-me')
expect(saved.futureField).toEqual({ keep: true })
expect(saved.h5Access).toBeDefined()
})
test('updateSettings normalizes origins and rejects invalid ones', async () => {
const service = new H5AccessService()
await expect(
service.updateSettings({
allowedOrigins: ['https://example.com/path', 'http://localhost:3000/foo'],
publicBaseUrl: 'https://public.example.com/app/',
}),
).resolves.toEqual({
enabled: false,
tokenPreview: null,
allowedOrigins: ['https://example.com', 'http://localhost:3000'],
publicBaseUrl: 'https://public.example.com/app',
})
await expect(
service.updateSettings({
allowedOrigins: ['https://*.example.com'],
}),
).rejects.toMatchObject({
statusCode: 400,
})
})
test('isOriginAllowed requires enabled state and matches normalized origins', async () => {
const service = new H5AccessService()
await service.updateSettings({
allowedOrigins: ['https://example.com/path'],
})
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
await service.enable()
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(true)
await expect(service.isOriginAllowed('https://other.example.com')).resolves.toBe(false)
await expect(service.isOriginAllowed('notaurl')).resolves.toBe(false)
})
test('malformed persisted enabled state without token hash is treated as disabled', async () => {
await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true })
await fs.writeFile(
getManagedSettingsPath(),
JSON.stringify({
h5Access: {
enabled: true,
allowedOrigins: ['https://example.com/path'],
publicBaseUrl: 'https://public.example.com',
},
}),
'utf-8',
)
const service = new H5AccessService()
await expect(service.getSettings()).resolves.toEqual({
enabled: false,
tokenPreview: null,
allowedOrigins: ['https://example.com'],
publicBaseUrl: 'https://public.example.com',
})
await expect(service.validateToken('anything')).resolves.toBe(false)
await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false)
})
test('concurrent h5 enable and provider managed settings update preserve both fields', async () => {
const h5Service = new H5AccessService()
const providerService = new ProviderService()
await Promise.all([
h5Service.enable(),
providerService.updateManagedSettings({
env: {
ANTHROPIC_MODEL: 'keep-me',
},
}),
])
const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as {
env?: {
ANTHROPIC_MODEL?: string
}
h5Access?: {
enabled?: boolean
tokenHash?: string | null
}
}
expect(saved.env?.ANTHROPIC_MODEL).toBe('keep-me')
expect(saved.h5Access?.enabled).toBe(true)
expect(saved.h5Access?.tokenHash).toEqual(expect.any(String))
})
})