mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.8
parent
2dd0143e10
commit
734d13d7cb
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user