package server import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "slices" "strings" "testing" "music-server/internal/config" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" "github.com/getkin/kin-openapi/routers" "github.com/getkin/kin-openapi/routers/gorillamux" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // loadSpec loads the OpenAPI 3.0 version of the spec the server publishes. func loadSpec(t *testing.T, e *testEnv) *openapi3.T { t.Helper() rec := e.do(http.MethodGet, "/openapi-3.0.json") require.Equal(t, http.StatusOK, rec.Code) spec, err := openapi3.NewLoader().LoadFromData(rec.Body.Bytes()) require.NoError(t, err) return spec } func TestOpenAPISpecIsValid(t *testing.T) { e := newTestEnv(t) spec := loadSpec(t, e) require.NoError(t, spec.Validate(context.Background())) // The 3.1 spec is valid JSON with the same paths. var spec31 struct { OpenAPI string `json:"openapi"` Paths map[string]any `json:"paths"` } require.NoError(t, json.Unmarshal(e.do(http.MethodGet, "/openapi.json").Body.Bytes(), &spec31)) assert.Equal(t, "3.1.0", spec31.OpenAPI) assert.Len(t, spec31.Paths, spec.Paths.Len()) } func TestOpenAPIDocumentsTheV1API(t *testing.T) { spec := loadSpec(t, newTestEnv(t)) var operations []string for path, item := range spec.Paths.Map() { assert.True(t, strings.HasPrefix(path, "/api/v1/"), "only /api/v1 is documented: %s", path) for method, op := range item.Operations() { operations = append(operations, method+" "+path) public := path == "/api/v1/health" || (path == "/api/v1/token" && method == http.MethodPost) if public { assert.Nil(t, op.Security, "%s %s needs no token", method, path) } else { require.NotNil(t, op.Security, "%s %s needs a token", method, path) assert.Equal(t, openapi3.SecurityRequirements{{bearerAuthName: {}}}, *op.Security) assert.Contains(t, op.Responses.Map(), "401", "%s %s documents 401", method, path) } } } slices.Sort(operations) assert.Equal(t, []string{ "DELETE /api/v1/token", "GET /api/v1/character", "GET /api/v1/character/list", "GET /api/v1/download", "GET /api/v1/download/linux", "GET /api/v1/download/list", "GET /api/v1/download/windows", "GET /api/v1/health", "GET /api/v1/music", "GET /api/v1/music/addPlayed", "GET /api/v1/music/addQue", "GET /api/v1/music/all", "GET /api/v1/music/all/order", "GET /api/v1/music/all/random", "GET /api/v1/music/info", "GET /api/v1/music/list", "GET /api/v1/music/next", "GET /api/v1/music/previous", "GET /api/v1/music/rand", "GET /api/v1/music/rand/classic", "GET /api/v1/music/rand/low", "GET /api/v1/music/reset", "GET /api/v1/music/soundTest", "GET /api/v1/songs/{uuid}", "GET /api/v1/songs/{uuid}/file", "GET /api/v1/soundtracks", "GET /api/v1/soundtracks/{uuid}", "GET /api/v1/statistics/songs/least-played", "GET /api/v1/statistics/songs/most-played", "GET /api/v1/statistics/soundtracks/last-played", "GET /api/v1/statistics/soundtracks/least-played", "GET /api/v1/statistics/soundtracks/most-played", "GET /api/v1/statistics/soundtracks/never-played", "GET /api/v1/statistics/soundtracks/oldest-played", "GET /api/v1/statistics/summary", "GET /api/v1/sync/progress", "GET /api/v1/version", "GET /api/v1/version/history", "POST /api/v1/sync", "POST /api/v1/sync/full", "POST /api/v1/token", "POST /api/v1/token/cleanup", "PUT /api/v1/music/played", }, operations, "/sync/reset is hidden and legacy routes are not documented") assert.Equal(t, "http", spec.Components.SecuritySchemes[bearerAuthName].Value.Type) assert.Equal(t, "bearer", spec.Components.SecuritySchemes[bearerAuthName].Value.Scheme) } // specValidator checks responses against the published spec. type specValidator struct { t *testing.T e *testEnv router routers.Router seen map[string]bool } func newSpecValidator(t *testing.T, e *testEnv) *specValidator { spec := loadSpec(t, e) spec.Servers = openapi3.Servers{{URL: "http://example.com"}} router, err := gorillamux.NewRouter(spec) require.NoError(t, err) return &specValidator{t: t, e: e, router: router, seen: map[string]bool{}} } // check sends a request and validates the response status, headers and body against the spec. func (v *specValidator) check(method, path, body string, wantStatus int, headers ...string) *httptest.ResponseRecorder { v.t.Helper() rec := v.e.doBody(method, path, body, headers...) require.Equal(v.t, wantStatus, rec.Code, "%s %s: %s", method, path, rec.Body.String()) req := httptest.NewRequest(method, path, strings.NewReader(body)) route, params, err := v.router.FindRoute(req) require.NoError(v.t, err, "%s %s is not in the spec", method, path) v.seen[method+" "+route.Path] = true input := &openapi3filter.ResponseValidationInput{ RequestValidationInput: &openapi3filter.RequestValidationInput{Request: req, PathParams: params, Route: route}, Status: rec.Code, Header: rec.Header(), Options: &openapi3filter.Options{IncludeResponseStatus: true}, } input.SetBodyBytes(rec.Body.Bytes()) if ct := rec.Header().Get("Content-Type"); strings.HasPrefix(ct, "audio/") || strings.HasPrefix(ct, "image/") { input.Body = io.NopCloser(bytes.NewReader(nil)) input.Options.ExcludeResponseBody = true } assert.NoError(v.t, openapi3filter.ValidateResponse(context.Background(), input), "%s %s", method, path) return rec } func TestResponsesMatchOpenAPI(t *testing.T) { gitea := newGitea(t, http.StatusOK) e := newTestEnv(t, func(c *config.Config) { c.ReleasesURL = gitea }) v := newSpecValidator(t, e) song := e.addSong("Game A", "Song", "0123456789") songURL := "/api/v1/songs/" + song.Uuid.String() // Empty states and errors. v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusNotFound) v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music?song=x", "", http.StatusUnprocessableEntity) v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) // Music. v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/addQue", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/addPlayed", "", http.StatusOK) v.check(http.MethodPut, "/api/v1/music/played?song=0", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/info", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/list", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music?song=0", "", http.StatusPartialContent, "Range", "bytes=0-1") v.check(http.MethodGet, "/api/v1/music/next", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/previous", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/rand/low", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/rand/classic", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/soundTest", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/all", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/all/random", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/all/order", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/music/reset", "", http.StatusOK) // Library. v.check(http.MethodGet, "/api/v1/soundtracks", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/soundtracks/"+song.SoundtrackUuid.String(), "", http.StatusOK) v.check(http.MethodGet, "/api/v1/soundtracks/00000000-0000-0000-0000-000000000000", "", http.StatusNotFound) v.check(http.MethodGet, songURL, "", http.StatusOK) v.check(http.MethodGet, songURL+"/file", "", http.StatusOK) // Characters, downloads, server info. v.check(http.MethodGet, "/api/v1/character/list", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=Aloy.jpg", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/character?name=../secret.png", "", http.StatusBadRequest) v.check(http.MethodGet, "/api/v1/character?name=Missing.png", "", http.StatusNotFound) v.check(http.MethodGet, "/api/v1/download", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/download/list", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/download/windows", "", http.StatusFound) v.check(http.MethodGet, "/api/v1/download/linux", "", http.StatusNotFound) v.check(http.MethodGet, "/api/v1/health", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/version", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/version/history", "", http.StatusOK) // Statistics (empty database). for _, path := range []string{ "/soundtracks/most-played", "/soundtracks/least-played", "/soundtracks/last-played", "/soundtracks/oldest-played", "/songs/most-played", "/songs/least-played", } { v.check(http.MethodGet, "/api/v1/statistics"+path+"?limit=5", "", http.StatusOK) } v.check(http.MethodGet, "/api/v1/statistics/soundtracks/never-played", "", http.StatusOK) v.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK) // Sync, including the locked state and the result after it finishes. release := e.blockSync() v.check(http.MethodPost, "/api/v1/sync", "", http.StatusOK) v.check(http.MethodPost, "/api/v1/sync/full", "", http.StatusLocked) v.check(http.MethodGet, "/api/v1/music/rand", "", http.StatusLocked) v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) release() v.check(http.MethodGet, "/api/v1/sync/progress", "", http.StatusOK) // Tokens and authentication. auth := newTestEnv(t, withAuth("secret")) va := newSpecValidator(t, auth) va.check(http.MethodPost, "/api/v1/token", `{"password":"wrong"}`, http.StatusUnauthorized) rec := va.check(http.MethodPost, "/api/v1/token", `{"password":"secret","client_type":"api"}`, http.StatusOK) var token TokenResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &token)) bearer := []string{"Authorization", "Bearer " + token.Token} va.check(http.MethodGet, "/api/v1/version", "", http.StatusUnauthorized) va.check(http.MethodPost, "/api/v1/token/cleanup", "", http.StatusOK, bearer...) va.check(http.MethodDelete, "/api/v1/token", "", http.StatusOK, bearer...) // Statistics with data, through the canned statistics store. stats := newTestEnv(t) stats.server.stats = statsService() stats.handler = stats.server.RegisterRoutes() vs := newSpecValidator(t, stats) vs.check(http.MethodGet, "/api/v1/statistics/soundtracks/most-played", "", http.StatusOK) vs.check(http.MethodGet, "/api/v1/statistics/summary", "", http.StatusOK) // Every documented operation was checked at least once (statistics and tokens use other environments). spec := loadSpec(t, e) for path, item := range spec.Paths.Map() { for method := range item.Operations() { key := method + " " + path assert.True(t, v.seen[key] || va.seen[key] || vs.seen[key], "no response check for %s", key) } } }