Files
2026-07-13 13:00:08 +08:00

787 lines
23 KiB
Go

package serve
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"reasonix/internal/config"
)
func TestGenerateToken(t *testing.T) {
t1 := generateToken()
t2 := generateToken()
if t1 == t2 {
t.Error("generateToken should produce unique values")
}
if len(t1) < 32 {
t.Errorf("token too short: %d bytes", len(t1))
}
// Should be base64url-encoded (no +, no /).
if strings.ContainsAny(t1, "+/") {
t.Errorf("token contains non-base64url chars: %q", t1)
}
}
func TestHashPassword(t *testing.T) {
h, err := HashPassword("test-password")
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(h, "$2a$12$") {
t.Errorf("unexpected bcrypt prefix: %s", h[:20])
}
// Same password should produce a different hash (random salt).
h2, _ := HashPassword("test-password")
if h == h2 {
t.Error("same password should produce different hash")
}
}
func TestAuthGateModeNone(t *testing.T) {
ag := newAuthGate(config.ServeConfig{}) // default: authNone
if ag.Mode() != "none" {
t.Errorf("mode = %q, want none", ag.Mode())
}
if ag.Token() != "" {
t.Errorf("token = %q, want empty", ag.Token())
}
// In none mode, requests pass through.
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.Get(ts.URL + "/status")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
func TestNormalizeAuthModeRejectsUnknown(t *testing.T) {
if _, err := NormalizeAuthMode("tokne"); err == nil {
t.Fatal("unknown auth mode should be rejected")
}
}
func TestInvalidAuthModeFailsClosed(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "tokne"})
if ag.Mode() != "invalid" {
t.Errorf("mode = %q, want invalid", ag.Mode())
}
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("handler should not run for invalid auth mode")
})))
defer ts.Close()
resp, err := http.Get(ts.URL + "/status")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
}
// ── Token mode tests ──
func TestTokenModeNoAuthReturns401(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.Get(ts.URL + "/status")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
}
func TestTokenModeValidCookie(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
req.AddCookie(&http.Cookie{Name: cookieToken, Value: "secret"})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
func TestTokenModeInvalidCookie(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
req.AddCookie(&http.Cookie{Name: cookieToken, Value: "wrong"})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
}
func TestTokenModeValidQueryParamRedirects(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
// Use a handler that records whether auth passed.
var passed bool
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
passed = true
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
// Don't follow redirects so we can inspect the 302.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(ts.URL + "/?token=secret")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("status = %d, want 302", resp.StatusCode)
}
// Check that the Set-Cookie header is present.
setCookie := resp.Header.Get("Set-Cookie")
if !strings.Contains(setCookie, cookieToken+"=secret") {
t.Errorf("Set-Cookie missing token: %s", setCookie)
}
if !strings.Contains(setCookie, "HttpOnly") {
t.Errorf("Set-Cookie missing HttpOnly: %s", setCookie)
}
if passed {
t.Error("handler should not have run (redirected first)")
}
}
func TestTokenModeLoopbackCookieAllowsLocalHTTP(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.Get(ts.URL + "/?token=secret")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
c := findCookie(resp.Cookies(), cookieToken)
if c == nil {
t.Fatal("token cookie missing")
}
if c.Secure {
t.Fatal("loopback HTTP token cookie should stay usable without Secure")
}
}
func TestTokenModeNonLoopbackCookieAllowsPlainHTTP(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, _ := http.NewRequest("GET", ts.URL+"/?token=secret", nil)
req.Host = "192.0.2.10:8787"
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
c := findCookie(resp.Cookies(), cookieToken)
if c == nil {
t.Fatal("token cookie missing")
}
if c.Secure {
t.Fatal("plain HTTP token cookie should stay usable without Secure")
}
}
func TestTokenModeInvalidQueryParam(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "secret"})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.Get(ts.URL + "/?token=wrong")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", resp.StatusCode)
}
}
func TestTokenModeAutoGeneratesToken(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token"})
if ag.Mode() != "token" {
t.Errorf("mode = %q, want token", ag.Mode())
}
if ag.Token() == "" {
t.Error("token should be auto-generated")
}
if len(ag.Token()) < 32 {
t.Errorf("auto-generated token too short: %d", len(ag.Token()))
}
}
// ── Password mode tests ──
func TestPasswordModeLoginPage(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.Get(ts.URL + "/login")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("login page status = %d, want 200", resp.StatusCode)
}
}
func TestPasswordModeNoSessionRedirects(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, _ := http.NewRequest("GET", ts.URL+"/", nil)
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("redirect status = %d, want 302", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if loc != "/login" {
t.Errorf("redirect location = %q, want /login", loc)
}
}
func TestPasswordModeAPIWithoutSessionReturns401(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
// Simulate a non-browser (fetch) request by not setting Accept: text/html.
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("API status = %d, want 401", resp.StatusCode)
}
}
func TestPasswordModeValidLogin(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
// Don't follow redirects so we can capture the session cookie.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
resp, err := client.PostForm(ts.URL+"/login", url.Values{"password": {"correct"}})
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("login status = %d, want 302", resp.StatusCode)
}
// Extract session cookie and verify it works.
cookies := resp.Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == cookieSession {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("no session cookie set after login")
}
// Use the session cookie to access a protected page.
req, _ := http.NewRequest("GET", ts.URL+"/status", nil)
req.AddCookie(sessionCookie)
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
t.Errorf("authenticated status = %d, want 200", resp2.StatusCode)
}
}
func TestPasswordModeNonLoopbackHTTPLoginCookieIsUsable(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
form := url.Values{"password": {"correct"}}
req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
req.Host = "192.0.2.10:8787"
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
sessionCookie := findCookie(resp.Cookies(), cookieSession)
if sessionCookie == nil {
t.Fatal("session cookie missing")
}
if sessionCookie.Secure {
t.Fatal("plain HTTP password session cookie should stay usable without Secure")
}
req, _ = http.NewRequest("GET", ts.URL+"/status", nil)
req.Host = "192.0.2.10:8787"
req.AddCookie(sessionCookie)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("authenticated non-loopback status = %d, want 200", resp.StatusCode)
}
}
func TestPasswordModeSanitizesRedirectCookie(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
form := url.Values{"password": {"correct"}}
req, _ := http.NewRequest("POST", ts.URL+"/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: cookieRedirect, Value: "//evil.example/path"})
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Errorf("login status = %d, want 302", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/" {
t.Fatalf("redirect location = %q, want /", loc)
}
}
func TestPasswordModeWrongPassword(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.PostForm(ts.URL+"/login", url.Values{"password": {"wrong"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("wrong password status = %d, want 401", resp.StatusCode)
}
// Check that no session cookie was set.
for _, c := range resp.Cookies() {
if c.Name == cookieSession {
t.Error("session cookie should not be set on wrong password")
}
}
}
func TestPasswordModeEmptyPassword(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("correct")})
ts := httptest.NewServer(ag.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})))
defer ts.Close()
resp, err := http.PostForm(ts.URL+"/login", url.Values{})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("empty password status = %d, want 401", resp.StatusCode)
}
}
// ── Session HMAC tests ──
func TestSignAndVerifySession(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
tok := ag.signSession()
if !ag.verifySession(tok) {
t.Error("valid session should verify")
}
}
func TestSessionVerifiesAcrossRestartWithSamePasswordHash(t *testing.T) {
hash := mustHash("test")
ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
tok := ag1.signSession()
ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: hash})
if !ag2.verifySession(tok) {
t.Fatal("session signed with a persisted password hash should verify after restart")
}
}
func TestSessionRejectsDifferentPasswordHash(t *testing.T) {
ag1 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("first")})
tok := ag1.signSession()
ag2 := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("second")})
if ag2.verifySession(tok) {
t.Fatal("session should not verify with a different password hash")
}
}
func TestSessionTampered(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
tok := ag.signSession()
// Tamper with the payload (change a character before the dot).
dot := strings.IndexByte(tok, '.')
tampered := tok[:dot-1] + string(tok[dot-1]^1) + tok[dot:]
if ag.verifySession(tampered) {
t.Error("tampered session should not verify")
}
}
func TestSessionWrongSignature(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
tok := ag.signSession()
dot := strings.LastIndexByte(tok, '.')
// Flip a hex character in the signature, ensuring it actually changes
// (the first nibble might already be 0 → "0" + sig[1:] would be a no-op).
sig := tok[dot+1:]
target := byte('0')
if sig[0] == target {
target = 'f' // guaranteed different
}
flipped := tok[:dot+1] + string(target) + sig[1:]
if ag.verifySession(flipped) {
t.Error("wrong-signature session should not verify")
}
}
func TestSessionExpired(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
// Craft a session with an expiry in the past.
tok := ag.signSession()
// The format is: expiry|nonce.sig
pipe := strings.IndexByte(tok, '|')
expired := "1000000000" + tok[pipe:] // Unix time in 2001
if ag.verifySession(expired) {
t.Error("expired session should not verify")
}
}
func TestSessionMalformed(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "password", PasswordHash: mustHash("test")})
for _, bad := range []string{
"",
"no-dot",
"no-pipe.signature",
".just-signature",
"payload.",
"payload.badhex",
} {
if ag.verifySession(bad) {
t.Errorf("malformed session %q should not verify", bad)
}
}
}
// ── Rate limiter tests ──
func TestRateLimiterAllowsFiveThenBlocks(t *testing.T) {
rl := newRateLimit()
ip := "192.0.2.1"
for i := 0; i < rateLimitMax; i++ {
if !rl.allow(ip) {
t.Fatalf("attempt %d should be allowed", i+1)
}
}
if rl.allow(ip) {
t.Error("6th attempt should be blocked")
}
}
func TestRateLimiterResetsAfterWindow(t *testing.T) {
rl := newRateLimit()
ip := "192.0.2.2"
// Exhaust the limit.
for i := 0; i < rateLimitMax; i++ {
rl.allow(ip)
}
if rl.allow(ip) {
t.Error("should be blocked after exhausting limit")
}
// Manually expire the window.
rl.mu.Lock()
w := rl.attempts[ip]
if w != nil {
w.start = time.Now().Add(-2 * rateLimitWin)
}
rl.mu.Unlock()
// Now it should be allowed again.
if !rl.allow(ip) {
t.Error("should be allowed after window expires")
}
}
func TestRateLimiterDifferentIPs(t *testing.T) {
rl := newRateLimit()
// Exhaust one IP.
for i := 0; i < rateLimitMax; i++ {
rl.allow("192.0.2.1")
}
// Another IP should still be allowed.
if !rl.allow("192.0.2.2") {
t.Error("different IP should not be rate-limited")
}
}
// ── clientIP tests ──
func TestClientIPRemoteAddr(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.0.2.42:12345"
if got := ag.clientIP(req); got != "192.0.2.42" {
t.Errorf("clientIP = %q, want 192.0.2.42", got)
}
}
func TestClientIPIgnoresXForwardedForWithoutProxy(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "192.0.2.1:12345"
req.Header.Set("X-Forwarded-For", "10.0.0.1, 10.0.0.2")
if got := ag.clientIP(req); got != "192.0.2.1" {
t.Errorf("should use RemoteAddr without behind_proxy, got %q", got)
}
}
func TestClientIPTrustsXForwardedForWithProxy(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "10.0.0.99:12345"
req.Header.Set("X-Forwarded-For", "192.0.2.42, 10.0.0.1")
if got := ag.clientIP(req); got != "192.0.2.42" {
t.Errorf("clientIP = %q, want 192.0.2.42", got)
}
}
// ── isTLS tests ──
func TestIsTLS(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
req, _ := http.NewRequest("GET", "/", nil)
if ag.isTLS(req) {
t.Error("plain request should not be TLS")
}
}
func TestIsTLSIgnoresForwardedProtoWithoutProxy(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if ag.isTLS(req) {
t.Error("should ignore X-Forwarded-Proto without behind_proxy")
}
}
func TestIsTLSTrustsForwardedProtoWithProxy(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if !ag.isTLS(req) {
t.Error("should trust X-Forwarded-Proto with behind_proxy")
}
}
func TestAuthCookieSecurePolicy(t *testing.T) {
ag := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x"})
for _, host := range []string{"localhost:8787", "127.0.0.1:8787", "[::1]:8787"} {
req, _ := http.NewRequest("GET", "http://"+host+"/", nil)
if ag.authCookieSecure(req) {
t.Errorf("loopback host %s should allow local HTTP cookies", host)
}
}
req, _ := http.NewRequest("GET", "http://192.0.2.10:8787/", nil)
if ag.authCookieSecure(req) {
t.Fatal("plain HTTP cookies should stay usable without Secure")
}
proxy := newAuthGate(config.ServeConfig{AuthMode: "token", Token: "x", BehindProxy: true})
req, _ = http.NewRequest("GET", "http://example.test/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if !proxy.authCookieSecure(req) {
t.Fatal("trusted forwarded HTTPS should mark cookies Secure")
}
}
func TestPlainHTTPAuthWarning(t *testing.T) {
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "none"}, "0.0.0.0:8787"); got != "" {
t.Fatalf("none auth warning = %q, want empty", got)
}
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, "127.0.0.1:8787"); got != "" {
t.Fatalf("loopback warning = %q, want empty", got)
}
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "token"}, "0.0.0.0:8787"); !strings.Contains(got, "non-loopback HTTP") {
t.Fatalf("non-loopback warning = %q, want HTTP exposure warning", got)
}
if got := PlainHTTPAuthWarning(config.ServeConfig{AuthMode: "password"}, ":8787"); !strings.Contains(got, "non-loopback HTTP") {
t.Fatalf("wildcard warning = %q, want HTTP exposure warning", got)
}
}
func TestSafeRedirectTarget(t *testing.T) {
for _, tc := range []struct {
in string
want string
}{
{"/", "/"},
{"/sessions?id=abc#frag", "/sessions?id=abc"},
{"", "/"},
{"https://evil.example/path", "/"},
{"//evil.example/path", "/"},
{`/\evil.example/path`, "/"},
{"/%2f%2fevil.example/path", "/"},
{"/%5cevil.example/path", "/"},
{"relative/path", "/"},
} {
if got := safeRedirectTarget(tc.in); got != tc.want {
t.Errorf("safeRedirectTarget(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// ── helpers ──
func mustHash(password string) string {
h, err := HashPassword(password)
if err != nil {
panic(err)
}
return h
}
func findCookie(cookies []*http.Cookie, name string) *http.Cookie {
for _, c := range cookies {
if c.Name == name {
return c
}
}
return nil
}