Files
DriverVault/API Server/internal/api/integrations_ankersolix_test.go
T
tajniak81andClaude Opus 5 f7472bada3 Reach the charger where it is, instead of waiting for it to call
OCPP asks the charger to dial us: a public endpoint, a TLS certificate, and a
route in through the customer's router. Our own handler then demanded two more
things the V1 does not offer — TLS on a charger that connects over ws://, and
Basic auth credentials the Anker app has no field for — so every connection was
turned away before the upgrade.

Anker publishes a Modbus TCP register map for this charger, and it inverts the
problem: we dial the charger, on its own network, with no inbound reachability
to arrange. That works for a charger behind a router that OCPP cannot reach at
all.

internal/modbus is the protocol, hand-rolled against the spec like the MQTT and
WebSocket clients beside it. The plugin's modbus.go is the V1's map: the same
0-8 status enum the cloud already reports, per-phase measurements, and the
writable registers behind start, stop, current limit, boost and phase mode. A
new "modbus" control mode routes the existing control endpoints down it, so the
REST surface, the rate limit, the confirmation step and the audit trail are the
ones already there.

The commands the register map has no equivalent for say so by name rather than
failing as unknown, and a current below the charger's 6 A floor is refused
because it pauses the charge rather than slowing it.

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

218 lines
6.5 KiB
Go

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)
}
}