329825ce86
- 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.
168 lines
5.1 KiB
Go
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)
|
|
}
|
|
}
|