Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go
T
tajniak81andClaude Opus 5.5 8849fcfb40 Anker Solix: what upstream v3.9.0 changed for a charger
anker-solix-api v3.9.0 (2026-09-29) is mostly PPS work — time-of-use plans
over the cloud API, device energy queries, a condensed V2 command map — and
none of that reaches an A5191; its MQTT map and command definitions are
unchanged from v3.8.1. Three things do:

- Singapore accounts live on the EU server (ha-anker-solix #593), so SG
  leaves the .com list.
- A charger still showing a finished session can be started again. The app
  offers Start while status is completed; the mode options did not
  (ha-anker-solix #629, #630).
- A re-login after a rejected token drops the payload handshake with the
  token, as upstream now does on a failed login. It was never reused — a
  successful login cleared it and ensureCipher refuses without a token —
  but it no longer outlives the token it was run under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:22:35 +02:00

458 lines
15 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)
}
}
// controlModesDisabled is the superadmin's hide-list: which of the modes the
// organizations and users below are offered at all. off is not among them —
// monitoring only is the fallback, so no layer may take it away.
hide, ok := fields["controlModesDisabled"]
if !ok {
t.Fatal("controlModesDisabled config field should be present")
}
if hide.Type != "multiselect" {
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
}
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
for _, o := range hide.Options {
if o.Value == "off" {
t.Error("off must not be hideable — it is what a charger falls back to")
}
if _, known := hideable[o.Value]; known {
hideable[o.Value] = true
}
}
for v, seen := range hideable {
if !seen {
t.Errorf("controlModesDisabled is missing option %q", v)
}
}
}
func TestRegistered(t *testing.T) {
var found bool
for _, v := range plugins.NewManager(plugins.NewMemoryStore(nil)).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,
"SG": serverEU, // moved off the COM list in upstream v3.9.0
"": 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")
}
}
func TestInvokeRequiresSiteID(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
for _, action := range []string{"charger-state", "site-status", "charge-energy"} {
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
!strings.Contains(err.Error(), "requires a siteId") {
t.Fatalf("action %q: expected siteId-required error, got %v", action, err)
}
}
if _, err := p.Invoke(context.Background(), "vehicle", nil); err == nil ||
!strings.Contains(err.Error(), "requires a vehicleId") {
t.Fatalf("expected vehicleId-required error, got %v", err)
}
}
func TestEnergyRange(t *testing.T) {
for in, want := range map[string]string{
"day": "day", "week": "week", "month": "month", "year": "year",
"YEAR": "year", " month ": "month",
"": "week", "decade": "week",
} {
if got := energyRange(in); got != want {
t.Errorf("energyRange(%q) = %q, want %q", in, got, want)
}
}
}
func TestChargerMode(t *testing.T) {
// Mirrors anker-solix-api's ev_charger_mode_state: a boost overrides
// everything, preparing splits on the countdowns, and the three active
// charging states all read as start_charge.
cases := []struct {
status string
boost bool
plugCountdown, startCountdown int
want string
}{
{stateCharging, true, 0, 0, modeBoostCharge},
{stateStandby, true, 0, 0, modeBoostCharge},
{statePreparing, false, 30, 0, modeWaitPlug},
{statePreparing, false, 0, 30, modeWaitStart},
{statePreparing, false, 30, 30, modeWaitPlug}, // plug wins over start
{statePreparing, false, 0, 0, modeStartCharge},
{stateCharging, false, 0, 0, modeStartCharge},
{stateChargerPaused, false, 0, 0, modeStartCharge},
{stateVehiclePaused, false, 0, 0, modeStartCharge},
{stateStandby, false, 0, 0, modeStopCharge},
{stateCompleted, false, 0, 0, modeStopCharge},
{stateDisabled, false, 0, 0, modeStopCharge},
{stateError, false, 0, 0, modeStopCharge},
{stateUnknown, false, 0, 0, modeStopCharge},
}
for _, c := range cases {
got := chargerMode(c.status, c.boost, c.plugCountdown, c.startCountdown)
if got != c.want {
t.Errorf("chargerMode(%q, boost=%v, %d, %d) = %q, want %q",
c.status, c.boost, c.plugCountdown, c.startCountdown, got, c.want)
}
}
}
func TestChargerModeOptions(t *testing.T) {
// Mirrors ev_charger_mode_options; results are sorted.
cases := []struct {
mode, status string
want []string
}{
{modeStartCharge, stateCharging, []string{modeBoostCharge, modeStartCharge, modeStopCharge}},
{modeWaitStart, statePreparing, []string{modeSkipDelay, modeStopCharge, modeWaitStart}},
{modeWaitPlug, statePreparing, []string{modeStopCharge, modeWaitPlug}},
{modeBoostCharge, stateCharging, []string{modeBoostCharge, modeStopCharge}},
// Stopped: startable from standby, and from completed since v3.9.0.
{modeStopCharge, stateStandby, []string{modeStartCharge, modeStopCharge}},
{modeStopCharge, stateCompleted, []string{modeStartCharge, modeStopCharge}},
{modeStopCharge, stateError, []string{modeStopCharge}},
{"", stateUnknown, []string{}},
}
for _, c := range cases {
got := chargerModeOptions(c.mode, c.status)
if len(got) != len(c.want) {
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
continue
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
break
}
}
}
}
func TestParseScenePiles(t *testing.T) {
body := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
{"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
{"device_sn":"","device_name":"nameless"},
{"device_sn":"EVSN2","operating_state":99}]}}}`)
got := parseScenePiles(body, "site-1")
if len(got) != 2 {
t.Fatalf("parsed %d chargers, want 2 (the SN-less entry is dropped)", len(got))
}
c := got[0]
if c.SN != "EVSN1" || c.Name != "Garage" || c.SiteID != "site-1" || c.Source != "scene" {
t.Errorf("identity = %+v", c)
}
if c.StatusDesc != stateCharging || c.Mode != modeStartCharge {
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateCharging, modeStartCharge)
}
if c.Charging == nil || !*c.Charging {
t.Error("a charging charger must read as charging")
}
if c.Power != "7.4" {
t.Errorf("power = %q, want 7.4", c.Power)
}
if c.OcppStatusDesc != "connected" {
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
}
// An unmapped status code degrades to unknown, and unknown stops.
if got[1].StatusDesc != stateUnknown || got[1].Mode != modeStopCharge {
t.Errorf("unmapped status = %q/%q, want %q/%q",
got[1].StatusDesc, got[1].Mode, stateUnknown, modeStopCharge)
}
if got[1].OcppStatus != nil {
t.Error("absent ocpp_connect_status must stay nil, not default to disconnected")
}
if parseScenePiles([]byte("not json"), "site-1") != nil {
t.Error("invalid json should parse to nil")
}
}
func TestParseHesChargers(t *testing.T) {
body := []byte(`{"code":0,"data":{"hasEvCharger":true,"evChargerInfos":[
{"evChargerSn":"EVSN9","evChargerStatus":0,"evChargerName":"V1 EV Charger"}]}}`)
got := parseHesChargers(body, "site-2")
if len(got) != 1 {
t.Fatalf("parsed %d chargers, want 1", len(got))
}
c := got[0]
if c.SN != "EVSN9" || c.Source != "hes" || c.SiteID != "site-2" {
t.Errorf("identity = %+v", c)
}
if c.StatusDesc != stateStandby || c.Mode != modeStopCharge {
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateStandby, modeStopCharge)
}
if c.Charging == nil || *c.Charging {
t.Error("a standby charger must not read as charging")
}
// Standby is the one stopped state that can be started again.
var startable bool
for _, o := range c.ModeOptions {
if o == modeStartCharge {
startable = true
}
}
if !startable {
t.Errorf("standby should offer start_charge, options = %v", c.ModeOptions)
}
if parseHesChargers([]byte("{"), "site-2") != nil {
t.Error("invalid json should parse to nil")
}
}
func TestStatusName(t *testing.T) {
for code, want := range map[int]string{
0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused,
4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled,
8: stateError, 9: stateUnknown, -1: stateUnknown,
} {
if got := statusName(code); got != want {
t.Errorf("statusName(%d) = %q, want %q", code, got, want)
}
}
}