Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
5.3 KiB
Go
169 lines
5.3 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// nonExistentTrackUUID is used by tests that exercise paths which don't
|
|
// require the track to actually exist (auth/UUID-shape rejection).
|
|
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
|
|
|
|
func TestCastStreamToken_HappyPath(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "alice", "hunter2", false)
|
|
artist := seedArtist(t, pool, "Artist")
|
|
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
|
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
|
trackID := uuidToString(track.ID)
|
|
h.streamSecret = []byte("cast-token-test-secret")
|
|
|
|
body, err := json.Marshal(castTokenRequest{
|
|
TrackID: trackID,
|
|
ExpSeconds: 3600,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withUser(req, user)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.handleCastStreamToken(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp castTokenResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Token == "" || resp.Exp == 0 || resp.URL == "" {
|
|
t.Fatalf("empty fields in response: %+v", resp)
|
|
}
|
|
if !strings.Contains(resp.URL, "token="+resp.Token) {
|
|
t.Fatalf("URL missing token query: %s", resp.URL)
|
|
}
|
|
if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
|
|
t.Fatalf("URL missing stream path: %s", resp.URL)
|
|
}
|
|
// Stream URL must carry a file extension so Sonos's URL probe can
|
|
// identify the audio format. Track seeded above is
|
|
// .flac via seedTrack's default file_format.
|
|
if !strings.Contains(resp.URL, "/stream.flac?") {
|
|
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
|
|
}
|
|
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
|
|
t.Fatal("returned token does not verify")
|
|
}
|
|
}
|
|
|
|
func TestCastStreamToken_RejectsBadUUID(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "alice", "hunter2", false)
|
|
h.streamSecret = []byte("cast-token-test-secret")
|
|
|
|
body, err := json.Marshal(castTokenRequest{TrackID: "not-a-uuid"})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withUser(req, user)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.handleCastStreamToken(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
|
|
h, _ := testHandlers(t)
|
|
h.streamSecret = []byte("cast-token-test-secret")
|
|
|
|
body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// NO withUser — handler must reject via requireUser.
|
|
w := httptest.NewRecorder()
|
|
|
|
h.handleCastStreamToken(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
user := seedUser(t, pool, "alice", "hunter2", false)
|
|
artist := seedArtist(t, pool, "Artist")
|
|
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
|
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
|
trackID := uuidToString(track.ID)
|
|
h.streamSecret = []byte("cast-token-test-secret")
|
|
|
|
// Request 1 second (below min 60), expect clamp to 60s.
|
|
body, err := json.Marshal(castTokenRequest{
|
|
TrackID: trackID,
|
|
ExpSeconds: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
before := time.Now().Unix()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = withUser(req, user)
|
|
w := httptest.NewRecorder()
|
|
|
|
h.handleCastStreamToken(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp castTokenResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
// Allow a small jitter window around the clamp target (60s).
|
|
delta := resp.Exp - before
|
|
if delta < 55 || delta > 70 {
|
|
t.Fatalf("expected ~60s expiry after clamp, got delta=%ds", delta)
|
|
}
|
|
}
|
|
|
|
func TestClampExpSeconds(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in int
|
|
want int
|
|
}{
|
|
{"zero defaults to 6h", 0, castTokenDefaultExp},
|
|
{"negative defaults to 6h", -1, castTokenDefaultExp},
|
|
{"below min clamps up", 30, castTokenMinExpSeconds},
|
|
{"exact min passes through", castTokenMinExpSeconds, castTokenMinExpSeconds},
|
|
{"mid-range passes through", 3600, 3600},
|
|
{"exact max passes through", castTokenMaxExpSeconds, castTokenMaxExpSeconds},
|
|
{"above max clamps down", castTokenMaxExpSeconds + 1, castTokenMaxExpSeconds},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := clampExpSeconds(tc.in); got != tc.want {
|
|
t.Fatalf("clampExpSeconds(%d) = %d, want %d", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|