- the two pinned jsDelivr scripts carry the per-request nonce next to their SRI hash; script-src holds only the nonce, no host source
308 lines
11 KiB
Go
308 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_-]+)';`).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(`<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])
|
|
}
|
|
// Every script tag, the two pinned assets included, carries the nonce.
|
|
tags := regexp.MustCompile(`<script[^>]*>`).FindAllString(page, -1)
|
|
if len(tags) != 3 {
|
|
t.Fatalf("script tags = %q, want 3", tags)
|
|
}
|
|
for _, tag := range tags {
|
|
if !strings.Contains(tag, `nonce="`+m[1]+`"`) {
|
|
t.Errorf("script tag %q lacks the CSP nonce", tag)
|
|
}
|
|
}
|
|
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></title><script>alert(1)</script> & 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")
|
|
}
|
|
}
|