package api import ( "net/http/httptest" "sync" "testing" "time" ) // 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 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 TestIsPrivateIP(t *testing.T) { t.Helper() cases := []struct { name string ip string want bool }{ {"public IPv4", "198.51.100.42", false}, {"private IPv4", "10.1.2.3", true}, {"loopback IPv4", "127.0.0.1", true}, {"link-local IPv6", "fe80::1", true}, {"loopback IPv6 with port", "[::1]:8443", true}, } 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") } }) } 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) // Should not panic }) t.Run("sets cookie with maxage -1", func(t *testing.T) { w := httptest.NewRecorder() clearCSRFCookie(w) 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 TestAuditEvent_Fields(t *testing.T) { ae := AuditEvent{ Timestamp: fixedTimeForTest(), Event: "login_attempt", User: "admin", IP: "192.168.1.100", Path: "/api/auth/login", Success: true, Details: "successful login", } if ae.Event != "login_attempt" { t.Errorf("Event = %q, want login_attempt", ae.Event) } if ae.User != "admin" { t.Errorf("User = %q, want admin", ae.User) } if ae.IP != "192.168.1.100" { t.Errorf("IP = %q, want 192.168.1.100", ae.IP) } if ae.Path != "/api/auth/login" { t.Errorf("Path = %q, want /api/auth/login", ae.Path) } if !ae.Success { t.Error("Success should be true") } if ae.Details != "successful login" { t.Errorf("Details = %q, want 'successful login'", ae.Details) } }