package server import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "time" "music-server/internal/backend" "music-server/internal/config" "music-server/internal/db/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestMusicRandomAndQueue(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "0123456789") rec := e.do(http.MethodGet, "/api/v1/music/rand") require.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) assert.Equal(t, "0123456789", rec.Body.String()) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addPlayed").Code) var info backend.SongInfo require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/info").Body.Bytes(), &info)) assert.Equal(t, "Game A", info.Soundtrack) assert.Equal(t, "Song", info.Song) for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music?song=0"} { assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) } assert.Equal(t, http.StatusOK, e.do(http.MethodPut, "/api/v1/music/played?song=0").Code) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodPut, "/api/v1/music/played?song=x").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/reset").Code) } func TestMusicQueueID(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "x") rec := e.do(http.MethodGet, "/api/v1/music/rand") require.Equal(t, http.StatusOK, rec.Code) first := rec.Header().Get("X-Queue-Id") require.NotEmpty(t, first, "a request without a queue ID gets a new queue") require.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/addQue").Code) rec = e.do(http.MethodGet, "/api/v1/music/list") assert.Equal(t, first, rec.Header().Get("X-Queue-Id"), "the sent queue is kept") assert.Contains(t, rec.Body.String(), `"Song":"Song"`) // Another client has its own queue. other := e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", "") assert.NotEqual(t, first, other.Header().Get("X-Queue-Id")) assert.JSONEq(t, "[]", other.Body.String()) e.queueID = first rec = e.do(http.MethodGet, "/api/v1/music/reset") require.Equal(t, http.StatusOK, rec.Code) second := rec.Header().Get("X-Queue-Id") assert.NotEqual(t, first, second, "reset starts a new queue") assert.JSONEq(t, "[]", e.do(http.MethodGet, "/api/v1/music/list").Body.String()) // The old ID has ended, so it gets a new queue too. rec = e.do(http.MethodGet, "/api/v1/music/list", "X-Queue-Id", first) assert.NotEqual(t, first, rec.Header().Get("X-Queue-Id")) assert.Empty(t, e.do(http.MethodGet, "/api/v1/music/all", "X-Queue-Id", "").Header().Get("X-Queue-Id"), "endpoints that do not use the queue do not start one") } func TestMusicQueueEndsAfterSync(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "x") e.do(http.MethodGet, "/api/v1/music/rand") e.do(http.MethodGet, "/api/v1/music/addQue") before := e.queueID require.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code) e.server.sync.Wait() rec := e.do(http.MethodGet, "/api/v1/music/list") assert.NotEqual(t, before, rec.Header().Get("X-Queue-Id")) assert.JSONEq(t, "[]", rec.Body.String()) } func TestMusicSongsAreNotCached(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "x") e.do(http.MethodGet, "/api/v1/music/rand") e.do(http.MethodGet, "/api/v1/music/addQue") for _, path := range []string{"/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic", "/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/soundTest"} { rec := e.do(http.MethodGet, path) require.Equal(t, http.StatusOK, rec.Code, path) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), path) } } func TestMusicRangeRequest(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "0123456789") rec := e.do(http.MethodGet, "/api/v1/music/rand", "Range", "bytes=2-5") assert.Equal(t, http.StatusPartialContent, rec.Code) assert.Equal(t, "bytes 2-5/10", rec.Header().Get("Content-Range")) assert.Equal(t, "2345", rec.Body.String()) assert.Equal(t, "audio/mpeg", rec.Header().Get("Content-Type")) } func TestMusicEmptyStates(t *testing.T) { e := newTestEnv(t) for _, path := range []string{"/api/v1/music/next", "/api/v1/music/previous", "/api/v1/music?song=0", "/api/v1/music/rand", "/api/v1/music/rand/low", "/api/v1/music/rand/classic"} { assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } // Invalid or missing parameters fail validation. assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music?song=abc").Code) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/music").Code) rec := e.do(http.MethodGet, "/api/v1/music/list") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, "[]", rec.Body.String()) } func TestMusicSoundCheck(t *testing.T) { e := newTestEnv(t) rec := e.do(http.MethodGet, "/api/v1/music/soundTest") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "sound check", rec.Body.String()) } func TestMusicSoundtrackLists(t *testing.T) { e := newTestEnv(t) e.addSong("B Game", "Song", "b") e.addSong("A Game", "Song", "a") var ordered []string require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all/order").Body.Bytes(), &ordered)) assert.Equal(t, []string{"A Game", "B Game"}, ordered) var random []string require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/music/all").Body.Bytes(), &random)) assert.ElementsMatch(t, ordered, random) } func TestMusicDatabaseError(t *testing.T) { e := newTestEnv(t) e.store.Err = errors.New("db down") assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/rand").Code) assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/music/all/order").Code) } func TestCharacterEndpoints(t *testing.T) { e := newTestEnv(t) var list []string require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/list").Body.Bytes(), &list)) assert.Equal(t, []string{"Adol.png", "Aloy.jpg"}, list) var details []struct { Name string `json:"name"` Modified int64 `json:"modified"` } require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/character/details").Body.Bytes(), &details)) require.Len(t, details, 2) assert.Equal(t, "Adol.png", details[0].Name) assert.Equal(t, "Aloy.jpg", details[1].Name) assert.Positive(t, details[0].Modified) rec := e.do(http.MethodGet, "/api/v1/character?name=Adol.png") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/png", rec.Header().Get("Content-Type")) rec = e.do(http.MethodGet, "/api/v1/character?name=Aloy.jpg") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "image/jpeg", rec.Header().Get("Content-Type")) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/character?name=Missing.png").Code) } func TestCharacterPathTraversal(t *testing.T) { e := newTestEnv(t) assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/character?name=").Code, "name is required") for _, name := range []string{"../secret.png", "..%2Fsecret.png", "%2Fetc%2Fpasswd", "../../etc/passwd"} { rec := e.do(http.MethodGet, "/api/v1/character?name="+name) assert.Equal(t, http.StatusBadRequest, rec.Code, name) assert.NotContains(t, rec.Body.String(), "secret", name) } } func newGitea(t *testing.T, status int) string { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(status) w.Write([]byte(`{"id":1,"name":"v2.0.0","assets":[{"name":"Player.exe","browser_download_url":"https://example.com/Player.exe"}]}`)) })) t.Cleanup(server.Close) return server.URL } func TestDownloadEndpoints(t *testing.T) { url := newGitea(t, http.StatusOK) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = url }) rec := e.do(http.MethodGet, "/api/v1/download") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `"v2.0.0"`, rec.Body.String()) rec = e.do(http.MethodGet, "/api/v1/download/list") assert.JSONEq(t, `["Player.exe"]`, rec.Body.String()) rec = e.do(http.MethodGet, "/api/v1/download/windows") assert.Equal(t, http.StatusFound, rec.Code) assert.Equal(t, "https://example.com/Player.exe", rec.Header().Get("Location")) assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, "/api/v1/download/linux").Code) } func TestDownloadUpstreamDown(t *testing.T) { e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = newGitea(t, http.StatusServiceUnavailable) }) for _, path := range []string{"/api/v1/download", "/api/v1/download/list", "/api/v1/download/windows"} { assert.Equal(t, http.StatusBadGateway, e.do(http.MethodGet, path).Code, path) } } func withAuth(password string) func(*config.Config) { return func(c *config.Config) { c.DisableSessionAuth = false c.APIPassword = password } } func (e *testEnv) createToken(password string) string { e.t.Helper() rec := e.doBody(http.MethodPost, "/api/v1/token", `{"password":"`+password+`"}`) require.Equal(e.t, http.StatusOK, rec.Code, rec.Body.String()) var resp TokenResponse require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &resp)) assert.Equal(e.t, "web", resp.ClientType) assert.WithinDuration(e.t, time.Now().Add(tokenLifetime), resp.ExpiresAt, time.Minute) return resp.Token } func TestTokenAuth(t *testing.T) { e := newTestEnv(t, withAuth("secret")) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version").Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Basic abc").Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer unknown").Code) assert.Equal(t, http.StatusUnauthorized, e.doBody(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`).Code) assert.Equal(t, http.StatusBadRequest, e.doBody(http.MethodPost, "/api/v1/token", `{not json`).Code) token := e.createToken("secret") auth := []string{"Authorization", "Bearer " + token} assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodDelete, "/api/v1/token", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/version", auth...).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodDelete, "/api/v1/token").Code) } func TestTokenOpenAccessWithoutPassword(t *testing.T) { e := newTestEnv(t, withAuth("")) token := e.createToken("anything") assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer "+token).Code) } func TestTokenCleanupRequiresAuth(t *testing.T) { e := newTestEnv(t, withAuth("secret")) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodPost, "/api/v1/token/cleanup").Code) token := e.createToken("secret") e.store.Sessions["old"] = repository.Session{Token: "old", ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Hour), Valid: true}} assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/token/cleanup", "Authorization", "Bearer "+token).Code) assert.False(t, e.store.HasSession("old")) assert.True(t, e.store.HasSession(token)) } func TestExpiredTokenIsDeleted(t *testing.T) { e := newTestEnv(t, withAuth("secret")) _, err := e.store.CreateSession(context.Background(), repository.CreateSessionParams{ Token: "expired", ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(-time.Minute), Valid: true}, }) require.NoError(t, err) rec := e.do(http.MethodGet, "/api/v1/version", "Authorization", "Bearer expired") assert.Equal(t, http.StatusUnauthorized, rec.Code) assert.Contains(t, rec.Body.String(), "Token expired") // The fake refuses to delete with a cancelled context, like a real database would. assert.Eventually(t, func() bool { return !e.store.HasSession("expired") }, time.Second, 10*time.Millisecond) } func TestTokenRateLimit(t *testing.T) { e := newTestEnv(t) for range 10 { assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`).Code) } rec := e.doBody(http.MethodPost, "/api/v1/token", `{}`) assert.Equal(t, http.StatusTooManyRequests, rec.Code) assert.Contains(t, rec.Body.String(), "Rate limit exceeded") } var ( statsSoundtrackUUID = uuid.New() statsSongUUID = uuid.New() ) // statsStore returns canned statistics. type statsStore struct { repository.Querier err error } func (s statsStore) GetMostPlayedSoundtracksWithSongs(_ context.Context, limit int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { return []repository.GetMostPlayedSoundtracksWithSongsRow{{ SoundtrackID: int32(limit), SoundtrackUuid: statsSoundtrackUUID, SoundtrackName: "Game A", Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`), }}, s.err } func (s statsStore) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) { return []repository.GetAllSoundtracksWithSongsRow{{ SoundtrackUuid: statsSoundtrackUUID, SoundtrackName: "Game A", AlternativeNames: []string{"GA"}, CoverModified: pgtype.Timestamptz{Time: time.Unix(1730000000, 0), Valid: true}, SoundtrackPlayed: 4, Songs: []byte(`[{"song_uuid":"` + statsSongUUID.String() + `","song_name":"Song","times_played":3}]`), }, { SoundtrackName: "Game B", }}, s.err } func (s statsStore) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) { return repository.GetStatisticsSummaryRow{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, s.err } func statsService() *backend.StatisticsService { return backend.NewStatisticsService(statsStore{}) } func TestStatisticsEndpoints(t *testing.T) { e := newTestEnv(t) e.server.stats = backend.NewStatisticsService(statsStore{}) e.handler = e.server.RegisterRoutes() var soundtracks []backend.SoundtrackWithSongs require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played").Body.Bytes(), &soundtracks)) require.Len(t, soundtracks, 1) assert.Equal(t, int32(10), soundtracks[0].SoundtrackID, "default limit") assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) assert.Equal(t, statsSoundtrackUUID, soundtracks[0].SoundtrackUuid) assert.Equal(t, statsSongUUID, soundtracks[0].Songs[0].SongUuid) require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit=500").Body.Bytes(), &soundtracks)) assert.Equal(t, int32(100), soundtracks[0].SoundtrackID, "limit is capped") for _, limit := range []string{"0", "-1", "abc"} { assert.Equal(t, http.StatusUnprocessableEntity, e.do(http.MethodGet, "/api/v1/statistics/soundtracks/most-played?limit="+limit).Code, limit) } require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Body.Bytes(), &soundtracks)) require.Len(t, soundtracks, 2, "every soundtrack, no limit") assert.Equal(t, "Game A", soundtracks[0].SoundtrackName) assert.Equal(t, int32(4), soundtracks[0].SoundtrackPlayed) assert.Equal(t, "Song", soundtracks[0].Songs[0].SongName) assert.Empty(t, soundtracks[1].Songs, "no songs is an empty list") assert.Equal(t, []string{"GA"}, soundtracks[0].AlternativeNames) require.NotNil(t, soundtracks[0].CoverModified) assert.Equal(t, int64(1730000000), *soundtracks[0].CoverModified) assert.Equal(t, []string{}, soundtracks[1].AlternativeNames, "no names is an empty list") assert.Nil(t, soundtracks[1].CoverModified) var summary backend.StatisticsSummary require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/statistics/summary").Body.Bytes(), &summary)) assert.Equal(t, backend.StatisticsSummary{TotalSoundtracks: 5, AvgSoundtrackPlays: 1.5}, summary) e.server.stats = backend.NewStatisticsService(statsStore{err: errors.New("db down")}) e.handler = e.server.RegisterRoutes() assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/summary").Code) assert.Equal(t, http.StatusInternalServerError, e.do(http.MethodGet, "/api/v1/statistics/soundtracks").Code) }