diff --git a/src/hooks/useDashboardCtx.test.ts b/src/hooks/useDashboardCtx.test.ts index 0444309..b4c4053 100644 --- a/src/hooks/useDashboardCtx.test.ts +++ b/src/hooks/useDashboardCtx.test.ts @@ -119,6 +119,48 @@ describe('useDashboardCtx', () => { expect(result.current.envs).toEqual(envsBefore) }) + // BUG-DASH-001 (P0): pre-fix a 67-char input persisted to + // localStorage and broke every subsequent API call. Now the input is + // clipped to the api cap (32 chars). The clipped string must still + // match the api regex `^[a-z0-9-]{1,32}$`. + it('addEnv clips an over-32-char input to the api cap', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + const longInput = 'a'.repeat(67) // 67 chars of valid char class + await act(async () => { mod.addEnv(longInput) }) + // The persisted env name must be ≤ 32 chars (api cap). + expect(result.current.env.length).toBeLessThanOrEqual(32) + expect(result.current.env).toMatch(/^[a-z0-9-]{1,32}$/) + }) + + // BUG-DASH-002: pre-fix the JS regex permitted underscores even + // though the api regex bans them. Underscore inputs would persist + // locally and then 400 on every API call. Now the underscore is + // stripped. + it('addEnv strips underscores (api regex forbids them)', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + await act(async () => { mod.addEnv('my_env_name') }) + // Underscore-stripped form is "myenvname" — the only valid api shape. + expect(result.current.envs.some((e: string) => e.includes('_'))).toBe(false) + expect(result.current.env).toMatch(/^[a-z0-9-]{1,32}$/) + }) + + // BUG-DASH-001 belt: a paste that's entirely punctuation must NOT + // change the live env. Pre-fix the empty-string fall-through worked + // because of `if (!clean)`; we keep that guard via the final regex + // gate. + it('addEnv leaves live env unchanged on all-invalid + over-cap input', async () => { + const mod = await load() + const { result } = renderHook(() => mod.useDashboardCtx()) + await waitFor(() => expect(result.current.me).not.toBeNull()) + const envBefore = result.current.env + await act(async () => { mod.addEnv('!!! !!! !!!') }) + expect(result.current.env).toBe(envBefore) + }) + it('resetBootstrap clears state and allows a fresh bootstrap', async () => { const mod = await load() const { result, rerender } = renderHook(() => mod.useDashboardCtx()) diff --git a/src/hooks/useDashboardCtx.ts b/src/hooks/useDashboardCtx.ts index 0b64723..0fb936d 100644 --- a/src/hooks/useDashboardCtx.ts +++ b/src/hooks/useDashboardCtx.ts @@ -88,9 +88,36 @@ export function setEnv(next: string) { refreshCounts() } +// BUG-DASH-001 (P0) + BUG-DASH-002: +// +// Pre-fix this function accepted env names of arbitrary length and +// stripped characters silently, then persisted the result to +// localStorage. A 67-char paste → every subsequent /api/v1/* call +// carried `?env=<67-char-name>` → vault 400 invalid_env, resources +// 200 with empty list, no UI affordance to delete the bad value +// (user had to clear localStorage from devtools). +// +// The api regex is `^[a-z0-9-]{1,32}$` (see api/internal/handlers/env.go +// + the `invalid_env` 400 branch). Underscores are NOT part of the +// api regex; the pre-fix JS regex `[^a-z0-9_-]` permitted them, +// producing names that the api would later reject — a different +// class of the same "client says yes, server says no" gap. +// +// Fix: align the JS regex with the api regex, enforce the 32-char +// cap up front, and return early if validation fails. The caller +// (EnvSwitcher) already gates `addEnv` behind a non-empty draft so +// no UI plumbing changes are required. +const ENV_REGEX = /^[a-z0-9-]{1,32}$/ +const ENV_MAX_LEN = 32 + export function addEnv(name: string) { - const clean = name.trim().toLowerCase().replace(/[^a-z0-9_-]/g, '') - if (!clean) return + // Lowercase + strip api-invalid chars; underscores no longer survive + // (BUG-DASH-002). Clip to the api cap of 32 chars (BUG-DASH-001). + const clean = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, ENV_MAX_LEN) + // Final regex gate: empty / leading-dash-only / any drift from the + // api regex → bail out without persisting. setEnv is NOT called, so + // the live env stays on the previous valid value (no broken state). + if (!ENV_REGEX.test(clean)) return if (!state.envs.includes(clean)) { state = { ...state, envs: [...state.envs, clean] } emit()