Files
MusicServer/internal/server/routes_test.go
T
Sansan fc7d787014
CI / docker (push) Successful in 47s
CI / integration (push) Successful in 17s
CI / test (push) Successful in 21s
CI / publish (push) Skipped
Serve the frontend's config.js with the API password
The bundled frontend gets a token with API_PASSWORD from config.js. The server now
generates config.js from its own config, so setting API_PASSWORD on the server
container is enough.
2026-10-06 19:01:17 +02:00

216 lines
7.6 KiB
Go

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 _, prefix := range []string{"", "/api/v1"} {
for _, path := range []string{"/sync", "/sync/full", "/sync/reset"} {
// GET falls through to the frontend's static files, which answer 404.
assert.Equal(t, http.StatusNotFound, e.do(http.MethodGet, prefix+path).Code, prefix+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) {
for _, prefix := range []string{"", "/api/v1"} {
t.Run("prefix="+prefix, func(t *testing.T) {
e := newTestEnv(t)
e.addSong("Game A", "Song", "a")
release := e.blockSync()
assert.Equal(t, http.StatusOK, e.do(http.MethodPost, prefix+"/sync").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/full").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodPost, prefix+"/sync/reset").Code)
assert.Equal(t, http.StatusLocked, e.do(http.MethodGet, prefix+"/music/rand").Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, prefix+"/music/info").Code, "info is available during sync")
progress := e.do(http.MethodGet, prefix+"/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, prefix+"/music/all").Code)
result := e.do(http.MethodGet, prefix+"/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, "/sync/reset").Code)
assert.Empty(t, e.store.Soundtracks)
assert.Empty(t, e.store.Songs)
}
func TestLegacyRoutesAreDeprecated(t *testing.T) {
e := newTestEnv(t)
rec := e.do(http.MethodGet, "/version")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "true", rec.Header().Get("Deprecation"))
rec = e.do(http.MethodGet, "/api/v1/version")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, rec.Header().Get("Deprecation"))
}
func TestVersionEndpoints(t *testing.T) {
e := newTestEnv(t)
var latest backend.VersionData
require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/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, "/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 _, prefix := range []string{"", "/api/v1"} {
for _, path := range []string{"/sync/new", "/sync/quick", "/sync/new/full"} {
assert.NotEqual(t, http.StatusOK, e.do(http.MethodPost, prefix+path).Code, prefix+path)
}
}
assert.False(t, e.server.sync.IsSyncing())
}
func TestV1HealthEndpoint(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 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)
}