Add a built-in Toyota Connected Europe plugin

Port pytoyoda's authentication and read-only data flow to a Go builtin
plugin behind the existing plugin contract. Implements the three-legged
ForgeRock/OAuth2 login (authenticate callback loop, authorize, token
exchange), silent refresh with full re-auth fallback, and the full
Toyota gateway header set with backoff on 429/5xx.

Exposes read-only capabilities: vehicles, telemetry, location, health,
status, electric, notifications, and service-history. Config takes a
MyToyota email/password and a Toyota/Lexus brand select.

Europe-only and read-only, matching pytoyoda's limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 09:45:04 +02:00
co-authored by Claude Opus 4.8
parent b6bb6b1df0
commit 5a729abd71
3 changed files with 808 additions and 0 deletions
@@ -10,3 +10,8 @@
// Until then, plugins are added at runtime as the "external" HTTP kind, which // Until then, plugins are added at runtime as the "external" HTTP kind, which
// needs no rebuild. See ../README.md. // needs no rebuild. See ../README.md.
package builtin package builtin
import (
// toyota — Toyota Connected Europe (MyToyota) read-only vehicle data.
_ "drivervault/apiserver/internal/plugins/builtin/toyota"
)
@@ -0,0 +1,679 @@
// Package toyota is a built-in connector for Toyota Connected Europe Services —
// the backend behind the MyToyota app. It is a Go re-implementation of the
// authentication and read-only data flow from the pytoyoda project
// (https://github.com/pytoyoda/pytoyoda), adapted to DriverVault's plugin
// contract.
//
// Scope & limitations (inherited from pytoyoda):
// - Europe only. Other Toyota regions use a different backend and are not
// supported.
// - Read-only. Only vehicle information is retrieved; no remote control
// (lock, climate, charging) commands are implemented.
// - Unofficial. This talks to a private API with hardcoded app credentials
// extracted from the MyToyota app; Toyota may change or break it at any time.
//
// Authentication is a three-legged OAuth2 (ForgeRock/OpenAM) flow:
// 1. POST the /authenticate endpoint in a callback loop, filling in the
// username then password, until a tokenId is returned.
// 2. GET the /authorize endpoint with that tokenId as a cookie; the 302
// redirect carries the authorization code in its Location query string.
// 3. POST the /access_token endpoint to exchange the code for an access +
// refresh + id token. The id token (a JWT) carries the account UUID.
//
// Access tokens are short-lived and refreshed with the refresh token; a full
// re-authentication is the fallback when refresh fails.
package toyota
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"drivervault/apiserver/internal/plugins"
)
// Upstream URLs and hardcoded app credentials. These mirror pytoyoda's const.py
// and are the values baked into the official MyToyota Android app (client
// "oneapp"); they are not user secrets.
const (
apiBaseURL = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io"
accessTokenURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/access_token"
authenticateURL = "https://b2c-login.toyota-europe.com/json/realms/root/realms/tme/authenticate?authIndexType=service&authIndexValue=oneapp"
authorizeURL = "https://b2c-login.toyota-europe.com/oauth2/realms/root/realms/tme/authorize?client_id=oneapp&scope=openid+profile+write&response_type=code&redirect_uri=com.toyota.oneapp:/oauth2Callback&code_challenge=plain&code_challenge_method=plain"
redirectURI = "com.toyota.oneapp:/oauth2Callback"
clientVersion = "2.14.0"
// apiKey is the app's public API-gateway key (not the user's secret).
apiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF"
// basicAuth is base64("oneapp:oneapp") — the OAuth client's basic credentials.
basicAuth = "basic b25lYXBwOm9uZWFwcA=="
userAgent = "okhttp/4.10.0"
)
// Read-only API endpoints exposed as capabilities.
const (
epVehicleGUID = "/v2/vehicle/guid"
epLocation = "/v1/location"
epHealthStatus = "/v1/vehiclehealth/status"
epRemoteStatus = "/v1/global/remote/status"
epElectricStatus = "/v1/global/remote/electric/status"
epTelemetry = "/v3/telemetry"
epNotifications = "/v2/notification/history"
epServiceHistory = "/v1/servicehistory/vehicle/summary"
)
// tokenExpiryMargin is subtracted from the reported token lifetime so a request
// never fires with a token that expires mid-flight.
const tokenExpiryMargin = 30 * time.Second
func init() {
plugins.Register("toyota", func() plugins.Plugin { return &Plugin{} })
}
// tokenInfo holds the currently-held OAuth tokens and the derived account UUID.
type tokenInfo struct {
accessToken string
refreshToken string
uuid string
expiration time.Time
}
// Plugin is the Toyota Connected Europe connector.
type Plugin struct {
username string
password string
brand string // "T" (Toyota) or "L" (Lexus)
mu sync.Mutex // guards tok and the login flow
tok *tokenInfo
// authClient does NOT follow redirects — the authorize step must see the
// raw 302 to read the code out of the Location header.
authClient *http.Client
// dataClient follows redirects for ordinary data requests.
dataClient *http.Client
}
// Descriptor returns the plugin's static metadata for the admin panel.
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "toyota",
Provider: "Toyota Connected Europe",
Version: "0.1.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryAPIsExternal,
AuthType: plugins.AuthOAuth2,
Capabilities: []plugins.Capability{
{ID: "vehicles", Method: "GET", Endpoint: epVehicleGUID, Description: "List vehicles registered to the account."},
{ID: "telemetry", Method: "GET", Endpoint: epTelemetry, Description: "Odometer, fuel level and range for a VIN."},
{ID: "location", Method: "GET", Endpoint: epLocation, Description: "Last known parked location for a VIN."},
{ID: "health", Method: "GET", Endpoint: epHealthStatus, Description: "Vehicle health / dashboard warning lights for a VIN."},
{ID: "status", Method: "GET", Endpoint: epRemoteStatus, Description: "General remote status (doors, windows, lights) for a VIN."},
{ID: "electric", Method: "GET", Endpoint: epElectricStatus, Description: "Battery level, EV range and charging status for a VIN."},
{ID: "notifications", Method: "GET", Endpoint: epNotifications, Description: "Notification history for a VIN."},
{ID: "service-history", Method: "GET", Endpoint: epServiceHistory, Description: "Dealer service history summary for a VIN."},
},
ConfigFields: []plugins.ConfigField{
{Key: "username", Label: "MyToyota email", Type: "text", Required: true,
Help: "The email address for your MyToyota (Toyota Connected Europe) account."},
{Key: "password", Label: "MyToyota password", Type: "password", Required: true, Secret: true,
Help: "Your MyToyota account password. Stored locally, sent only to Toyota's login endpoint."},
{Key: "brand", Label: "Brand", Type: "select", Default: "T",
Help: "Vehicle brand tied to the account.",
Options: []plugins.SelectOption{
{Value: "T", Label: "Toyota"},
{Value: "L", Label: "Lexus"},
}},
},
}
}
// Init applies resolved config and builds the HTTP clients. It performs no
// network I/O; login happens lazily on the first request or health check.
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.username = strings.TrimSpace(config["username"])
p.password = config["password"]
p.brand = strings.TrimSpace(config["brand"])
if p.brand == "" {
p.brand = "T"
}
// Config change invalidates any cached token.
p.tok = nil
p.authClient = &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
p.dataClient = &http.Client{Timeout: 30 * time.Second}
return nil
}
// HealthCheck logs in (if needed) and lists the account's vehicles.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
body, _, err := p.apiRequest(ctx, http.MethodGet, epVehicleGUID, "", nil)
lat := time.Since(start).Milliseconds()
if err != nil {
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())}
}
detail := "authenticated; vehicles reachable"
if n, ok := countVehicles(body); ok {
detail = fmt.Sprintf("authenticated; %d vehicle(s) on account", n)
}
return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail}
}
// Invoke runs a named read-only capability. Every capability except "vehicles"
// requires a "vin" in params. The upstream JSON is returned verbatim.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
var pp struct {
VIN string `json:"vin"`
}
if len(params) > 0 {
if err := json.Unmarshal(params, &pp); err != nil {
return nil, fmt.Errorf("toyota: invalid params: %w", err)
}
}
pp.VIN = strings.TrimSpace(pp.VIN)
var endpoint string
needVIN := true
switch action {
case "vehicles":
endpoint, needVIN = epVehicleGUID, false
case "telemetry":
endpoint = epTelemetry
case "location":
endpoint = epLocation
case "health":
endpoint = epHealthStatus
case "status":
endpoint = epRemoteStatus
case "electric":
endpoint = epElectricStatus
case "notifications":
endpoint = epNotifications
case "service-history":
endpoint = epServiceHistory
default:
return nil, fmt.Errorf("toyota: unknown action %q", action)
}
if needVIN && pp.VIN == "" {
return nil, fmt.Errorf("toyota: action %q requires a vin", action)
}
body, _, err := p.apiRequest(ctx, http.MethodGet, endpoint, pp.VIN, nil)
if err != nil {
return nil, err
}
return json.RawMessage(body), nil
}
// Shutdown releases pooled connections.
func (p *Plugin) Shutdown(context.Context) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.dataClient != nil {
p.dataClient.CloseIdleConnections()
}
if p.authClient != nil {
p.authClient.CloseIdleConnections()
}
return nil
}
// ---- token management --------------------------------------------------------
// ensureToken guarantees a non-expired access token, refreshing or performing a
// full authentication as needed. Serialized by p.mu so concurrent requests
// trigger at most one login.
func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.tok != nil && time.Now().UTC().Before(p.tok.expiration) {
return *p.tok, nil
}
if p.tok != nil && p.tok.refreshToken != "" {
if err := p.refreshTokens(ctx); err == nil {
return *p.tok, nil
}
// fall through to a full re-authentication
}
if err := p.authenticate(ctx); err != nil {
return tokenInfo{}, err
}
return *p.tok, nil
}
// authenticate runs the full three-legged login. Caller holds p.mu.
func (p *Plugin) authenticate(ctx context.Context) error {
if p.username == "" || p.password == "" {
return errors.New("toyota: username and password are required")
}
tokenID, err := p.performAuthentication(ctx)
if err != nil {
return err
}
code, err := p.performAuthorization(ctx, tokenID)
if err != nil {
return err
}
return p.retrieveTokens(ctx, code)
}
// performAuthentication drives the ForgeRock callback loop and returns the
// tokenId. The response object is echoed back in full each round (preserving
// authId and any fields we don't model), with only the input values filled in.
func (p *Plugin) performAuthentication(ctx context.Context) (string, error) {
var data map[string]any
for i := 0; i < 10; i++ {
if cbs, ok := data["callbacks"].([]any); ok {
for _, c := range cbs {
cb, ok := c.(map[string]any)
if !ok {
continue
}
switch typ, _ := cb["type"].(string); typ {
case "NameCallback":
if output0(cb) == "User Name" {
setInput0(cb, p.username)
}
case "PasswordCallback":
setInput0(cb, p.password)
case "TextOutputCallback":
if output0(cb) == "User Not Found" {
return "", errors.New("toyota: authentication failed — user not found")
}
}
}
}
payload := []byte("{}")
if data != nil {
b, err := json.Marshal(data)
if err != nil {
return "", fmt.Errorf("toyota: encode auth payload: %w", err)
}
payload = b
}
resp, err := p.postRaw(ctx, authenticateURL, "application/json", payload, false)
if err != nil {
return "", err
}
body, status := resp.body, resp.status
if status != http.StatusOK {
return "", fmt.Errorf("toyota: authentication failed (HTTP %d): %s", status, shorten(string(body)))
}
data = map[string]any{}
if err := json.Unmarshal(body, &data); err != nil {
return "", fmt.Errorf("toyota: decode auth response: %w", err)
}
if tid, ok := data["tokenId"].(string); ok && tid != "" {
return tid, nil
}
}
return "", errors.New("toyota: authentication failed — no tokenId after 10 rounds")
}
// performAuthorization exchanges the tokenId (as an SSO cookie) for an
// authorization code carried in the 302 Location header.
func (p *Plugin) performAuthorization(ctx context.Context, tokenID string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, authorizeURL, nil)
if err != nil {
return "", err
}
req.Header.Set("cookie", "iPlanetDirectoryPro="+tokenID)
resp, err := p.authClient.Do(req)
if err != nil {
return "", fmt.Errorf("toyota: authorize request: %w", err)
}
defer drain(resp)
if resp.StatusCode != http.StatusFound {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return "", fmt.Errorf("toyota: authorization failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
}
code := extractCode(resp.Header.Get("location"))
if code == "" {
return "", errors.New("toyota: authorization redirect carried no code")
}
return code, nil
}
// retrieveTokens exchanges the authorization code for the token set and stores
// it. Caller holds p.mu.
func (p *Plugin) retrieveTokens(ctx context.Context, code string) error {
form := url.Values{}
form.Set("client_id", "oneapp")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("grant_type", "authorization_code")
form.Set("code_verifier", "plain")
return p.tokenRequest(ctx, form)
}
// refreshTokens uses the stored refresh token to obtain a fresh access token.
// Caller holds p.mu.
func (p *Plugin) refreshTokens(ctx context.Context) error {
if p.tok == nil || p.tok.refreshToken == "" {
return errors.New("toyota: no refresh token")
}
form := url.Values{}
form.Set("client_id", "oneapp")
form.Set("redirect_uri", redirectURI)
form.Set("grant_type", "refresh_token")
form.Set("code_verifier", "plain")
form.Set("refresh_token", p.tok.refreshToken)
return p.tokenRequest(ctx, form)
}
// tokenRequest POSTs a form to the access-token endpoint and stores the result.
func (p *Plugin) tokenRequest(ctx context.Context, form url.Values) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, accessTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("authorization", basicAuth)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := p.authClient.Do(req)
if err != nil {
return fmt.Errorf("toyota: token request: %w", err)
}
defer drain(resp)
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("toyota: token request failed (HTTP %d): %s", resp.StatusCode, shorten(string(body)))
}
return p.storeTokens(body)
}
// storeTokens parses a token response, decodes the account UUID from the id
// token, and updates p.tok. Caller holds p.mu.
func (p *Plugin) storeTokens(body []byte) error {
var td struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &td); err != nil {
return fmt.Errorf("toyota: decode token response: %w", err)
}
var missing []string
if td.AccessToken == "" {
missing = append(missing, "access_token")
}
if td.IDToken == "" {
missing = append(missing, "id_token")
}
if td.RefreshToken == "" {
missing = append(missing, "refresh_token")
}
if td.ExpiresIn == 0 {
missing = append(missing, "expires_in")
}
if len(missing) > 0 {
return fmt.Errorf("toyota: token response missing fields: %s", strings.Join(missing, ", "))
}
uuid, err := jwtUUID(td.IDToken)
if err != nil {
return fmt.Errorf("toyota: %w", err)
}
lifetime := time.Duration(td.ExpiresIn)*time.Second - tokenExpiryMargin
if lifetime < 0 {
lifetime = 0
}
p.tok = &tokenInfo{
accessToken: td.AccessToken,
refreshToken: td.RefreshToken,
uuid: uuid,
expiration: time.Now().UTC().Add(lifetime),
}
return nil
}
// ---- data requests -----------------------------------------------------------
// apiRequest performs an authenticated request against the data API, retrying
// transient failures (429 and 5xx) with exponential backoff. It returns the
// response body and status; a non-2xx final status is returned as an error.
func (p *Plugin) apiRequest(ctx context.Context, method, endpoint, vin string, params url.Values) ([]byte, int, error) {
tok, err := p.ensureToken(ctx)
if err != nil {
return nil, 0, err
}
target := apiBaseURL + endpoint
if len(params) > 0 {
target += "?" + params.Encode()
}
backoffs := []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastBody []byte
var lastStatus int
for attempt := 0; attempt <= len(backoffs); attempt++ {
req, err := http.NewRequestWithContext(ctx, method, target, nil)
if err != nil {
return nil, 0, err
}
p.applyHeaders(req.Header, vin, tok)
resp, err := p.dataClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("toyota: request %s: %w", endpoint, err)
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
drain(resp)
lastBody, lastStatus = body, resp.StatusCode
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted {
return body, resp.StatusCode, nil
}
transient := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError
if !transient || attempt == len(backoffs) {
break
}
select {
case <-ctx.Done():
return nil, lastStatus, ctx.Err()
case <-time.After(backoffs[attempt]):
}
}
return lastBody, lastStatus, fmt.Errorf("toyota: request %s failed (HTTP %d): %s", endpoint, lastStatus, shorten(string(lastBody)))
}
// applyHeaders writes the full set of headers Toyota's gateway expects. Keys are
// set directly on the map (lowercase, uncanonicalized) to match the app exactly
// on HTTP/1.1; HTTP/2 lowercases all header names regardless.
func (p *Plugin) applyHeaders(h http.Header, vin string, tok tokenInfo) {
set := func(k, v string) { h[k] = []string{v} }
set("x-api-key", apiKey)
set("API_KEY", apiKey)
set("x-guid", tok.uuid)
set("guid", tok.uuid)
set("x-client-ref", hmacSHA256(clientVersion, tok.uuid))
set("x-correlationid", genUUID())
set("x-appversion", clientVersion)
set("x-channel", "ONEAPP")
set("x-brand", p.brand)
set("x-region", "EU")
set("authorization", "Bearer "+tok.accessToken)
set("user-agent", userAgent)
if p.brand == "L" {
set("x-appbrand", "L")
set("brand", "L")
}
if vin != "" {
set("vin", vin)
}
}
// ---- small helpers -----------------------------------------------------------
// rawResponse is a fully-read response used by the auth flow.
type rawResponse struct {
status int
body []byte
}
// postRaw POSTs a body and reads the whole response. When followRedirects is
// false the no-redirect authClient is used.
func (p *Plugin) postRaw(ctx context.Context, u, contentType string, body []byte, followRedirects bool) (rawResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return rawResponse{}, err
}
req.Header.Set("Content-Type", contentType)
client := p.authClient
if followRedirects {
client = p.dataClient
}
resp, err := client.Do(req)
if err != nil {
return rawResponse{}, fmt.Errorf("toyota: POST %s: %w", u, err)
}
defer drain(resp)
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return rawResponse{status: resp.StatusCode, body: b}, nil
}
// output0 returns callback.output[0].value.
func output0(cb map[string]any) string {
out, _ := cb["output"].([]any)
if len(out) == 0 {
return ""
}
m, _ := out[0].(map[string]any)
v, _ := m["value"].(string)
return v
}
// setInput0 sets callback.input[0].value in place.
func setInput0(cb map[string]any, val string) {
in, _ := cb["input"].([]any)
if len(in) == 0 {
return
}
if m, ok := in[0].(map[string]any); ok {
m["value"] = val
}
}
// extractCode pulls the ?code= parameter out of a redirect Location, tolerating
// the app's custom URI scheme (com.toyota.oneapp:/oauth2Callback?code=…).
func extractCode(location string) string {
i := strings.IndexByte(location, '?')
if i < 0 {
return ""
}
vals, err := url.ParseQuery(location[i+1:])
if err != nil {
return ""
}
return vals.Get("code")
}
// jwtUUID base64url-decodes a JWT's payload (without signature verification, as
// pytoyoda does) and returns its "uuid" claim.
func jwtUUID(idToken string) (string, error) {
parts := strings.Split(idToken, ".")
if len(parts) < 2 {
return "", errors.New("malformed id token")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
// tolerate padded encodings
if payload, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
return "", fmt.Errorf("decode id token: %w", err)
}
}
var claims struct {
UUID string `json:"uuid"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("decode id token claims: %w", err)
}
if claims.UUID == "" {
return "", errors.New("id token has no uuid claim")
}
return claims.UUID, nil
}
// hmacSHA256 returns the lowercase hex HMAC-SHA256 of message under key.
func hmacSHA256(key, message string) string {
m := hmac.New(sha256.New, []byte(key))
m.Write([]byte(message))
return hex.EncodeToString(m.Sum(nil))
}
// genUUID returns a random RFC-4122 v4 UUID string.
func genUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand should never fail; fall back to a fixed correlation id.
return "00000000-0000-4000-8000-000000000000"
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
// countVehicles best-effort extracts the vehicle count from a /vehicle/guid
// response. The payload is an array of vehicle objects under "payload".
func countVehicles(body []byte) (int, bool) {
var env struct {
Payload []json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(body, &env); err != nil || env.Payload == nil {
return 0, false
}
return len(env.Payload), true
}
// drain closes a response body after discarding any remainder so the connection
// can be reused.
func drain(resp *http.Response) {
if resp != nil && resp.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
_ = resp.Body.Close()
}
}
// shorten trims long/multiline upstream messages for health details and errors.
func shorten(s string) string {
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
if len(s) > 200 {
return s[:200] + "…"
}
return s
}
@@ -0,0 +1,124 @@
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")
}
// Required credential fields must be present.
req := map[string]bool{}
for _, f := range d.ConfigFields {
if f.Required {
req[f.Key] = true
}
}
for _, k := range []string{"username", "password"} {
if !req[k] {
t.Errorf("config field %q should be required", k)
}
}
}
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(t.TempDir()+"/plugins.json").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)
}
}