Files
pulse/internal/api/security_test.go
T
rcourtman 2a7019b0fa Use typed Proxmox runners and preserve cross-clock action evidence
Proxmox planning and dispatch now require a unique credential-admitted typed
runner with durable receipts. Development authentication preserves explicit
bearer identity so runner activation keeps its tenant and credential scope.

Preserve observer and receiver timestamps from their separate clocks instead
of rejecting or rewriting valid evidence. Keep completed execution separate
from stale or inconclusive verification, and label independent observations
accurately in action reviews.

Verified with targeted race suites, action-review tests and frontend build,
plus a real Assistant start plan and approved VM110 start/stop with independent
Proxmox confirmation. Final action reviews passed Playwright at 1440, 900 and
390 pixels, including retained completed, rejected and expired history.
2026-09-07 12:00:48 +01:00

2444 lines
72 KiB
Go

package api
import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// fixedTimeForTest returns a fixed time for deterministic testing
func fixedTimeForTest() time.Time {
return time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC)
}
func resetTrustedProxyConfig() {
trustedProxyCIDRs = nil
trustedProxyConfigErr = nil
trustedProxyOnce = sync.Once{}
}
func TestGetClientIPRejectsSpoofedLoopback(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "198.51.100.42:54321"
req.Header.Set("X-Forwarded-For", "127.0.0.1")
if got := GetClientIP(req); got != "198.51.100.42" {
t.Fatalf("expected remote IP when proxy is untrusted, got %q", got)
}
}
func TestGetClientIPUsesForwardedForTrustedProxy(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyConfig()
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Forwarded-For", "203.0.113.44")
if got := GetClientIP(req); got != "203.0.113.44" {
t.Fatalf("expected forwarded IP for trusted proxy, got %q", got)
}
}
func TestGetClientIPEmptyRemoteAddr(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "" // Empty remote addr
if got := GetClientIP(req); got != "" {
t.Fatalf("expected empty string for empty RemoteAddr, got %q", got)
}
}
func TestGetClientIPUsesXRealIPTrustedProxy(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyConfig()
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "127.0.0.1:54321"
// No X-Forwarded-For, but has X-Real-IP
req.Header.Set("X-Real-IP", "203.0.113.55")
if got := GetClientIP(req); got != "203.0.113.55" {
t.Fatalf("expected X-Real-IP for trusted proxy, got %q", got)
}
}
func TestValidateTrustedProxyCIDRsFromEnvRejectsWildcard(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")
resetTrustedProxyConfig()
err := ValidateTrustedProxyCIDRsFromEnv()
if err == nil || !strings.Contains(err.Error(), "wildcard trust range") {
t.Fatalf("expected wildcard trust range error, got %v", err)
}
}
func TestIsTrustedProxyIPRejectsWildcardCIDR(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "0.0.0.0/0")
resetTrustedProxyConfig()
if isTrustedProxyIP("203.0.113.10") {
t.Fatal("expected wildcard trusted proxy CIDR to fail closed")
}
}
func TestIsTrustedProxyIP(t *testing.T) {
tests := []struct {
name string
envCIDR string
ipStr string
want bool
}{
{
name: "empty string returns false",
envCIDR: "127.0.0.1/32",
ipStr: "",
want: false,
},
{
name: "invalid IP returns false",
envCIDR: "127.0.0.1/32",
ipStr: "not-an-ip",
want: false,
},
{
name: "IP not in CIDR range returns false",
envCIDR: "10.0.0.0/8",
ipStr: "192.168.1.1",
want: false,
},
{
name: "IP in CIDR range returns true",
envCIDR: "10.0.0.0/8",
ipStr: "10.1.2.3",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", tt.envCIDR)
resetTrustedProxyConfig()
if got := isTrustedProxyIP(tt.ipStr); got != tt.want {
t.Errorf("isTrustedProxyIP(%q) = %v, want %v", tt.ipStr, got, tt.want)
}
})
}
}
func TestIsPrivateIP(t *testing.T) {
cases := []struct {
name string
ip string
want bool
}{
// Public IPs
{"public IPv4", "198.51.100.42", false},
{"public IPv4 Google DNS", "8.8.8.8", false},
{"public IPv6", "2001:4860:4860::8888", false},
// Private IPv4 ranges (RFC1918)
{"private IPv4 10.x.x.x", "10.1.2.3", true},
{"private IPv4 10.0.0.0", "10.0.0.0", true},
{"private IPv4 10.255.255.255", "10.255.255.255", true},
{"private IPv4 172.16.x.x", "172.16.0.1", true},
{"private IPv4 172.31.x.x", "172.31.255.255", true},
{"private IPv4 192.168.x.x", "192.168.1.100", true},
// Loopback
{"loopback IPv4", "127.0.0.1", true},
{"loopback IPv4 127.0.0.0", "127.0.0.0", true},
{"loopback IPv4 127.255.255.255", "127.255.255.255", true},
{"loopback IPv6", "::1", true},
{"loopback IPv6 with port", "[::1]:8443", true},
// Link-local
{"link-local IPv4", "169.254.1.1", true},
{"link-local IPv6", "fe80::1", true},
// Link-local multicast
{"link-local multicast IPv4", "224.0.0.1", true},
{"link-local multicast IPv6", "ff02::1", true},
// Unique local IPv6 (fc00::/7)
{"unique local IPv6 fc00", "fc00::1", true},
{"unique local IPv6 fd00", "fd00::1", true},
// Edge cases - empty/invalid
{"empty string", "", false},
{"invalid IP", "not-an-ip", false},
{"invalid format", "999.999.999.999", false},
// With port numbers
{"private IPv4 with port", "192.168.1.1:8080", true},
{"public IPv4 with port", "8.8.8.8:53", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := isPrivateIP(tc.ip); got != tc.want {
t.Fatalf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
}
})
}
}
func TestIsTrustedNetwork(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ip string
trustedNetworks []string
expected bool
}{
// Nil trusted networks - falls back to private IP check
{
name: "private IP trusted when no networks configured",
ip: "10.0.0.5",
trustedNetworks: nil,
expected: true,
},
{
name: "public IP untrusted when no networks configured",
ip: "198.51.100.42",
trustedNetworks: nil,
expected: false,
},
// Empty trusted networks slice - falls back to private IP check
{
name: "private IP trusted with empty networks",
ip: "192.168.1.1",
trustedNetworks: []string{},
expected: true,
},
// Custom CIDR networks
{
name: "IP within custom CIDR trusted",
ip: "203.0.113.44:8080",
trustedNetworks: []string{"203.0.113.0/24"},
expected: true,
},
{
name: "IP outside custom CIDR untrusted",
ip: "198.51.100.42",
trustedNetworks: []string{"203.0.113.0/24"},
expected: false,
},
// Edge cases - empty/invalid input
{
name: "empty IP string returns false",
ip: "",
trustedNetworks: []string{"10.0.0.0/8"},
expected: false,
},
{
name: "invalid IP returns false",
ip: "not-an-ip",
trustedNetworks: []string{"10.0.0.0/8"},
expected: false,
},
// Invalid CIDR is skipped, not matched
{
name: "invalid CIDR in list is skipped",
ip: "10.0.0.5",
trustedNetworks: []string{"invalid-cidr", "10.0.0.0/8"},
expected: true,
},
{
name: "only invalid CIDRs returns false",
ip: "10.0.0.5",
trustedNetworks: []string{"invalid-cidr", "also-invalid"},
expected: false,
},
// Whitespace handling in CIDRs
{
name: "CIDR with whitespace is trimmed",
ip: "10.0.0.5",
trustedNetworks: []string{" 10.0.0.0/8 "},
expected: true,
},
// Multiple valid CIDRs
{
name: "IP matches second CIDR",
ip: "172.16.5.10",
trustedNetworks: []string{"10.0.0.0/8", "172.16.0.0/12"},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := isTrustedNetwork(tt.ip, tt.trustedNetworks)
if result != tt.expected {
t.Errorf("isTrustedNetwork(%q, %v) = %v, want %v", tt.ip, tt.trustedNetworks, result, tt.expected)
}
})
}
}
func TestExtractRemoteIP(t *testing.T) {
cases := []struct {
name string
remoteAddr string
want string
}{
// Empty input
{"empty string", "", ""},
// IPv4 with port
{"IPv4 with port", "192.168.1.100:54321", "192.168.1.100"},
{"localhost with port", "127.0.0.1:8080", "127.0.0.1"},
{"public IP with port", "203.0.113.44:443", "203.0.113.44"},
// IPv4 without port
{"IPv4 without port", "192.168.1.100", "192.168.1.100"},
{"localhost without port", "127.0.0.1", "127.0.0.1"},
// IPv6 with port (bracketed)
{"IPv6 loopback with port", "[::1]:8080", "::1"},
{"IPv6 full with port", "[2001:db8::1]:443", "2001:db8::1"},
{"IPv6 link-local with port", "[fe80::1]:8080", "fe80::1"},
// IPv6 without port (bracketed)
{"IPv6 loopback bracketed", "[::1]", "::1"},
{"IPv6 full bracketed", "[2001:db8::1]", "2001:db8::1"},
// IPv6 without brackets (raw)
{"IPv6 loopback raw", "::1", "::1"},
{"IPv6 full raw", "2001:db8::1", "2001:db8::1"},
// Edge cases
{"port only", ":8080", ""},
{"brackets only", "[]", ""},
{"whitespace", " ", " "},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := extractRemoteIP(tc.remoteAddr)
if got != tc.want {
t.Errorf("extractRemoteIP(%q) = %q, want %q", tc.remoteAddr, got, tc.want)
}
})
}
}
func TestFirstValidForwardedIP(t *testing.T) {
cases := []struct {
name string
header string
want string
}{
// Empty input
{"empty string", "", ""},
{"whitespace only", " ", ""},
// Single IP
{"single IPv4", "192.168.1.100", "192.168.1.100"},
{"single IPv4 with whitespace", " 192.168.1.100 ", "192.168.1.100"},
{"single IPv6", "2001:db8::1", "2001:db8::1"},
{"single IPv6 bracketed", "[2001:db8::1]", "2001:db8::1"},
{"single IPv6 loopback", "::1", "::1"},
// Multiple IPs (comma-separated)
{"two IPs first valid", "192.168.1.100, 10.0.0.1", "192.168.1.100"},
{"two IPs with spaces", " 192.168.1.100 , 10.0.0.1 ", "192.168.1.100"},
{"three IPs", "203.0.113.1, 10.0.0.1, 172.16.0.1", "203.0.113.1"},
// Invalid first, valid second
{"invalid first then valid", "not-an-ip, 192.168.1.100", "192.168.1.100"},
{"empty first then valid", ", 192.168.1.100", "192.168.1.100"},
{"garbage then valid", "garbage, foobar, 10.0.0.1", "10.0.0.1"},
// All invalid
{"all invalid", "not-an-ip, also-invalid", ""},
{"hostnames not IPs", "example.com, localhost", ""},
// Mixed IPv4 and IPv6
{"IPv6 first then IPv4", "2001:db8::1, 192.168.1.1", "2001:db8::1"},
{"IPv4 first then IPv6", "192.168.1.1, 2001:db8::1", "192.168.1.1"},
// Edge cases
{"IP with port rejected", "192.168.1.100:8080", ""},
{"bracketed IPv6 with port rejected", "[2001:db8::1]:443", ""},
{"multiple commas", "192.168.1.1,,,10.0.0.1", "192.168.1.1"},
{"only commas", ",,,", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := firstValidForwardedIP(tc.header)
if got != tc.want {
t.Errorf("firstValidForwardedIP(%q) = %q, want %q", tc.header, got, tc.want)
}
})
}
}
func TestIsPrivateIPExtended(t *testing.T) {
// Extended test cases beyond the basic ones in TestIsPrivateIP
cases := []struct {
name string
ip string
want bool
}{
// RFC 1918 private ranges
{"10.x.x.x range start", "10.0.0.0", true},
{"10.x.x.x range middle", "10.128.64.32", true},
{"10.x.x.x range end", "10.255.255.255", true},
{"172.16-31.x.x start", "172.16.0.0", true},
{"172.16-31.x.x middle", "172.24.128.64", true},
{"172.16-31.x.x end", "172.31.255.255", true},
{"172.15.x.x outside range", "172.15.255.255", false},
{"172.32.x.x outside range", "172.32.0.0", false},
{"192.168.x.x start", "192.168.0.0", true},
{"192.168.x.x end", "192.168.255.255", true},
{"192.169.x.x outside range", "192.169.0.0", false},
// Loopback
{"loopback start", "127.0.0.0", true},
{"loopback middle", "127.128.64.32", true},
{"loopback end", "127.255.255.255", true},
// IPv6 private/local
{"IPv6 loopback", "::1", true},
{"IPv6 unique local fc00::/7 start", "fc00::1", true},
{"IPv6 unique local fd00::", "fd00::1234", true},
{"IPv6 link-local fe80::/10", "fe80::abcd:1234", true},
// Public IPs
{"Google DNS", "8.8.8.8", false},
{"Cloudflare DNS", "1.1.1.1", false},
{"documentation range 192.0.2.x", "192.0.2.1", false},
{"documentation range 198.51.100.x", "198.51.100.1", false},
{"documentation range 203.0.113.x", "203.0.113.1", false},
{"IPv6 public", "2001:4860:4860::8888", false},
// With ports
{"private with port", "192.168.1.1:8080", true},
{"public with port", "8.8.8.8:53", false},
{"IPv6 loopback with port", "[::1]:443", true},
{"IPv6 public with port", "[2001:4860:4860::8888]:443", false},
// Invalid inputs
{"empty string", "", false},
{"invalid IP", "not-an-ip", false},
{"hostname", "example.com", false},
{"partial IP", "192.168", false},
{"IPv4 with extra octet", "192.168.1.1.1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := isPrivateIP(tc.ip)
if got != tc.want {
t.Errorf("isPrivateIP(%q) = %v, want %v", tc.ip, got, tc.want)
}
})
}
}
// resetFailedLogins clears the failed login state for testing
func resetFailedLogins() {
failedMu.Lock()
defer failedMu.Unlock()
failedLogins = make(map[string]*FailedLogin)
}
// resetSessionTracking clears session tracking state for testing
func resetSessionTracking() {
sessionsMu.Lock()
defer sessionsMu.Unlock()
allSessions = make(map[string][]string)
}
func TestRecordFailedLogin(t *testing.T) {
resetFailedLogins()
t.Run("increments count on each failure", func(t *testing.T) {
resetFailedLogins()
identifier := "test-user-1"
RecordFailedLogin(identifier)
attempts, _, _ := GetLockoutInfo(identifier)
if attempts != 1 {
t.Errorf("attempts = %d, want 1", attempts)
}
RecordFailedLogin(identifier)
attempts, _, _ = GetLockoutInfo(identifier)
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
RecordFailedLogin(identifier)
attempts, _, _ = GetLockoutInfo(identifier)
if attempts != 3 {
t.Errorf("attempts = %d, want 3", attempts)
}
})
t.Run("triggers lockout at max attempts", func(t *testing.T) {
resetFailedLogins()
identifier := "test-user-2"
// Record up to max failed attempts
for i := 0; i < maxFailedAttempts; i++ {
RecordFailedLogin(identifier)
}
attempts, lockedUntil, isLocked := GetLockoutInfo(identifier)
if attempts != maxFailedAttempts {
t.Errorf("attempts = %d, want %d", attempts, maxFailedAttempts)
}
if !isLocked {
t.Error("expected isLocked = true")
}
if lockedUntil.IsZero() {
t.Error("expected lockedUntil to be set")
}
})
t.Run("independent identifiers", func(t *testing.T) {
resetFailedLogins()
identifier1 := "user-a"
identifier2 := "user-b"
RecordFailedLogin(identifier1)
RecordFailedLogin(identifier1)
RecordFailedLogin(identifier2)
attempts1, _, _ := GetLockoutInfo(identifier1)
attempts2, _, _ := GetLockoutInfo(identifier2)
if attempts1 != 2 {
t.Errorf("identifier1 attempts = %d, want 2", attempts1)
}
if attempts2 != 1 {
t.Errorf("identifier2 attempts = %d, want 1", attempts2)
}
})
}
func TestClearFailedLogins(t *testing.T) {
resetFailedLogins()
t.Run("clears failed login count", func(t *testing.T) {
resetFailedLogins()
identifier := "test-user-clear"
RecordFailedLogin(identifier)
RecordFailedLogin(identifier)
attempts, _, _ := GetLockoutInfo(identifier)
if attempts != 2 {
t.Errorf("attempts before clear = %d, want 2", attempts)
}
ClearFailedLogins(identifier)
attempts, _, _ = GetLockoutInfo(identifier)
if attempts != 0 {
t.Errorf("attempts after clear = %d, want 0", attempts)
}
})
t.Run("clearing nonexistent identifier does not panic", func(t *testing.T) {
resetFailedLogins()
ClearFailedLogins("nonexistent-user")
// Should not panic
})
t.Run("clears lockout state", func(t *testing.T) {
resetFailedLogins()
identifier := "locked-user"
// Lock the account
for i := 0; i < maxFailedAttempts; i++ {
RecordFailedLogin(identifier)
}
_, _, isLocked := GetLockoutInfo(identifier)
if !isLocked {
t.Error("expected account to be locked before clear")
}
ClearFailedLogins(identifier)
_, _, isLocked = GetLockoutInfo(identifier)
if isLocked {
t.Error("expected account to not be locked after clear")
}
})
}
func TestGetLockoutInfo(t *testing.T) {
resetFailedLogins()
t.Run("nonexistent identifier returns zeros", func(t *testing.T) {
resetFailedLogins()
attempts, lockedUntil, isLocked := GetLockoutInfo("unknown-user")
if attempts != 0 {
t.Errorf("attempts = %d, want 0", attempts)
}
if !lockedUntil.IsZero() {
t.Errorf("lockedUntil = %v, want zero time", lockedUntil)
}
if isLocked {
t.Error("expected isLocked = false")
}
})
t.Run("returns correct attempts below lockout", func(t *testing.T) {
resetFailedLogins()
identifier := "partial-user"
RecordFailedLogin(identifier)
RecordFailedLogin(identifier)
attempts, _, isLocked := GetLockoutInfo(identifier)
if attempts != 2 {
t.Errorf("attempts = %d, want 2", attempts)
}
if isLocked {
t.Error("expected isLocked = false for attempts below max")
}
})
t.Run("isLocked true only when attempts >= max and within lockout period", func(t *testing.T) {
resetFailedLogins()
identifier := "locked-user-test"
// Record max attempts to trigger lockout
for i := 0; i < maxFailedAttempts; i++ {
RecordFailedLogin(identifier)
}
attempts, lockedUntil, isLocked := GetLockoutInfo(identifier)
if attempts != maxFailedAttempts {
t.Errorf("attempts = %d, want %d", attempts, maxFailedAttempts)
}
if !isLocked {
t.Error("expected isLocked = true")
}
if lockedUntil.IsZero() {
t.Error("expected lockedUntil to be set")
}
})
t.Run("expired lockout returns zeros", func(t *testing.T) {
resetFailedLogins()
identifier := "expired-lockout-user"
// Directly set an expired lockout entry
failedMu.Lock()
failedLogins[identifier] = &FailedLogin{
Count: maxFailedAttempts,
LastAttempt: time.Now().Add(-time.Hour),
LockedUntil: time.Now().Add(-time.Minute), // Expired
}
failedMu.Unlock()
attempts, lockedUntil, isLocked := GetLockoutInfo(identifier)
if attempts != 0 {
t.Errorf("attempts = %d, want 0 for expired lockout", attempts)
}
if !lockedUntil.IsZero() {
t.Errorf("lockedUntil = %v, want zero time for expired lockout", lockedUntil)
}
if isLocked {
t.Error("expected isLocked = false for expired lockout")
}
})
}
func TestResetLockout(t *testing.T) {
resetFailedLogins()
t.Run("resets lockout state", func(t *testing.T) {
resetFailedLogins()
identifier := "admin-reset-test"
// Lock the account
for i := 0; i < maxFailedAttempts; i++ {
RecordFailedLogin(identifier)
}
_, _, isLocked := GetLockoutInfo(identifier)
if !isLocked {
t.Error("expected account to be locked before reset")
}
ResetLockout(identifier)
attempts, _, isLocked := GetLockoutInfo(identifier)
if isLocked {
t.Error("expected account to not be locked after reset")
}
if attempts != 0 {
t.Errorf("attempts = %d, want 0 after reset", attempts)
}
})
t.Run("resetting nonexistent identifier does not panic", func(t *testing.T) {
resetFailedLogins()
ResetLockout("nonexistent-admin-user")
// Should not panic
})
}
func TestTrackUserSession(t *testing.T) {
resetSessionTracking()
t.Run("tracks new user session", func(t *testing.T) {
resetSessionTracking()
TrackUserSession("alice", "session-1")
username := GetSessionUsername("session-1")
if username != "alice" {
t.Errorf("username = %q, want alice", username)
}
})
t.Run("tracks multiple sessions for same user", func(t *testing.T) {
resetSessionTracking()
TrackUserSession("bob", "session-a")
TrackUserSession("bob", "session-b")
TrackUserSession("bob", "session-c")
if GetSessionUsername("session-a") != "bob" {
t.Error("session-a should belong to bob")
}
if GetSessionUsername("session-b") != "bob" {
t.Error("session-b should belong to bob")
}
if GetSessionUsername("session-c") != "bob" {
t.Error("session-c should belong to bob")
}
})
t.Run("tracks sessions for multiple users", func(t *testing.T) {
resetSessionTracking()
TrackUserSession("user1", "sess-1")
TrackUserSession("user2", "sess-2")
TrackUserSession("user3", "sess-3")
if GetSessionUsername("sess-1") != "user1" {
t.Error("sess-1 should belong to user1")
}
if GetSessionUsername("sess-2") != "user2" {
t.Error("sess-2 should belong to user2")
}
if GetSessionUsername("sess-3") != "user3" {
t.Error("sess-3 should belong to user3")
}
})
}
func TestGetSessionUsername(t *testing.T) {
resetSessionTracking()
t.Run("returns empty for unknown session", func(t *testing.T) {
resetSessionTracking()
username := GetSessionUsername("unknown-session")
if username != "" {
t.Errorf("username = %q, want empty string", username)
}
})
t.Run("returns correct username for tracked session", func(t *testing.T) {
resetSessionTracking()
TrackUserSession("testuser", "test-session-id")
username := GetSessionUsername("test-session-id")
if username != "testuser" {
t.Errorf("username = %q, want testuser", username)
}
})
t.Run("handles multiple users and sessions", func(t *testing.T) {
resetSessionTracking()
TrackUserSession("alice", "alice-session-1")
TrackUserSession("alice", "alice-session-2")
TrackUserSession("bob", "bob-session-1")
if GetSessionUsername("alice-session-1") != "alice" {
t.Error("alice-session-1 should belong to alice")
}
if GetSessionUsername("alice-session-2") != "alice" {
t.Error("alice-session-2 should belong to alice")
}
if GetSessionUsername("bob-session-1") != "bob" {
t.Error("bob-session-1 should belong to bob")
}
if GetSessionUsername("unknown") != "" {
t.Error("unknown session should return empty string")
}
})
}
func TestClearCSRFCookie(t *testing.T) {
t.Run("nil writer does not panic", func(t *testing.T) {
clearCSRFCookie(nil, nil)
// Should not panic
})
t.Run("sets cookie with maxage -1", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/", nil)
clearCSRFCookie(w, r)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
cookie := cookies[0]
if cookie.Name != "pulse_csrf" {
t.Errorf("cookie name = %q, want pulse_csrf", cookie.Name)
}
if cookie.Value != "" {
t.Errorf("cookie value = %q, want empty string", cookie.Value)
}
if cookie.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", cookie.MaxAge)
}
})
}
func TestIssueNewCSRFCookie(t *testing.T) {
t.Run("nil writer returns empty string", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/test", nil)
token := issueNewCSRFCookie(nil, req, "session-id")
if token != "" {
t.Errorf("token = %q, want empty string", token)
}
})
t.Run("nil request returns empty string", func(t *testing.T) {
w := httptest.NewRecorder()
token := issueNewCSRFCookie(w, nil, "session-id")
if token != "" {
t.Errorf("token = %q, want empty string", token)
}
})
t.Run("empty session ID returns empty string", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
token := issueNewCSRFCookie(w, req, "")
if token != "" {
t.Errorf("token = %q, want empty string for empty session", token)
}
})
t.Run("whitespace only session ID returns empty string", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
token := issueNewCSRFCookie(w, req, " ")
if token != "" {
t.Errorf("token = %q, want empty string for whitespace session", token)
}
})
t.Run("valid session returns non-empty token", func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
token := issueNewCSRFCookie(w, req, "valid-session-id")
if token == "" {
t.Error("expected non-empty token for valid session")
}
// Check that a cookie was set
cookies := w.Result().Cookies()
found := false
for _, c := range cookies {
if c.Name == "pulse_csrf" && c.Value == token {
found = true
break
}
}
if !found {
t.Error("expected pulse_csrf cookie to be set with the token value")
}
})
}
func TestFailedLogin_Fields(t *testing.T) {
fl := FailedLogin{
Count: 3,
LastAttempt: fixedTimeForTest(),
LockedUntil: fixedTimeForTest().Add(15 * 60 * 1000000000),
}
if fl.Count != 3 {
t.Errorf("Count = %d, want 3", fl.Count)
}
if fl.LastAttempt.IsZero() {
t.Error("LastAttempt should not be zero")
}
if fl.LockedUntil.IsZero() {
t.Error("LockedUntil should not be zero")
}
}
func TestSecurityHeadersWithConfig_EmbeddingDisabled(t *testing.T) {
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
false, // allowEmbedding
"", // allowedOrigins
false, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Check X-Frame-Options is set to DENY when embedding is disabled
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
t.Errorf("X-Frame-Options = %q, want DENY", got)
}
// Check CSP has frame-ancestors 'none'
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'none'") {
t.Errorf("CSP should contain frame-ancestors 'none', got: %s", csp)
}
// In production mode, CSP should use nonce-based directives
if !strings.Contains(csp, "'nonce-") {
t.Errorf("CSP should contain nonce in production mode, got: %s", csp)
}
if strings.Contains(csp, "'unsafe-inline'") {
t.Errorf("CSP should not contain 'unsafe-inline' in production mode, got: %s", csp)
}
if strings.Contains(csp, "'unsafe-eval'") {
t.Errorf("CSP should not contain 'unsafe-eval' in production mode, got: %s", csp)
}
// Check other security headers are present
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := rec.Header().Get("X-XSS-Protection"); got != "0" {
t.Errorf("X-XSS-Protection = %q, want '0'", got)
}
if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" {
t.Errorf("Referrer-Policy = %q, want strict-origin-when-cross-origin", got)
}
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Errorf("Strict-Transport-Security = %q, want empty for non-HTTPS request", got)
}
}
func TestSecurityHeadersWithConfig_SetsHSTSForTLSRequest(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
handler := SecurityHeadersWithConfig(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}), false, "", false)
req := httptest.NewRequest(http.MethodGet, "https://example.com/test", nil)
req.TLS = &tls.ConnectionState{}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Strict-Transport-Security"); got != "max-age=31536000; includeSubDomains; preload" {
t.Fatalf("Strict-Transport-Security = %q, want max-age=31536000; includeSubDomains; preload", got)
}
}
func TestSecurityHeadersWithConfig_DoesNotTrustForwardedProtoFromUntrustedPeer(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "")
resetTrustedProxyConfig()
handler := SecurityHeadersWithConfig(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}), false, "", false)
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "198.51.100.20:44321"
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Strict-Transport-Security"); got != "" {
t.Fatalf("Strict-Transport-Security = %q, want empty for untrusted forwarded proto", got)
}
}
func TestSecurityHeadersWithConfig_SetsHSTSForTrustedProxyHTTPS(t *testing.T) {
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "127.0.0.1/32")
resetTrustedProxyConfig()
handler := SecurityHeadersWithConfig(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}), false, "", false)
req := httptest.NewRequest(http.MethodGet, "http://example.com/test", nil)
req.RemoteAddr = "127.0.0.1:54321"
req.Header.Set("X-Forwarded-Proto", "https")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Strict-Transport-Security"); got != "max-age=31536000; includeSubDomains; preload" {
t.Fatalf("Strict-Transport-Security = %q, want max-age=31536000; includeSubDomains; preload", got)
}
}
func TestSecurityHeadersWithConfig_EmbeddingEnabledNoOrigins(t *testing.T) {
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
true, // allowEmbedding
"", // allowedOrigins - empty defaults to 'self' for clickjacking protection
false, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// X-Frame-Options should NOT be set when embedding is allowed
if got := rec.Header().Get("X-Frame-Options"); got != "" {
t.Errorf("X-Frame-Options = %q, want empty (not set)", got)
}
// Check CSP has frame-ancestors 'self' (safe default when no origins specified)
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self'") {
t.Errorf("CSP should contain \"frame-ancestors 'self'\", got: %s", csp)
}
}
func TestSecurityHeadersWithConfig_EmbeddingEnabledWithOrigins(t *testing.T) {
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
true, // allowEmbedding
"https://example.com, https://other.com", // allowedOrigins
false, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// X-Frame-Options should NOT be set when embedding is allowed
if got := rec.Header().Get("X-Frame-Options"); got != "" {
t.Errorf("X-Frame-Options = %q, want empty (not set)", got)
}
// Check CSP has frame-ancestors with specific origins
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self' https://example.com https://other.com") {
t.Errorf("CSP should contain specific frame-ancestors, got: %s", csp)
}
}
func TestSecurityHeadersWithConfig_EmbeddingWithEmptyOriginEntries(t *testing.T) {
// Test handling of origins with empty entries (e.g., trailing comma)
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}),
true, // allowEmbedding
"https://example.com, , ,", // allowedOrigins with empty entries
false, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Check CSP has frame-ancestors with only non-empty origins
csp := rec.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-ancestors 'self' https://example.com") {
t.Errorf("CSP should contain frame-ancestors with filtered origins, got: %s", csp)
}
}
func TestSecurityHeadersWithConfig_NextHandlerCalled(t *testing.T) {
called := false
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}),
false,
"",
false, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !called {
t.Error("next handler was not called")
}
}
func TestSecurityHeadersWithConfig_DevMode(t *testing.T) {
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// In dev mode, no nonce should be in context
if nonce := CSPNonceFromContext(r.Context()); nonce != "" {
t.Errorf("expected no nonce in dev mode context, got %q", nonce)
}
w.WriteHeader(http.StatusOK)
}),
false, // allowEmbedding
"", // allowedOrigins
true, // devMode
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
csp := rec.Header().Get("Content-Security-Policy")
// Dev mode should have unsafe-inline and unsafe-eval
if !strings.Contains(csp, "'unsafe-inline'") {
t.Errorf("CSP in dev mode should contain 'unsafe-inline', got: %s", csp)
}
if !strings.Contains(csp, "'unsafe-eval'") {
t.Errorf("CSP in dev mode should contain 'unsafe-eval', got: %s", csp)
}
// Dev mode should NOT have nonce
if strings.Contains(csp, "'nonce-") {
t.Errorf("CSP in dev mode should not contain nonce, got: %s", csp)
}
}
func TestSecurityHeadersWithConfig_ProductionNonceInContext(t *testing.T) {
var capturedNonce string
handler := SecurityHeadersWithConfig(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedNonce = CSPNonceFromContext(r.Context())
w.WriteHeader(http.StatusOK)
}),
false, // allowEmbedding
"", // allowedOrigins
false, // devMode (production)
)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Nonce should be present in context in production mode
if capturedNonce == "" {
t.Fatal("expected nonce in production mode context, got empty string")
}
// Nonce in CSP header should match context nonce
csp := rec.Header().Get("Content-Security-Policy")
expectedFragment := "'nonce-" + capturedNonce + "'"
if !strings.Contains(csp, expectedFragment) {
t.Errorf("CSP should contain %s, got: %s", expectedFragment, csp)
}
}
func TestCSPNonceFromContext_Empty(t *testing.T) {
ctx := context.Background()
if got := CSPNonceFromContext(ctx); got != "" {
t.Errorf("CSPNonceFromContext on empty context = %q, want empty", got)
}
}
func TestCSPNonceFromContext_RoundTrip(t *testing.T) {
ctx := context.WithValue(context.Background(), cspNonceKey{}, "test-nonce-123")
if got := CSPNonceFromContext(ctx); got != "test-nonce-123" {
t.Errorf("CSPNonceFromContext = %q, want test-nonce-123", got)
}
}
func TestLoadTrustedProxyCIDRs_InvalidCIDR(t *testing.T) {
// Test that invalid CIDR is logged and skipped
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "invalid/cidr, 10.0.0.0/8")
resetTrustedProxyConfig()
// Trigger loading
_ = isTrustedProxyIP("10.0.0.1")
// The valid CIDR should still work
if !isTrustedProxyIP("10.0.0.1") {
t.Error("10.0.0.1 should be trusted after loading valid CIDR")
}
}
func TestLoadTrustedProxyCIDRs_InvalidIP(t *testing.T) {
// Test that invalid IP (no CIDR notation) is logged and skipped
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "not-an-ip, 192.168.1.1")
resetTrustedProxyConfig()
// Trigger loading
_ = isTrustedProxyIP("192.168.1.1")
// The valid IP should still work
if !isTrustedProxyIP("192.168.1.1") {
t.Error("192.168.1.1 should be trusted after loading valid IP")
}
}
func TestLoadTrustedProxyCIDRs_IPv6(t *testing.T) {
// Test IPv6 address handling (uses 128 bits for mask)
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "::1, 2001:db8::1")
resetTrustedProxyConfig()
// Trigger loading
_ = isTrustedProxyIP("::1")
// IPv6 addresses should be trusted
if !isTrustedProxyIP("::1") {
t.Error("::1 should be trusted")
}
if !isTrustedProxyIP("2001:db8::1") {
t.Error("2001:db8::1 should be trusted")
}
}
func TestLoadTrustedProxyCIDRs_EmptyEntries(t *testing.T) {
// Test that empty entries in the list are skipped
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8, , , , 192.168.0.0/16")
resetTrustedProxyConfig()
// Trigger loading
_ = isTrustedProxyIP("10.0.0.1")
// Both valid CIDRs should work
if !isTrustedProxyIP("10.0.0.1") {
t.Error("10.0.0.1 should be trusted")
}
if !isTrustedProxyIP("192.168.1.1") {
t.Error("192.168.1.1 should be trusted")
}
}
func TestLoadTrustedProxyCIDRs_MixedValidInvalid(t *testing.T) {
// Test mix of valid CIDRs, valid IPs, invalid CIDRs, and invalid IPs
t.Setenv("PULSE_TRUSTED_PROXY_CIDRS", "10.0.0.0/8, bad-cidr/99, 172.16.0.1, not-valid, ::1")
resetTrustedProxyConfig()
// Trigger loading
_ = isTrustedProxyIP("10.0.0.1")
// Valid entries should work
if !isTrustedProxyIP("10.0.0.1") {
t.Error("10.0.0.1 should be trusted (from valid CIDR)")
}
if !isTrustedProxyIP("172.16.0.1") {
t.Error("172.16.0.1 should be trusted (from valid IP)")
}
if !isTrustedProxyIP("::1") {
t.Error("::1 should be trusted (from valid IPv6)")
}
// Invalid entries should not cause problems
if isTrustedProxyIP("192.168.1.1") {
t.Error("192.168.1.1 should not be trusted (not in any valid entry)")
}
}
// resetAdminBypassState resets the admin bypass state for testing
func resetAdminBypassState() {
adminBypassState.once = sync.Once{}
adminBypassState.enabled = false
adminBypassState.declined = false
}
func TestAdminBypassEnabled_NotRequested(t *testing.T) {
// When ALLOW_ADMIN_BYPASS is not set to "1", bypass should be disabled
t.Setenv("ALLOW_ADMIN_BYPASS", "")
resetAdminBypassState()
if adminBypassEnabled() {
t.Error("adminBypassEnabled() should return false when ALLOW_ADMIN_BYPASS is not '1'")
}
}
func TestAdminBypassEnabled_WithPulseDev(t *testing.T) {
// When ALLOW_ADMIN_BYPASS=1 and PULSE_DEV=true, bypass should be enabled
t.Setenv("ALLOW_ADMIN_BYPASS", "1")
t.Setenv("PULSE_DEV", "true")
t.Setenv("NODE_ENV", "")
resetAdminBypassState()
if !adminBypassEnabled() {
t.Error("adminBypassEnabled() should return true when ALLOW_ADMIN_BYPASS=1 and PULSE_DEV=true")
}
}
func TestAdminBypassEnabled_WithNodeEnvDevelopment(t *testing.T) {
// When ALLOW_ADMIN_BYPASS=1 and NODE_ENV=development, bypass should be enabled
t.Setenv("ALLOW_ADMIN_BYPASS", "1")
t.Setenv("PULSE_DEV", "")
t.Setenv("NODE_ENV", "development")
resetAdminBypassState()
if !adminBypassEnabled() {
t.Error("adminBypassEnabled() should return true when ALLOW_ADMIN_BYPASS=1 and NODE_ENV=development")
}
}
func TestAdminBypassEnabled_NodeEnvCaseInsensitive(t *testing.T) {
// NODE_ENV comparison should be case-insensitive
t.Setenv("ALLOW_ADMIN_BYPASS", "1")
t.Setenv("PULSE_DEV", "")
t.Setenv("NODE_ENV", "DEVELOPMENT")
resetAdminBypassState()
if !adminBypassEnabled() {
t.Error("adminBypassEnabled() should return true when NODE_ENV=DEVELOPMENT (case-insensitive)")
}
}
func TestAdminBypassEnabled_DeclinedOutsideDevMode(t *testing.T) {
// When ALLOW_ADMIN_BYPASS=1 but not in dev mode, bypass should be declined
t.Setenv("ALLOW_ADMIN_BYPASS", "1")
t.Setenv("PULSE_DEV", "")
t.Setenv("NODE_ENV", "production")
resetAdminBypassState()
if adminBypassEnabled() {
t.Error("adminBypassEnabled() should return false when ALLOW_ADMIN_BYPASS=1 but not in dev mode")
}
// Verify the declined flag was set
if !adminBypassState.declined {
t.Error("adminBypassState.declined should be true when bypass is ignored outside dev mode")
}
}
func TestCheckCSRF_SafeMethods(t *testing.T) {
tests := []struct {
method string
}{
{"GET"},
{"HEAD"},
{"OPTIONS"},
}
for _, tt := range tests {
t.Run(tt.method, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, "/api/test", nil)
// Safe methods should always return true regardless of CSRF state
result := CheckCSRF(w, req)
if !result {
t.Errorf("CheckCSRF(%s) = false, want true for safe method", tt.method)
}
})
}
}
func TestCheckCSRF_APITokenAuth_NoSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.Header.Set("X-API-Token", "some-api-token")
// No session cookie => no CSRF check needed (the API client is purely
// header-authenticated). Regression coverage for the header+cookie case
// lives in security_regression_test.go.
result := CheckCSRF(w, req)
if !result {
t.Error("CheckCSRF should return true when X-API-Token is present and no session cookie")
}
}
func TestCheckCSRF_BasicAuth_NoSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
// No session cookie => no CSRF check needed.
result := CheckCSRF(w, req)
if !result {
t.Error("CheckCSRF should return true when Basic Authorization header is present and no session cookie")
}
}
func TestCheckCSRF_BearerAuth_NoSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.Header.Set("Authorization", "Bearer some-token")
// No session cookie => no CSRF check needed.
result := CheckCSRF(w, req)
if !result {
t.Error("CheckCSRF should return true when Bearer Authorization header is present and no session cookie")
}
}
func TestCheckCSRF_UnknownAuthorizationSchemeDoesNotBypass(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.Header.Set("Authorization", "Digest abc123")
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "test-session-id-1234567890",
})
result := CheckCSRF(w, req)
if result {
t.Error("CheckCSRF should return false for unknown Authorization schemes when session cookie is present")
}
}
func TestCheckCSRF_NoSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
// No session cookie set
// Without session cookie, CSRF check is not needed
result := CheckCSRF(w, req)
if !result {
t.Error("CheckCSRF should return true when no session cookie is present")
}
}
func TestCheckCSRF_MissingCSRFToken(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "test-session-id-1234567890",
})
// No CSRF token set
// Missing CSRF token should fail
result := CheckCSRF(w, req)
if result {
t.Error("CheckCSRF should return false when CSRF token is missing")
}
// Should set X-CSRF-Token header with new token
newToken := w.Header().Get("X-CSRF-Token")
if newToken == "" {
t.Error("CheckCSRF should issue new CSRF token in header when missing")
}
}
func TestCheckCSRF_InvalidCSRFToken(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "test-session-id-1234567890",
})
req.Header.Set("X-CSRF-Token", "invalid-csrf-token")
// Invalid CSRF token should fail
result := CheckCSRF(w, req)
if result {
t.Error("CheckCSRF should return false when CSRF token is invalid")
}
// Should set X-CSRF-Token header with new token
newToken := w.Header().Get("X-CSRF-Token")
if newToken == "" {
t.Error("CheckCSRF should issue new CSRF token in header when invalid")
}
}
func TestCheckCSRF_ValidCSRFToken(t *testing.T) {
// Initialize stores with temp directory
dir := t.TempDir()
InitCSRFStore(dir)
// Create a session ID
sessionID := "valid-session-id-12345678"
// Generate a valid CSRF token for this session
validToken := generateCSRFToken(sessionID)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: sessionID,
})
req.Header.Set("X-CSRF-Token", validToken)
// Valid CSRF token should pass
result := CheckCSRF(w, req)
if !result {
t.Error("CheckCSRF should return true when CSRF token is valid")
}
}
func TestCheckCSRF_CSRFTokenFromFormValue(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/test?csrf_token=form-token-value", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "test-session-id-1234567890",
})
// csrf_token is set as query param which is read by FormValue
// The token won't validate, but we're testing that FormValue is checked
result := CheckCSRF(w, req)
// Will fail because token doesn't match session
if result {
t.Error("CheckCSRF should still validate the token from FormValue")
}
}
func TestCheckCSRF_UnsafeMethods(t *testing.T) {
methods := []string{"POST", "PUT", "DELETE", "PATCH"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest(method, "/api/test", nil)
req.AddCookie(&http.Cookie{
Name: "pulse_session",
Value: "test-session-id-1234567890",
})
// No CSRF token
// Unsafe methods without valid CSRF should fail
result := CheckCSRF(w, req)
if result {
t.Errorf("CheckCSRF(%s) should return false without valid CSRF token", method)
}
})
}
}
func TestRequireAdmin_NoAuthConfiguredAllowsAccess(t *testing.T) {
// When no auth is configured at all, CheckAuth returns true (allows access)
cfg := &config.Config{}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := newLoopbackRequest("GET", "/api/admin/test", nil)
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler when no auth is configured")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_APIOnlyModeRejectsNoToken(t *testing.T) {
// When only API tokens are configured, requests without token should be rejected
rawToken := "test-admin-token-12345"
record, _ := config.NewAPITokenRecord(rawToken, "admin-token", []string{"admin"})
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
// No token provided
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler without API token in API-only mode")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAdmin_InvalidBasicAuthRejectsRequest(t *testing.T) {
// When basic auth is configured, invalid credentials should be rejected
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler with invalid credentials")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAdmin_InvalidBasicAuthAPIPathReturnsJSON(t *testing.T) {
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAdmin Content-Type = %q, want application/json", ct)
}
if body := w.Body.String(); !strings.Contains(body, "Authentication required") {
t.Errorf("RequireAdmin body = %q, want to contain 'Authentication required'", body)
}
}
func TestRequireAdmin_InvalidBasicAuthAcceptJSONReturnsJSON(t *testing.T) {
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/test", nil)
req.Header.Set("Accept", "application/json")
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAdmin Content-Type = %q, want application/json", ct)
}
}
func TestRequireAdmin_InvalidBasicAuthNonAPIReturnsPlainText(t *testing.T) {
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
// Should be plain text error
body := w.Body.String()
if !strings.Contains(body, "Unauthorized") {
t.Errorf("RequireAdmin body = %q, want to contain 'Unauthorized'", body)
}
}
func TestRequireAdmin_ProxyAuthAdminAllowed(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "admin-user")
req.Header.Set("X-Remote-Roles", "admin|user")
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler for authenticated admin proxy user")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_ProxyAuthNonAdminForbidden(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
req.Header.Set("X-Remote-Roles", "user|viewer") // No admin role
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler for non-admin proxy user")
}
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRequireAdmin_ProxyAuthNonAdminAPIPathReturnsJSON(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
req.Header.Set("X-Remote-Roles", "user")
handler(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAdmin Content-Type = %q, want application/json", ct)
}
if body := w.Body.String(); !strings.Contains(body, "Admin privileges required") {
t.Errorf("RequireAdmin body = %q, want to contain 'Admin privileges required'", body)
}
}
func TestRequireAdmin_ProxyAuthNonAdminAcceptJSONReturnsJSON(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/test", nil)
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
req.Header.Set("X-Remote-Roles", "user")
handler(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAdmin Content-Type = %q, want application/json", ct)
}
}
func TestRequireAdmin_ProxyAuthNonAdminNonAPIReturnsPlainText(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
req.Header.Set("X-Remote-Roles", "user")
handler(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
// Should be plain text error
body := w.Body.String()
if !strings.Contains(body, "Admin privileges required") {
t.Errorf("RequireAdmin body = %q, want to contain 'Admin privileges required'", body)
}
}
func TestRequireAdmin_ProxyAuthNoRoleHeaderDefaultsToAdmin(t *testing.T) {
// When ProxyAuthRoleHeader is not configured, all authenticated users are admins
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
// No ProxyAuthRoleHeader set
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "any-user")
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler when no role checking is configured")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
// Configuring a role header is the operator's signal that admin access is
// role-gated. When ProxyAuthAdminRole is left unset the gate must fall back to
// the documented default role rather than switching off: the empty-admin-role
// short circuit let a proxied "user" read and write /api/system/settings.
func TestRequireAdmin_ProxyAuthRoleHeaderWithoutAdminRoleStillGates(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
// No ProxyAuthAdminRole set - must resolve to the documented default.
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
req.Header.Set("X-Remote-Roles", "user|viewer")
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler for a non-admin proxy user when the admin role is unset")
}
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRequireAdmin_ProxyAuthRoleHeaderWithoutAdminRoleAllowsDefaultRole(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "admin-user")
req.Header.Set("X-Remote-Roles", config.DefaultProxyAuthAdminRole+"|user")
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler for a proxy user carrying the default admin role")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_ProxyAuthConfiguredRoleHeaderMissingForbidden(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "regular-user")
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler when configured role header is missing")
}
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRequireAdmin_NonAdminSessionForbidden(t *testing.T) {
InitSessionStore(t.TempDir())
cfg := &config.Config{AuthUser: "admin"}
sessionToken := "require-admin-member-session"
GetSessionStore().CreateSession(sessionToken, time.Hour, "test-agent", "127.0.0.1", "member")
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
w := httptest.NewRecorder()
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler for non-admin session user")
}
if w.Code != http.StatusForbidden {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusForbidden)
}
if !strings.Contains(w.Body.String(), "Admin privileges required") {
t.Errorf("RequireAdmin body = %q, want to contain 'Admin privileges required'", w.Body.String())
}
}
func TestRequireAdmin_ConfiguredAdminSessionAllowed(t *testing.T) {
InitSessionStore(t.TempDir())
cfg := &config.Config{AuthUser: "admin"}
sessionToken := "require-admin-session"
GetSessionStore().CreateSession(sessionToken, time.Hour, "test-agent", "127.0.0.1", "admin")
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: sessionToken})
w := httptest.NewRecorder()
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler for configured admin session user")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_ProxyAuthInvalidSecretUnauthorized(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "wrong-secret")
req.Header.Set("X-Remote-User", "admin-user")
handler(w, req)
if handlerCalled {
t.Error("RequireAdmin should not call handler with invalid proxy secret")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAdmin_ProxyAuthCustomRoleSeparator(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "administrator",
ProxyAuthRoleSeparator: ",",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "admin-user")
req.Header.Set("X-Remote-Roles", "user,administrator,viewer") // Comma separated
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler for admin with custom role separator")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_ProxyAuthTrimSpacesInRoles(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
ProxyAuthRoleHeader: "X-Remote-Roles",
ProxyAuthAdminRole: "admin",
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "admin-user")
req.Header.Set("X-Remote-Roles", "user| admin |viewer") // Spaces around admin
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler when role matches after trimming spaces")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAdmin_NoProxyAuthAuthenticatedAllowed(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
resetSessionStoreForTests()
t.Cleanup(resetSessionStoreForTests)
InitSessionStore(t.TempDir())
// When proxy auth is not configured, authenticated users are considered admins
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handlerCalled := false
handler := RequireAdmin(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/admin/test", nil)
req.SetBasicAuth("testuser", "password123")
handler(w, req)
if !handlerCalled {
t.Error("RequireAdmin should call handler for basic auth authenticated user")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAdmin returned status %d, want %d", w.Code, http.StatusOK)
}
}
// RequireAuth tests
func TestRequireAuth_NoAuthConfiguredAllowsAccess(t *testing.T) {
// When no auth is configured at all, CheckAuth returns true (allows access)
cfg := &config.Config{}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := newLoopbackRequest("GET", "/api/test", nil)
handler(w, req)
if !handlerCalled {
t.Error("RequireAuth should call handler when no auth is configured")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAuth_APIOnlyModeRejectsNoToken(t *testing.T) {
// When only API tokens are configured, requests without token should be rejected
rawToken := "test-api-token-12345"
record, _ := config.NewAPITokenRecord(rawToken, "test-token", []string{"read"})
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
// No token provided
handler(w, req)
if handlerCalled {
t.Error("RequireAuth should not call handler without API token in API-only mode")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAuth_APIOnlyModeAcceptsValidToken(t *testing.T) {
rawToken := "test-api-token-12345"
record, _ := config.NewAPITokenRecord(rawToken, "test-token", []string{"read"})
cfg := &config.Config{
APITokens: []config.APITokenRecord{*record},
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("X-API-Token", rawToken)
handler(w, req)
if !handlerCalled {
t.Error("RequireAuth should call handler with valid API token")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAuth_InvalidBasicAuthRejectsRequest(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if handlerCalled {
t.Error("RequireAuth should not call handler with invalid credentials")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAuth_InvalidBasicAuthAPIPathReturnsJSON(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAuth Content-Type = %q, want application/json", ct)
}
if body := w.Body.String(); !strings.Contains(body, "Authentication required") {
t.Errorf("RequireAuth body = %q, want to contain 'Authentication required'", body)
}
}
func TestRequireAuth_InvalidBasicAuthAcceptJSONReturnsJSON(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
req.Header.Set("Accept", "application/json")
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("RequireAuth Content-Type = %q, want application/json", ct)
}
}
func TestRequireAuth_InvalidBasicAuthNonAPIReturnsPlainText(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
req.SetBasicAuth("testuser", "wrongpassword")
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
// Should be plain text error
body := w.Body.String()
if !strings.Contains(body, "Unauthorized") {
t.Errorf("RequireAuth body = %q, want to contain 'Unauthorized'", body)
}
}
func TestRequireAuth_ValidBasicAuthAllowsAccess(t *testing.T) {
resetFailedLogins()
t.Cleanup(resetFailedLogins)
resetSessionStoreForTests()
t.Cleanup(resetSessionStoreForTests)
InitSessionStore(t.TempDir())
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.SetBasicAuth("testuser", "password123")
handler(w, req)
if !handlerCalled {
t.Error("RequireAuth should call handler for basic auth authenticated user")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAuth_ProxyAuthAllowsAccess(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("X-Proxy-Secret", "secret123")
req.Header.Set("X-Remote-User", "proxyuser")
handler(w, req)
if !handlerCalled {
t.Error("RequireAuth should call handler for proxy authenticated user")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAuth_ProxyAuthInvalidSecretRejects(t *testing.T) {
cfg := &config.Config{
ProxyAuthSecret: "secret123",
ProxyAuthUserHeader: "X-Remote-User",
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("X-Proxy-Secret", "wrong-secret")
req.Header.Set("X-Remote-User", "proxyuser")
handler(w, req)
if handlerCalled {
t.Error("RequireAuth should not call handler with invalid proxy secret")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestRequireAuth_BearerTokenAllowsAccess(t *testing.T) {
// Bearer tokens are only checked when basic auth is also configured
// (not in API-only mode)
rawToken := "test-bearer-token-12345"
record, _ := config.NewAPITokenRecord(rawToken, "bearer-token", []string{"read"})
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
APITokens: []config.APITokenRecord{*record},
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("Authorization", "Bearer "+rawToken)
handler(w, req)
if !handlerCalled {
t.Error("RequireAuth should call handler with valid Bearer token")
}
if w.Code != http.StatusOK {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusOK)
}
}
func TestRequireAuth_InvalidBearerTokenRejects(t *testing.T) {
// Bearer tokens are only checked when basic auth is also configured
rawToken := "test-bearer-token-12345"
record, _ := config.NewAPITokenRecord(rawToken, "bearer-token", []string{"read"})
hashedPass, _ := auth.HashPassword("password123")
cfg := &config.Config{
AuthUser: "testuser",
AuthPass: hashedPass,
APITokens: []config.APITokenRecord{*record},
}
handlerCalled := false
handler := RequireAuth(cfg, func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/api/test", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
handler(w, req)
if handlerCalled {
t.Error("RequireAuth should not call handler with invalid Bearer token")
}
if w.Code != http.StatusUnauthorized {
t.Errorf("RequireAuth returned status %d, want %d", w.Code, http.StatusUnauthorized)
}
}