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 (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)
|
|
}
|
|
})
|
|
}
|
|
}
|