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).
This commit is contained in:
Tobias Gesellchen
2026-09-04 21:13:44 +02:00
parent 78c847f929
commit fd62f6fbeb
3 changed files with 78 additions and 4 deletions
@@ -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))
}
+11 -4
View File
@@ -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 {
+57
View File
@@ -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()