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_-]+)';`).FindStringSubmatch(csp) if m == nil { t.Fatalf("CSP %q", csp) } if strings.Contains(csp, "https:") { t.Errorf("CSP %q names a host source; script-src must hold only the nonce", 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(` & 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, "") || strings.Count(page, "") != 1 { t.Fatalf("title not escaped:\n%s", page) } if !strings.Contains(page, "</title><script>alert(1)</script> & co") { 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, "API documentation") { t.Fatal("default title missing") } } // cspDirectives splits a Content-Security-Policy value into its directives. func cspDirectives(csp string) map[string][]string { out := map[string][]string{} for _, part := range strings.Split(csp, ";") { fields := strings.Fields(part) if len(fields) == 0 { continue } out[fields[0]] = fields[1:] } return out } func TestAPIDocsCSPNoHostSource(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) } nonces := map[string]bool{} for range 2 { rec := apiGet(h, "/api-docs") if rec.Code != http.StatusOK { t.Fatalf("GET /api-docs = %d", rec.Code) } dirs := cspDirectives(rec.Header().Get("Content-Security-Policy")) script := dirs["script-src"] if len(script) != 1 || !strings.HasPrefix(script[0], "'nonce-") || !strings.HasSuffix(script[0], "'") { t.Fatalf("script-src = %q, want exactly one nonce source", script) } nonce := strings.TrimSuffix(strings.TrimPrefix(script[0], "'nonce-"), "'") if nonce == "" { t.Fatal("empty nonce") } for _, d := range []string{"object-src", "base-uri", "frame-ancestors"} { if got := dirs[d]; len(got) != 1 || got[0] != "'none'" { t.Errorf("%s = %q, want 'none'", d, got) } } page := rec.Body.String() tags := regexp.MustCompile(`]*>`).FindAllString(page, -1) if len(tags) != 3 { t.Fatalf("script tags = %q, want 3", tags) } for _, tag := range tags { if !strings.Contains(tag, ` nonce="`+nonce+`"`) { t.Errorf("script tag %q lacks nonce %q", tag, nonce) } } for _, sri := range []string{ "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW", "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw", "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF", } { if !strings.Contains(page, `integrity="`+sri+`"`) { t.Errorf("page lacks integrity %s", sri) } } nonces[nonce] = true } if len(nonces) != 2 { t.Fatal("two requests got the same nonce") } }