package server import ( "context" "encoding/json" "net" "net/http" "path/filepath" "sync/atomic" "testing" "time" "music-server/internal/backend" "music-server/internal/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // blockSync replaces the sync service with one that keeps running until the returned function is called. func (e *testEnv) blockSync() (release func()) { ch := make(chan struct{}) e.server.sync = backend.NewSyncService(e.store, e.cfg.MusicPath, func(context.Context) { <-ch }) e.handler = e.server.RegisterRoutes() return func() { close(ch); e.server.sync.Wait() } } func TestDestructiveSyncEndpointsIgnoreGet(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") for _, path := range []string{"/api/v1/sync", "/api/v1/sync/full", "/api/v1/sync/reset"} { // GET falls through to the frontend's static files, which answer 404. assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } assert.False(t, e.server.sync.IsSyncing(), "no sync was started") assert.Len(t, e.store.Songs, 1, "nothing was deleted") } func TestSyncStartAndLock(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") release := e.blockSync() assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/full").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, "/api/v1/sync/reset").Code) assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, "/api/v1/music/rand").Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/info").Code, "info is available during sync") progress := e.do(http.MethodGet, "/api/v1/sync/progress") assert.Equal(t, http.StatusOK, progress.Code) assert.Contains(t, progress.Body.String(), `"progress"`) release() assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/music/all").Code) result := e.do(http.MethodGet, "/api/v1/sync/progress") assert.Contains(t, result.Body.String(), `"total_time"`) } func TestSyncResetEndpoint(t *testing.T) { e := newTestEnv(t) e.addSong("Game A", "Song", "a") assert.Equal(t, http.StatusOK, e.do(http.MethodPost, "/api/v1/sync/reset").Code) assert.Empty(t, e.store.Soundtracks) assert.Empty(t, e.store.Songs) } func TestLegacyRoutesAreRemoved(t *testing.T) { e := newTestEnv(t) for _, path := range []string{"/version", "/health", "/music/list", "/music/rand", "/characters", "/sync/progress"} { assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, path).Code, path) } assert.Equal(t, http.StatusMethodNotAllowed, e.do(http.MethodPost, "/sync").Code) assert.False(t, e.server.sync.IsSyncing()) } func TestVersionEndpoints(t *testing.T) { e := newTestEnv(t) var latest backend.VersionData require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version").Body.Bytes(), &latest)) assert.Equal(t, backend.GetLatestVersion(), latest) var history []backend.VersionData require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/api/v1/version/history").Body.Bytes(), &history)) assert.Equal(t, backend.GetVersionHistory(), history) } func TestHealthEndpoint(t *testing.T) { e := newTestEnv(t) rec := e.do(http.MethodGet, "/api/v1/health") assert.Equal(t, http.StatusOK, rec.Code) assert.JSONEq(t, `{"status":"up"}`, rec.Body.String()) } func TestSyncAliasesAreRemoved(t *testing.T) { e := newTestEnv(t) for _, path := range []string{"/api/v1/sync/new", "/api/v1/sync/quick", "/api/v1/sync/new/full"} { assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, path).Code, path) } assert.False(t, e.server.sync.IsSyncing()) } func TestDocsAndSpecLinks(t *testing.T) { e := newTestEnv(t) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/docs").Code) for _, path := range []string{"/openapi.json", "/openapi.yaml", "/openapi-3.0.json"} { assert.Equal(t, http.StatusOK, e.do(http.MethodGet, path).Code, path) } rec := e.do(http.MethodGet, "/openapi") assert.Equal(t, http.StatusMovedPermanently, rec.Code) assert.Equal(t, "/openapi.json", rec.Header().Get("Location")) assert.Equal(t, "/docs", e.do(http.MethodGet, "/swagger/index.html").Header().Get("Location")) } func TestSearchPageAndFrontend(t *testing.T) { e := newTestEnv(t) e.addSong("Kingdom Hearts", "Dearly Beloved", "x") assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/search").Code) rec := e.do(http.MethodPost, "/find") assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Kingdom Hearts") rec = e.do(http.MethodGet, "/") assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "frontend") } func TestFrontendConfigHasAPIPassword(t *testing.T) { e := newTestEnv(t, func(c *config.Config) { c.APIPassword = `pa'ss"word` }) writeFile(t, filepath.Join(e.cfg.FrontendPath, "config.js"), "stale") rec := e.do(http.MethodGet, "/config.js") assert.Equal(t, http.StatusOK, rec.Code) assert.Equal(t, "text/javascript; charset=utf-8", rec.Header().Get("Content-Type")) assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) assert.Equal(t, `window.__RUNTIME_CONFIG__ = {"API_HOSTNAME":"","API_PASSWORD":"pa'ss\"word"};`+"\n", rec.Body.String()) } func TestShutdownWaitsForRequestsBeforeClosingDatabase(t *testing.T) { e := newTestEnv(t) started := make(chan struct{}) release := make(chan struct{}) var requestDone atomic.Bool e.server.httpServer.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started) <-release requestDone.Store(true) }) var closedAfterRequest atomic.Bool e.db.onClose = func() { closedAfterRequest.Store(requestDone.Load()) } ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) go e.server.httpServer.Serve(ln) responded := make(chan error, 1) go func() { resp, err := http.Get("http://" + ln.Addr().String()) if err == nil { resp.Body.Close() } responded <- err }() <-started shutdownDone := make(chan error, 1) go func() { shutdownDone <- e.server.Shutdown(context.Background()) }() time.Sleep(50 * time.Millisecond) assert.False(t, e.db.closed.Load(), "database must stay open while a request is running") close(release) require.NoError(t, <-responded) require.NoError(t, <-shutdownDone) assert.True(t, e.db.closed.Load()) assert.True(t, closedAfterRequest.Load()) } func TestTrustedProxies(t *testing.T) { // Without trusted proxies the forwarded header is ignored, so all requests share one bucket. e := newTestEnv(t) for i := range 10 { e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0."+string(rune('0'+i))) } assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.1.1").Code) // httptest requests come from 192.0.2.1, which is trusted here. e = newTestEnv(t, func(c *config.Config) { _, trusted, _ := net.ParseCIDR("192.0.2.0/24") c.TrustedProxies = []*net.IPNet{trusted} }) for range 10 { e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1") } assert.Equal(t, http.StatusTooManyRequests, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.1").Code) assert.Equal(t, http.StatusOK, e.doBody(http.MethodPost, "/api/v1/token", `{}`, "X-Forwarded-For", "10.0.0.2").Code) }