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 { findPrivateLanAddress, H5AccessService, resolveEffectiveH5PublicBaseUrl, } 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('configured public URL overrides stale stored local URLs', async () => { const service = new H5AccessService() await service.updateSettings({ publicBaseUrl: 'http://192.168.0.102:5179', }) process.env.CLAUDE_H5_PUBLIC_BASE_URL = 'https://chat.example.com/app/' const result = await service.enable() expect(result.settings.publicBaseUrl).toBe('https://chat.example.com/app') }) test('auto LAN mode fills blank or loopback URLs but preserves manual LAN URLs', () => { expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, storedPublicBaseUrl: null, configuredPublicBaseUrl: null, autoPublicBaseUrl: 'http://192.168.0.102:39876', })).toBe('http://192.168.0.102:39876') expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, storedPublicBaseUrl: 'http://127.0.0.1:5179', configuredPublicBaseUrl: null, autoPublicBaseUrl: 'http://192.168.0.102:39876', })).toBe('http://192.168.0.102:39876') expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, storedPublicBaseUrl: 'http://192.168.1.100:54064', configuredPublicBaseUrl: null, autoPublicBaseUrl: 'http://172.20.16.1:39876', })).toBe('http://192.168.1.100:54064') expect(resolveEffectiveH5PublicBaseUrl({ enabled: true, storedPublicBaseUrl: 'https://chat.example.com/app', configuredPublicBaseUrl: null, autoPublicBaseUrl: 'http://192.168.0.102:39876', })).toBe('https://chat.example.com/app') }) test('auto LAN detection prefers physical adapters over WSL and Docker virtual adapters', () => { expect(findPrivateLanAddress({ 'vEthernet (WSL)': [{ address: '172.20.16.1', netmask: '255.255.240.0', family: 'IPv4', mac: '00:15:5d:00:00:01', internal: false, cidr: '172.20.16.1/20', }], 'Docker Desktop': [{ address: '172.17.0.1', netmask: '255.255.0.0', family: 'IPv4', mac: '02:42:ac:11:00:01', internal: false, cidr: '172.17.0.1/16', }], 'Wi-Fi': [{ address: '192.168.1.100', netmask: '255.255.255.0', family: 'IPv4', mac: 'aa:bb:cc:dd:ee:ff', internal: false, cidr: '192.168.1.100/24', }], })).toBe('192.168.1.100') }) 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)) }) })