package handlers_test import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "thermograph/frontend/internal/handlers" ) // newStaticDir builds a throwaway static dir with the SPA shells and assets // the tests poke at. func newStaticDir(t *testing.T) string { t.Helper() dir := t.TempDir() shell := "\n\nt\n" + `` + "\n__ORIGIN__\n" for _, f := range []string{"calendar.html", "score.html", "compare.html", "legend.html", "subscriptions.html"} { if err := os.WriteFile(filepath.Join(dir, f), []byte(shell), 0o644); err != nil { t.Fatal(err) } } // day.html deliberately missing: the missing-shell test uses /day. if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('hi')\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(dir, "icons"), 0o755); err != nil { t.Fatal(err) } return dir } func newMux(t *testing.T, base string, opt func(*handlers.Options)) *http.ServeMux { t.Helper() o := handlers.Options{Base: base, StaticDir: newStaticDir(t)} if opt != nil { opt(&o) } mux := http.NewServeMux() handlers.New(o).Register(mux) return mux } func get(mux *http.ServeMux, method, target string, hdr map[string]string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, target, nil) for k, v := range hdr { r.Header.Set(k, v) } w := httptest.NewRecorder() mux.ServeHTTP(w, r) return w } // --- SPA shells (app.py's _page) --------------------------------------------- func TestShellServesWithOriginFilledIn(t *testing.T) { mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://example.com/thermograph/calendar", nil) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { t.Errorf("content-type = %q", ct) } body := w.Body.String() if strings.Contains(body, "__ORIGIN__") { t.Error("__ORIGIN__ placeholder not substituted") } if !strings.Contains(body, `content="http://example.com/thermograph/calendar"`) { t.Errorf("origin prefix missing from og:url: %s", body) } if w.Header().Get("ETag") == "" { t.Error("no ETag on shell response") } // HEAD serves the same route with an empty body (methods=["GET","HEAD"]). h := get(mux, "HEAD", "http://example.com/thermograph/calendar", nil) if h.Code != http.StatusOK || h.Body.Len() != 0 { t.Errorf("HEAD: status = %d, body = %d bytes", h.Code, h.Body.Len()) } } func TestShellETagExactMatch304(t *testing.T) { mux := newMux(t, "/thermograph", nil) first := get(mux, "GET", "http://example.com/thermograph/legend", nil) etag := first.Header().Get("ETag") if etag == "" { t.Fatal("no ETag") } // Exact match -> 304 with the ETag and no body (app.py's _not_modified). w := get(mux, "GET", "http://example.com/thermograph/legend", map[string]string{"If-None-Match": etag}) if w.Code != http.StatusNotModified { t.Fatalf("status = %d, want 304", w.Code) } if w.Header().Get("ETag") != etag { t.Error("304 must carry the ETag") } if w.Body.Len() != 0 { t.Error("304 must have no body") } // The Python compared the raw header — a list containing the ETag does // NOT match (unlike the content pages' comma-set semantics). w = get(mux, "GET", "http://example.com/thermograph/legend", map[string]string{"If-None-Match": `W/"other", ` + etag}) if w.Code != http.StatusOK { t.Errorf("list If-None-Match: status = %d, want 200 (exact-match semantics)", w.Code) } } func TestShellPerOriginMemo(t *testing.T) { mux := newMux(t, "/thermograph", nil) a := get(mux, "GET", "http://a.example/thermograph/score", nil) b := get(mux, "GET", "http://b.example/thermograph/score", nil) if !strings.Contains(a.Body.String(), "http://a.example/thermograph") { t.Error("first origin not substituted") } if !strings.Contains(b.Body.String(), "http://b.example/thermograph") { t.Error("second origin not substituted (memo leaked across origins?)") } if a.Header().Get("ETag") == b.Header().Get("ETag") { t.Error("different origins must get different ETags") } } func TestShellForwardedHeadersWinOverHost(t *testing.T) { // The proxied topology: Host is the internal hop, X-Forwarded-* is the // browser-facing origin — the forwarded values must win (content._origin). mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://frontend:8080/thermograph/compare", map[string]string{ "X-Forwarded-Proto": "https", "X-Forwarded-Host": "thermograph.org", }) if !strings.Contains(w.Body.String(), "https://thermograph.org/thermograph") { t.Errorf("forwarded origin not used: %s", w.Body.String()) } } func TestShellHeadVerifyInjection(t *testing.T) { mux := newMux(t, "/thermograph", func(o *handlers.Options) { o.GoogleVerify = `g"tok&en` o.BingVerify = "bing-tok" }) w := get(mux, "GET", "http://example.com/thermograph/calendar", nil) body := w.Body.String() want := "\n " + `` + "\n " + `` if !strings.Contains(body, want) { t.Errorf("verification metas not injected after :\n%s", body) } } func TestShellMissingFileIs500(t *testing.T) { mux := newMux(t, "/thermograph", nil) // day.html not written w := get(mux, "GET", "http://example.com/thermograph/day", nil) if w.Code != http.StatusInternalServerError { t.Errorf("status = %d, want 500", w.Code) } } // --- static assets (app.py's _CachedStaticFiles mount) ----------------------- func TestStaticAssetServes(t *testing.T) { mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://example.com/thermograph/app.js", nil) if w.Code != http.StatusOK { t.Fatalf("status = %d", w.Code) } if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=300" { t.Errorf("Cache-Control = %q", cc) } if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") { t.Errorf("content-type = %q", ct) } // Conditional refetch: the stat-derived ETag answers with an empty 304 // that still carries the Cache-Control window. etag := w.Header().Get("ETag") if etag == "" { t.Fatal("no ETag on static response") } c := get(mux, "GET", "http://example.com/thermograph/app.js", map[string]string{"If-None-Match": etag}) if c.Code != http.StatusNotModified { t.Fatalf("conditional status = %d, want 304", c.Code) } if cc := c.Header().Get("Cache-Control"); cc != "public, max-age=300" { t.Errorf("304 Cache-Control = %q", cc) } } func TestStaticMissingIs404(t *testing.T) { mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://example.com/thermograph/nope.css", nil) if w.Code != http.StatusNotFound { t.Fatalf("status = %d", w.Code) } if w.Body.String() != "Not Found" { t.Errorf("body = %q, want Starlette's plain Not Found", w.Body.String()) } } func TestStaticIndexHTMLIs404(t *testing.T) { // index.html must not linger behind the static mount as indexable // duplicate content now that / is server-rendered — and Go's FileServer // would 301 it to "./" even when absent, so the custom handler must 404 // (tests/unit/test_pages.py::test_old_static_index_is_gone). mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://example.com/thermograph/index.html", nil) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404 (no redirect, no listing)", w.Code) } } func TestStaticDirectoryIs404(t *testing.T) { mux := newMux(t, "/thermograph", nil) for _, target := range []string{ "http://example.com/thermograph/icons", // existing directory "http://example.com/thermograph/icons/", // trailing slash "http://example.com/thermograph/app.js/", } { if w := get(mux, "GET", target, nil); w.Code != http.StatusNotFound { t.Errorf("%s: status = %d, want 404", target, w.Code) } } } func TestStaticTraversalIs404(t *testing.T) { dir := newStaticDir(t) // A secret OUTSIDE the static dir must be unreachable even when the // handler is invoked directly with an uncleaned path (the content // package's lazy IndexNow route delegates without a mux in front). if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.txt"), []byte("s"), 0o644); err != nil { t.Fatal(err) } static := handlers.New(handlers.Options{Base: "/thermograph", StaticDir: dir}).Static() r := httptest.NewRequest("GET", "http://example.com/ignored", nil) r.URL.Path = "/thermograph/../secret.txt" // bypass URL parsing/cleaning w := httptest.NewRecorder() static.ServeHTTP(w, r) if w.Code != http.StatusNotFound { t.Errorf("status = %d, want 404", w.Code) } } // --- bare-BASE redirect + root-base topology --------------------------------- func TestBareBaseRedirects307(t *testing.T) { mux := newMux(t, "/thermograph", nil) w := get(mux, "GET", "http://example.com/thermograph", nil) if w.Code != http.StatusTemporaryRedirect { t.Fatalf("status = %d, want 307 (Starlette redirect_slashes)", w.Code) } if loc := w.Header().Get("Location"); loc != "/thermograph/" { t.Errorf("Location = %q", loc) } } func TestRootBaseTopology(t *testing.T) { // The deployed clean-root topology: THERMOGRAPH_BASE=/ -> Base == "". mux := newMux(t, "", nil) if w := get(mux, "GET", "http://example.com/app.js", nil); w.Code != http.StatusOK { t.Errorf("/app.js: status = %d", w.Code) } w := get(mux, "GET", "http://example.com/calendar", nil) if w.Code != http.StatusOK { t.Fatalf("/calendar: status = %d", w.Code) } if !strings.Contains(w.Body.String(), `content="http://example.com/calendar"`) { t.Errorf("origin prefix at root base wrong: %s", w.Body.String()) } }