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>
244 lines
7.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|