package toyota import ( "context" "encoding/base64" "encoding/json" "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") } } // 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 route was not part of that migration and must stay put. if epElectricStatus != "/v1/global/remote/electric/status" { t.Errorf("electric endpoint = %q, want the unmoved global route", epElectricStatus) } } // 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) } }