Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
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)
|
|
}
|
|
}
|