Metrics are served on METRICS_PORT (default 2112, "off" to disable), never on the public port. Besides the Go runtime and process metrics this adds: - http_requests_total and http_request_duration_seconds per route pattern, from an Echo middleware. - musicserver_songs_requested_total and musicserver_songs_played_total by pick mode (random, low, classic). The mode is now stored with each queued song (migration 000006, not released yet). - Library size, sync duration, last successful sync, broken songs from the last full sync, and the database connection pool. Refs #35, #36, #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
10 KiB
Go
256 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"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)
|
|
}
|
|
|
|
func TestCORSAllowedOrigins(t *testing.T) {
|
|
e := newTestEnv(t, func(c *config.Config) { c.CORSAllowedOrigins = []string{"https://music.example.com"} })
|
|
|
|
rec := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://music.example.com")
|
|
assert.Equal(t, http.StatusOK, rec.Code)
|
|
assert.Equal(t, "https://music.example.com", rec.Header().Get("Access-Control-Allow-Origin"))
|
|
assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "X-Queue-Id")
|
|
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials"))
|
|
|
|
preflight := e.do(http.MethodOptions, "/api/v1/music/list", "Origin", "https://music.example.com",
|
|
"Access-Control-Request-Method", "GET", "Access-Control-Request-Headers", "authorization,x-queue-id")
|
|
assert.Equal(t, http.StatusNoContent, preflight.Code)
|
|
assert.Equal(t, "https://music.example.com", preflight.Header().Get("Access-Control-Allow-Origin"))
|
|
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "X-Queue-Id")
|
|
assert.Contains(t, preflight.Header().Get("Access-Control-Allow-Headers"), "Authorization")
|
|
|
|
other := e.do(http.MethodGet, "/api/v1/music/list", "Origin", "https://evil.example.com")
|
|
assert.Empty(t, other.Header().Get("Access-Control-Allow-Origin"), "other origins get no CORS headers")
|
|
}
|
|
|
|
func TestCORSWithoutAllowedOrigins(t *testing.T) {
|
|
e := newTestEnv(t)
|
|
rec := e.do(http.MethodGet, "/api/v1/version", "Origin", "https://music.example.com")
|
|
assert.Equal(t, http.StatusOK, rec.Code, "the request itself works; the browser blocks reading it")
|
|
assert.Empty(t, rec.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
|
|
func TestMetricsAreNotServedOnTheMainPort(t *testing.T) {
|
|
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
|
|
require.NotNil(t, e.server.metrics)
|
|
for _, path := range []string{"/metrics", "/api/v1/metrics"} {
|
|
rec := e.do(http.MethodGet, path)
|
|
assert.Equal(t, http.StatusNotFound, rec.Code, path)
|
|
assert.NotContains(t, rec.Body.String(), "go_goroutines", path)
|
|
}
|
|
}
|
|
|
|
func TestMetricsOff(t *testing.T) {
|
|
assert.Nil(t, newTestEnv(t).server.metrics, "MetricsPort 0 means no metrics server")
|
|
}
|
|
|
|
func TestRequestMetricsUseRoutePatterns(t *testing.T) {
|
|
e := newTestEnv(t, func(c *config.Config) { c.MetricsPort = 2112 })
|
|
song := e.addSong("Game A", "Song", "x")
|
|
e.do(http.MethodGet, "/api/v1/songs/"+song.Uuid.String())
|
|
e.do(http.MethodGet, "/no/such/page?secret=1")
|
|
|
|
rec := httptest.NewRecorder()
|
|
e.server.metrics.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
body := rec.Body.String()
|
|
assert.Contains(t, body, `http_requests_total{code="200",method="GET",route="/api/v1/songs/:uuid"}`)
|
|
assert.NotContains(t, body, song.Uuid.String(), "IDs never become labels")
|
|
assert.NotContains(t, body, "secret", "query strings never become labels")
|
|
assert.NotContains(t, body, "/no/such/page", "unknown URLs count under the frontend's /* route")
|
|
}
|