diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 7f6579c..0211d9b 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -939,6 +939,11 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Route("/device", func(r chi.Router) { r.Post("/", server.HandleMargeAddDevice) r.Post("/{device}", server.HandleMargeAddDevice) + // PUT is the rename / update path — speakers fire + // this against PUT /streaming/account/{a}/device/{d} + // when the user renames via Bose App or + // `soundtouch-cli name set`. Issue #285. + r.Put("/{device}", server.HandleMargeUpdateDevice) }) r.Route("/device/{device}", func(r chi.Router) { diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index ad937ac..4995f77 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -155,5 +155,6 @@ POST /streaming/support/power_on handlers.( POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm +PUT /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeUpdateDevice-fm PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm diff --git a/pkg/service/handlers/handlers_marge.go b/pkg/service/handlers/handlers_marge.go index c7162ee..b3d2da4 100644 --- a/pkg/service/handlers/handlers_marge.go +++ b/pkg/service/handlers/handlers_marge.go @@ -600,6 +600,62 @@ func (s *Server) HandleMargeAddDevice(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(data) } +// HandleMargeUpdateDevice handles the speaker's rename PUT against +// /streaming/account/{account}/device/{device}. The speaker fires +// this whenever the user renames it via the Bose App or via +// `soundtouch-cli name set`; before this handler existed AfterTouch +// returned 502, the speaker retried in a loop, and the App showed +// the rename hanging indefinitely (issue #285). +// +// The expected payload mirrors the POST shape: +// +// NEWDEVID +// +// AddDeviceToAccount is already an upsert via ds.SaveDeviceInfo, so +// rather than introduce a parallel UpdateDevice function we route +// the PUT through the same persistence path. The semantic delta is +// purely in the HTTP envelope: 200 (not 201), no Location header, +// and the deviceID in the body has to match the URL — a mismatch +// means the speaker is targeting the wrong record and we refuse +// rather than silently re-key. +func (s *Server) HandleMargeUpdateDevice(w http.ResponseWriter, r *http.Request) { + account := chi.URLParam(r, "account") + if !validatePathID(account) { + http.Error(w, "Invalid account ID", http.StatusBadRequest) + return + } + + device := chi.URLParam(r, "device") + if !validatePathID(device) { + http.Error(w, "Invalid device ID", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusInternalServerError) + return + } + + bodyDeviceID, data, err := marge.AddDeviceToAccount(s.ds, account, body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if bodyDeviceID != device { + http.Error(w, + fmt.Sprintf("device ID in body (%q) does not match URL (%q)", bodyDeviceID, device), + http.StatusBadRequest) + + return + } + + w.Header().Set("Content-Type", "application/vnd.bose.streaming-v1.2+xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} + // HandleMargeRemovePreset removes a preset for the specified account and device. func (s *Server) HandleMargeRemovePreset(w http.ResponseWriter, r *http.Request) { account := chi.URLParam(r, "account") diff --git a/pkg/service/handlers/issue285_regression_test.go b/pkg/service/handlers/issue285_regression_test.go new file mode 100644 index 0000000..63c538a --- /dev/null +++ b/pkg/service/handlers/issue285_regression_test.go @@ -0,0 +1,208 @@ +package handlers + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/models" + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// TestIssue285_RenamePutAcceptedAndPersisted reproduces the rename +// loop documented in issue #285: +// +// https://github.com/gesellix/Bose-SoundTouch/issues/285 +// +// When a user renames an ST10 via the Bose App or via +// `soundtouch-cli name set`, the speaker fires PUT +// /streaming/account/{accountID}/device/{deviceID} with a body of +// the form: +// +// NEW +// +// Before this commit the router only registered POST for that path; +// PUT fell through to the chi router's default handling and the +// speaker observed HTTP 502 (captured verbatim in +// _/i285/Rename.log:38: "SimpleURLFetcher: retry needed, Curl 0, +// http 502, retries remaining 0"). The speaker retried in a loop +// and the Bose App showed the rename spinning indefinitely. +// +// The fixture at testdata/issue285/rename_request.xml is the exact +// payload from the log (line 36) — `deviceid="884AEAEEBD27"`, +// `Wohnzimmer SB`. The test: +// +// 1. Pre-seeds the datastore with a device record under the +// reporter's accountID + deviceID so the PUT is updating, not +// creating. +// 2. Replays the rename PUT. +// 3. Asserts: +// - HTTP 200 (NOT 201; this is an update, not a create — speakers +// observed 502 before, so any 2xx is the headline fix, but +// pinning 200 protects against accidentally returning 201 +// which would change the Location-header contract). +// - Response body carries the new name verbatim. +// - Persisted Sources/DeviceInfo on disk reflects the new name. +// +// When future work decides to preserve `createdOn` across updates +// (currently AddDeviceToAccount rewrites both timestamps), update +// the test to also assert that — the rename request from the log +// does NOT carry a createdOn, so any value our marge response +// emits is purely our choice and should be stable. +func TestIssue285_RenamePutAcceptedAndPersisted(t *testing.T) { + tempDir, err := os.MkdirTemp("", "issue285-") + if err != nil { + t.Fatalf("mkdir temp: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := datastore.NewDataStore(tempDir) + _ = ds.Initialize() + + const ( + accountID = "3981561" + deviceID = "884AEAEEBD27" + oldName = "Wohnzimmer" + newName = "Wohnzimmer SB" + ) + + // 1. Seed datastore with the device under its original name — + // modelling a pre-existing paired device the user is now + // renaming. + if err := ds.SaveDeviceInfo(accountID, deviceID, &models.ServiceDeviceInfo{ + DeviceID: deviceID, + AccountID: accountID, + Name: oldName, + IPAddress: "192.168.0.109", + }); err != nil { + t.Fatalf("seed datastore: %v", err) + } + + // 2. Spin up the router and replay the captured rename PUT. + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + + t.Cleanup(ts.Close) + + body, err := os.ReadFile(filepath.Join("testdata", "issue285", "rename_request.xml")) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + + // Sanity-check the fixture before trusting any downstream + // assertion against it. + if !bytes.Contains(body, []byte(`deviceid="`+deviceID+`"`)) { + t.Fatalf("fixture missing expected deviceid=%q; got:\n%s", deviceID, body) + } + + if !bytes.Contains(body, []byte(``+newName+``)) { + t.Fatalf("fixture missing expected new name %q; got:\n%s", newName, body) + } + + req, err := http.NewRequest(http.MethodPut, + ts.URL+"/streaming/account/"+accountID+"/device/"+deviceID, + bytes.NewReader(body)) + if err != nil { + t.Fatalf("build request: %v", err) + } + + req.Header.Set("Content-Type", "application/xml") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // 3. Headline assertion: the speaker observed 502 before — any + // 2xx fixes the loop. Pin 200 specifically so we don't drift + // into 201/Created (which would change the Location-header + // contract POST gets). + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("PUT status = %d, want 200; body:\n%s", resp.StatusCode, respBody) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read response: %v", err) + } + + // Response shape: NEW + if !bytes.Contains(respBody, []byte(`deviceid="`+deviceID+`"`)) { + t.Errorf("response missing deviceid=%q; body:\n%s", deviceID, respBody) + } + + if !bytes.Contains(respBody, []byte(``+newName+``)) { + t.Errorf("response missing new name %q; body:\n%s", newName, respBody) + } + + if strings.Contains(string(respBody), ``+oldName+``) { + t.Errorf("response still carries old name %q; body:\n%s", oldName, respBody) + } + + // 4. Persistence assertion: the datastore now reflects the new + // name. This is what the Bose App reads back on its next + // /streaming/account/.../full poll, which is what closes the + // visible rename loop. + persisted, err := ds.GetDeviceInfo(accountID, deviceID) + if err != nil { + t.Fatalf("read persisted device info: %v", err) + } + + if persisted.Name != newName { + t.Errorf("persisted Name = %q, want %q", persisted.Name, newName) + } +} + +// TestIssue285_RenamePutRejectsMismatchedDeviceID pins the safety +// check: if the speaker (or a bug elsewhere) ever sends a PUT with +// a body whose `deviceid="…"` doesn't match the URL's `{device}` +// segment, we refuse with 400 rather than silently re-key the +// persisted record under the wrong account/device. +func TestIssue285_RenamePutRejectsMismatchedDeviceID(t *testing.T) { + tempDir, err := os.MkdirTemp("", "issue285-mismatch-") + if err != nil { + t.Fatalf("mkdir temp: %v", err) + } + defer os.RemoveAll(tempDir) + + ds := datastore.NewDataStore(tempDir) + _ = ds.Initialize() + + r, _ := setupRouter("http://localhost:8001", ds) + ts := httptest.NewServer(r) + + t.Cleanup(ts.Close) + + const urlDeviceID = "884AEAEEBD27" + + // Body claims a different deviceID than the URL. + body := []byte(`` + + `RogueDEADBEEFCAFE`) + + req, err := http.NewRequest(http.MethodPut, + ts.URL+"/streaming/account/3981561/device/"+urlDeviceID, + bytes.NewReader(body)) + if err != nil { + t.Fatalf("build request: %v", err) + } + + req.Header.Set("Content-Type", "application/xml") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + respBody, _ := io.ReadAll(resp.Body) + t.Fatalf("PUT status = %d, want 400; body:\n%s", resp.StatusCode, respBody) + } +} diff --git a/pkg/service/handlers/main_test.go b/pkg/service/handlers/main_test.go index 87bcb19..373c16f 100644 --- a/pkg/service/handlers/main_test.go +++ b/pkg/service/handlers/main_test.go @@ -47,6 +47,8 @@ func setupRouter(targetURL string, ds *datastore.DataStore) (*chi.Mux, *Server) r.Route("/account/{account}/device", func(r chi.Router) { r.Post("/", server.HandleMargeAddDevice) r.Post("/{device}", server.HandleMargeAddDevice) + // Rename PUT — mirrors the production router. Issue #285. + r.Put("/{device}", server.HandleMargeUpdateDevice) }) r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents) r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent) diff --git a/pkg/service/handlers/testdata/issue285/rename_request.xml b/pkg/service/handlers/testdata/issue285/rename_request.xml new file mode 100644 index 0000000..19a720e --- /dev/null +++ b/pkg/service/handlers/testdata/issue285/rename_request.xml @@ -0,0 +1 @@ +Wohnzimmer SB884AEAEEBD27