package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "strings" "testing" "time" ) func hmacHex(secret, msg string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(msg)) return hex.EncodeToString(mac.Sum(nil)) } func headersOf(pairs ...string) http.Header { h := http.Header{} for i := 0; i+1 < len(pairs); i += 2 { h.Set(pairs[i], pairs[i+1]) } return h } func TestClientIdentityHeaderKind(t *testing.T) { id := ClientIdentity(headersOf("X-Device-Id", " device-1 ", "User-Agent", "UA/1.0"), "s3cret") if id.Kind != "header" { t.Errorf("Kind = %q, want header", id.Kind) } if id.UserAgent != "UA/1.0" { t.Errorf("UserAgent = %q", id.UserAgent) } if want := hmacHex("s3cret", "header:device-1"); id.Key != want { t.Errorf("Key = %q, want %q", id.Key, want) } } func TestClientIdentityFallbacks(t *testing.T) { // x-client-id is the fallback declared identity. id := ClientIdentity(headersOf("X-Client-Id", "client-9"), "k") if id.Kind != "header" || id.Key != hmacHex("k", "header:client-9") { t.Errorf("x-client-id fallback = %+v", id) } // Empty x-device-id falls through to x-client-id (JS || semantics). id = ClientIdentity(headersOf("X-Device-Id", "", "X-Client-Id", "client-9"), "k") if id.Kind != "header" || id.Key != hmacHex("k", "header:client-9") { t.Errorf("empty x-device-id fallback = %+v", id) } // No declared identity -> user-agent kind. id = ClientIdentity(headersOf("User-Agent", "Agent X"), "k") if id.Kind != "user-agent" || id.Key != hmacHex("k", "user-agent:Agent X") { t.Errorf("user-agent kind = %+v", id) } // Nothing at all -> unknown. id = ClientIdentity(http.Header{}, "k") if id.Kind != "unknown" || id.UserAgent != "" || id.Key != hmacHex("k", "unknown:unknown") { t.Errorf("unknown kind = %+v", id) } } func TestClientIdentityTruncation(t *testing.T) { longDevice := strings.Repeat("d", 200) id := ClientIdentity(headersOf("X-Device-Id", longDevice), "k") if want := hmacHex("k", "header:"+strings.Repeat("d", 128)); id.Key != want { t.Errorf("declared identity not truncated to 128: Key = %q want %q", id.Key, want) } longUA := strings.Repeat("u", 300) id = ClientIdentity(headersOf("User-Agent", longUA), "k") if len([]rune(id.UserAgent)) != 200 { t.Errorf("UserAgent length = %d, want 200", len([]rune(id.UserAgent))) } if want := hmacHex("k", "user-agent:"+strings.Repeat("u", 200)); id.Key != want { t.Errorf("user agent not truncated to 200 in key") } } var testNow = time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC) func isoAgo(d time.Duration) string { return ISOFromTime(testNow.Add(-d)) } func TestActiveClientsWindow(t *testing.T) { d := EmptyData() d.SubscriptionClients = []*SubscriptionClient{ {CustomerID: "c1", ClientKey: "fresh", LastSeenAt: isoAgo(time.Hour)}, {CustomerID: "c1", ClientKey: "stale", LastSeenAt: isoAgo(25 * time.Hour)}, {CustomerID: "c1", ClientKey: "boundary", LastSeenAt: isoAgo(24 * time.Hour)}, // > cutoff is strict {CustomerID: "c2", ClientKey: "other", LastSeenAt: isoAgo(time.Hour)}, {CustomerID: "c1", ClientKey: "garbage", LastSeenAt: "not-a-date"}, // NaN in JS: excluded } got := ActiveClients(d, "c1", testNow) if len(got) != 1 || got[0] != "fresh" { t.Errorf("ActiveClients = %v, want [fresh]", got) } } func TestObservedIPsWindow(t *testing.T) { d := EmptyData() // observationTtlMinutes defaults to 10 d.Observations = []*Observation{ {CustomerID: "c1", IP: "1.1.1.1", LastSeenAt: isoAgo(5 * time.Minute)}, {CustomerID: "c1", IP: "2.2.2.2", LastSeenAt: isoAgo(11 * time.Minute)}, {CustomerID: "c2", IP: "3.3.3.3", LastSeenAt: isoAgo(time.Minute)}, {CustomerID: "c1", IP: "4.4.4.4", LastSeenAt: "garbage"}, } got := ObservedIPs(d, "c1", testNow) if len(got) != 1 || got[0].IP != "1.1.1.1" { t.Errorf("ObservedIPs default TTL = %v", got) } d.Settings.ObservationTtlMinutes = 60 got = ObservedIPs(d, "c1", testNow) if len(got) != 2 { t.Errorf("ObservedIPs 60m TTL = %d entries, want 2", len(got)) } // TTL of 0 falls back to 10 like JS `|| 10`. d.Settings.ObservationTtlMinutes = 0 got = ObservedIPs(d, "c1", testNow) if len(got) != 1 { t.Errorf("ObservedIPs 0 TTL fallback = %d entries, want 1", len(got)) } } func TestPruneAccess(t *testing.T) { d := EmptyData() d.SubscriptionAccess = []*SubscriptionAccess{ {CustomerID: "c1", At: isoAgo(29 * 24 * time.Hour)}, {CustomerID: "c1", At: isoAgo(31 * 24 * time.Hour)}, {CustomerID: "c1", At: "garbage"}, } d.SubscriptionClients = []*SubscriptionClient{ {CustomerID: "c1", ClientKey: "fresh", LastSeenAt: isoAgo(23 * time.Hour)}, {CustomerID: "c1", ClientKey: "stale", LastSeenAt: isoAgo(24*time.Hour + time.Second)}, } d.Observations = []*Observation{ {CustomerID: "c1", IP: "1.1.1.1", LastSeenAt: isoAgo(9 * time.Minute)}, {CustomerID: "c1", IP: "2.2.2.2", LastSeenAt: isoAgo(10*time.Minute + time.Second)}, } PruneAccess(d, testNow) if len(d.SubscriptionAccess) != 1 { t.Errorf("access after prune = %d, want 1", len(d.SubscriptionAccess)) } if len(d.SubscriptionClients) != 1 || d.SubscriptionClients[0].ClientKey != "fresh" { t.Errorf("clients after prune = %+v", d.SubscriptionClients) } if len(d.Observations) != 1 || d.Observations[0].IP != "1.1.1.1" { t.Errorf("observations after prune = %+v", d.Observations) } } func TestPruneAccessKeepsLast10000(t *testing.T) { d := EmptyData() for i := 0; i < 10005; i++ { d.SubscriptionAccess = append(d.SubscriptionAccess, &SubscriptionAccess{ CustomerID: "c1", At: isoAgo(time.Minute), ClientKey: fmt.Sprintf("k%05d", i), }) } PruneAccess(d, testNow) if len(d.SubscriptionAccess) != 10000 { t.Fatalf("access len = %d, want 10000 (slice(-10000))", len(d.SubscriptionAccess)) } if d.SubscriptionAccess[0].ClientKey != "k00005" { t.Errorf("first retained = %q, want k00005", d.SubscriptionAccess[0].ClientKey) } }