cc-haha/desktop/src/lib/desktopRuntime.test.ts
程序员阿江(Relakkes) b4242d894d Prevent remote H5 tokens from leaking into local browser sessions
Browser H5 startup now binds the in-memory bearer token only when the selected server URL is non-loopback, so stored remote credentials cannot follow a later localhost, 127.0.0.1, or IPv6 loopback startup. Token verification errors also use the structured HTTP status when available instead of relying only on message text.

Constraint: Browser H5 may reuse localStorage across remote and local server URLs.

Rejected: Clear all remembered H5 connection state on every loopback startup | preserving the remote URL is useful for the next remote retry and the token alone is the leak risk.

Confidence: high

Scope-risk: narrow

Directive: Keep browser auth token assignment gated by requiresH5AuthForServerUrl before adding new bootstrap paths.

Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/lib/desktopRuntime.test.ts src/components/layout/AppShell.test.tsx
2026-05-10 00:55:30 +08:00

128 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const clientMocks = vi.hoisted(() => ({
defaultBaseUrl: 'http://127.0.0.1:3456',
setBaseUrl: vi.fn(),
setAuthToken: vi.fn(),
postVerify: vi.fn(),
}))
vi.mock('../api/client', () => ({
api: {
post: clientMocks.postVerify,
},
getDefaultBaseUrl: () => clientMocks.defaultBaseUrl,
setAuthToken: clientMocks.setAuthToken,
setBaseUrl: clientMocks.setBaseUrl,
}))
import {
H5ConnectionRequiredError,
H5_SERVER_URL_STORAGE_KEY,
H5_TOKEN_STORAGE_KEY,
initializeDesktopServerUrl,
isLoopbackHostname,
requiresH5AuthForServerUrl,
} from './desktopRuntime'
describe('desktopRuntime browser H5 bootstrap', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
window.localStorage.clear()
window.history.pushState({}, '', '/')
globalThis.fetch = originalFetch
})
afterEach(() => {
vi.useRealTimers()
globalThis.fetch = originalFetch
})
it('treats IPv6 loopback as local', () => {
expect(isLoopbackHostname('[::1]')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true)
})
it('clears an invalid token but preserves the remembered remote server URL', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(
Object.assign(new Error('Invalid or missing H5 access token'), { status: 401 }),
)
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com/app',
message: 'The saved H5 token is no longer valid.',
} satisfies Partial<H5ConnectionRequiredError>)
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://public.example.com/app',
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
it('does not reuse stored remote H5 tokens for loopback browser startup', async () => {
window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F%5B%3A%3A1%5D%3A3456')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe('http://[::1]:3456')
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://[::1]:3456')
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(clientMocks.postVerify).not.toHaveBeenCalled()
})
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com')
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://unreachable.example.com',
message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.',
} satisfies Partial<H5ConnectionRequiredError>)
await vi.runAllTimersAsync()
await startup
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://unreachable.example.com',
)
})
it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'h5_token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch'))
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com',
message: 'Unable to verify the H5 access token.',
} satisfies Partial<H5ConnectionRequiredError>)
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://public.example.com',
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
})