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>
218 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|