From fd62f6fbebad33da414fb218466b4d8ca0593381 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Fri, 4 Sep 2026 20:28:51 +0200 Subject: [PATCH] fix(player): route HandleAPIDevice through stereo-pair projection The singular GET /api/control/devices/{id} bypassed the projection that HandleAPIDevices and both WebSocket frames already apply. A hidden stereo-pair member was absent from the list but still fully fetchable, unprojected, by its own id. Add deviceViewForID and return 404 for a hidden member's own id, consistent with it already being absent from the list. Found in code review of PR #665 (finding #3). --- .../soundtouchweb/device_projection.go | 10 ++++ pkg/service/soundtouchweb/handler.go | 15 +++-- pkg/service/soundtouchweb/handler_test.go | 57 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/pkg/service/soundtouchweb/device_projection.go b/pkg/service/soundtouchweb/device_projection.go index cbb5fc99..0acf3e87 100644 --- a/pkg/service/soundtouchweb/device_projection.go +++ b/pkg/service/soundtouchweb/device_projection.go @@ -76,6 +76,16 @@ func (app *WebApp) deviceViewSnapshot() map[string]deviceView { return projectDeviceEntries(app.DeviceSnapshot()) } +// deviceViewForID projects the registry into a single logical control +// target and reports whether id is currently visible in the player-facing +// inventory. A hidden stereo-pair member is not visible under its own id -- +// only its pair's master key exposes it, via StereoPair.Members. +func (app *WebApp) deviceViewForID(id string) (deviceView, bool) { + view, ok := app.deviceViewSnapshot()[id] + + return view, ok +} + func projectDeviceEntries(snapshot []DeviceEntry) map[string]deviceView { return projectCapturedDeviceEntries(captureDeviceProjectionEntries(snapshot)) } diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index bd0fe8b8..f523b848 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -294,14 +294,21 @@ func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) { go app.ConnectDeviceWebSocket(deviceID, device) } + // Route through the same stereo-pair projection as HandleAPIDevices and + // the WebSocket frames. A hidden pair member is exactly as unaddressable + // here as it is from the list -- otherwise it would be absent from + // "devices" but still fully fetchable, unprojected, by its own id. + view, visible := app.deviceViewForID(deviceID) + if !visible { + app.sendError(w, "Device not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") response := webtypes.APIResponse{ Success: true, - Data: map[string]interface{}{ - "info": device.DeviceInfo, - "status": device.Status(), - }, + Data: view, } if err := json.NewEncoder(w).Encode(response); err != nil { diff --git a/pkg/service/soundtouchweb/handler_test.go b/pkg/service/soundtouchweb/handler_test.go index 88ea9082..f6143832 100644 --- a/pkg/service/soundtouchweb/handler_test.go +++ b/pkg/service/soundtouchweb/handler_test.go @@ -163,6 +163,63 @@ func TestHandleAPIDevice(t *testing.T) { } } +func TestHandleAPIDevice_MasterIncludesStereoPairProjection(t *testing.T) { + app := NewWebApp() + group := testStereoGroup() + app.AddDevice("192.0.2.10", projectionDevice("192.0.2.10", "left-id", "Living Room", true, group).Device) + app.AddDevice("192.0.2.11", projectionDevice("192.0.2.11", "right-id", "Living Room", true, group).Device) + + req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.10", nil) + req = withChiParams(req, map[string]string{"id": "192.0.2.10"}) + w := httptest.NewRecorder() + + app.HandleAPIDevice(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response webtypes.APIResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + data, ok := response.Data.(map[string]interface{}) + if !ok { + t.Fatalf("response.Data = %#v, want a map", response.Data) + } + + if _, ok := data["stereoPair"]; !ok { + t.Fatalf("response.Data = %#v, want a stereoPair projection for the pair master", data) + } +} + +func TestHandleAPIDevice_HiddenPairMemberNotFound(t *testing.T) { + app := NewWebApp() + group := testStereoGroup() + app.AddDevice("192.0.2.10", projectionDevice("192.0.2.10", "left-id", "Living Room", true, group).Device) + app.AddDevice("192.0.2.11", projectionDevice("192.0.2.11", "right-id", "Living Room", true, group).Device) + + req := httptest.NewRequest("GET", "/api/control/devices/192.0.2.11", nil) + req = withChiParams(req, map[string]string{"id": "192.0.2.11"}) + w := httptest.NewRecorder() + + app.HandleAPIDevice(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("Expected status %d for a hidden pair member's own id, got %d", http.StatusNotFound, w.Code) + } + + var response webtypes.APIResponse + if err := json.NewDecoder(w.Body).Decode(&response); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if response.Success { + t.Fatal("Expected success=false for a hidden pair member's own id") + } +} + func TestHandleAPIControl_InvalidDevice(t *testing.T) { app := createTestApp()