Test / test (push) Failing after 25m47s
Go port of the read path of pybyd 0.0.75 (the client behind jkaberg/hass-byd-vehicle): the bangcle white-box AES envelope, the signing/checkcode layer, login, vehicle list, and realtime readings. Scraping runs on a cron schedule rather than per Prometheus request because each reading wakes the car's T-Box and drains the traction battery; /metrics serves the last result. Every numeric field of the realtime payload becomes a gauge, so the exporter does not need a hand-maintained field list that goes stale when BYD changes the payload. Tests pin the wire format against pybyd byte-for-byte and drive the client end to end against a fake cloud that speaks the real protocol. Co-Authored-By: Claude Opus 5 <[email protected]>
147 lines
3.9 KiB
Go
147 lines
3.9 KiB
Go
package byd
|
|
|
|
// Expected values come from pybyd 0.0.75 — regenerate with testdata/gen_fixtures.py.
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestHashing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
got string
|
|
want string
|
|
}{
|
|
{"md5Hex", md5Hex("hunter2"), "2AB96390C7DBE3439DE74D0C9B0B1767"},
|
|
{"pwdLoginKey", pwdLoginKey("hunter2"), "38075EADB88F8484DDC00C4D272D9F4C"},
|
|
{"sha1Mixed", sha1Mixed("abc"), "A9993E36476816aBA3e25717850C26c9Cd0D89d"},
|
|
}
|
|
for _, tt := range tests {
|
|
if tt.got != tt.want {
|
|
t.Errorf("%s = %s, want %s", tt.name, tt.got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSha1MixedDropsZeros covers the odd half of the algorithm. Note the
|
|
// filtering is indexed against the pre-filter string, not the result, so the
|
|
// output can still contain '0' at an even position — the only thing guaranteed
|
|
// is that it comes out shorter than a plain 40-char hex digest.
|
|
func TestSha1MixedDropsZeros(t *testing.T) {
|
|
got := sha1Mixed("abc")
|
|
if len(got) >= 40 {
|
|
t.Errorf("sha1Mixed(%q) = %q (%d chars); zero filtering did not run", "abc", got, len(got))
|
|
}
|
|
if strings.ToLower(got) == got || strings.ToUpper(got) == got {
|
|
t.Errorf("sha1Mixed(%q) = %q; expected mixed case", "abc", got)
|
|
}
|
|
}
|
|
|
|
func TestJmapPreservesInsertionOrder(t *testing.T) {
|
|
// A Go map would sort these keys and change the checkcode, so this is the
|
|
// guard on the ordered marshalling the whole protocol depends on.
|
|
m := jmap{}
|
|
m.set("zeta", "1")
|
|
m.set("alpha", "2")
|
|
|
|
b, err := marshalCompact(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := `{"zeta":"1","alpha":"2"}`; string(b) != want {
|
|
t.Fatalf("marshalCompact = %s, want %s", b, want)
|
|
}
|
|
|
|
got, err := checkcode(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := "3d6e01c0671c8e4d2e2fbda3466886b0"; got != want {
|
|
t.Errorf("checkcode = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestJmapSetOverwritesInPlace(t *testing.T) {
|
|
m := jmap{}
|
|
m.set("a", "1")
|
|
m.set("b", "2")
|
|
m.set("a", "3") // must update, not append, or the order shifts
|
|
|
|
b, _ := marshalCompact(m)
|
|
if want := `{"a":"3","b":"2"}`; string(b) != want {
|
|
t.Errorf("marshalCompact = %s, want %s", b, want)
|
|
}
|
|
}
|
|
|
|
func TestJmapCloneIsIndependent(t *testing.T) {
|
|
orig := jmap{}
|
|
orig.set("a", "1")
|
|
|
|
clone := orig.clone()
|
|
clone.set("b", "2")
|
|
clone.set("a", "changed")
|
|
|
|
if orig.get("a") != "1" || len(orig) != 1 {
|
|
t.Errorf("clone mutated the original: %v", orig)
|
|
}
|
|
}
|
|
|
|
// TestMarshalCompactDoesNotEscapeHTML guards a real difference between Go and
|
|
// Python: encoding/json escapes < > & by default, which would change every
|
|
// checkcode for a payload containing them.
|
|
func TestMarshalCompactDoesNotEscapeHTML(t *testing.T) {
|
|
m := jmap{}
|
|
m.set("q", "a<b&c>d")
|
|
b, err := marshalCompact(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if want := `{"q":"a<b&c>d"}`; string(b) != want {
|
|
t.Errorf("marshalCompact = %s, want %s", b, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildSignStringSortsKeys(t *testing.T) {
|
|
m := jmap{}
|
|
m.set("b", "2")
|
|
m.set("a", "1")
|
|
m.set("c", "3")
|
|
if got, want := buildSignString(m, "pw"), "a=1&b=2&c=3&password=pw"; got != want {
|
|
t.Errorf("buildSignString = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAESRoundTrip(t *testing.T) {
|
|
key := pwdLoginKey("hunter2")
|
|
const plain = `{"a":1,"b":"ü"}`
|
|
|
|
ct, err := aesEncryptHex(plain, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := aesDecryptUTF8(ct, key)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != plain {
|
|
t.Errorf("round trip = %q, want %q", got, plain)
|
|
}
|
|
}
|
|
|
|
func TestAESRejectsBadInput(t *testing.T) {
|
|
key := pwdLoginKey("hunter2")
|
|
if _, err := aesEncryptHex("x", "not-hex"); err == nil {
|
|
t.Error("expected an error for a non-hex key")
|
|
}
|
|
if _, err := aesEncryptHex("x", "AABB"); err == nil {
|
|
t.Error("expected an error for a 2-byte key")
|
|
}
|
|
if _, err := aesDecryptUTF8("zz", key); err == nil {
|
|
t.Error("expected an error for non-hex ciphertext")
|
|
}
|
|
if _, err := aesDecryptUTF8("AABB", key); err == nil {
|
|
t.Error("expected an error for a partial block")
|
|
}
|
|
}
|