package api import ( "context" "encoding/json" "net/http/httptest" "testing" "time" "drivervault/apiserver/internal/pb" ) func TestNormalizeControlMode(t *testing.T) { cases := map[string]string{ "off": "off", "own": "own", "proxy": "proxy", "modbus": "modbus", "OWN": "own", " Proxy": "proxy", "Modbus ": "modbus", "": "", // unset — cascade continues to the next layer "bogus": "", // unknown — treated as unset } for in, want := range cases { if got := normalizeControlMode(in); got != want { t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want) } } } // binding builds a hashed binding the way handleAnkerControlToken does, so the // index tests exercise the real hash-at-rest path. func binding(token string) ankerChargerBinding { return ankerChargerBinding{TokenHash: hashToken(token), TokenHint: tokenHint(token)} } func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) { ci := newControlIndex() ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-old")}) if _, ok := ci.lookup("tok-old"); !ok { t.Fatal("tok-old should resolve after first set") } // Regenerate: same charger, new token. The old token must stop resolving. ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok-new")}) if _, ok := ci.lookup("tok-old"); ok { t.Error("tok-old should be invalidated after regeneration") } b, ok := ci.lookup("tok-new") if !ok || b.UserID != "u1" || b.Serial != "SN1" { t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok) } } func TestControlIndexRevoke(t *testing.T) { ci := newControlIndex() ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("tok")}) // Revoke = setUser with the charger removed. ci.setUser("u1", map[string]ankerChargerBinding{}) if _, ok := ci.lookup("tok"); ok { t.Error("token should not resolve after revocation") } } func TestControlIndexIsolatesUsers(t *testing.T) { ci := newControlIndex() ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a")}) ci.setUser("u2", map[string]ankerChargerBinding{"SN2": binding("b")}) // Re-setting u1 must not touch u2's token. ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("a2")}) if _, ok := ci.lookup("b"); !ok { t.Error("u2's token should be untouched when u1 is updated") } } func TestControlIndexStoresNoPlaintext(t *testing.T) { ci := newControlIndex() ci.setUser("u1", map[string]ankerChargerBinding{"SN1": binding("super-secret-token")}) // The plaintext must never appear as an index key. ci.mu.RLock() defer ci.mu.RUnlock() if _, ok := ci.byHash["super-secret-token"]; ok { t.Fatal("index must key on the hash, never the plaintext token") } } func TestAnkerBindingFor(t *testing.T) { raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"tokenHash":"abc","tokenHint":"1234"}}}}`) if got := ankerBindingFor(raw, "SN1").TokenHash; got != "abc" { t.Errorf("binding hash = %q, want abc", got) } if got := ankerBindingFor(raw, "other").TokenHash; got != "" { t.Errorf("unknown charger should have empty hash, got %q", got) } if got := ankerBindingFor(nil, "SN1").TokenHash; got != "" { t.Errorf("nil settings should have empty hash, got %q", got) } } func TestHashAndHint(t *testing.T) { tok := "0123456789abcdef" if h := hashToken(tok); len(h) != 64 || h == tok { t.Errorf("hashToken should be a 64-char hex digest distinct from input, got %q", h) } if hashToken("a") == hashToken("b") { t.Error("distinct tokens must hash differently") } if got := tokenHint(tok); got != "cdef" { t.Errorf("tokenHint = %q, want cdef", got) } if got := tokenHint("ab"); got != "ab" { t.Errorf("short-token hint = %q, want ab", got) } } func TestIsDestructiveAction(t *testing.T) { for _, a := range []string{"reset", "unlock"} { if !isDestructiveAction(a) { t.Errorf("%q should be destructive", a) } } for _, a := range []string{"start", "stop", "limit", "clear-limit", "availability", "trigger", "config"} { if isDestructiveAction(a) { t.Errorf("%q should not be destructive", a) } } } func TestRateLimiter(t *testing.T) { rl := newRateLimiter(2, time.Minute) if !rl.allow("k") || !rl.allow("k") { t.Fatal("first two calls should be allowed") } if rl.allow("k") { t.Error("third call in the window should be blocked") } if !rl.allow("other") { t.Error("a different key must have its own budget") } } func TestReauthenticateGuards(t *testing.T) { // With PocketBase unconfigured, re-auth must fail closed regardless of input, // and never treat a missing password/email as success. s := &Server{pb: pb.New("", "", "")} ctx := context.Background() cases := []struct { name string who *callerIdentity pass string }{ {"nil caller", nil, "pw"}, {"empty email", &callerIdentity{ID: "u1"}, "pw"}, {"empty password", &callerIdentity{ID: "u1", Email: "a@b.c"}, ""}, {"pb unconfigured", &callerIdentity{ID: "u1", Email: "a@b.c"}, "pw"}, } for _, c := range cases { if s.reauthenticate(ctx, c.who, c.pass) { t.Errorf("%s: reauthenticate should fail closed", c.name) } } } func TestAnkerUpstreamAllowed(t *testing.T) { ok := []string{ "wss://ankerpower-api-eu.anker.com/ocpp/CP1", "wss://ankerpower-api.anker.com/x", "ws://anker.com/y", } bad := []string{ "wss://evil.example.com/ocpp/CP1", "wss://anker.com.evil.net/x", "not a url at all ::::", "", } for _, u := range ok { if !ankerUpstreamAllowed(u) { t.Errorf("%q should be allowed", u) } } for _, u := range bad { if ankerUpstreamAllowed(u) { t.Errorf("%q should be rejected", u) } } } func TestExtractWSURL(t *testing.T) { cases := map[string]string{ `{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1", `{"a":{"b":[{"endpoint":"ws://x/y"}]}}`: "ws://x/y", `{"data":{"note":"https://not-a-ws-url"}}`: "", `{"empty":true}`: "", } for in, want := range cases { if got := extractWSURL(json.RawMessage(in)); got != want { t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want) } } } func TestOCPPEndpoint(t *testing.T) { s := &Server{} // Plain HTTP request → ws:// r := httptest.NewRequest("GET", "http://host.example/x", nil) r.Host = "host.example" if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" { t.Errorf("endpoint = %q", got) } // Behind a TLS-terminating proxy → wss:// r2 := httptest.NewRequest("GET", "http://host.example/x", nil) r2.Host = "host.example" r2.Header.Set("X-Forwarded-Proto", "https") if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" { t.Errorf("tls endpoint = %q", got) } }