Files
DriverVault/API Server/internal/plugins/builtin/toyota/toyota_test.go
T
tajniak81andClaude Opus 4.8 5e435c5f77 Add per-user Toyota integration with a settings cascade
Let each user run the Toyota Connected plugin under their own MyToyota
credentials and enable/disable it for themselves in the Web App, while a
superadmin (and, in an organization, an org admin) can impose settings
from above. Resolution is a cascade — top wins, and a lower level only
fills fields the levels above left blank:

  - org user:      API Server (superadmin) -> org admin -> user
  - org-less user: API Server (superadmin) -> user

The MyToyota email + password resolve together as a pair from the highest
layer that supplies an email; brand resolves on its own; enablement is
strictly per-user, gated by the global master switch and the org gate.

API Server:
  - plugins.Manager gains RawConfig / HealthCheckWith / InvokeWith so the
    cascade can read global config and probe/invoke under a per-caller
    resolved config.
  - internal/api/integrations.go resolves the cascade and serves
    GET/PUT /api/integrations/toyota, POST .../health, GET .../vehicles.
    Secrets and inherited usernames are masked before leaving the server.
  - The toyota builtin's credentials are no longer required at the global
    layer, so the master switch can be enabled without global credentials.
  - setup-pocketbase.mjs adds a pluginSettings JSON field to the users and
    organizations collections (the user and org layers of the cascade).

Web App:
  - api.js gains getToyota/saveToyota/testToyota.
  - Settings grows an Integrations section: an enable toggle, credential
    fields with locked / "inherited from" states, a brand select, an
    org-scope switch for admins, and a live test-connection button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 11:08:58 +02:00

131 lines
3.7 KiB
Go

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")
}
}
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)
}
}