Files
DriverVault/API Server/internal/plugins/builtin/toyota/toyota_test.go
T
tajniak81andClaude Opus 5.5 e7d2ef84cd Toyota: the electric read moves too, and a plug-in hybrid gets its fuel
pytoyoda went from 5.2.7 to 6.0.0. Two changes cross over to a connector that
returns raw JSON.

The electric status read was the one route left on /v1/global/remote after the
mid-2026 retirement. Toyota has now fenced it the same way, and the app reads
the same data from /v1/vehicle/electric/status; that is where the electric
capability now points. Upstream took the route from a capture of the app and
has proved it only with unit tests, so the old route is kept: when the new one
answers 403 or 404, the read is tried once on the old one before it fails.

Upstream also widened the electric gate from fuelType "E" to "E" or "I", since
"I" is Toyota's code for a plug-in hybrid. The car importer passed the code
through the generic normalizer, which knows neither letter, so an imported
Toyota came in without a fuel type. "E" now imports as electric and "I" as
hybrid; other letters stay unset because nothing pins them down.

The plugin is now 0.4.0.

Left where they were: phevUsableBatteryLevel, a new field on the electric
payload, already reaches the card because the payload is passed through
whole. The trip list has stopped returning routes, which are now one call per
trip on /v1/trips/{id}; this connector has never read trips.

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

270 lines
9.4 KiB
Go

