- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded migration runner; each module's migrations are name-prefixed (D-012) - audit: the append-only audit log; the actor threads via context (R-1) - users: the user_id/GUID/email anchors (D-013) - config: the YAML settings file (D-035) + validation - httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security headers · redaction - middleware: correlation id · audit · rate limiting - mail: the invite/recovery mailer seam - server: the core DOOR — the resolution switch, the dispatch registry (RegisterModule), the role gates, /setup · enroll/login · recovery flows, the reserved `static` workflow, and the loopback-only bootstrap (D-025)
217 lines
6.1 KiB
Go
217 lines
6.1 KiB
Go
package httpx_test
|
|
|
|
import (
|
|
"context"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
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 TestClientIPTrustedPeerResolvesLeftmostUntrusted(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", "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 leftmost non-trusted 198.51.100.7", 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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
|
|
if got := httpx.ClientIP(r, tp); got != "10.0.0.2" {
|
|
t.Errorf("ClientIP = %q, want the last hop 10.0.0.2", 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"))
|
|
}
|
|
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
|
|
}
|