feat(service): dual-mount the admin API under /api/{setup,mgmt} (refs #451)

Route-transition step 1: add /api/setup/* and /api/mgmt/* as purely additive
aliases of the existing /setup/* and /mgmt/* admin-tier routes, registered from
one shared closure so the legacy and new paths stay byte-identical. The old
paths remain live (no-break upgrade); the admin-SPA repoint and the
old-route deprecation signal are deliberate follow-ups.

- /api/mgmt carries the same Basic Auth as /mgmt. The browser OAuth callbacks
  (/mgmt/{spotify,amazon}/callback) are externally-pinned (provider redirect
  URIs) and stay at /mgmt only — not aliased.
- /api/setup serves data only; the Stockholm setup-wizard static catch-all
  (/setup/*) stays under /setup.
- peer-probe is now part of the shared setup registration, so it is served at
  both /setup/peer-probe and /api/setup/peer-probe (previously a one-off
  top-level /setup/peer-probe route).
- New TestDualRouteEquivalence fires the same request at the old and new path
  and asserts identical status + body — the harness that guards each
  dual-routing step.

Frozen speaker contract untouched. Router golden updated.
make test-http-client: 95 requests, 0 failed. go test + golangci-lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-06-06 20:33:03 +02:00
co-authored by Claude Opus 4.8
parent 2dd0143e10
commit 734d13d7cb
3 changed files with 215 additions and 37 deletions
@@ -0,0 +1,86 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
)
// TestDualRouteEquivalence verifies the issue #451 step-1 aliasing invariant:
// each admin-tier route served at both its legacy path and the new /api/* path
// returns an identical response (same handler, same middleware). It fires the
// same request at the old and new path and asserts equal status + body.
//
// The cases use endpoints whose body does not embed per-request time/random
// values, so the only thing that can differ is the routing — which is exactly
// what we want to pin while the routes are dual-mounted.
func TestDualRouteEquivalence(t *testing.T) {
ds := datastore.NewDataStore(t.TempDir())
_ = ds.Initialize()
server := handlers.NewServer(ds, nil, "http://localhost:8000", true, false, false)
r := setupRouter(server, nil)
ts := httptest.NewServer(r)
defer ts.Close()
cases := []struct {
method string
oldPath string
newPath string
}{
{http.MethodGet, "/setup/version", "/api/setup/version"},
{http.MethodGet, "/setup/settings", "/api/setup/settings"},
{http.MethodGet, "/setup/tts/config", "/api/setup/tts/config"},
{http.MethodGet, "/setup/logging-settings", "/api/setup/logging-settings"},
{http.MethodGet, "/setup/interaction-stats", "/api/setup/interaction-stats"},
{http.MethodGet, "/setup/dns-discoveries", "/api/setup/dns-discoveries"},
// /mgmt is Basic-Auth'd; without credentials both paths must reject
// identically — that pins the auth gate is mirrored onto /api/mgmt too.
{http.MethodGet, "/mgmt/accounts/", "/api/mgmt/accounts/"},
{http.MethodGet, "/mgmt/spotify/accounts", "/api/mgmt/spotify/accounts"},
{http.MethodGet, "/mgmt/amazon/accounts", "/api/mgmt/amazon/accounts"},
}
for _, c := range cases {
t.Run(c.method+" "+c.newPath, func(t *testing.T) {
oldStatus, oldBody := doEquivReq(t, ts.URL, c.method, c.oldPath)
newStatus, newBody := doEquivReq(t, ts.URL, c.method, c.newPath)
if oldStatus != newStatus {
t.Errorf("status mismatch for %s vs %s: old=%d new=%d", c.oldPath, c.newPath, oldStatus, newStatus)
}
if !bytes.Equal(oldBody, newBody) {
t.Errorf("body mismatch for %s vs %s:\n old=%q\n new=%q", c.oldPath, c.newPath, oldBody, newBody)
}
})
}
}
func doEquivReq(t *testing.T, base, method, path string) (int, []byte) {
t.Helper()
req, err := http.NewRequest(method, base+path, nil)
if err != nil {
t.Fatalf("build request %s: %v", path, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request %s: %v", path, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body %s: %v", path, err)
}
return resp.StatusCode, body
}
+66 -37
View File
@@ -1111,12 +1111,6 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/", server.HandleRoot)
r.Get("/health", server.HandleHealth)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for
// any inbound from that IP. Used post-migration where the daemon
// caches its swUpdateUrl at boot and the active round-trip can't
// reach it without a reboot.
r.Post("/setup/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
@@ -1339,47 +1333,67 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Get("/auth", server.HandleSpeakerAuth)
})
// Management API (admin tier). Registered under both /mgmt (legacy) and
// /api/mgmt (new canonical — issue #451 route-transition step 1) from one
// shared registration so the two paths stay byte-identical; both carry the
// same Basic Auth. The browser OAuth callbacks are externally-pinned
// (provider redirect URIs) and therefore stay at /mgmt only, not aliased.
mountMgmtAuthed := func(r chi.Router) {
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
}
r.Route("/mgmt", func(r chi.Router) {
// Browser OAuth callbacks — no auth required (provider redirects the
// user's browser here directly). The authorization code is single-use,
// short-lived, and useless without the client_secret.
// short-lived, and useless without the client_secret. Not aliased under
// /api/mgmt (externally-pinned redirect URIs).
r.Get("/spotify/callback", server.HandleMgmtSpotifyCallback)
r.Get("/amazon/callback", server.HandleMgmtAmazonCallback)
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Route("/amazon", func(r chi.Router) {
r.Post("/init", server.HandleMgmtAmazonInit)
r.Post("/confirm", server.HandleMgmtAmazonConfirm)
r.Get("/accounts", server.HandleMgmtAmazonAccounts)
r.Get("/token", server.HandleMgmtAmazonToken)
r.Post("/prime", server.HandleMgmtPrimeDeviceAmazon)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
mountMgmtAuthed(r)
})
})
r.Route("/setup", func(r chi.Router) {
r.Route("/api/mgmt", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
mountMgmtAuthed(r)
})
})
// Setup / admin API (admin tier). Registered under both /setup (legacy) and
// /api/setup (new canonical) from one shared registration. The Stockholm
// setup-wizard static catch-all is a frontend concern and stays under /setup
// only — /api/setup serves data only.
mountSetupAPI := func(r chi.Router) {
r.Get("/devices", server.HandleListDiscoveredDevices)
r.Post("/devices", server.HandleAddManualDevice)
r.Delete("/devices/{deviceId}", server.HandleRemoveDevice)
@@ -1399,6 +1413,12 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/reboot/{deviceId}", server.HandleRebootDevice)
r.Get("/account-id-suggestions/{deviceId}", server.HandleAccountIDSuggestions)
r.Post("/pair-account/{deviceId}", server.HandlePairAccount)
// Passive peer-reachability probe. Registers a device IP with the
// in-process observer, nudges :8090/swUpdateCheck, and waits for any
// inbound from that IP. Used post-migration where the daemon caches its
// swUpdateUrl at boot and the active round-trip can't reach it without a
// reboot.
r.Post("/peer-probe/{deviceId}", server.HandlePeerProbe)
r.Post("/trust-ca/{deviceId}", server.HandleTrustCACert)
r.Post("/ensure-remote-services/{deviceId}", server.HandleEnsureRemoteServices)
r.Post("/remove-remote-services/{deviceId}", server.HandleRemoveRemoteServices)
@@ -1431,15 +1451,24 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
r.Post("/health/dns-path-probe", server.HandleDNSPathProbe)
r.Get("/export/diagnostic", server.HandleExportDiagnostic)
r.Get("/logs", server.HandleGetLogs)
}
// Serve Stockholm setup wizard pages for paths not matched by the management API.
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
r.Route("/setup", func(r chi.Router) {
mountSetupAPI(r)
// Serve Stockholm setup wizard pages for paths not matched by the
// management API. The Stockholm frontend has a setup/ directory that must
// be accessible at /setup/*. Frontend-only — not mirrored under /api/setup.
if stockholmHandler != nil {
r.Get("/*", stockholmHandler.HandleStatic)
r.Get("/", stockholmHandler.HandleStatic)
}
})
r.Route("/api/setup", func(r chi.Router) {
mountSetupAPI(r)
})
if stockholmHandler != nil {
stockholmHandler.Mount(r)
}
+63
View File
@@ -4,6 +4,11 @@ DELETE /accounts/{account}/devices/{device} handlers.(
DELETE /accounts/{account}/group handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
DELETE /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
DELETE /api/setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /api/setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /api/setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /api/setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /api/setup/sources/{account}/{device}/{sourceID} handlers.(*Server).HandleDeleteSource-fm
DELETE /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInDeleteFavorite-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter handlers.(*Server).HandleSiriusXMLiveAdapter-fm
DELETE /core02/svc-bmx-adapter-siriusxm-everest-eco1/prod/live-adapter/* handlers.(*Server).HandleSiriusXMLiveAdapterSubpath-fm
@@ -28,6 +33,35 @@ GET /accounts/{account}/devices/{device}/presets handlers.(
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /api/mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /api/mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /api/mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /api/mgmt/amazon/accounts handlers.(*Server).HandleMgmtAmazonAccounts-fm
GET /api/mgmt/amazon/token handlers.(*Server).HandleMgmtAmazonToken-fm
GET /api/mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /api/mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /api/mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /api/setup/account-id-suggestions/{deviceId} handlers.(*Server).HandleAccountIDSuggestions-fm
GET /api/setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /api/setup/device-summary/{deviceId} handlers.(*Server).HandleDeviceSummary-fm
GET /api/setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /api/setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /api/setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /api/setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /api/setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /api/setup/export/diagnostic handlers.(*Server).HandleExportDiagnostic-fm
GET /api/setup/health handlers.(*Server).HandleHealthChecks-fm
GET /api/setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /api/setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /api/setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /api/setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /api/setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /api/setup/logging-settings handlers.(*Server).HandleGetLoggingSettings-fm
GET /api/setup/logs handlers.(*Server).HandleGetLogs-fm
GET /api/setup/settings handlers.(*Server).HandleGetSettings-fm
GET /api/setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /api/setup/tts/config handlers.(*Server).HandleTTSConfig-fm
GET /api/setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /bmx-icons/* handlers.(*Server).HandleBmxIcons
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
@@ -119,6 +153,35 @@ POST /accounts/{account}/group handlers.(
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /api/mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /api/mgmt/amazon/confirm handlers.(*Server).HandleMgmtAmazonConfirm-fm
POST /api/mgmt/amazon/init handlers.(*Server).HandleMgmtAmazonInit-fm
POST /api/mgmt/amazon/prime handlers.(*Server).HandleMgmtPrimeDeviceAmazon-fm
POST /api/mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /api/mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /api/mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /api/mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /api/setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /api/setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /api/setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /api/setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /api/setup/health/dns-path-probe handlers.(*Server).HandleDNSPathProbe-fm
POST /api/setup/health/fix handlers.(*Server).HandleHealthFix-fm
POST /api/setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
POST /api/setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /api/setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
POST /api/setup/peer-probe/{deviceId} handlers.(*Server).HandlePeerProbe-fm
POST /api/setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /api/setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /api/setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /api/setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /api/setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /api/setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /api/setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /api/setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /api/setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /api/setup/tts/speak handlers.(*Server).HandleTTSSpeak-fm
POST /bmx/tunein/v1/favorite/{stationID} handlers.(*Server).HandleTuneInFavorite-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm