diff --git a/web/src/lib/backend-config.test.ts b/web/src/lib/backend-config.test.ts index 4775d3d1..1ed1abce 100644 --- a/web/src/lib/backend-config.test.ts +++ b/web/src/lib/backend-config.test.ts @@ -109,3 +109,39 @@ describe('buildTransportConfig', () => { expect(buildTransportConfig()?.wsURL).toBe('ws://localhost:9880/ws'); }); }); + +describe('VITE_BACKEND_URL build-time fallback normalization', () => { + it('normalizes a VITE_BACKEND_URL that contains query and hash', async () => { + // Re-import the module with a stubbed env so the module-level constant is + // re-evaluated. vi.resetModules() clears the module registry, and + // vi.stubEnv sets import.meta.env before the fresh import runs. + vi.stubEnv('VITE_BACKEND_URL', 'https://api.example.com?x=1#frag'); + vi.resetModules(); + const mod = await import('./backend-config'); + + // No localStorage override → falls back to BUILD_TIME_BACKEND_URL. + localStorage.clear(); + const backend = mod.getBackendUrl(); + expect(backend).toBe('https://api.example.com'); + + const cfg = mod.buildTransportConfig(); + expect(cfg).not.toBeUndefined(); + expect(cfg!.baseURL).toBe('https://api.example.com/api'); + expect(cfg!.wsURL).toBe('wss://api.example.com/ws'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('normalizes a VITE_BACKEND_URL with trailing slash', async () => { + vi.stubEnv('VITE_BACKEND_URL', 'https://api.example.com/'); + vi.resetModules(); + const mod = await import('./backend-config'); + + localStorage.clear(); + expect(mod.getBackendUrl()).toBe('https://api.example.com'); + + vi.unstubAllEnvs(); + vi.resetModules(); + }); +}); diff --git a/web/src/lib/backend-config.ts b/web/src/lib/backend-config.ts index 36999c99..f24380af 100644 --- a/web/src/lib/backend-config.ts +++ b/web/src/lib/backend-config.ts @@ -34,13 +34,31 @@ export class BackendStorageError extends Error { } } -/** Build-time fallback (empty string when unset). */ -const BUILD_TIME_BACKEND_URL: string = - (import.meta.env.VITE_BACKEND_URL as string | undefined)?.trim() ?? ''; +/** + * Normalizes a raw backend URL string to `origin + pathname` with no trailing + * slash and no query/hash. Returns an empty string for empty/whitespace input + * or unparseable values. This is the same contract as setBackendUrl so that + * VITE_BACKEND_URL and runtime overrides share identical semantics. + */ +function normalizeBackendOrigin(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + const parsed = new URL(trimmed); + return (parsed.origin + parsed.pathname).replace(/\/+$/, ''); + } catch { + return ''; + } +} + +/** Build-time fallback, fully normalized (empty string when unset or unparseable). */ +const BUILD_TIME_BACKEND_URL: string = normalizeBackendOrigin( + (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? '', +); /** * Returns the configured backend base origin (no trailing slash), or an empty - * string when the UI should use its own origin (same-origin default). + */ export function getBackendUrl(): string { let stored = '';