Files
DriverVault/API Server/internal/api/integrations_ankersolix_test.go
T
tajniak81andClaude Opus 5 3064bff8ad The charger's own card list gets a door, and says where it differs
0104 was already implemented and already in the action catalogue; nothing
routed to it, so the only way to see the device's list was as a side
effect of writing a card. It has an endpoint now, and the panel a button.

The two lists are compared where they meet: a card the charger holds and
the account has forgotten still opens it, and a card only the account
holds will not, and neither shows anywhere else. The comparison is drawn
only when they disagree, and the device's reading is dropped on a refresh
rather than measured against an account list from a later moment.

The new test asks all four card routes without a token: a capability the
plugin implements and the catalogue advertises is still unusable if
nothing routes to it, and no other test here would notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:35:27 +02:00

244 lines
7.7 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/pb"
)
func TestNormalizeControlMode(t *testing.T) {
cases := map[string]string{
"off": "off",
"own": "own",
"proxy": "proxy",
"modbus": "modbus",
"mqtt": "mqtt",
"OWN": "own",
" Proxy": "proxy",
"Modbus ": "modbus",
" MQTT ": "mqtt",
"": "", // 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)
}
}
// The four card routes are reachable at all. A capability the plugin implements
// and the action catalogue advertises is still unusable if nothing routes to it,
// and that is not a failure any other test here would notice: the plugin's own
// tests pass, and the panel simply has no button. Each route is asked for
// without a token, so what is being checked is that the request reached the
// authentication middleware rather than a 404.
func TestAnkerCardRoutesAreRegistered(t *testing.T) {
h := New(config.Config{}, nil).Handler()
for _, tc := range []struct{ method, path string }{
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards"},
{"POST", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/scan"},
{"GET", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/charger"},
{"DELETE", "/api/integrations/anker-solix/chargers/SN1/rfid-cards/AABBCCDD"},
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
if rr.Code == http.StatusNotFound {
t.Errorf("%s %s is not routed", tc.method, tc.path)
}
}
}