package httpx_test import ( "context" "crypto/tls" "net" "net/http" "net/http/httptest" "strings" "testing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" ) func TestSetAndClearSessionCookie(t *testing.T) { rec := httptest.NewRecorder() httpx.SetSessionCookie(rec, httpx.SessionCookieName, "abc123", true) cookies := rec.Result().Cookies() if len(cookies) != 1 { t.Fatalf("got %d cookies, want 1", len(cookies)) } c := cookies[0] if c.Name != httpx.SessionCookieName || c.Value != "abc123" { t.Errorf("cookie = %s=%s", c.Name, c.Value) } if !c.HttpOnly { t.Error("cookie is not HttpOnly") } if !c.Secure { t.Error("cookie is not Secure") } if c.SameSite != http.SameSiteLaxMode { t.Errorf("SameSite = %v, want Lax", c.SameSite) } if c.Path != "/" { t.Errorf("Path = %q, want /", c.Path) } // Clearing expires it. rec2 := httptest.NewRecorder() httpx.ClearSessionCookie(rec2, httpx.SessionCookieName, true) if got := rec2.Result().Cookies()[0].MaxAge; got >= 0 { t.Errorf("cleared cookie MaxAge = %d, want negative", got) } } func TestParseTrustedProxies(t *testing.T) { tp, err := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8", ""}) if err != nil { t.Fatalf("ParseTrustedProxies: %v", err) } if !tp.Contains(parseIP(t, "127.0.0.1")) { t.Error("127.0.0.1 not trusted") } if !tp.Contains(parseIP(t, "10.1.2.3")) { t.Error("10.1.2.3 not covered by 10.0.0.0/8") } if tp.Contains(parseIP(t, "8.8.8.8")) { t.Error("8.8.8.8 reported trusted") } if _, err := httpx.ParseTrustedProxies([]string{"not-an-ip"}); err == nil { t.Error("ParseTrustedProxies(bad) = nil, want error") } } // TestRequestIsHTTPS pins D-043: https is decided by the transport — a direct // TLS connection, or a TRUSTED proxy's X-Forwarded-Proto. An untrusted peer's // header is ignored. func TestRequestIsHTTPS(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"10.0.0.5"}) newReq := func(peer string, useTLS bool, xfp string) *http.Request { r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = peer if useTLS { r.TLS = &tls.ConnectionState{} } if xfp != "" { r.Header.Set("X-Forwarded-Proto", xfp) } return r } cases := []struct { name string req *http.Request want bool }{ {"direct TLS", newReq("203.0.113.9:4444", true, ""), true}, {"trusted proxy, XFP https", newReq("10.0.0.5:4444", false, "https"), true}, {"trusted proxy, XFP http", newReq("10.0.0.5:4444", false, "http"), false}, {"trusted proxy, no XFP", newReq("10.0.0.5:4444", false, ""), false}, {"trusted proxy, XFP list", newReq("10.0.0.5:4444", false, "https, http"), true}, {"untrusted peer spoofs XFP https", newReq("203.0.113.9:4444", false, "https"), false}, {"loopback peer, no XFP", newReq("127.0.0.1:4444", false, ""), false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := httpx.RequestIsHTTPS(tc.req, tp); got != tc.want { t.Errorf("RequestIsHTTPS = %v, want %v", got, tc.want) } }) } } // TestSecureFromRequestDefaultsFalse pins the transport-neutral default: a // request that never passed through the door is plain http, so cookie writers // fall back to the non-Secure (bare) behaviour. func TestSecureFromRequestDefaultsFalse(t *testing.T) { r := httptest.NewRequest("GET", "/", nil) if httpx.SecureFromRequest(r) { t.Error("SecureFromRequest = true with no door-set value, want false") } ctx := httpx.WithSecure(r.Context(), true) if !httpx.SecureFromRequest(r.WithContext(ctx)) { t.Error("SecureFromRequest did not read the door-set value") } } // TestSetupRequestAllowed pins D-025 (revised): only a loopback TCP peer with // NO X-Forwarded-For is the direct tunnel; everything else — including a // loopback peer carrying XFF, or an unparseable peer — fails closed. func TestSetupRequestAllowed(t *testing.T) { cases := []struct { name string peer string xff string want bool }{ {"loopback peer, no XFF", "127.0.0.1:52345", "", true}, {"loopback peer with XFF", "127.0.0.1:52345", "203.0.113.9", false}, {"remote peer", "203.0.113.9:4444", "", false}, {"remote peer with XFF", "203.0.113.9:4444", "198.51.100.7", false}, {"empty peer", "", "", false}, {"unparseable peer", "not-an-address", "", false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest("GET", "/setup", nil) r.RemoteAddr = tc.peer if tc.xff != "" { r.Header.Set("X-Forwarded-For", tc.xff) } if got := httpx.SetupRequestAllowed(r); got != tc.want { t.Errorf("SetupRequestAllowed = %v, want %v", got, tc.want) } }) } } func TestClientIPUntrustedPeerIgnoresXFF(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"}) r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "203.0.113.9:5555" r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") got := httpx.ClientIP(r, tp) if got != "203.0.113.9" { t.Errorf("ClientIP = %q, want the peer (XFF from an untrusted peer must be ignored)", got) } // SET, never append. if xff := r.Header.Get("X-Forwarded-For"); xff != "203.0.113.9" { t.Errorf("X-Forwarded-For = %q, want it SET to %q", xff, "203.0.113.9") } } func TestClientIPTrustedPeerResolvesRightmostUntrusted(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "127.0.0.1:5555" // An append-style proxy (nginx default) appends the peer it SAW to the // right of whatever the client sent. Here a client forged "1.2.3.4" and the // proxy appended the real client "198.51.100.7" (H1). r.Header.Set("X-Forwarded-For", "1.2.3.4, 198.51.100.7, 10.0.0.5") got := httpx.ClientIP(r, tp) if got != "198.51.100.7" { t.Errorf("ClientIP = %q, want the rightmost untrusted 198.51.100.7 (H1: the leftmost entry is forgeable)", got) } if xff := r.Header.Get("X-Forwarded-For"); xff != "198.51.100.7" { t.Errorf("X-Forwarded-For = %q, want it SET (replaced), not appended", xff) } } // TestClientIPIgnoresForgedLeftmostEntry is the H1 regression: the leftmost // entry must never win, or an append-style proxy gives the client a fresh // rate-limit bucket and a forged audit client_ip (A3/A11). func TestClientIPIgnoresForgedLeftmostEntry(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1"}) r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "127.0.0.1:5555" r.Header.Set("X-Forwarded-For", "9.9.9.9, 198.51.100.7") if got := httpx.ClientIP(r, tp); got != "198.51.100.7" { t.Errorf("ClientIP = %q, want 198.51.100.7 (the forged 9.9.9.9 must be ignored, H1)", got) } } func TestClientIPAllHopsTrusted(t *testing.T) { tp, _ := httpx.ParseTrustedProxies([]string{"127.0.0.1", "10.0.0.0/8"}) r := httptest.NewRequest("GET", "/", nil) r.RemoteAddr = "127.0.0.1:5555" r.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2") // Every hop is trusted: the peer is the only address we can vouch for. if got := httpx.ClientIP(r, tp); got != "127.0.0.1" { t.Errorf("ClientIP = %q, want the peer 127.0.0.1 when every hop is trusted", got) } } // TestIsLoopbackHost pins the nudge's origin predicate (SPEC-006 §8): a NAME // is not loopback, and only an empty/unknown host fails closed to loopback. func TestIsLoopbackHost(t *testing.T) { cases := []struct { host string want bool }{ {"127.0.0.1", true}, {"127.0.0.1:8080", true}, {"localhost", true}, {"localhost:8080", true}, {"[::1]:8080", true}, {"example.com", false}, {"myday.example.com:443", false}, {"203.0.113.9", false}, {"", true}, // unknown → suppress the nudge (fail closed) } for _, tc := range cases { if got := httpx.IsLoopbackHost(tc.host); got != tc.want { t.Errorf("IsLoopbackHost(%q) = %v, want %v", tc.host, got, tc.want) } } } // TestIsLoopbackIPFailsClosed pins the setup-mode gate's predicate (H2): an // empty or unparseable resolved client IP must NOT count as loopback. func TestIsLoopbackIPFailsClosed(t *testing.T) { cases := []struct { ip string want bool }{ {"127.0.0.1", true}, {"127.0.0.5", true}, {"::1", true}, {"localhost", true}, {"[::1]", true}, {"203.0.113.9", false}, {"10.0.0.5", false}, {"", false}, // unparseable: fail closed {"not-an-ip", false}, {"0.0.0.0", false}, } for _, tc := range cases { if got := httpx.IsLoopbackIP(tc.ip); got != tc.want { t.Errorf("IsLoopbackIP(%q) = %v, want %v", tc.ip, got, tc.want) } } } // TestSessionCookieNameForPrefix pins L1s: the `__Host-` prefix is used exactly // when the cookie is Secure (a browser rejects a `__Host-` cookie over http). func TestSessionCookieNameForPrefix(t *testing.T) { if got := httpx.SessionCookieNameFor(true); got != "__Host-myday_session" { t.Errorf("SessionCookieNameFor(true) = %q, want the __Host- prefix", got) } if got := httpx.SessionCookieNameFor(false); got != httpx.SessionCookieName { t.Errorf("SessionCookieNameFor(false) = %q, want the bare name", got) } } func TestRedactPath(t *testing.T) { cases := []struct { in string mustGone string }{ {"/setup/token/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, {"/access/reset/SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, {"/x/token=SUPERSECRETTOKEN", "SUPERSECRETTOKEN"}, } for _, tc := range cases { got := httpx.RedactPath(tc.in) if strings.Contains(got, tc.mustGone) { t.Errorf("RedactPath(%q) = %q, still contains the credential", tc.in, got) } if !strings.Contains(got, httpx.Redacted) { t.Errorf("RedactPath(%q) = %q, want a %s marker", tc.in, got, httpx.Redacted) } } // The GUID is public and must survive verbatim (D-001). const guid = "AbCdEf0123456789" if got := httpx.RedactPath("/" + guid + "/home"); got != "/"+guid+"/home" { t.Errorf("RedactPath preserved a GUID: got %q", got) } } func TestRedactQuery(t *testing.T) { got := httpx.RedactQuery("token=SECRET&view=open") if strings.Contains(got, "SECRET") { t.Errorf("RedactQuery = %q, still contains the token", got) } if !strings.Contains(got, "view=open") { t.Errorf("RedactQuery = %q, want the non-sensitive param kept", got) } } func TestSetSecurityHeaders(t *testing.T) { rec := httptest.NewRecorder() httpx.SetSecurityHeaders(rec, false) h := rec.Header() if h.Get("X-Content-Type-Options") != "nosniff" { t.Error("missing X-Content-Type-Options: nosniff") } if h.Get("Referrer-Policy") != "no-referrer" { t.Error("missing Referrer-Policy") } if !strings.Contains(h.Get("Content-Security-Policy"), "default-src 'self'") { t.Errorf("CSP = %q", h.Get("Content-Security-Policy")) } // L4s: the isolation trio. if got := h.Get("Permissions-Policy"); !strings.Contains(got, "geolocation=()") { t.Errorf("Permissions-Policy = %q, want the feature allowlist", got) } if got := h.Get("Cross-Origin-Opener-Policy"); got != "same-origin" { t.Errorf("Cross-Origin-Opener-Policy = %q, want same-origin", got) } if got := h.Get("Cross-Origin-Resource-Policy"); got != "same-origin" { t.Errorf("Cross-Origin-Resource-Policy = %q, want same-origin", got) } if h.Get("Strict-Transport-Security") != "" { t.Error("HSTS set for an insecure request") } rec2 := httptest.NewRecorder() httpx.SetSecurityHeaders(rec2, true) if rec2.Header().Get("Strict-Transport-Security") == "" { t.Error("HSTS missing for a secure request") } } func TestCSRF(t *testing.T) { csrf, err := httpx.NewCSRF([]byte("a-test-csrf-key-0123456789abcdef")) if err != nil { t.Fatalf("NewCSRF: %v", err) } tok := csrf.Token("session-1") if tok == "" { t.Fatal("Token returned empty") } if !csrf.Valid("session-1", tok) { t.Error("Valid rejected the correct token") } if csrf.Valid("session-2", tok) { t.Error("Valid accepted a token bound to another session") } if csrf.Valid("session-1", "") { t.Error("Valid accepted an empty token") } if csrf.Valid("", tok) { t.Error("Valid accepted an empty session id") } if csrf.Valid("session-1", tok+"x") { t.Error("Valid accepted a tampered token") } if _, err := httpx.NewCSRF(nil); err == nil { t.Error("NewCSRF(nil) = nil, want an error (fail closed)") } } func TestTokenContext(t *testing.T) { ctx := httpx.WithToken(context.Background(), "tok-9") if got := httpx.TokenFrom(ctx); got != "tok-9" { t.Errorf("TokenFrom = %q, want tok-9", got) } if got := httpx.TokenFrom(context.Background()); got != "" { t.Errorf("TokenFrom(background) = %q, want empty", got) } } func parseIP(t *testing.T, s string) (ip net.IP) { t.Helper() ip = net.ParseIP(s) if ip == nil { t.Fatalf("ParseIP(%q) = nil", s) } return ip }