diff --git a/API Server/internal/plugins/builtin/builtin.go b/API Server/internal/plugins/builtin/builtin.go index fcf6605..e3311a7 100644 --- a/API Server/internal/plugins/builtin/builtin.go +++ b/API Server/internal/plugins/builtin/builtin.go @@ -10,3 +10,8 @@ // Until then, plugins are added at runtime as the "external" HTTP kind, which // needs no rebuild. See ../README.md. package builtin + +import ( + // toyota — Toyota Connected Europe (MyToyota) read-only vehicle data. + _ "drivervault/apiserver/internal/plugins/builtin/toyota" +) diff --git a/API Server/internal/plugins/builtin/toyota/toyota.go b/API Server/internal/plugins/builtin/toyota/toyota.go new file mode 100644 index 0000000..9ee8138 --- /dev/null +++ b/API Server/internal/plugins/builtin/toyota/toyota.go @@ -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 +} diff --git a/API Server/internal/plugins/builtin/toyota/toyota_test.go b/API Server/internal/plugins/builtin/toyota/toyota_test.go new file mode 100644 index 0000000..bb9abb9 --- /dev/null +++ b/API Server/internal/plugins/builtin/toyota/toyota_test.go @@ -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) + } +}