Files
minstrel/internal/api/cast_token_test.go
T
bvandeusen 8cd2383a42
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 9m34s
test(server): seed track for cast-token tests + assert file-ext in URL
2026-06-04 07:44:33 -04:00

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