thermograph/frontend/server/internal/handlers/handlers_test.go
emi a4ecb51401
Some checks failed
secrets-guard / encrypted (push) Successful in 24s
shell-lint / shellcheck (push) Successful in 26s
Deploy frontend to LAN dev server / build (push) Successful in 2m11s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m20s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 2m28s
Deploy backend to LAN dev server / build (push) Successful in 2m45s
PR build (required check) / changes (pull_request) Successful in 16s
secrets-guard / encrypted (pull_request) Successful in 16s
shell-lint / shellcheck (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Has been skipped
Deploy frontend to LAN dev server / deploy (push) Successful in 39s
PR build (required check) / build-backend (pull_request) Successful in 1m21s
PR build (required check) / gate (pull_request) Successful in 3s
Deploy backend to LAN dev server / deploy (push) Failing after 2m31s
web/worker: add a process-level liveness heartbeat (#80)
2026-07-25 04:13:47 +00:00

277 lines
9.5 KiB
Go

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 := "<!doctype html>\n<html><head>\n<title>t</title>\n" +
`<meta property="og:url" content="__ORIGIN__/calendar">` +
"\n</head><body>__ORIGIN__</body></html>\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 := "<head>\n " +
`<meta name="google-site-verification" content="g&#34;tok&amp;en">` + "\n " +
`<meta name="msvalidate.01" content="bing-tok">`
if !strings.Contains(body, want) {
t.Errorf("verification metas not injected after <head>:\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())
}
}