diff --git a/pkg/service/bmx/tunein.go b/pkg/service/bmx/tunein.go index 234f1748..13d142a5 100644 --- a/pkg/service/bmx/tunein.go +++ b/pkg/service/bmx/tunein.go @@ -214,15 +214,18 @@ func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSecti if children, ok := m["children"].([]interface{}); ok && len(children) > 0 { name, _ := m["text"].(string) + section := models.BmxNavSection{ Name: name, Items: make([]models.BmxNavItem, 0, len(children)), } + for _, child := range children { cm, ok := child.(map[string]interface{}) if !ok { continue } + childType, _ := cm["type"].(string) if childType == "audio" { section.Items = append(section.Items, tuneInNavigatePlayItem(cm)) @@ -230,7 +233,9 @@ func tuneInSectionsAshx(tuneInURI string, subsection *int) ([]models.BmxNavSecti section.Items = append(section.Items, tuneInNavigateLink(cm)) } } + sections = append(sections, section) + continue } diff --git a/pkg/service/handlers/handlers_bmx_test.go b/pkg/service/handlers/handlers_bmx_test.go index 7a0a5482..5ad8312e 100644 --- a/pkg/service/handlers/handlers_bmx_test.go +++ b/pkg/service/handlers/handlers_bmx_test.go @@ -204,7 +204,7 @@ func TestHandleTuneInToken(t *testing.T) { defer ts.Close() // Even when the speaker presents a refresh_token from a prior session, - // the handler always mints a fresh token rather than echoing the input + // the handler always mints its own token rather than echoing the input // back verbatim. payload := `{"grant_type":"refresh_token","refresh_token":"test-refresh-token"}` res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader(payload)) @@ -235,7 +235,7 @@ func TestHandleTuneInToken(t *testing.T) { t.Errorf("Expected access_token and refresh_token to match, got %q and %q", accessToken, refreshToken) } if accessToken == "test-refresh-token" { - t.Error("Expected a freshly generated token, not an echo of the request's refresh_token") + t.Error("Expected a minted token, not an echo of the request's refresh_token") } embedded, ok := resp["_embedded"].(map[string]interface{}) @@ -289,6 +289,29 @@ func TestHandleTuneInToken_Bootstrap(t *testing.T) { } } +// TestHandleTuneInToken_MalformedBodyRejected covers the request-validation +// path that stayed in place alongside the unconditional-mint fix: a body +// that isn't even valid JSON is not a normal bootstrap call (which is still +// well-formed JSON, just with an empty/absent refresh_token — see +// TestHandleTuneInToken_Bootstrap), so it should be rejected rather than +// silently minting a token anyway. +func TestHandleTuneInToken_MalformedBodyRejected(t *testing.T) { + r, _ := setupRouter("http://localhost:8001", nil) + + ts := httptest.NewServer(r) + defer ts.Close() + + res, err := http.Post(ts.URL+"/bmx/tunein/v1/token", "application/json", strings.NewReader("not json")) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusBadRequest { + t.Errorf("Expected status 400 for a malformed body, got %v", res.Status) + } +} + func TestHandleTuneInPlayback_Authorized(t *testing.T) { r, _ := setupRouter("http://localhost:8001", nil) diff --git a/pkg/service/handlers/handlers_bmx_tunein.go b/pkg/service/handlers/handlers_bmx_tunein.go index ef96e65b..e3e6939d 100644 --- a/pkg/service/handlers/handlers_bmx_tunein.go +++ b/pkg/service/handlers/handlers_bmx_tunein.go @@ -131,8 +131,30 @@ func (s *Server) HandleTuneInPlaybackPodcast(w http.ResponseWriter, r *http.Requ // ContentItem selection with INVALID_SOURCE, even though /sources reported // TUNEIN as READY (READY only reflects registry presence, not a live // account). Match HandleOrionToken's unconditional-generation shape -// instead: always mint a fresh token, regardless of what the speaker sent. -func (s *Server) HandleTuneInToken(w http.ResponseWriter, _ *http.Request) { +// instead: always mint a token, regardless of what the speaker sent. +// +// The token itself is a stable, constant value (datastore.GenerateSerialSecret +// is a pure function of the hardcoded "tunein" literal), not a fresh or +// per-device secret — it's the same value for every device and every call. +// That's fine today only because the Authorization gate is disabled for all +// TuneIn handlers (see HandleTuneInReport below) and nothing validates the +// token's uniqueness; if either of those ever changes, this would need a +// real per-device/per-session token instead. +func (s *Server) HandleTuneInToken(w http.ResponseWriter, r *http.Request) { + // The unconditional mint above means we never use the decoded values, + // but we still decode the body so a genuinely malformed request (not a + // normal bootstrap call, which is valid JSON with an empty/absent + // refresh_token) gets a 400 instead of silently succeeding. + var req struct { + GrantType string `json:"grant_type"` + RefreshToken string `json:"refresh_token"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + token := datastore.GenerateSerialSecret("tunein") resp := map[string]interface{}{