diff --git a/docs/services/routing.md b/docs/services/routing.md index f0a1dd5..4a3d491 100644 --- a/docs/services/routing.md +++ b/docs/services/routing.md @@ -267,3 +267,30 @@ This fragment belongs in `config/http.yaml`. A route group then lists `cors:spa` A profile is checked like `http.cors`: credentials with a `*` origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming `http.cors_profiles..`. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets `paths` fails too. ServeMux answers an `OPTIONS` request on a path registered only for `GET` or `POST` with 405 before any route middleware runs, so the middleware alone would never see a browser's preflight. surf therefore registers one `OPTIONS ` route for every path whose routes name `cors:`, owned by `surf` in `route:list`. It answers the preflight from the profile, with the same headers as the global preflight, and runs no other middleware: a preflight carries no credentials, so no auth, throttle or body limit applies. The start-up fails when a route names an unknown profile, when two routes on one path name different profiles, when a plugin already declared an `OPTIONS` route on that path, and when the path also matches `http.cors.paths`, because the global wrapper answers every preflight on its paths before routing. + +## API documentation page + +An application can publish a Swagger UI page for its public API. The page is off by default; with `http.api_docs.enabled: true` surf mounts two public routes, without any middleware: + +- `GET /api-docs`: the Swagger UI page. +- `GET /api-docs.json`: the OpenAPI document as compact JSON. + +The document comes from the one plugin that implements `surf.APIDocsProvider`. It returns the document as YAML or JSON, usually a file it embeds: + +```go src=modules/surf/example_test.go#BlogPlugin.APIDocsSpec +// APIDocsSpec supplies the document behind the opt-in /api-docs page +// (surf.APIDocsProvider). +func (p *BlogPlugin) APIDocsSpec() ([]byte, error) { + return []byte(openAPISpec), nil +} +``` + +surf converts the document once at start-up: a document starting with `{` must be valid JSON, anything else is read as YAML, and the result keeps the source's key order. A document that does not convert fails the start-up with the plugin's name, and so do zero or several providers while the page is enabled. Nothing is generated from the code: the document describes exactly the surface you choose to publish. + +| Key | Default | Controls | +|-----|---------|----------| +| `http.api_docs.enabled` | `false` | Mounts the page and the JSON route. | +| `http.api_docs.path` | `/api-docs` | The page path; the JSON route is the same path plus `.json`. It must be an absolute URL path, and neither route may replace a plugin route. | +| `http.api_docs.title` | `API documentation` | The page title. | + +The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. diff --git a/modules/surf/README.md b/modules/surf/README.md index b7c749b..786cc81 100644 --- a/modules/surf/README.md +++ b/modules/surf/README.md @@ -36,6 +36,7 @@ Use cases: - Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay. - `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale. - Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET /{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`). +- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a nonce-based `Content-Security-Policy`. - Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies. - A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands. @@ -136,6 +137,8 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `surf.TrustedProxies` | Parses `http.trusted_proxies` into CIDR prefixes. | | `surf.CORSConfig` | CORS settings; `surf.LoadCORSConfig` reads them from config. | | `surf.LoadCORSProfiles` | Reads and validates the `http.cors_profiles.` blocks the `cors:` middleware uses. | +| `surf.APIDocsProvider` | Implemented by the one plugin that supplies the OpenAPI document (`APIDocsSpec`, YAML or JSON) for the opt-in api-docs page. | +| `surf.SwaggerUIVersion` | The exact swagger-ui-dist release the api-docs page loads, pinned so its assets carry SRI hashes. | | `surf.LocaleResolver` | Optional backpack-published request locale; `Resolve` returns a validated code and `Rewrite` strips an enabled prefix before routing. | | `surf.LocaleFromPrincipal` | Middleware that applies the signed-in user's preferred locale. | | `surf.ServeCommand` | The `serve` console command. | @@ -162,6 +165,9 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur | `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere). A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. | | `turnstile.field` | `turnstile_token` | Request field (query, JSON body or form body) carrying the Turnstile token. | | `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. | +| `http.api_docs.enabled` | `false` | Mounts `GET ` (Swagger UI page) and `GET .json` (the spec of the one `surf.APIDocsProvider` plugin). Both are public. Environment: `SUMMER_HTTP__API_DOCS__ENABLED`. | +| `http.api_docs.path` | `/api-docs` | Page path; the JSON route adds `.json`. Must be an absolute URL path; a plugin route on either path fails boot. | +| `http.api_docs.title` | `API documentation` | Page title. | | `http.access_log` | `false` | Logs one `http request` record per request (method, matched pattern, status, duration in milliseconds; never the path or query). Environment: `SUMMER_HTTP__ACCESS_LOG`. | | `storage.uploads.serve` | `false` | Mounts the uploads route `GET /{path...}` (prefix default `/storage/uploads`; it must then be an absolute URL path, or start-up fails) on `attach.StaticHandlerPublic` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Prefer a separate origin for uploads in production. Environment: `SUMMER_STORAGE__UPLOADS__SERVE`. | @@ -191,8 +197,8 @@ The `serve` command also opens the database through [lagoon](../lagoon/README.md ## Dependencies - SummerCMS modules: [backpack](../backpack/README.md), [bonfire](../bonfire/README.md), [bouncer](../bouncer/README.md), [cabana](../cabana/README.md), [compass](../compass/README.md), [conga](../conga/README.md) (the in-process job worker of `serve`), [cooler](../cooler/README.md) (the shared rate-limit store `serve` installs), [lagoon](../lagoon/README.md) (including `lagoon/attach`), [pact](../pact/README.md), [party](../party/README.md), [towel](../towel/README.md), [wire](../wire/README.md). -- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`). -- Standard library: `bytes`, `context`, `fmt`, `math`, `net`, `net/http`, `net/netip`, `os`, `os/signal`, `regexp`, `strconv`, `strings`, `sync`, `syscall`, `time`. +- Third-party: `gocloud.dev/blob` (uploads bucket opened by `serve`), `github.com/goccy/go-yaml` (api-docs spec conversion). +- Standard library: `bytes`, `context`, `crypto/rand`, `encoding/base64`, `encoding/json`, `errors`, `fmt`, `html/template`, `log/slog`, `math`, `mime`, `net`, `net/http`, `net/netip`, `net/url`, `os`, `os/signal`, `regexp`, `sort`, `strconv`, `strings`, `sync`, `syscall`, `time`. - Tests additionally use `github.com/testcontainers/testcontainers-go` (Redis container). ## Testing diff --git a/modules/surf/apidocs.go b/modules/surf/apidocs.go new file mode 100644 index 0000000..aee7bf5 --- /dev/null +++ b/modules/surf/apidocs.go @@ -0,0 +1,210 @@ +package surf + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "net/http" + "sort" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/goccy/go-yaml" +) + +// APIDocsProvider is implemented by the one plugin that supplies the +// application's OpenAPI document for the opt-in /api-docs page. APIDocsSpec +// returns the document as YAML or JSON, typically an embedded file; surf +// converts it to compact JSON once at boot. +type APIDocsProvider interface { + APIDocsSpec() ([]byte, error) +} + +// SwaggerUIVersion is the swagger-ui-dist release the /api-docs page loads +// from jsDelivr. The version is exact so the assets can carry Subresource +// Integrity hashes; a floating version could change under the page. +const SwaggerUIVersion = "5.33.1" + +// The pinned swagger-ui-dist assets and their integrity values (checked +// against the npm tarball of the release). +const ( + swaggerUICSS = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui.css" + swaggerUICSSIntegrity = "sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW" + swaggerUIBundle = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-bundle.js" + swaggerUIBundleIntegrity = "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw" + swaggerUIPreset = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js" + swaggerUIPresetIntegrity = "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF" + swaggerUICDN = "https://cdn.jsdelivr.net" +) + +const ( + // apiDocsPluginID owns the api-docs routes in the route table. + apiDocsPluginID = "summercms.apidocs" + defaultAPIDocsPath = "/api-docs" + defaultAPIDocsName = "API documentation" +) + +// apiDocsPage is the Swagger UI shell. The asset URLs and integrity values +// are constants written into the template text; the title, the nonce and the +// spec URL go through html/template escaping. +var apiDocsPage = template.Must(template.New("apidocs").Parse(` + + + + + {{.Title}} + + + +
+ + + + + +`)) + +// mountAPIDocs registers GET (the Swagger UI page) and +// GET .json (the spec) when http.api_docs.enabled is +// true. Exactly one plugin must implement APIDocsProvider; its spec is +// converted to compact JSON here, once, and a spec that does not convert +// fails boot naming the plugin. Both routes are public (no middleware) and +// may not replace a route a plugin declared. +func mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error { + if r == nil || app == nil || app.Config == nil || !app.Config.Bool("http.api_docs.enabled") { + return nil + } + path, err := apiDocsPath(app.Config.String("http.api_docs.path")) + if err != nil { + return err + } + jsonPath := path + ".json" + title := strings.TrimSpace(app.Config.String("http.api_docs.title")) + if title == "" { + title = defaultAPIDocsName + } + var ids []string + var provider APIDocsProvider + for _, p := range plugins { + if dp, ok := p.(APIDocsProvider); ok { + ids = append(ids, p.ID()) + provider = dp + } + } + switch len(ids) { + case 0: + return errors.New("surf: http.api_docs.enabled is true but no plugin implements surf.APIDocsProvider") + case 1: + default: + sort.Strings(ids) + return fmt.Errorf("surf: http.api_docs.enabled is true and several plugins implement surf.APIDocsProvider (%s); exactly one may", strings.Join(ids, ", ")) + } + raw, err := provider.APIDocsSpec() + if err != nil { + return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err) + } + spec, err := apiDocsJSON(raw) + if err != nil { + return fmt.Errorf("surf: api docs spec of plugin %q: %w", ids[0], err) + } + for _, p := range []string{path, jsonPath} { + if owner, ok := r.seen[http.MethodGet+" "+p]; ok { + return fmt.Errorf("surf: route GET %s (plugin %q) collides with the api docs page http.api_docs.path mounts", p, owner) + } + } + r.BindPlugin(apiDocsPluginID) + r.GroupRaw("", nil, func(g pact.Router) { + g.Get(path, apiDocsPageHandler(title, jsonPath)) + g.Get(jsonPath, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(spec) + }) + }) + return nil +} + +// apiDocsPath validates http.api_docs.path with the uploads prefix rules: an +// absolute URL path without scheme, host, query, wildcard or dot segments. +func apiDocsPath(raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return defaultAPIDocsPath, nil + } + path, err := uploadsPrefix(raw) + if err != nil { + return "", fmt.Errorf("surf: http.api_docs.path must be an absolute URL path such as /api-docs (no scheme, host, query or wildcard), got %q", raw) + } + return path, nil +} + +// apiDocsJSON converts a YAML or JSON OpenAPI document to compact JSON with +// the source key order. A body starting with { must be valid JSON; anything +// else is read as YAML (key order kept). The result must be a JSON object. +func apiDocsJSON(raw []byte) ([]byte, error) { + src := bytes.TrimSpace(raw) + if len(src) == 0 { + return nil, errors.New("the spec is empty") + } + var converted []byte + if src[0] == '{' { + if !json.Valid(src) { + return nil, errors.New("the spec starts with { but is not valid JSON") + } + converted = src + } else { + out, err := yaml.YAMLToJSON(src) + if err != nil { + return nil, fmt.Errorf("the spec is not valid YAML: %w", err) + } + converted = out + } + var buf bytes.Buffer + if err := json.Compact(&buf, converted); err != nil { + return nil, fmt.Errorf("the spec does not convert to JSON: %w", err) + } + if buf.Len() == 0 || buf.Bytes()[0] != '{' { + return nil, errors.New("the spec is not an object") + } + return buf.Bytes(), nil +} + +// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce for +// its one inline script. +func apiDocsPageHandler(title, specURL string) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + nonceBytes := make([]byte, 18) + if _, err := rand.Read(nonceBytes); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + nonce := base64.RawURLEncoding.EncodeToString(nonceBytes) + var page bytes.Buffer + if err := apiDocsPage.Execute(&page, struct{ Title, Nonce, SpecURL string }{title, nonce, specURL}); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + h := w.Header() + h.Set("Content-Type", "text/html; charset=utf-8") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"' "+swaggerUICDN+"; object-src 'none'; base-uri 'none'; frame-ancestors 'none'") + _, _ = w.Write(page.Bytes()) + } +} diff --git a/modules/surf/example_test.go b/modules/surf/example_test.go index a7bf8b2..32641ed 100644 --- a/modules/surf/example_test.go +++ b/modules/surf/example_test.go @@ -72,6 +72,24 @@ func (p *BlogPlugin) Buckets() map[string]surf.Bucket { } } +// openAPISpec is the plugin's hand-written OpenAPI document. A plugin +// usually keeps it in a file and embeds it with //go:embed. +const openAPISpec = `openapi: 3.0.3 +info: + title: Blog API + version: "1" +paths: + /api/blog/posts/{id}: + get: + summary: Show a post +` + +// APIDocsSpec supplies the document behind the opt-in /api-docs page +// (surf.APIDocsProvider). +func (p *BlogPlugin) APIDocsSpec() ([]byte, error) { + return []byte(openAPISpec), nil +} + // Routes is the Go form of the plugin's routes.php. func (p *BlogPlugin) Routes(r pact.Router) error { r.Group("/api/blog", surf.Use("throttle:60,1"), func(g pact.Router) { @@ -195,6 +213,27 @@ func ExampleClientIP() { // 203.0.113.7 } +func ExampleAPIDocsProvider() { + cfg, err := compass.Open(compass.Options{Dir: "config", Env: "development", Environ: []string{}}) + if err != nil { + fmt.Println(err) + return + } + _ = cfg.Set("http.body_limits.default_bytes", 1048576) + _ = cfg.Set("http.body_limits.upload_bytes", 1048576) + _ = cfg.Set("http.api_docs.enabled", true) + h, err := surf.Assemble(backpack.New(cfg), []party.Plugin{&BlogPlugin{}}) + if err != nil { + fmt.Println(err) + return + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/api-docs.json", nil)) + fmt.Println(rec.Code, rec.Body.String()) + // Output: + // 200 {"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/blog/posts/{id}":{"get":{"summary":"Show a post"}}}} +} + // TestDocsDeclarations checks the plugin declarations the Routing and Rate // limiting pages show. func TestDocsDeclarations(t *testing.T) { @@ -205,4 +244,7 @@ func TestDocsDeclarations(t *testing.T) { if b := p.Buckets()["blog.comments"]; b.Max != 1 || b.Decay != time.Minute { t.Fatalf("Buckets() = %+v", b) } + if spec, err := p.APIDocsSpec(); err != nil || !strings.HasPrefix(string(spec), "openapi:") { + t.Fatalf("APIDocsSpec() = %q, %v", spec, err) + } } diff --git a/modules/surf/router.go b/modules/surf/router.go index c7abbd2..c3b91a7 100644 --- a/modules/surf/router.go +++ b/modules/surf/router.go @@ -611,6 +611,9 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) { if err := mountUploads(r, app); err != nil { return nil, err } + if err := mountAPIDocs(r, app, plugins); err != nil { + return nil, err + } admin, err := cabana.Activate(app, plugins) if err != nil { return nil, err diff --git a/modules/surf/transport_smoke_test.go b/modules/surf/transport_smoke_test.go index 42b73eb..69fd8fa 100644 --- a/modules/surf/transport_smoke_test.go +++ b/modules/surf/transport_smoke_test.go @@ -347,3 +347,66 @@ func TestTurnstileMiddlewareSmoke(t *testing.T) { t.Fatalf("turnstile:maybe error = %v", err) } } + +// apiDocsPlugin supplies an OpenAPI document whose keys are not in +// alphabetical order. +type apiDocsPlugin struct{ id string } + +func (p apiDocsPlugin) ID() string { return p.id } +func (p apiDocsPlugin) Requires() []string { return nil } +func (p apiDocsPlugin) Register(*backpack.App) error { return nil } +func (p apiDocsPlugin) Boot(*backpack.App) error { return nil } +func (p apiDocsPlugin) APIDocsSpec() ([]byte, error) { + return []byte("openapi: 3.0.3\ninfo:\n title: Blog API\n version: \"1\"\npaths:\n /api/posts:\n get:\n summary: List posts\n"), nil +} + +func TestAPIDocsSmoke(t *testing.T) { + get := func(h http.Handler, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + return rec + } + off, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}), []party.Plugin{apiDocsPlugin{id: "acme.blog"}}) + if err != nil { + t.Fatal(err) + } + if rec := get(off, "/api-docs"); rec.Code != http.StatusNotFound { + t.Fatalf("api docs off: GET /api-docs = %d, want 404", rec.Code) + } + + onFiles := func() map[string]string { + return map[string]string{"http.yaml": transportBodyLimits + "api_docs:\n enabled: true\n"} + } + h, err := Assemble(transportApp(t, "development", onFiles()), []party.Plugin{apiDocsPlugin{id: "acme.blog"}}) + if err != nil { + t.Fatal(err) + } + rec := get(h, "/api-docs.json") + want := `{"openapi":"3.0.3","info":{"title":"Blog API","version":"1"},"paths":{"/api/posts":{"get":{"summary":"List posts"}}}}` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Fatalf("GET /api-docs.json = %d %s, want %s", rec.Code, rec.Body.String(), want) + } + rec = get(h, "/api-docs") + page := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(page, "swagger-ui-dist@5.33.1") { + t.Fatalf("GET /api-docs = %d, page lacks the pinned version", rec.Code) + } + if n := strings.Count(page, `integrity="sha384-`); n != 3 { + t.Errorf("page has %d integrity attributes, want 3", n) + } + csp := rec.Header().Get("Content-Security-Policy") + i := strings.Index(csp, "'nonce-") + if i < 0 { + t.Fatalf("CSP %q has no nonce", csp) + } + nonce := csp[i+len("'nonce-"):] + nonce = nonce[:strings.IndexByte(nonce, '\'')] + if nonce == "" || !strings.Contains(page, `nonce="`+nonce+`"`) { + t.Errorf("page script nonce does not match CSP %q", csp) + } + + _, err = BuildRouter(transportApp(t, "development", onFiles()), []party.Plugin{apiDocsPlugin{id: "acme.blog"}, apiDocsPlugin{id: "acme.shop"}}) + if err == nil || !strings.Contains(err.Error(), "acme.blog") || !strings.Contains(err.Error(), "acme.shop") { + t.Fatalf("two providers error = %v", err) + } +}