From d12b2cfe360272bbf4504d29030b1350aaa0dc12 Mon Sep 17 00:00:00 2001 From: Nicholas Hodaly Date: Mon, 27 Apr 2026 12:46:09 -0700 Subject: [PATCH] server: serve SPA index.html for /users, /agents, /forgot-password The SPA fallback in server.go is an explicit allow-list, not a true catch-all. Three top-level frontend routes (/users, /agents, /forgot-password) were missing from the list, so a hard refresh on those pages 404'd while client-side navigation worked. Add a regression test that hits every top-level route in web/src/router.tsx through the real mux. /invite/{token} is excluded because the more-specific API handler at GET /invite/{token} wins over the SPA fallback at /invite/{token...}. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/server/server.go | 3 +++ internal/server/server_test.go | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/internal/server/server.go b/internal/server/server.go index 07b26a9..8abbd0f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -741,6 +741,9 @@ func New(addr string, store Store, encKey []byte, notifier *notify.Notifier, ini // SPA catch-all: serve index.html for all frontend routes mux.HandleFunc("GET /login", s.handleSPA) mux.HandleFunc("GET /register", s.handleSPA) + mux.HandleFunc("GET /forgot-password", s.handleSPA) + mux.HandleFunc("GET /users", s.handleSPA) + mux.HandleFunc("GET /agents", s.handleSPA) mux.HandleFunc("GET /vaults/{$}", s.handleSPA) mux.HandleFunc("GET /vaults/{name...}", s.handleSPA) mux.HandleFunc("GET /invite/{token...}", s.handleSPA) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 94cab65..859de92 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -5592,3 +5592,50 @@ func TestServiceRemoveUnauthenticated(t *testing.T) { t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String()) } } + +// TestSPACatchAllRoutes locks the SPA fallback contract: every top-level +// frontend route must serve index.html so a hard refresh doesn't 404. +func TestSPACatchAllRoutes(t *testing.T) { + srv := newTestServer() + + // /invite/abc is not tested here. Two routes could match it: the + // API handler at GET /invite/{token} and the SPA fallback at + // GET /invite/{token...}. Go's ServeMux prefers the single-segment + // pattern, so the request hits handleInviteRedeem (which 404s + // because the mock store has no invite) instead of handleSPA — a + // 404 here would not mean the SPA route is broken. + paths := []string{ + "/", + "/login", + "/register", + "/forgot-password", + "/users", + "/agents", + "/change-password", + "/oauth/callback", + "/account/settings", + "/manage/settings", + "/vaults/", + "/vaults/default", + "/approve/1", + } + for _, p := range paths { + t.Run(p, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, p, nil) + rec := httptest.NewRecorder() + srv.httpServer.Handler.ServeHTTP(rec, req) + if rec.Code == http.StatusNotFound { + t.Fatalf("expected non-404 for SPA route, got %d: %s", rec.Code, rec.Body.String()) + } + }) + } + + t.Run("unknown path stays 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/definitely-not-a-route", nil) + rec := httptest.NewRecorder() + srv.httpServer.Handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unregistered path, got %d: %s", rec.Code, rec.Body.String()) + } + }) +}