Files
Bose-SoundTouch/pkg/testutils/tunein/handlers.go
T
Tobias GesellchenandClaude Opus 4.8 9bfe2a1a08 fix(ci): gate http-client tests on mock readiness; address semgrep findings (refs #451)
The integration suite flaked in CI: with three `go run` mocks now compiling
concurrently, the spotify/amazon mocks weren't listening within the fixed
`sleep 10`, so the registration requests at the start of the suite hit a
connection-refused and the "Account exists" assertions (and the cascading amazon
oauth token test) failed. Locally it passed because the mock builds were warm.

Replace the fixed sleep with real readiness gating:
- Add a /healthz endpoint to the spotify, amazon and tunein mocks.
- Give all four CI services (the three mocks + soundtouch-service) a compose
  healthcheck (busybox wget; all images are alpine-based), and make the service
  depend_on the mocks being service_healthy.
- `docker compose up -d --build --wait` blocks until everything is healthy, so
  the JetBrains client only runs against a fully-ready stack.

Also clear the two semgrep advisories on the new TuneIn mock:
- cmd/mock-*: annotate the intentional plaintext ListenAndServe with nosemgrep
  (throwaway loopback/CI test servers, never production).
- pkg/testutils/tunein: sanitize the query-supplied guide id to a safe charset
  before interpolating it into the JSON/XML response (raw-html-format).

make test-http-client: 73 requests, 0 failed (clean testdata, healthcheck-gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:11:24 +02:00

101 lines
3.9 KiB
Go

// Package tunein provides shared handlers for mocking the TuneIn
// (radiotime.com) upstream API, so integration tests for the BMX TuneIn
// endpoints do not depend on the live TuneIn service.
//
// It covers the OPML endpoints the service calls for station playback:
// - GET /Tune.ashx?id=<guideID>&formats=... -> stream URLs (JSON body[])
// - GET /describe.ashx?id=<guideID> -> station name + logo (OPML XML)
//
// Responses use only documentation-safe values (RFC-5737 192.0.2.0/24 hosts).
// Endpoints that are not yet mocked (navigate, search, profile contents) return
// 404 so a test that needs them fails loudly and we know to add a fixture; see
// tests/integration/http-client/TUNEIN-MOCK-MISSING.md.
package tunein
import (
"fmt"
"log"
"net/http"
"regexp"
)
// unsafeGuideIDChars matches anything outside the TuneIn guide-id charset
// (e.g. s166521, p290778, t472593281). Stripping them before the id is
// interpolated into the JSON/XML response keeps a caller from injecting markup
// or breaking the document (the input is attacker-controlled query data).
var unsafeGuideIDChars = regexp.MustCompile(`[^A-Za-z0-9._-]`)
func safeGuideID(id string) string {
return unsafeGuideIDChars.ReplaceAllString(id, "")
}
// NewTuneInHandler returns an http.Handler configured with the mocked TuneIn
// OPML endpoints.
func NewTuneInHandler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
mux.HandleFunc("/Tune.ashx", HandleTune)
mux.HandleFunc("/describe.ashx", HandleDescribe)
mux.HandleFunc("/", HandleCatchAll)
return mux
}
// HandleTune simulates TuneIn's Tune.ashx stream-resolution endpoint. The
// service parses the JSON body[] array for {url} entries
// (bmx.parseTuneInStreamBody); we return two documentation-safe stream URLs so
// the multi-stream failover path is exercised too.
func HandleTune(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
log.Printf("[TuneIn Mock] Tune.ashx id=%s formats=%s", sanitizeLog(id), sanitizeLog(r.URL.Query().Get("formats")))
if id == "" {
http.Error(w, `{"head":{"status":"400"}}`, http.StatusBadRequest)
return
}
id = safeGuideID(id)
body := fmt.Sprintf(`{"head":{"status":"200"},"body":[`+
`{"url":"http://192.0.2.20:8000/%s/stream-1.mp3","media_type":"mp3","reliability":99,"bitrate":128,"is_direct":true},`+
`{"url":"http://192.0.2.20:8000/%s/stream-2.mp3","media_type":"mp3","reliability":95,"bitrate":128,"is_direct":true}`+
`]}`, id, id)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}
// HandleDescribe simulates TuneIn's describe.ashx metadata endpoint. The service
// reads the first <outline> element's text + image attributes
// (bmx.TuneInDescribeMeta).
func HandleDescribe(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
log.Printf("[TuneIn Mock] describe.ashx id=%s", sanitizeLog(id))
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
id = safeGuideID(id)
body := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>`+
`<opml version="1">`+
`<head><title>%s</title><status>200</status></head>`+
`<body><outline type="object" text="Mock Radio %s" image="http://192.0.2.20:8000/%s/logo.png"/></body>`+
`</opml>`, id, id, id)
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
_, _ = w.Write([]byte(body))
}
// HandleCatchAll logs and 404s any TuneIn endpoint that is not mocked yet
// (navigate, search, profile contents), making the gap visible to a failing
// test rather than silently returning wrong data.
func HandleCatchAll(w http.ResponseWriter, r *http.Request) {
log.Printf("[TuneIn Mock] UNMOCKED %s %s — add a fixture (see TUNEIN-MOCK-MISSING.md)",
sanitizeLog(r.Method), sanitizeLog(r.URL.RequestURI()))
http.Error(w, "tunein mock: endpoint not implemented", http.StatusNotFound)
}