Files
GraceSolutions 329825ce86 fix(auth): sanitize redirect, read token from storage, 301 legacy /auth/* paths
- Add sanitizeRedirect/safeRedirectTarget helper; reject anything that isn't
  a safe local path (must start with a single /, no scheme, no whitespace,
  not /login itself). Apply in RequireAuth when encoding the current pathname
  and in AuthLogin when consuming ?redirect=.
- Make configureApi's getToken read localStorage directly via readCurrentToken
  so the first request after login cannot race the AuthContext re-render that
  previously owned the token via a React closure.
- Redirect legacy /auth/* (including the pre-flatten /auth/auth1/login) with
  HTTP 301 to /login in the webui handler so stale bookmarks can't seed the
  SPA router with a malformed URL.
- Regression test TestHandler_LegacyAuthPathRedirectsToLogin covering four
  legacy path shapes.
2026-04-23 17:12:41 -04:00

168 lines
5.1 KiB
Go

package webui
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
)
const (
indexBody = "<!doctype html><html><body>index</body></html>"
apiKeysBody = "<!doctype html><html><body>api-keys</body></html>"
cssBody = "body{color:red}"
immutableCC = "public, max-age=31536000, immutable"
noCacheCC = "no-cache"
contentTypeH = "Content-Type"
cacheCtrlH = "Cache-Control"
)
func newTestFS() fstest.MapFS {
return fstest.MapFS{
"index.html": {Data: []byte(indexBody)},
"api-keys/index.html": {Data: []byte(apiKeysBody)},
"_next/static/css/app.abcdef.css": {Data: []byte(cssBody)},
}
}
func doGet(t *testing.T, h http.Handler, path string) *http.Response {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Result()
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
_ = resp.Body.Close()
return string(b)
}
func TestHandler_RootServesIndex(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), "/")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get(contentTypeH); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
if got := readBody(t, resp); got != indexBody {
t.Errorf("body = %q, want %q", got, indexBody)
}
}
// TestHandler_IndexHtmlDoesNotRedirect guards against a regression where
// the handler delegated to http.FileServer, which 301-redirects any URL
// ending in /index.html to ./ and produced infinite loops on every UI
// route that resolved to a directory index.
func TestHandler_IndexHtmlDoesNotRedirect(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), "/index.html")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (no redirect)", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "" {
t.Errorf("Location = %q, want empty (no redirect)", loc)
}
if got := readBody(t, resp); got != indexBody {
t.Errorf("body = %q, want %q", got, indexBody)
}
}
func TestHandler_RouteResolvesToDirectoryIndex(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), "/api-keys")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := readBody(t, resp); got != apiKeysBody {
t.Errorf("body = %q, want %q", got, apiKeysBody)
}
if cc := resp.Header.Get(cacheCtrlH); cc != noCacheCC {
t.Errorf("Cache-Control = %q, want %q", cc, noCacheCC)
}
}
func TestHandler_NextStaticAssetCacheHeaders(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), "/_next/static/css/app.abcdef.css")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if cc := resp.Header.Get(cacheCtrlH); cc != immutableCC {
t.Errorf("Cache-Control = %q, want %q", cc, immutableCC)
}
if ct := resp.Header.Get(contentTypeH); !strings.HasPrefix(ct, "text/css") {
t.Errorf("Content-Type = %q, want text/css*", ct)
}
if got := readBody(t, resp); got != cssBody {
t.Errorf("body = %q, want %q", got, cssBody)
}
}
func TestHandler_SPAFallbackForUnknownPath(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), "/this/route/does/not/exist")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (SPA fallback)", resp.StatusCode)
}
if got := readBody(t, resp); got != indexBody {
t.Errorf("body = %q, want SPA index %q", got, indexBody)
}
}
func TestHandler_PlaceholderWhenNoIndex(t *testing.T) {
empty := fstest.MapFS{}
resp := doGet(t, handlerForFS(empty), "/")
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
body := readBody(t, resp)
if !strings.Contains(body, "OrchestrAD") {
t.Errorf("placeholder body missing 'OrchestrAD': %q", body)
}
}
// TestHandler_LegacyAuthPathRedirectsToLogin locks in the 301 redirect from
// pre-flatten Spike template paths to /login, so stale bookmarks can never
// feed a junk URL back into the SPA router.
func TestHandler_LegacyAuthPathRedirectsToLogin(t *testing.T) {
cases := []string{
"/auth/auth1/login",
"/auth/auth1/login/",
"/auth/auth1",
"/auth/auth2/register",
}
for _, p := range cases {
p := p
t.Run(p, func(t *testing.T) {
resp := doGet(t, handlerForFS(newTestFS()), p)
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want 301 for %s", resp.StatusCode, p)
}
if loc := resp.Header.Get("Location"); loc != "/login" {
t.Errorf("Location = %q, want /login for %s", loc, p)
}
})
}
}
func TestHandler_HeadRequestOmitsBody(t *testing.T) {
req := httptest.NewRequest(http.MethodHead, "/", nil)
rec := httptest.NewRecorder()
handlerForFS(newTestFS()).ServeHTTP(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if cl := resp.Header.Get("Content-Length"); cl == "" || cl == "0" {
t.Errorf("Content-Length = %q, want non-zero size", cl)
}
if got := readBody(t, resp); got != "" {
t.Errorf("HEAD body = %q, want empty", got)
}
}