Files
summercms/modules/surf/apidocs_test.go
Jakub Zych 055f51c94f test(19.3-05): surf CORS, profiles, api-docs and store lookup; cabana reorder hook
- cors_profile_test.go: credentials with * refused (global and profile),
  pattern errors, anchored wildcard origins, Vary: Origin on dynamic
  answers, api/www credentialed case, unchanged * answers, profile
  preflight routes and boot errors, turnstile factory params
- apidocs_test.go: off by default, pinned 5.33.1 page with the RESEARCH
  SRI hashes and nonce CSP, ordered compact JSON, provider count, path
  validation, escaped title
- store_lookup_test.go: MemoryStore fallback, published Store, no dial
- reorder_hook_test.go (Postgres): hook sees the new order in the tx,
  its error rolls back, no hook answers unchanged
- gate: surf names in the go stage, cabana names in the docker stage
2026-10-15 19:30:52 +02:00

295 lines
11 KiB
Go

package surf
import (
"errors"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Unit tests for the opt-in /api-docs page (D-16, D-17) of phase 19.3.
// specPlugin supplies a fixed spec (or error) for /api-docs.
type specPlugin struct {
id string
spec string
err error
}
func (p specPlugin) ID() string { return p.id }
func (p specPlugin) Requires() []string { return nil }
func (p specPlugin) Register(*backpack.App) error { return nil }
func (p specPlugin) Boot(*backpack.App) error { return nil }
func (p specPlugin) APIDocsSpec() ([]byte, error) { return []byte(p.spec), p.err }
const orderedSpecYAML = `openapi: 3.0.3
info:
version: "2"
title: Zoo API
paths:
/zebras:
get:
summary: List zebras
/aardvarks:
post:
summary: Add an aardvark
components: {}
`
func apiDocsApp(t *testing.T, apiDocs string) *backpack.App {
t.Helper()
return transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + apiDocs})
}
func apiGet(h http.Handler, path string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
return rec
}
func TestAPIDocsOffByDefault(t *testing.T) {
for name, cfg := range map[string]string{
"unset": "",
"enabled false": "api_docs:\n enabled: false\n path: /docs\n",
"only the title": "api_docs:\n title: Docs\n",
} {
t.Run(name, func(t *testing.T) {
router, err := BuildRouter(apiDocsApp(t, cfg), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
for _, ri := range router.Routes() {
if ri.PluginID == apiDocsPluginID {
t.Fatalf("api docs route mounted while off: %+v", ri)
}
}
h, err := router.compile()
if err != nil {
t.Fatal(err)
}
for _, p := range []string{"/api-docs", "/api-docs.json", "/docs"} {
if rec := apiGet(h, p); rec.Code != http.StatusNotFound {
t.Fatalf("GET %s = %d while off", p, rec.Code)
}
}
})
}
// Off needs no provider, and a broken spec is never read.
if _, err := Assemble(apiDocsApp(t, ""), []party.Plugin{specPlugin{id: "acme.zoo", err: errors.New("boom")}}); err != nil {
t.Fatalf("off with a failing provider: %v", err)
}
if _, err := Assemble(apiDocsApp(t, ""), nil); err != nil {
t.Fatalf("off without a provider: %v", err)
}
}
func TestAPIDocsServesPinnedPage(t *testing.T) {
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
if SwaggerUIVersion != "5.33.1" {
t.Fatalf("SwaggerUIVersion = %q", SwaggerUIVersion)
}
rec := apiGet(h, "/api-docs")
page := rec.Body.String()
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/html; charset=utf-8" {
t.Fatalf("GET /api-docs = %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
// The integrity values from RESEARCH (npm tarball and jsDelivr bytes).
for asset, sri := range map[string]string{
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css": "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW",
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js": "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw",
"https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js": "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF",
} {
if !strings.Contains(page, `"`+asset+`" integrity="`+sri+`" crossorigin="anonymous"`) {
t.Errorf("page lacks %s with integrity %s and crossorigin", asset, sri)
}
}
if n := strings.Count(page, `integrity="sha384-`); n != 3 {
t.Errorf("integrity attributes = %d, want 3", n)
}
if n := strings.Count(page, `crossorigin="anonymous"`); n != 3 {
t.Errorf("crossorigin attributes = %d, want 3", n)
}
if strings.Contains(page, "swagger-ui-dist@5/") || strings.Contains(page, "@latest") {
t.Error("page loads a floating version")
}
hd := rec.Header()
if hd.Get("X-Content-Type-Options") != "nosniff" || hd.Get("Referrer-Policy") != "no-referrer" {
t.Errorf("headers = %v", hd)
}
csp := hd.Get("Content-Security-Policy")
m := regexp.MustCompile(`script-src 'nonce-([A-Za-z0-9_-]+)' https://cdn\.jsdelivr\.net;`).FindStringSubmatch(csp)
if m == nil {
t.Fatalf("CSP %q", csp)
}
for _, part := range []string{"object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
if !strings.Contains(csp, part) {
t.Errorf("CSP %q lacks %s", csp, part)
}
}
scripts := regexp.MustCompile(`<script nonce="([^"]+)">`).FindAllStringSubmatch(page, -1)
if len(scripts) != 1 || scripts[0][1] != m[1] {
t.Fatalf("inline script nonces %v, CSP nonce %q", scripts, m[1])
}
if !strings.Contains(page, `url: "/api-docs.json"`) || !strings.Contains(page, "validatorUrl: null") {
t.Error("page does not load the local spec with the validator off")
}
// A fresh nonce per request.
rec2 := apiGet(h, "/api-docs")
if rec2.Header().Get("Content-Security-Policy") == csp {
t.Error("nonce reused across requests")
}
spec := apiGet(h, "/api-docs.json")
if spec.Code != http.StatusOK || spec.Header().Get("Content-Type") != "application/json" || spec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("GET /api-docs.json = %d %v", spec.Code, spec.Header())
}
// Both routes are public: a request without credentials gets them.
router, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
n := 0
for _, ri := range router.Routes() {
if ri.PluginID == apiDocsPluginID {
n++
if ri.Method != http.MethodGet || len(ri.Middleware) != 0 {
t.Errorf("api docs route %+v", ri)
}
}
}
if n != 2 {
t.Fatalf("api docs routes = %d, want 2", n)
}
}
func TestAPIDocsSpecJSONOrder(t *testing.T) {
want := `{"openapi":"3.0.3","info":{"version":"2","title":"Zoo API"},"paths":{"/zebras":{"get":{"summary":"List zebras"}},"/aardvarks":{"post":{"summary":"Add an aardvark"}}},"components":{}}`
got, err := apiDocsJSON([]byte(orderedSpecYAML))
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Fatalf("apiDocsJSON = %s\nwant %s", got, want)
}
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
if rec := apiGet(h, "/api-docs.json"); rec.Body.String() != want {
t.Fatalf("served %s", rec.Body.String())
}
}
func TestAPIDocsJSONSpecInput(t *testing.T) {
got, err := apiDocsJSON([]byte("\n {\n \"openapi\": \"3.1.0\",\n \"paths\": {\"/b\": {}, \"/a\": {}},\n \"info\": {\"title\": \"T\"}\n}\n"))
if err != nil {
t.Fatal(err)
}
if string(got) != `{"openapi":"3.1.0","paths":{"/b":{},"/a":{}},"info":{"title":"T"}}` {
t.Fatalf("JSON input = %s", got)
}
for name, raw := range map[string]string{
"empty": " \n",
"broken json": `{"openapi": "3.0.3",`,
"broken yaml": "openapi: [3.0\n : x",
"not an object": "- a\n- b\n",
"scalar document": "just text",
} {
if out, err := apiDocsJSON([]byte(raw)); err == nil {
t.Errorf("%s: converted to %s, want an error", name, out)
}
}
// A spec that does not convert, or a provider error, fails boot naming the plugin.
for name, p := range map[string]specPlugin{
"bad spec": {id: "acme.zoo", spec: `{"broken"`},
"provider error": {id: "acme.zoo", err: errors.New("embedded file missing")},
} {
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{p})
if err == nil || !strings.Contains(err.Error(), `"acme.zoo"`) {
t.Errorf("%s: error = %v", name, err)
}
}
}
func TestAPIDocsProviderCount(t *testing.T) {
plain := routesPluginFunc{id: "acme.plain", routes: func(pact.Router) {}}
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{plain})
if err == nil || !strings.Contains(err.Error(), "no plugin implements surf.APIDocsProvider") {
t.Fatalf("zero providers: %v", err)
}
_, err = BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{
specPlugin{id: "acme.zoo", spec: orderedSpecYAML}, plain, specPlugin{id: "acme.blog", spec: orderedSpecYAML},
})
if err == nil || !strings.Contains(err.Error(), "acme.blog, acme.zoo") || !strings.Contains(err.Error(), "exactly one") {
t.Fatalf("two providers: %v", err)
}
}
func TestAPIDocsPathValidation(t *testing.T) {
zoo := specPlugin{id: "acme.zoo", spec: orderedSpecYAML}
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n path: /docs/api/\n"), []party.Plugin{zoo})
if err != nil {
t.Fatal(err)
}
if rec := apiGet(h, "/docs/api"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `url: "/docs/api.json"`) {
t.Fatalf("GET /docs/api = %d", rec.Code)
}
if rec := apiGet(h, "/docs/api.json"); rec.Code != http.StatusOK {
t.Fatalf("GET /docs/api.json = %d", rec.Code)
}
if rec := apiGet(h, "/api-docs"); rec.Code != http.StatusNotFound {
t.Fatalf("default path still served: %d", rec.Code)
}
for _, bad := range []string{"docs", "https://example.com/docs", "/docs?x=1", "/docs/{rest}", "/docs/../admin", "/docs//api", "/do cs", `/docs\x`} {
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n path: '"+bad+"'\n"), []party.Plugin{zoo})
if err == nil || !strings.Contains(err.Error(), "http.api_docs.path") {
t.Errorf("path %q: error = %v", bad, err)
}
}
// A plugin route on the docs path collides.
clash := routesPluginFunc{id: "acme.clash", routes: func(r pact.Router) {
r.Get("/api-docs.json", okHandler)
}}
_, err = BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{zoo, clash})
if err == nil || !strings.Contains(err.Error(), "/api-docs.json") || !strings.Contains(err.Error(), "acme.clash") {
t.Fatalf("collision error = %v", err)
}
}
func TestAPIDocsTitleEscaped(t *testing.T) {
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n title: '</title><script>alert(1)</script> & co'\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
page := apiGet(h, "/api-docs").Body.String()
if strings.Contains(page, "<script>alert(1)</script>") || strings.Count(page, "</title>") != 1 {
t.Fatalf("title not escaped:\n%s", page)
}
if !strings.Contains(page, "<title>&lt;/title&gt;&lt;script&gt;alert(1)&lt;/script&gt; &amp; co</title>") {
t.Fatalf("escaped title missing:\n%s", page)
}
// The default title.
h, err = Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
if page := apiGet(h, "/api-docs").Body.String(); !strings.Contains(page, "<title>API documentation</title>") {
t.Fatal("default title missing")
}
}