Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go
T
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:40:22 +02:00

253 lines
7.5 KiB
Go

package ankersolix
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"regexp"
"strings"
"testing"
"drivervault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
d := (&Plugin{}).Descriptor()
if d.Name != "anker-solix" {
t.Fatalf("name = %q, want anker-solix", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
fields := map[string]plugins.ConfigField{}
for _, f := range d.ConfigFields {
fields[f.Key] = f
}
for _, k := range []string{"email", "password", "country"} {
if _, ok := fields[k]; !ok {
t.Errorf("config field %q should be present", k)
}
}
if fields["email"].Required || fields["password"].Required {
t.Error("credentials must not be required at the global layer")
}
if !fields["password"].Secret {
t.Error("password field must be marked secret")
}
// controlMode is a select advertising the three OCPP control paths.
cm, ok := fields["controlMode"]
if !ok {
t.Fatal("controlMode config field should be present")
}
if cm.Type != "select" {
t.Errorf("controlMode type = %q, want select", cm.Type)
}
if cm.Default != "off" {
t.Errorf("controlMode default = %q, want off", cm.Default)
}
want := map[string]bool{"off": false, "own": false, "proxy": false}
for _, o := range cm.Options {
if _, known := want[o.Value]; known {
want[o.Value] = true
}
}
for v, seen := range want {
if !seen {
t.Errorf("controlMode is missing option %q", v)
}
}
}
func TestRegistered(t *testing.T) {
var found bool
for _, v := range plugins.NewManager(t.TempDir() + "/plugins.json").List() {
if v.Name == "anker-solix" {
found = true
}
}
if !found {
t.Fatal("anker-solix not registered with the plugin manager")
}
}
func TestServerSelection(t *testing.T) {
cases := map[string]string{
"DE": serverEU,
"GB": serverEU, // not in the COM list → EU default
"US": serverCOM,
"AU": serverCOM,
"": serverEU, // empty → defaults to DE → EU
}
for country, want := range cases {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"country": country})
if p.apiBase != want {
t.Errorf("country %q: apiBase = %q, want %q", country, p.apiBase, want)
}
}
}
func TestEncryptPasswordRoundTrip(t *testing.T) {
// The shared secret is 32 bytes; the reference encrypts AES-256-CBC with the
// key as its own IV[:16] and PKCS#7 padding, base64-encoded. Verify our
// output decrypts back to the plaintext under the same scheme.
shared := make([]byte, 32)
if _, err := rand.Read(shared); err != nil {
t.Fatal(err)
}
const pw = "s3cr3t-pässwörd!"
enc, err := encryptPassword(pw, shared)
if err != nil {
t.Fatalf("encryptPassword: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("base64 decode: %v", err)
}
if len(raw)%aes.BlockSize != 0 || len(raw) == 0 {
t.Fatalf("ciphertext length %d not a positive multiple of block size", len(raw))
}
block, _ := aes.NewCipher(shared)
out := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, shared[:aes.BlockSize]).CryptBlocks(out, raw)
// strip PKCS#7 padding
pad := int(out[len(out)-1])
if pad < 1 || pad > aes.BlockSize {
t.Fatalf("bad padding byte %d", pad)
}
if got := string(out[:len(out)-pad]); got != pw {
t.Fatalf("round-trip = %q, want %q", got, pw)
}
}
func TestEncryptPasswordShortKey(t *testing.T) {
if _, err := encryptPassword("x", make([]byte, 16)); err == nil {
t.Fatal("expected error for a too-short shared key")
}
}
func TestMD5Hex(t *testing.T) {
// gtoken = md5(user_id). Verify against the standard library.
want := md5.Sum([]byte("user-123"))
if got := md5hex("user-123"); got != hex.EncodeToString(want[:]) {
t.Fatalf("md5hex = %q, want %q", got, hex.EncodeToString(want[:]))
}
if len(md5hex("anything")) != 32 {
t.Fatal("md5 hex digest must be 32 chars")
}
}
func TestClientPublicKeyFormat(t *testing.T) {
// The client public key sent to Anker is the uncompressed P-256 point
// (0x04 || X || Y = 65 bytes) in hex, and Anker's server key must parse.
priv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
pub := priv.PublicKey().Bytes()
if len(pub) != 65 || pub[0] != 0x04 {
t.Fatalf("client public key not uncompressed 65-byte point: len=%d first=0x%02x", len(pub), pub[0])
}
serverBytes, err := hex.DecodeString(serverPublicKeyHex)
if err != nil {
t.Fatalf("server key hex: %v", err)
}
if _, err := ecdh.P256().NewPublicKey(serverBytes); err != nil {
t.Fatalf("server public key does not parse as a P-256 point: %v", err)
}
}
func TestTimezoneFormat(t *testing.T) {
gmt, ms := timezone()
if !regexp.MustCompile(`^GMT[+-]\d{2}:\d{2}$`).MatchString(gmt) {
t.Fatalf("timezone string %q does not match GMT+HH:MM", gmt)
}
// The millisecond offset must be consistent with the string's whole hours.
if (ms/1000)%60 != 0 && strings.HasSuffix(gmt, ":00") {
t.Fatalf("offset ms %d inconsistent with %q", ms, gmt)
}
}
func TestInvokeRequiresSN(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
for _, action := range []string{"charger-status", "charge-stats", "charge-orders", "ocpp-info"} {
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
!strings.Contains(err.Error(), "requires an sn") {
t.Fatalf("action %q: expected sn-required error, got %v", action, err)
}
}
if _, err := p.Invoke(context.Background(), "bogus", nil); err == nil ||
!strings.Contains(err.Error(), "unknown action") {
t.Fatalf("expected unknown-action error, got %v", err)
}
}
func TestCountChargers(t *testing.T) {
body := []byte(`{"code":0,"msg":"success!","data":{"evChargers":[],"userBindEvChargersCount":2}}`)
if n, ok := countChargers(body); !ok || n != 2 {
t.Fatalf("countChargers = (%d,%v), want (2,true)", n, ok)
}
if _, ok := countChargers([]byte("not json")); ok {
t.Fatal("expected ok=false for invalid json")
}
}
func TestApiErrorAndAuthCode(t *testing.T) {
if _, _, ok := apiError([]byte(`{"code":0,"msg":"success!"}`)); ok {
t.Fatal("code 0 must not be an error")
}
code, msg, ok := apiError([]byte(`{"code":10000,"msg":"boom"}`))
if !ok || code != 10000 || msg != "boom" {
t.Fatalf("apiError = (%d,%q,%v)", code, msg, ok)
}
if !isAuthCode([]byte(`{"code":401,"msg":"token invalid"}`)) {
t.Fatal("401 should be an auth code")
}
if isAuthCode([]byte(`{"code":10000,"msg":"other"}`)) {
t.Fatal("10000 should not be an auth code")
}
}
// TestLoginBodyShape guards the exact login payload the reference builds, without
// hitting the network: it re-derives the encrypted password and confirms it
// decrypts under the same ECDH shared secret. (Sanity check on the wiring.)
func TestLoginBodyShape(t *testing.T) {
priv, _ := ecdh.P256().GenerateKey(rand.Reader)
serverBytes, _ := hex.DecodeString(serverPublicKeyHex)
serverPub, _ := ecdh.P256().NewPublicKey(serverBytes)
shared, err := priv.ECDH(serverPub)
if err != nil {
t.Fatal(err)
}
if len(shared) != 32 {
t.Fatalf("shared secret len = %d, want 32", len(shared))
}
enc, err := encryptPassword("pw", shared)
if err != nil {
t.Fatal(err)
}
raw, _ := base64.StdEncoding.DecodeString(enc)
block, _ := aes.NewCipher(shared)
out := make([]byte, len(raw))
cipher.NewCBCDecrypter(block, shared[:aes.BlockSize]).CryptBlocks(out, raw)
pad := int(out[len(out)-1])
if !bytes.Equal(out[:len(out)-pad], []byte("pw")) {
t.Fatal("login password encryption did not round-trip")
}
}