fix(bmx): correct misleading docs, restore TuneIn token request validation

HandleTuneInToken's docstring described the minted token as "fresh",
but datastore.GenerateSerialSecret("tunein") is a pure function of a
hardcoded literal -- it returns the identical value for every device
and every call, not a per-session secret. Correct the framing and
document why the constant value is safe today (Authorization gate
disabled for all TuneIn handlers, nothing validates uniqueness), so a
future change relying on per-device uniqueness doesn't get misled.

Also restore request-body validation dropped when the handler stopped
using the body's values: a genuine bootstrap call is still well-formed
JSON (confirmed against a captured real request), just with an empty
refresh_token, so decoding-but-discarding the body still rejects only
truly malformed requests with 400, without reintroducing the original
echo bug.

Also fixes 4 pre-existing wsl_v5 lint findings in the reordered
children-check in bmx/tunein.go (whitespace only, no behavior change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-08-29 17:46:52 +02:00
co-authored by Claude Sonnet 5
parent 22d77c23f3
commit cff94d5d96
3 changed files with 54 additions and 4 deletions
+5
View File
@@ -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
}
+25 -2
View File
@@ -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)
+24 -2
View File
@@ -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{}{