NextMyDay/internal/core/httpx/httpx_test.go
Claus Lohmar c7229e23ff feat(setup): gate setup with the config toggle and an optional code
D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:

- setup: true      -> /setup serves the wizard (the code form first when
                      `setup_code` is set, via the existing D-044 gate);
                      GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
                      setup" boilerplate; every other route -> 404; the
                      wizard is not claimable.
- completion       -> writes `setup: false` and creates the admin.

`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). The peer/tunnel gate is retired: `httpx.
SetupRequestAllowed` and `httpx.IsLoopbackIP` are deleted; the XFF/XFP
handling is kept for the client IP and the Secure/__Host- cookies + HSTS
(A6/A11/D-043). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.

New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.

Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
2026-10-15 06:47:33 +00:00

333 lines
11 KiB
Go

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")
}
}
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)
}
}
}
// 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
}