From 8370dcf5d0b2d872f66c6a0e10e19e793a3ccae0 Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 3 Oct 2026 01:10:51 +0200 Subject: [PATCH 1/7] fix(api): allow configured loopback driver probes --- go/internal/api/api_driver_secrets_test.go | 4 +- go/internal/api/api_drivers_debug.go | 64 +++++++++++++++++++-- go/internal/api/api_drivers_debug_test.go | 65 ++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/go/internal/api/api_driver_secrets_test.go b/go/internal/api/api_driver_secrets_test.go index 025d4d827..c5cb0ba21 100644 --- a/go/internal/api/api_driver_secrets_test.go +++ b/go/internal/api/api_driver_secrets_test.go @@ -165,12 +165,12 @@ func TestRejectUnsafeProbeTargetsCoversHTTP(t *testing.T) { HTTP: &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1"}}, }, } - if err := rejectUnsafeProbeTargets(cfg); err == nil { + if err := rejectUnsafeProbeTargets(cfg, ""); err == nil { t.Fatal("HTTP loopback probe should be refused") } cfg.Config["host"] = "192.168.1.10" cfg.Capabilities.HTTP.AllowedHosts = []string{"inverter.local"} - if err := rejectUnsafeProbeTargets(cfg); err != nil { + if err := rejectUnsafeProbeTargets(cfg, ""); err != nil { t.Fatalf("LAN HTTP probe refused: %v", err) } } diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index aff4dd444..322c33499 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -237,7 +237,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { resolved.ResolveDriverPaths(baseDir) cfg = resolved.Drivers[0] - if err := rejectUnsafeProbeTargets(cfg); err != nil { + if err := rejectUnsafeProbeTargets(cfg, s.configuredProbeLoopbackHost(cfg)); err != nil { writeJSON(w, 400, map[string]string{"error": err.Error()}) return } @@ -323,7 +323,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { // rejectUnsafeProbeTargets checks every host a driver test might dial: // MQTT, Modbus, config.host / config.url, and HTTP/WS/TCP allowlists. -func rejectUnsafeProbeTargets(cfg config.Driver) error { +func rejectUnsafeProbeTargets(cfg config.Driver, allowedLoopbackHost string) error { if mq := cfg.EffectiveMQTT(); mq != nil { if err := rejectUnsafeProbeHost(mq.Host); err != nil { return fmt.Errorf("mqtt host: %w", err) @@ -342,7 +342,7 @@ func rejectUnsafeProbeTargets(cfg config.Driver) error { } if u, ok := cfg.Config["url"].(string); ok && strings.TrimSpace(u) != "" { if host := hostFromProbeURL(u); host != "" { - if err := rejectUnsafeProbeHost(host); err != nil { + if err := rejectUnsafeProbeHostOrConfiguredLoopback(host, allowedLoopbackHost); err != nil { return fmt.Errorf("config.url: %w", err) } } @@ -353,7 +353,7 @@ func rejectUnsafeProbeTargets(cfg config.Driver) error { if strings.TrimSpace(h) == "" { continue } - if err := rejectUnsafeProbeHost(hostFromAllowlistEntry(h)); err != nil { + if err := rejectUnsafeProbeHostOrConfiguredLoopback(hostFromAllowlistEntry(h), allowedLoopbackHost); err != nil { return fmt.Errorf("http allowlist: %w", err) } } @@ -381,6 +381,62 @@ func rejectUnsafeProbeTargets(cfg config.Driver) error { return nil } +// configuredProbeLoopbackHost permits a test to reach a loopback URL only +// when that exact URL is already saved for the same enabled driver and Lua +// file. A probe cannot introduce a new loopback destination in its request. +func (s *Server) configuredProbeLoopbackHost(probe config.Driver) string { + if probe.Name == "" || probe.Lua == "" || probe.Config == nil { + return "" + } + current, ok := s.configuredDriver(probe.Name) + if !ok || current.Disabled || current.Lua == "" || + filepath.Clean(current.Lua) != filepath.Clean(probe.Lua) || + !sameProbeHTTPAllowlist(current.Capabilities.HTTP, probe.Capabilities.HTTP) { + return "" + } + savedURL, ok := current.Config["url"].(string) + if !ok || savedURL == "" || probe.Config["url"] != savedURL { + return "" + } + u, err := url.Parse(savedURL) + if err != nil || u.Host == "" || + (!strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https")) { + return "" + } + ip := net.ParseIP(u.Hostname()) + if ip == nil || !ip.IsLoopback() { + return "" + } + return ip.String() +} + +func sameProbeHTTPAllowlist(saved, probe *config.HTTPCapability) bool { + if (saved == nil) != (probe == nil) { + return false + } + if saved == nil { + return true + } + if len(saved.AllowedHosts) != len(probe.AllowedHosts) { + return false + } + for i := range saved.AllowedHosts { + if saved.AllowedHosts[i] != probe.AllowedHosts[i] { + return false + } + } + return true +} + +func rejectUnsafeProbeHostOrConfiguredLoopback(host, allowedLoopbackHost string) error { + host = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")) + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() && + allowedLoopbackHost != "" && ip.String() == allowedLoopbackHost { + return nil + } + return rejectUnsafeProbeHost(host) +} + func hostFromProbeURL(raw string) string { u, err := url.Parse(raw) if err != nil || u.Host == "" { diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index 45e17c3af..b0c7b4306 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -265,6 +265,71 @@ func TestHandleDriverTestRestoresMaskedSecrets(t *testing.T) { } } +func TestConfiguredProbeLoopbackHostRequiresSameEnabledDriverAndURL(t *testing.T) { + driver := config.Driver{ + Name: "audi-vag", + Lua: "/var/lib/ftw/drivers/vw_merged.lua", + Config: map[string]any{ + "url": "http://127.0.0.1:8787", + }, + } + live := &config.Config{Drivers: []config.Driver{driver}} + srv := New(&Deps{Cfg: live}) + + if got := srv.configuredProbeLoopbackHost(driver); got != "127.0.0.1" { + t.Fatalf("configured loopback host = %q, want 127.0.0.1", got) + } + + changed := driver + changed.Lua = "/var/lib/ftw/drivers/other.lua" + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("different Lua file was trusted: %q", got) + } + changed = driver + changed.Config = map[string]any{"url": "http://127.0.0.1:8080"} + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("changed URL was trusted: %q", got) + } + changed = driver + changed.Capabilities.HTTP = &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1"}} + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("changed HTTP allowlist was trusted: %q", got) + } + live.Drivers[0].Disabled = true + if got := srv.configuredProbeLoopbackHost(driver); got != "" { + t.Errorf("disabled configured driver was trusted: %q", got) + } +} + +func TestRejectUnsafeProbeTargetsAllowsOnlyConfiguredLoopbackException(t *testing.T) { + cfg := config.Driver{ + Config: map[string]any{"url": "http://127.0.0.1:8787"}, + Capabilities: config.Capabilities{ + HTTP: &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1:8787"}}, + }, + } + if err := rejectUnsafeProbeTargets(cfg, ""); err == nil { + t.Fatal("unconfigured loopback URL was accepted") + } + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err != nil { + t.Fatalf("configured loopback URL was rejected: %v", err) + } + + cfg.Config["url"] = "http://127.0.0.2:8787" + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("different loopback URL was accepted") + } + cfg.Config["url"] = "http://169.254.169.254:8787" + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("link-local URL was accepted by the loopback exception") + } + cfg.Config["url"] = "http://127.0.0.1:8787" + cfg.MQTT = &config.MQTTConfig{Host: "127.0.0.1"} + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("loopback MQTT target was accepted by the HTTP URL exception") + } +} + func TestIsSensitiveKey(t *testing.T) { sensitive := []string{ "password", "Password", "mqtt_password", "passwd", "client_secret", From c62073faf164fdf0d0a373d18f0203440c13a66a Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 3 Oct 2026 01:10:51 +0200 Subject: [PATCH 2/7] chore(changeset): note configured loopback probe fix --- .changeset/vw-loopback-probe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/vw-loopback-probe.md diff --git a/.changeset/vw-loopback-probe.md b/.changeset/vw-loopback-probe.md new file mode 100644 index 000000000..ab795345b --- /dev/null +++ b/.changeset/vw-loopback-probe.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Allow Test connection to use the exact loopback URL already saved for an enabled driver. From cc7e24cc08161845d4bc649121ef94da21f10adb Mon Sep 17 00:00:00 2001 From: segran2 Date: Fri, 2 Oct 2026 23:24:49 +0200 Subject: [PATCH 3/7] fix(api): allow configured loopback driver probes --- go/internal/api/api_drivers_debug_test.go | 65 +++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index b0c7b4306..99f75316b 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -330,6 +330,71 @@ func TestRejectUnsafeProbeTargetsAllowsOnlyConfiguredLoopbackException(t *testin } } +func TestConfiguredProbeLoopbackHostRequiresSameEnabledDriverAndURL(t *testing.T) { + driver := config.Driver{ + Name: "audi-vag", + Lua: "/var/lib/ftw/drivers/vw_merged.lua", + Config: map[string]any{ + "url": "http://127.0.0.1:8787", + }, + } + live := &config.Config{Drivers: []config.Driver{driver}} + srv := New(&Deps{Cfg: live}) + + if got := srv.configuredProbeLoopbackHost(driver); got != "127.0.0.1" { + t.Fatalf("configured loopback host = %q, want 127.0.0.1", got) + } + + changed := driver + changed.Lua = "/var/lib/ftw/drivers/other.lua" + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("different Lua file was trusted: %q", got) + } + changed = driver + changed.Config = map[string]any{"url": "http://127.0.0.1:8080"} + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("changed URL was trusted: %q", got) + } + changed = driver + changed.Capabilities.HTTP = &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1"}} + if got := srv.configuredProbeLoopbackHost(changed); got != "" { + t.Errorf("changed HTTP allowlist was trusted: %q", got) + } + live.Drivers[0].Disabled = true + if got := srv.configuredProbeLoopbackHost(driver); got != "" { + t.Errorf("disabled configured driver was trusted: %q", got) + } +} + +func TestRejectUnsafeProbeTargetsAllowsOnlyConfiguredLoopbackException(t *testing.T) { + cfg := config.Driver{ + Config: map[string]any{"url": "http://127.0.0.1:8787"}, + Capabilities: config.Capabilities{ + HTTP: &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1:8787"}}, + }, + } + if err := rejectUnsafeProbeTargets(cfg, ""); err == nil { + t.Fatal("unconfigured loopback URL was accepted") + } + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err != nil { + t.Fatalf("configured loopback URL was rejected: %v", err) + } + + cfg.Config["url"] = "http://127.0.0.2:8787" + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("different loopback URL was accepted") + } + cfg.Config["url"] = "http://169.254.169.254:8787" + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("link-local URL was accepted by the loopback exception") + } + cfg.Config["url"] = "http://127.0.0.1:8787" + cfg.MQTT = &config.MQTTConfig{Host: "127.0.0.1"} + if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { + t.Fatal("loopback MQTT target was accepted by the HTTP URL exception") + } +} + func TestIsSensitiveKey(t *testing.T) { sensitive := []string{ "password", "Password", "mqtt_password", "passwd", "client_secret", From 6438ec66a9f3e513fe3e8daaf438b321f3d85f8c Mon Sep 17 00:00:00 2001 From: Segran Date: Sat, 3 Oct 2026 01:49:08 +0200 Subject: [PATCH 4/7] fix(api): share persisted secrets with driver probes --- go/internal/api/api_drivers_debug.go | 24 ++++ go/internal/api/api_drivers_debug_test.go | 127 +++++++++++++--------- 2 files changed, 99 insertions(+), 52 deletions(-) diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 322c33499..943c6e3fa 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -267,6 +267,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { if displayName == "" { displayName = filepath.Base(cfg.Lua) } + secretOwner := displayName testName := "__test_" + safeProbeName(displayName) + "_" + strconv.FormatInt(time.Now().UnixNano(), 36) cfg.Name = testName if cfg.BatteryCapacityWh <= 0 { @@ -280,6 +281,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { reg.MQTTFactory = s.deps.DriverMQTTFactory reg.ModbusFactory = s.deps.DriverModbusFactory reg.ARPLookup = s.deps.DriverARPLookup + s.wireDriverProbeSecrets(reg, testName, secretOwner) ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() @@ -321,6 +323,28 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { } } +func driverSecretStateKey(driverName, key string) string { + return "driver_secret:" + driverName + ":" + key +} + +func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName, secretOwner string) { + if s.deps.State == nil || strings.TrimSpace(secretOwner) == "" { + return + } + ownerFor := func(driverName string) string { + if driverName == probeName { + return secretOwner + } + return driverName + } + reg.SecretOverride = func(driverName, key string) (string, bool) { + return s.deps.State.LoadConfig(driverSecretStateKey(ownerFor(driverName), key)) + } + reg.SecretPersister = func(driverName, key, value string) error { + return s.deps.State.SaveConfig(driverSecretStateKey(ownerFor(driverName), key), value) + } +} + // rejectUnsafeProbeTargets checks every host a driver test might dial: // MQTT, Modbus, config.host / config.url, and HTTP/WS/TCP allowlists. func rejectUnsafeProbeTargets(cfg config.Driver, allowedLoopbackHost string) error { diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index 99f75316b..53bd4371e 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" ) // /api/drivers/test handler-level coverage. The probe path runs a real @@ -265,68 +266,90 @@ func TestHandleDriverTestRestoresMaskedSecrets(t *testing.T) { } } -func TestConfiguredProbeLoopbackHostRequiresSameEnabledDriverAndURL(t *testing.T) { - driver := config.Driver{ - Name: "audi-vag", - Lua: "/var/lib/ftw/drivers/vw_merged.lua", - Config: map[string]any{ - "url": "http://127.0.0.1:8787", - }, - } - live := &config.Config{Drivers: []config.Driver{driver}} - srv := New(&Deps{Cfg: live}) - - if got := srv.configuredProbeLoopbackHost(driver); got != "127.0.0.1" { - t.Fatalf("configured loopback host = %q, want 127.0.0.1", got) - } - - changed := driver - changed.Lua = "/var/lib/ftw/drivers/other.lua" - if got := srv.configuredProbeLoopbackHost(changed); got != "" { - t.Errorf("different Lua file was trusted: %q", got) - } - changed = driver - changed.Config = map[string]any{"url": "http://127.0.0.1:8080"} - if got := srv.configuredProbeLoopbackHost(changed); got != "" { - t.Errorf("changed URL was trusted: %q", got) +func TestHandleDriverTestUsesOriginalDriverSecretState(t *testing.T) { + dir := t.TempDir() + luaPath := filepath.Join(dir, "oauth_probe.lua") + luaSrc := ` +function driver_init(config) + host.set_poll_interval(50) + if config and config.refresh_token == "fresh-token" then + host.emit_metric("used_fresh_token", 1) + host.persist_secret("refresh_token", "rotated-token") + else + host.emit_metric("used_stale_token", 1) + end +end +function driver_poll() end +function driver_command() end +function driver_default_mode() end +function driver_cleanup() end +` + if err := os.WriteFile(luaPath, []byte(luaSrc), 0o644); err != nil { + t.Fatalf("write lua: %v", err) } - changed = driver - changed.Capabilities.HTTP = &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1"}} - if got := srv.configuredProbeLoopbackHost(changed); got != "" { - t.Errorf("changed HTTP allowlist was trusted: %q", got) + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) } - live.Drivers[0].Disabled = true - if got := srv.configuredProbeLoopbackHost(driver); got != "" { - t.Errorf("disabled configured driver was trusted: %q", got) + t.Cleanup(func() { _ = st.Close() }) + if err := st.SaveConfig(driverSecretStateKey("myuplink", "refresh_token"), "fresh-token"); err != nil { + t.Fatalf("save secret override: %v", err) } -} -func TestRejectUnsafeProbeTargetsAllowsOnlyConfiguredLoopbackException(t *testing.T) { - cfg := config.Driver{ - Config: map[string]any{"url": "http://127.0.0.1:8787"}, - Capabilities: config.Capabilities{ - HTTP: &config.HTTPCapability{AllowedHosts: []string{"127.0.0.1:8787"}}, + live := &config.Config{Drivers: []config.Driver{{ + Name: "myuplink", + Lua: luaPath, + Config: map[string]any{ + "refresh_token": "stale-token", + }, + }}} + srv := New(&Deps{ + Cfg: live, + CfgMu: &sync.RWMutex{}, + ConfigPath: filepath.Join(dir, "config.yaml"), + State: st, + }) + body, _ := json.Marshal(map[string]any{ + "name": "myuplink", + "lua": luaPath, + "config": map[string]any{ + "refresh_token": "stale-token", }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/test", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) } - if err := rejectUnsafeProbeTargets(cfg, ""); err == nil { - t.Fatal("unconfigured loopback URL was accepted") + var resp driverProbeResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, rr.Body.String()) } - if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err != nil { - t.Fatalf("configured loopback URL was rejected: %v", err) + if !resp.OK { + t.Fatalf("probe.ok = false, error=%q (body=%s)", resp.Error, rr.Body.String()) } - - cfg.Config["url"] = "http://127.0.0.2:8787" - if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { - t.Fatal("different loopback URL was accepted") + if resp.Health == nil || !strings.HasPrefix(resp.Health.Name, "__test_myuplink_") { + t.Fatalf("probe health name = %+v, want temporary myuplink probe", resp.Health) } - cfg.Config["url"] = "http://169.254.169.254:8787" - if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { - t.Fatal("link-local URL was accepted by the loopback exception") + var usedFresh, usedStale bool + for _, m := range resp.Metrics { + switch m.Name { + case "used_fresh_token": + usedFresh = m.Value == 1 + case "used_stale_token": + usedStale = true + } } - cfg.Config["url"] = "http://127.0.0.1:8787" - cfg.MQTT = &config.MQTTConfig{Host: "127.0.0.1"} - if err := rejectUnsafeProbeTargets(cfg, "127.0.0.1"); err == nil { - t.Fatal("loopback MQTT target was accepted by the HTTP URL exception") + if !usedFresh || usedStale { + t.Fatalf("metrics = %+v, want fresh token metric only", resp.Metrics) + } + if got, ok := st.LoadConfig(driverSecretStateKey("myuplink", "refresh_token")); !ok || got != "rotated-token" { + t.Fatalf("original driver secret = %q ok=%v, want rotated-token", got, ok) + } + if _, ok := st.LoadConfig(driverSecretStateKey(resp.Health.Name, "refresh_token")); ok { + t.Fatalf("probe wrote secret under temporary name %q", resp.Health.Name) } } From b9ce94e5ab9f58c09f1b1cf8c0e2582221b9e603 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 3 Oct 2026 12:22:38 +0000 Subject: [PATCH 5/7] fix(api): limit probe secret reuse to the same driver Share persisted OAuth state with Test connection only when the probe matches the saved driver name and Lua file. An explicit new token is not overwritten or written back onto the live driver. Signed-off-by: Cursor Agent Co-authored-by: Fredrik Ahlgren --- .changeset/vw-loopback-probe.md | 2 +- go/internal/api/api_drivers_debug.go | 55 ++++++++- go/internal/api/api_drivers_debug_test.go | 135 ++++++++++++++++++++++ 3 files changed, 187 insertions(+), 5 deletions(-) diff --git a/.changeset/vw-loopback-probe.md b/.changeset/vw-loopback-probe.md index ab795345b..4e396dc77 100644 --- a/.changeset/vw-loopback-probe.md +++ b/.changeset/vw-loopback-probe.md @@ -2,4 +2,4 @@ "ftw": patch --- -Allow Test connection to use the exact loopback URL already saved for an enabled driver. +Let Test connection reuse the saved driver's OAuth tokens and its exact loopback URL. diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 943c6e3fa..4fd469f0c 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -267,7 +267,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { if displayName == "" { displayName = filepath.Base(cfg.Lua) } - secretOwner := displayName + probe := cfg testName := "__test_" + safeProbeName(displayName) + "_" + strconv.FormatInt(time.Now().UnixNano(), 36) cfg.Name = testName if cfg.BatteryCapacityWh <= 0 { @@ -281,7 +281,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { reg.MQTTFactory = s.deps.DriverMQTTFactory reg.ModbusFactory = s.deps.DriverModbusFactory reg.ARPLookup = s.deps.DriverARPLookup - s.wireDriverProbeSecrets(reg, testName, secretOwner) + s.wireDriverProbeSecrets(reg, testName, probe) ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() @@ -327,10 +327,51 @@ func driverSecretStateKey(driverName, key string) string { return "driver_secret:" + driverName + ":" + key } -func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName, secretOwner string) { - if s.deps.State == nil || strings.TrimSpace(secretOwner) == "" { +func (s *Server) sameConfiguredProbeDriver(probe config.Driver) (config.Driver, bool) { + if strings.TrimSpace(probe.Name) == "" || strings.TrimSpace(probe.Lua) == "" { + return config.Driver{}, false + } + current, ok := s.configuredDriver(probe.Name) + if !ok || current.Lua == "" || + filepath.Clean(current.Lua) != filepath.Clean(probe.Lua) { + return config.Driver{}, false + } + return current, true +} + +func probeConfigSecret(cfg config.Driver, key string) (string, bool) { + if cfg.Config == nil { + return "", false + } + raw, ok := cfg.Config[key].(string) + if !ok { + return "", false + } + value := strings.TrimSpace(raw) + if value == "" || value == maskedPlaceholder { + return "", false + } + return value, true +} + +func probePostedDifferentSecret(probe, live config.Driver, key string) bool { + posted, ok := probeConfigSecret(probe, key) + if !ok { + return false + } + saved, _ := probeConfigSecret(live, key) + return posted != saved +} + +func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, probe config.Driver) { + if s.deps.State == nil { return } + live, ok := s.sameConfiguredProbeDriver(probe) + if !ok { + return + } + secretOwner := live.Name ownerFor := func(driverName string) string { if driverName == probeName { return secretOwner @@ -338,9 +379,15 @@ func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName, secret return driverName } reg.SecretOverride = func(driverName, key string) (string, bool) { + if driverName == probeName && probePostedDifferentSecret(probe, live, key) { + return "", false + } return s.deps.State.LoadConfig(driverSecretStateKey(ownerFor(driverName), key)) } reg.SecretPersister = func(driverName, key, value string) error { + if driverName == probeName && probePostedDifferentSecret(probe, live, key) { + return nil + } return s.deps.State.SaveConfig(driverSecretStateKey(ownerFor(driverName), key), value) } } diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index 53bd4371e..3d9f4ff72 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -353,6 +353,141 @@ function driver_cleanup() end } } +func writeOAuthProbeLua(t *testing.T, dir, name string) string { + t.Helper() + luaPath := filepath.Join(dir, name) + luaSrc := ` +function driver_init(config) + host.set_poll_interval(50) + local token = "" + if config and config.refresh_token then + token = config.refresh_token + end + host.emit_metric("token_" .. token, 1) + host.persist_secret("refresh_token", "rotated-" .. token) +end +function driver_poll() end +function driver_command() end +function driver_default_mode() end +function driver_cleanup() end +` + if err := os.WriteFile(luaPath, []byte(luaSrc), 0o644); err != nil { + t.Fatalf("write lua: %v", err) + } + return luaPath +} + +func metricToken(resp driverProbeResp) string { + const prefix = "token_" + for _, m := range resp.Metrics { + if strings.HasPrefix(m.Name, prefix) && m.Value == 1 { + return strings.TrimPrefix(m.Name, prefix) + } + } + return "" +} + +func TestHandleDriverTestDoesNotShareSecretsWithDifferentLua(t *testing.T) { + dir := t.TempDir() + liveLua := writeOAuthProbeLua(t, dir, "myuplink.lua") + otherLua := writeOAuthProbeLua(t, dir, "other.lua") + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.SaveConfig(driverSecretStateKey("myuplink", "refresh_token"), "fresh-token"); err != nil { + t.Fatalf("save secret override: %v", err) + } + + live := &config.Config{Drivers: []config.Driver{{ + Name: "myuplink", + Lua: liveLua, + Config: map[string]any{"refresh_token": "stale-token"}, + }}} + srv := New(&Deps{ + Cfg: live, + CfgMu: &sync.RWMutex{}, + ConfigPath: filepath.Join(dir, "config.yaml"), + State: st, + }) + body, _ := json.Marshal(map[string]any{ + "name": "myuplink", + "lua": otherLua, + "config": map[string]any{"refresh_token": "stale-token"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/test", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + var resp driverProbeResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, rr.Body.String()) + } + if !resp.OK { + t.Fatalf("probe.ok = false, error=%q (body=%s)", resp.Error, rr.Body.String()) + } + if got := metricToken(resp); got != "stale-token" { + t.Fatalf("used token = %q, want posted stale-token, not the live driver's override", got) + } + if got, ok := st.LoadConfig(driverSecretStateKey("myuplink", "refresh_token")); !ok || got != "fresh-token" { + t.Fatalf("live secret = %q ok=%v, want unchanged fresh-token", got, ok) + } +} + +func TestHandleDriverTestKeepsExplicitReauthToken(t *testing.T) { + dir := t.TempDir() + luaPath := writeOAuthProbeLua(t, dir, "oauth_probe.lua") + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.SaveConfig(driverSecretStateKey("myuplink", "refresh_token"), "fresh-token"); err != nil { + t.Fatalf("save secret override: %v", err) + } + + live := &config.Config{Drivers: []config.Driver{{ + Name: "myuplink", + Lua: luaPath, + Config: map[string]any{"refresh_token": "stale-token"}, + }}} + srv := New(&Deps{ + Cfg: live, + CfgMu: &sync.RWMutex{}, + ConfigPath: filepath.Join(dir, "config.yaml"), + State: st, + }) + body, _ := json.Marshal(map[string]any{ + "name": "myuplink", + "lua": luaPath, + "config": map[string]any{"refresh_token": "new-account-token"}, + }) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/test", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + var resp driverProbeResp + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, rr.Body.String()) + } + if !resp.OK { + t.Fatalf("probe.ok = false, error=%q (body=%s)", resp.Error, rr.Body.String()) + } + if got := metricToken(resp); got != "new-account-token" { + t.Fatalf("used token = %q, want explicit new-account-token", got) + } + if got, ok := st.LoadConfig(driverSecretStateKey("myuplink", "refresh_token")); !ok || got != "fresh-token" { + t.Fatalf("live secret = %q ok=%v, want unchanged fresh-token", got, ok) + } +} + func TestConfiguredProbeLoopbackHostRequiresSameEnabledDriverAndURL(t *testing.T) { driver := config.Driver{ Name: "audi-vag", From 969f63298581802073bedfbe6a544c8e82e9fd3d Mon Sep 17 00:00:00 2001 From: Segran Date: Sun, 4 Oct 2026 23:18:44 +0200 Subject: [PATCH 6/7] fix(api): restart driver after probe rotates shared secret --- go/internal/api/api_drivers_debug.go | 47 +++++- go/internal/api/api_drivers_debug_test.go | 184 ++++++++++++++++++++++ 2 files changed, 225 insertions(+), 6 deletions(-) diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index 4fd469f0c..a37583419 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -11,6 +11,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net" "net/http" "net/url" @@ -21,6 +22,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "github.com/srcfl/ftw/go/internal/config" @@ -281,11 +283,23 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { reg.MQTTFactory = s.deps.DriverMQTTFactory reg.ModbusFactory = s.deps.DriverModbusFactory reg.ARPLookup = s.deps.DriverARPLookup - s.wireDriverProbeSecrets(reg, testName, probe) + probeChangedSharedSecret := s.wireDriverProbeSecrets(reg, testName, probe) ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) defer cancel() started := time.Now() + probeAdded := false + defer func() { + if probeAdded { + reg.RemoveProbe(cfg.Name) + } + if !probeChangedSharedSecret() || s.deps.Registry == nil { + return + } + if err := s.deps.Registry.RestartByName(context.Background(), probe.Name); err != nil { + slog.Warn("driver probe secret changed but restart failed", "driver", probe.Name, "err", err) + } + }() if err := reg.AddProbe(ctx, cfg); err != nil { writeJSON(w, 200, driverProbeResp{ Name: displayName, @@ -295,7 +309,7 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { }) return } - defer reg.RemoveProbe(cfg.Name) + probeAdded = true ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() @@ -363,13 +377,20 @@ func probePostedDifferentSecret(probe, live config.Driver, key string) bool { return posted != saved } -func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, probe config.Driver) { +func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, probe config.Driver) func() bool { + var mu sync.Mutex + changed := false + changedSharedSecret := func() bool { + mu.Lock() + defer mu.Unlock() + return changed + } if s.deps.State == nil { - return + return changedSharedSecret } live, ok := s.sameConfiguredProbeDriver(probe) if !ok { - return + return changedSharedSecret } secretOwner := live.Name ownerFor := func(driverName string) string { @@ -388,8 +409,22 @@ func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, if driverName == probeName && probePostedDifferentSecret(probe, live, key) { return nil } - return s.deps.State.SaveConfig(driverSecretStateKey(ownerFor(driverName), key), value) + owner := ownerFor(driverName) + stateKey := driverSecretStateKey(owner, key) + if old, ok := s.deps.State.LoadConfig(stateKey); ok && old == value { + return nil + } + if err := s.deps.State.SaveConfig(stateKey, value); err != nil { + return err + } + if driverName == probeName && owner == secretOwner { + mu.Lock() + changed = true + mu.Unlock() + } + return nil } + return changedSharedSecret } // rejectUnsafeProbeTargets checks every host a driver test might dial: diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index 3d9f4ff72..ca53615bb 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -11,7 +12,9 @@ import ( "testing" "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/drivers" "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" ) // /api/drivers/test handler-level coverage. The probe path runs a real @@ -591,3 +594,184 @@ func TestRedactDumpLog(t *testing.T) { t.Errorf("redactDumpLog dropped benign text: %q", got) } } + +func writeProbeRestartLua(t *testing.T, dir string) string { + t.Helper() + luaPath := filepath.Join(dir, "probe_restart.lua") + luaSrc := ` +function driver_init(config) + host.set_poll_interval(50) + if config and config.rotate_secret then + host.persist_secret("refresh_token", config.persist_value) + end +end +function driver_poll() end +function driver_command() end +function driver_default_mode() end +function driver_cleanup() end +` + if err := os.WriteFile(luaPath, []byte(luaSrc), 0o644); err != nil { + t.Fatalf("write lua: %v", err) + } + return luaPath +} + +func TestHandleDriverTestRestartsRunningDriverAfterRefreshTokenRotation(t *testing.T) { + dir := t.TempDir() + luaPath := writeProbeRestartLua(t, dir) + + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + const secretKey = "refresh_token" + if err := st.SaveConfig(driverSecretStateKey("myuplink", secretKey), "fresh-token"); err != nil { + t.Fatalf("save secret: %v", err) + } + + tel := telemetry.NewStore() + reg := drivers.NewRegistry(tel) + reg.SecretOverride = func(driverName, key string) (string, bool) { + return st.LoadConfig(driverSecretStateKey(driverName, key)) + } + reg.SecretPersister = func(driverName, key, value string) error { + return st.SaveConfig(driverSecretStateKey(driverName, key), value) + } + t.Cleanup(reg.ShutdownAll) + + liveDriver := config.Driver{ + Name: "myuplink", + Lua: luaPath, + Config: map[string]any{ + "refresh_token": "stale-token", + }, + } + if err := reg.Add(context.Background(), liveDriver); err != nil { + t.Fatalf("add live driver: %v", err) + } + + before, ok := reg.ControlStatus("myuplink") + if !ok { + t.Fatal("live driver missing before probe") + } + + live := &config.Config{Drivers: []config.Driver{liveDriver}} + srv := New(&Deps{ + Cfg: live, + CfgMu: &sync.RWMutex{}, + ConfigPath: filepath.Join(dir, "config.yaml"), + State: st, + Registry: reg, + }) + + body, _ := json.Marshal(map[string]any{ + "name": "myuplink", + "lua": luaPath, + "config": map[string]any{ + "refresh_token": "stale-token", + "rotate_secret": true, + "persist_value": "rotated-token", + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/test", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + + if got, ok := st.LoadConfig(driverSecretStateKey("myuplink", secretKey)); !ok || got != "rotated-token" { + t.Fatalf("persisted secret = %q ok=%v, want rotated-token", got, ok) + } + + after, ok := reg.ControlStatus("myuplink") + if !ok { + t.Fatal("live driver missing after probe") + } + if after.Generation <= before.Generation { + t.Fatalf("generation = %d after probe, want greater than %d after rotated shared secret", + after.Generation, before.Generation) + } +} + +func TestHandleDriverTestDoesNotRestartRunningDriverWhenSecretUnchanged(t *testing.T) { + dir := t.TempDir() + luaPath := writeProbeRestartLua(t, dir) + + st, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + + const secretKey = "refresh_token" + if err := st.SaveConfig(driverSecretStateKey("myuplink", secretKey), "same-token"); err != nil { + t.Fatalf("save secret: %v", err) + } + + tel := telemetry.NewStore() + reg := drivers.NewRegistry(tel) + reg.SecretOverride = func(driverName, key string) (string, bool) { + return st.LoadConfig(driverSecretStateKey(driverName, key)) + } + reg.SecretPersister = func(driverName, key, value string) error { + return st.SaveConfig(driverSecretStateKey(driverName, key), value) + } + t.Cleanup(reg.ShutdownAll) + + liveDriver := config.Driver{ + Name: "myuplink", + Lua: luaPath, + Config: map[string]any{ + "refresh_token": "stale-token", + }, + } + if err := reg.Add(context.Background(), liveDriver); err != nil { + t.Fatalf("add live driver: %v", err) + } + + before, ok := reg.ControlStatus("myuplink") + if !ok { + t.Fatal("live driver missing before probe") + } + + live := &config.Config{Drivers: []config.Driver{liveDriver}} + srv := New(&Deps{ + Cfg: live, + CfgMu: &sync.RWMutex{}, + ConfigPath: filepath.Join(dir, "config.yaml"), + State: st, + Registry: reg, + }) + + body, _ := json.Marshal(map[string]any{ + "name": "myuplink", + "lua": luaPath, + "config": map[string]any{ + "refresh_token": "stale-token", + "rotate_secret": true, + "persist_value": "same-token", + }, + }) + req := httptest.NewRequest(http.MethodPost, "/api/drivers/test", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + + after, ok := reg.ControlStatus("myuplink") + if !ok { + t.Fatal("live driver missing after probe") + } + if after.Generation != before.Generation { + t.Fatalf("generation changed from %d to %d even though shared secret was unchanged", + before.Generation, after.Generation) + } +} From 95ede972e4bbc217f8ad80be60499f7ca0c5d017 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 6 Oct 2026 09:46:59 +0200 Subject: [PATCH 7/7] fix(api): use credential owner for shared probe rotations Signed-off-by: Fredrik Ahlgren --- go/internal/api/api_drivers_debug.go | 22 ++++++++++++++-------- go/internal/api/api_drivers_debug_test.go | 21 ++++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/go/internal/api/api_drivers_debug.go b/go/internal/api/api_drivers_debug.go index a37583419..14fb4b6be 100644 --- a/go/internal/api/api_drivers_debug.go +++ b/go/internal/api/api_drivers_debug.go @@ -293,11 +293,12 @@ func (s *Server) handleDriverTest(w http.ResponseWriter, r *http.Request) { if probeAdded { reg.RemoveProbe(cfg.Name) } - if !probeChangedSharedSecret() || s.deps.Registry == nil { + restartName := probeChangedSharedSecret() + if restartName == "" || s.deps.Registry == nil { return } - if err := s.deps.Registry.RestartByName(context.Background(), probe.Name); err != nil { - slog.Warn("driver probe secret changed but restart failed", "driver", probe.Name, "err", err) + if err := s.deps.Registry.RestartByName(context.Background(), restartName); err != nil { + slog.Warn("driver probe secret changed but restart failed", "driver", restartName, "err", err) } }() if err := reg.AddProbe(ctx, cfg); err != nil { @@ -377,13 +378,17 @@ func probePostedDifferentSecret(probe, live config.Driver, key string) bool { return posted != saved } -func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, probe config.Driver) func() bool { +func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, probe config.Driver) func() string { var mu sync.Mutex changed := false - changedSharedSecret := func() bool { + restartName := "" + changedSharedSecret := func() string { mu.Lock() defer mu.Unlock() - return changed + if changed { + return restartName + } + return "" } if s.deps.State == nil { return changedSharedSecret @@ -392,7 +397,8 @@ func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, if !ok { return changedSharedSecret } - secretOwner := live.Name + restartName = live.Name + secretOwner := live.SecretOwner() ownerFor := func(driverName string) string { if driverName == probeName { return secretOwner @@ -414,7 +420,7 @@ func (s *Server) wireDriverProbeSecrets(reg *drivers.Registry, probeName string, if old, ok := s.deps.State.LoadConfig(stateKey); ok && old == value { return nil } - if err := s.deps.State.SaveConfig(stateKey, value); err != nil { + if err := s.deps.State.SaveDriverSecret(owner, key, value); err != nil { return err } if driverName == probeName && owner == secretOwner { diff --git a/go/internal/api/api_drivers_debug_test.go b/go/internal/api/api_drivers_debug_test.go index ca53615bb..bbf90a863 100644 --- a/go/internal/api/api_drivers_debug_test.go +++ b/go/internal/api/api_drivers_debug_test.go @@ -604,6 +604,8 @@ function driver_init(config) if config and config.rotate_secret then host.persist_secret("refresh_token", config.persist_value) end + host.emit_metric("probe_ready", 1) + host.emit_metric("used_rotated_token", config.refresh_token == "rotated-token" and 1 or 0) end function driver_poll() end function driver_command() end @@ -627,7 +629,7 @@ func TestHandleDriverTestRestartsRunningDriverAfterRefreshTokenRotation(t *testi t.Cleanup(func() { _ = st.Close() }) const secretKey = "refresh_token" - if err := st.SaveConfig(driverSecretStateKey("myuplink", secretKey), "fresh-token"); err != nil { + if err := st.SaveConfig(driverSecretStateKey("credential-myuplink", secretKey), "fresh-token"); err != nil { t.Fatalf("save secret: %v", err) } @@ -642,8 +644,9 @@ func TestHandleDriverTestRestartsRunningDriverAfterRefreshTokenRotation(t *testi t.Cleanup(reg.ShutdownAll) liveDriver := config.Driver{ - Name: "myuplink", - Lua: luaPath, + Name: "myuplink", + CredentialOwner: "credential-myuplink", + Lua: luaPath, Config: map[string]any{ "refresh_token": "stale-token", }, @@ -684,7 +687,7 @@ func TestHandleDriverTestRestartsRunningDriverAfterRefreshTokenRotation(t *testi t.Fatalf("status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) } - if got, ok := st.LoadConfig(driverSecretStateKey("myuplink", secretKey)); !ok || got != "rotated-token" { + if got, ok := st.LoadConfig(driverSecretStateKey("credential-myuplink", secretKey)); !ok || got != "rotated-token" { t.Fatalf("persisted secret = %q ok=%v, want rotated-token", got, ok) } @@ -696,6 +699,9 @@ func TestHandleDriverTestRestartsRunningDriverAfterRefreshTokenRotation(t *testi t.Fatalf("generation = %d after probe, want greater than %d after rotated shared secret", after.Generation, before.Generation) } + if got, _, ok := tel.LatestMetric("myuplink", "used_rotated_token"); !ok || got != 1 { + t.Fatalf("restarted driver did not read the owner's new token: %v %v", got, ok) + } } func TestHandleDriverTestDoesNotRestartRunningDriverWhenSecretUnchanged(t *testing.T) { @@ -709,7 +715,7 @@ func TestHandleDriverTestDoesNotRestartRunningDriverWhenSecretUnchanged(t *testi t.Cleanup(func() { _ = st.Close() }) const secretKey = "refresh_token" - if err := st.SaveConfig(driverSecretStateKey("myuplink", secretKey), "same-token"); err != nil { + if err := st.SaveConfig(driverSecretStateKey("credential-myuplink", secretKey), "same-token"); err != nil { t.Fatalf("save secret: %v", err) } @@ -724,8 +730,9 @@ func TestHandleDriverTestDoesNotRestartRunningDriverWhenSecretUnchanged(t *testi t.Cleanup(reg.ShutdownAll) liveDriver := config.Driver{ - Name: "myuplink", - Lua: luaPath, + Name: "myuplink", + CredentialOwner: "credential-myuplink", + Lua: luaPath, Config: map[string]any{ "refresh_token": "stale-token", },