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
277 lines
9.5 KiB
Go
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"tok&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())
|
|
}
|
|
}
|