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 (see task #610). 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) } }) } }