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(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, "": 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") } }