diff --git a/cmd/soundtouch-service/dual_route_equivalence_test.go b/cmd/soundtouch-service/dual_route_equivalence_test.go new file mode 100644 index 0000000..ccfacb6 --- /dev/null +++ b/cmd/soundtouch-service/dual_route_equivalence_test.go @@ -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 +} diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 09efb2b..c8c228c 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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) } diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 42f8912..02421d4 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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