// Package bydtest provides a fake BYD cloud for tests. // // It speaks the real protocol — unwraps the bangcle envelope, checks the // checkcode, decrypts encryData and encrypts its replies the same way — so // tests against it exercise the whole client stack rather than a mocked-out // transport. // // The server side is written independently of package byd (its own hashing and // AES helpers, its own hard-coded endpoint paths) on purpose: if both sides // shared a buggy helper, the bug would cancel out and the test would pass. package bydtest import ( "crypto/aes" "crypto/cipher" "crypto/md5" "encoding/hex" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "git.tinetunnel.com/thistine/byd-exporter/internal/bangcle" ) // Endpoint paths, spelled out here rather than imported, so that a typo in the // client's path constants shows up as an unexpected-endpoint failure. const ( LoginPath = "/app/account/login" VehiclesPath = "/app/account/getAllListByUserId" RealtimeTriggerPath = "/vehicleInfo/vehicle/vehicleRealTimeRequest" RealtimeResultPath = "/vehicleInfo/vehicle/vehicleRealTimeResult" ) // Credentials the fake accepts. Tests configure their client to match. const ( Password = "hunter2" UserID = 42 SignToken = "sign-token-value" EncryToken = "encry-token-value" VIN = "LC0TEST0000000001" ) // Server is a fake BYD cloud. The exported fields tune its behaviour; the // counters record what the client did. type Server struct { *httptest.Server t *testing.T // LoginCode is the response code returned by the login endpoint ("0" is success). LoginCode string // ReadyAfterPolls is how many polls of the result endpoint it takes before // a real reading appears. 1 means the first poll answers. ReadyAfterPolls int // Vehicles is the vehicle-list payload. Nil means one default car. Vehicles []map[string]any // Realtime is the reading returned once ready. Nil means a default one. Realtime map[string]any // Logins and Polls count requests to those endpoints. Logins int Polls int } // New starts a fake cloud with sensible defaults and registers its shutdown. func New(t *testing.T) *Server { t.Helper() s := &Server{t: t, LoginCode: "0", ReadyAfterPolls: 1} s.Server = httptest.NewServer(http.HandlerFunc(s.handle)) t.Cleanup(s.Close) return s } // DefaultVehicles and DefaultRealtime are what the fake returns unless a test // overrides them. func DefaultVehicles() []map[string]any { return []map[string]any{{ "vin": VIN, "modelName": "SEALION 7", "brandName": "BYD", "autoAlias": "Daily", "autoPlate": "AB-1234", "energyType": 0, }} } func DefaultRealtime() map[string]any { return map[string]any{ "requestSerial": "serial-1", "onlineState": 1, "elecPercent": 82, "enduranceMileage": 391, "totalMileage": 14203, "leftFrontTirepressure": 240, "vin": VIN, } } func (s *Server) handle(w http.ResponseWriter, r *http.Request) { s.t.Helper() outer, raw, ok := s.unwrap(w, r) if !ok { return } s.verifyCheckcode(raw, outer) switch r.URL.Path { case LoginPath: s.Logins++ // The login response is keyed off the password, since there is no // session token yet. s.reply(w, loginKey(Password), s.LoginCode, map[string]any{ "token": map[string]any{ "userId": UserID, // a number, as the cloud sends it "signToken": SignToken, "encryToken": EncryToken, }, }) case VehiclesPath: vehicles := s.Vehicles if vehicles == nil { vehicles = DefaultVehicles() } s.reply(w, contentKey(), "0", vehicles) case RealtimeTriggerPath: // The trigger only acknowledges; the car has to wake up first. s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"}) case RealtimeResultPath: s.Polls++ if s.Polls < s.ReadyAfterPolls { s.reply(w, contentKey(), "0", map[string]any{"requestSerial": "serial-1"}) return } reading := s.Realtime if reading == nil { reading = DefaultRealtime() } s.reply(w, contentKey(), "0", reading) default: s.t.Errorf("client called an unexpected endpoint: %s", r.URL.Path) http.Error(w, "not found", http.StatusNotFound) } } // unwrap decodes the bangcle envelope and returns the outer payload both parsed // and raw (the raw bytes are needed to verify the checkcode). func (s *Server) unwrap(w http.ResponseWriter, r *http.Request) (map[string]any, []byte, bool) { s.t.Helper() body, err := io.ReadAll(r.Body) if err != nil { s.t.Errorf("reading request body: %v", err) return nil, nil, false } var wrapper struct { Request string `json:"request"` } if err := json.Unmarshal(body, &wrapper); err != nil { s.t.Errorf("request body is not JSON: %s", body) return nil, nil, false } raw, err := bangcle.Decode(wrapper.Request) if err != nil { s.t.Errorf("client sent an undecodable envelope: %v", err) return nil, nil, false } var outer map[string]any if err := json.Unmarshal(raw, &outer); err != nil { s.t.Errorf("outer payload is not JSON: %s", raw) return nil, nil, false } return outer, raw, true } // verifyCheckcode recomputes the checkcode the way the cloud does. It also // asserts that checkcode is the final field, because the hash covers exactly // the object that precedes it — get the field order wrong and this fails. func (s *Server) verifyCheckcode(raw []byte, outer map[string]any) { s.t.Helper() got, _ := outer["checkcode"].(string) if got == "" { s.t.Error("request carries no checkcode") return } body := string(raw) idx := strings.LastIndex(body, `,"checkcode":`) if idx < 0 || !strings.HasSuffix(body, `"}`) { s.t.Errorf("checkcode is not the last field: %s", body) return } sum := md5.Sum([]byte(body[:idx] + "}")) h := hex.EncodeToString(sum[:]) if want := h[24:32] + h[8:16] + h[16:24] + h[0:8]; got != want { s.t.Errorf("checkcode = %s, want %s", got, want) } } // reply encrypts respondData with key and wraps the response in an envelope. func (s *Server) reply(w http.ResponseWriter, key, code string, respondData any) { s.t.Helper() body := map[string]any{"code": code, "message": "ok"} if code == "0" { payload, err := json.Marshal(respondData) if err != nil { s.t.Fatal(err) } body["respondData"] = encryptHex(s.t, payload, key) } outer, err := json.Marshal(body) if err != nil { s.t.Fatal(err) } resp, err := json.Marshal(map[string]string{"response": bangcle.Encode(outer)}) if err != nil { s.t.Fatal(err) } w.Header().Set("Content-Type", "application/json") w.Write(resp) } // The server-side key derivations, written out rather than imported from the // client so the two implementations can disagree. func md5Upper(s string) string { sum := md5.Sum([]byte(s)) return strings.ToUpper(hex.EncodeToString(sum[:])) } func loginKey(password string) string { return md5Upper(md5Upper(password)) } func contentKey() string { return md5Upper(EncryToken) } func encryptHex(t *testing.T, plaintext []byte, keyHex string) string { t.Helper() key, err := hex.DecodeString(keyHex) if err != nil { t.Fatalf("bad key %q: %v", keyHex, err) } block, err := aes.NewCipher(key) if err != nil { t.Fatal(err) } pad := 16 - len(plaintext)%16 // PKCS#7: always 1..16 bytes data := append([]byte{}, plaintext...) for i := 0; i < pad; i++ { data = append(data, byte(pad)) } out := make([]byte, len(data)) cipher.NewCBCEncrypter(block, make([]byte, 16)).CryptBlocks(out, data) return strings.ToUpper(hex.EncodeToString(out)) }