package toyota
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"testing"
"drivervault/apiserver/internal/plugins"
)
func TestDescriptor(t *testing.T) {
d := (&Plugin{}).Descriptor()
if d.Name != "toyota" {
t.Fatalf("name = %q, want toyota", d.Name)
}
if d.Kind != plugins.KindBuiltin {
t.Fatalf("kind = %q, want builtin", d.Kind)
}
if len(d.Capabilities) == 0 {
t.Fatal("expected capabilities")
}
// The credential + brand fields must be advertised. They are intentionally
// NOT required at the global layer — credentials come from the per-user
// cascade — and the password field must be marked secret.
fields := map[string]plugins.ConfigField{}
for _, f := range d.ConfigFields {
fields[f.Key] = f
}
for _, k := range []string{"username", "password", "brand"} {
if _, ok := fields[k]; !ok {
t.Errorf("config field %q should be present", k)
}
}
if fields["username"].Required || fields["password"].Required {
t.Error("credentials must not be required at the global layer (per-user cascade)")
}
if !fields["password"].Secret {
t.Error("password field must be marked secret")
}
// The three brands that share the backend must all be offered: a value the
// select does not list cannot be chosen from the panel.
brands := map[string]bool{}
for _, o := range fields["brand"].Options {
brands[o.Value] = true
}
for _, b := range []string{"T", "L", "S"} {
if !brands[b] {
t.Errorf("brand option %q should be offered", b)
}
}
}
// TestBrandAuth pins the per-brand login: Toyota and Lexus are the MyToyota app
// in the "tme" realm, Subaru is its own client in "alliance-subaru". A brand on
// the wrong realm authenticates against a user directory it is not in, which
// reads as a bad password.
func TestBrandAuth(t *testing.T) {
for _, brand := range []string{"", "T", "L"} {
a := authFor(brand)
if a.clientID != "oneapp" || !strings.Contains(a.authenticateURL, "/realms/tme/") {
t.Errorf("brand %q: expected the oneapp client in realm tme, got %q at %s", brand, a.clientID, a.authenticateURL)
}
}
s := authFor("S")
if !strings.HasSuffix(s.clientID, ".subaru.com") || !strings.Contains(s.accessTokenURL, "/realms/alliance-subaru/") {
t.Errorf("Subaru: expected its own client in realm alliance-subaru, got %q at %s", s.clientID, s.accessTokenURL)
}
if s.redirectURI != "com.subaru.oneapp:/oauth2Callback" {
t.Errorf("Subaru redirect = %q", s.redirectURI)
}
if !strings.Contains(s.authorizeURL, "client_id="+s.clientID+"&") || !strings.Contains(s.authorizeURL, "redirect_uri="+s.redirectURI+"&") {
t.Errorf("Subaru authorize URL does not carry its own client and redirect: %s", s.authorizeURL)
}
// The three URLs of one brand must share a realm.
if !strings.Contains(s.authorizeURL, "/realms/alliance-subaru/") || !strings.Contains(s.authenticateURL, "/realms/alliance-subaru/") {
t.Error("Subaru login URLs disagree on the realm")
}
if s.basicAuth == toyotaAuth.basicAuth {
t.Error("Subaru must present its own client credentials")
}
// Init picks the client for the configured brand.
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p", "brand": "S"})
if p.auth.clientID != s.clientID {
t.Errorf("Init(brand=S) chose client %q", p.auth.clientID)
}
}
// TestHeadersFollowUpstream keeps the request headers in step with what the app
// sends: x-user-region on every call, and the brand named twice more for the
// non-Toyota brands only.
func TestHeadersFollowUpstream(t *testing.T) {
tok := tokenInfo{accessToken: "at", uuid: "uuid-1"}
headers := func(brand string) http.Header {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p", "brand": brand})
h := http.Header{}
p.applyHeaders(h, "VIN1", tok)
return h
}
for _, brand := range []string{"T", "L", "S"} {
h := headers(brand)
if got := h["x-user-region"]; len(got) != 1 || got[0] != "EU" {
t.Errorf("brand %s: x-user-region = %v, want [EU]", brand, got)
}
if got := h["x-brand"]; len(got) != 1 || got[0] != brand {
t.Errorf("brand %s: x-brand = %v", brand, got)
}
_, appbrand := h["x-appbrand"]
_, bare := h["brand"]
if brand == "T" && (appbrand || bare) {
t.Error("a Toyota account must not send x-appbrand/brand")
}
if brand != "T" && (!appbrand || !bare || h["x-appbrand"][0] != brand || h["brand"][0] != brand) {
t.Errorf("brand %s: x-appbrand=%v brand=%v, want both = %s", brand, h["x-appbrand"], h["brand"], brand)
}
}
}
// TestEndpointsFollowUpstream guards the mid-2026 migration: Toyota put the
// /v1/global/remote read routes behind AWS SigV4, where a Bearer token is
// answered with 403, and the app moved to /v1/vehicle/*. Falling back to the old
// paths would leave the cards permanently in error.
func TestEndpointsFollowUpstream(t *testing.T) {
retired := []string{
"/v1/global/remote/status",
"/v1/global/remote/climate-status",
"/v1/global/remote/climate-settings",
}
for _, c := range (&Plugin{}).Descriptor().Capabilities {
for _, dead := range retired {
if c.Endpoint == dead {
t.Errorf("capability %q still points at the retired %s", c.ID, dead)
}
}
}
if epRemoteStatus != "/v1/vehicle/status" {
t.Errorf("status endpoint = %q, want /v1/vehicle/status", epRemoteStatus)
}
// The electric read moved later than the rest, and keeps its old route only
// as the fallback for a gateway that has not caught up.
if epElectricStatus != "/v1/vehicle/electric/status" {
t.Errorf("electric endpoint = %q, want /v1/vehicle/electric/status", epElectricStatus)
}
if epElectricStatusLegacy != "/v1/global/remote/electric/status" {
t.Errorf("electric fallback = %q, want the old global route", epElectricStatusLegacy)
}
for status, want := range map[int]bool{403: true, 404: true, 401: false, 429: false, 500: false, 0: false} {
if got := electricFallback(status); got != want {
t.Errorf("electricFallback(%d) = %v, want %v", status, got, want)
}
}
}
// TestActionsCoverCapabilities keeps Invoke and the descriptor in step: every
// advertised capability must be callable, since the car tab drives the plugin by
// the ids the descriptor publishes.
func TestActionsCoverCapabilities(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p"})
for _, c := range p.Descriptor().Capabilities {
if c.ID == "vehicles" {
continue // the only action that takes no vin
}
// No vin, so the call is rejected before any network I/O — an "unknown
// action" here means the switch in Invoke was never extended.
_, err := p.Invoke(context.Background(), c.ID, nil)
if err == nil {
t.Errorf("action %q: expected an error without a vin", c.ID)
continue
}
if strings.Contains(err.Error(), "unknown action") {
t.Errorf("capability %q is advertised but not wired into Invoke", c.ID)
}
}
}
func TestRegistered(t *testing.T) {
// The plugin must self-register via init() so the manager can construct it.
var found bool
for _, v := range plugins.NewManager(plugins.NewMemoryStore(nil)).List() {
if v.Name == "toyota" {
found = true
}
}
if !found {
t.Fatal("toyota not registered with the plugin manager")
}
}
func TestHMACSHA256(t *testing.T) {
// Mirrors pytoyoda: hmac.new(b"2.14.0", b"abc", sha256).hexdigest().
got := hmacSHA256("2.14.0", "abc")
if len(got) != 64 {
t.Fatalf("hex digest length = %d, want 64", len(got))
}
// Deterministic: same key/message → same digest.
if got != hmacSHA256("2.14.0", "abc") {
t.Fatal("hmac not deterministic")
}
// Key matters: a different client version changes the digest.
if got == hmacSHA256("9.9.9", "abc") {
t.Fatal("hmac ignored the key")
}
}
func TestJWTUUID(t *testing.T) {
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"uuid":"abc-123","aud":"oneappsdkclient"}`))
token := "header." + payload + ".sig"
uuid, err := jwtUUID(token)
if err != nil {
t.Fatalf("jwtUUID: %v", err)
}
if uuid != "abc-123" {
t.Fatalf("uuid = %q, want abc-123", uuid)
}
if _, err := jwtUUID("not-a-jwt"); err == nil {
t.Error("expected error for malformed token")
}
}
func TestExtractCode(t *testing.T) {
loc := "com.toyota.oneapp:/oauth2Callback?code=AUTHCODE123&state=xyz"
if got := extractCode(loc); got != "AUTHCODE123" {
t.Fatalf("code = %q, want AUTHCODE123", got)
}
if got := extractCode("com.toyota.oneapp:/oauth2Callback"); got != "" {
t.Fatalf("expected empty code, got %q", got)
}
}
func TestCallbackHelpers(t *testing.T) {
// A NameCallback shaped like ForgeRock's response.
var cb map[string]any
_ = json.Unmarshal([]byte(`{
"type":"NameCallback",
"output":[{"name":"prompt","value":"User Name"}],
"input":[{"name":"IDToken1","value":""}]
}`), &cb)
if output0(cb) != "User Name" {
t.Fatalf("output0 = %q", output0(cb))
}
setInput0(cb, "driver@example.com")
in := cb["input"].([]any)[0].(map[string]any)
if in["value"] != "driver@example.com" {
t.Fatalf("input value = %v, want driver@example.com", in["value"])
}
}
func TestInvokeRequiresVIN(t *testing.T) {
p := &Plugin{}
_ = p.Init(context.Background(), map[string]string{"username": "u", "password": "p"})
if _, err := p.Invoke(context.Background(), "telemetry", nil); err == nil ||
!strings.Contains(err.Error(), "requires a vin") {
t.Fatalf("expected vin-required error, got %v", 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)
}
}