- surf: APIDocsProvider (one plugin) supplies a YAML or JSON OpenAPI document, converted once at boot to compact JSON in source key order; zero or several providers or a spec that does not convert fail boot - http.api_docs.enabled/path/title mount GET /api-docs and /api-docs.json (summercms.apidocs, public, refusing to replace a plugin route) - the page loads swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and crossorigin on all three assets, a per-request CSP nonce for its inline script, nosniff and no-referrer - docs: API documentation page section, README rows, ExampleAPIDocsProvider
413 lines
14 KiB
Go
413 lines
14 KiB
Go
package surf
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
)
|
|
|
|
// Smoke tests for plan 02 of phase 19.3 (cookie transport, credentialed CORS,
|
|
// CORS profiles, Turnstile, api-docs); plan 05 owns full coverage.
|
|
|
|
const transportBodyLimits = `body_limits:
|
|
default_bytes: 1048576
|
|
upload_bytes: 1048576
|
|
`
|
|
|
|
// transportApp builds an app from config files (name -> YAML body) in
|
|
// environment env.
|
|
func transportApp(t *testing.T, env string, files map[string]string) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if _, ok := files["app.yaml"]; !ok {
|
|
files["app.yaml"] = "name: transport-test\n"
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=" + env}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return backpack.New(cfg)
|
|
}
|
|
|
|
// routesPluginFunc is a test plugin whose routes come from a func.
|
|
type routesPluginFunc struct {
|
|
id string
|
|
routes func(pact.Router)
|
|
}
|
|
|
|
func (p routesPluginFunc) ID() string { return p.id }
|
|
func (p routesPluginFunc) Requires() []string { return nil }
|
|
func (p routesPluginFunc) Register(*backpack.App) error { return nil }
|
|
func (p routesPluginFunc) Boot(*backpack.App) error { return nil }
|
|
func (p routesPluginFunc) Routes(r pact.Router) error {
|
|
p.routes(r)
|
|
return nil
|
|
}
|
|
|
|
func varyHas(h http.Header, value string) bool {
|
|
for _, line := range h.Values("Vary") {
|
|
for _, v := range strings.Split(line, ",") {
|
|
if strings.EqualFold(strings.TrimSpace(v), value) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestCookieRefreshAcrossSubdomainsSmoke(t *testing.T) {
|
|
const secret = "transport-smoke-secret-with-32-bytes-at-least"
|
|
const issuer = "https://api.example.com/api/auth/login"
|
|
app := transportApp(t, "development", map[string]string{
|
|
"app.yaml": "name: transport-test\nurl: https://api.example.com\n",
|
|
"auth.yaml": "cookie:\n domain: example.com\n",
|
|
"http.yaml": transportBodyLimits + `cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["https://www.example.com"]
|
|
allowed_methods: ["*"]
|
|
allowed_headers: ["*"]
|
|
supports_credentials: true
|
|
`,
|
|
})
|
|
cookieCfg, err := bouncer.LoadCookieConfig(app.Config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refreshTTL := 20160 * time.Minute
|
|
bl := bouncer.NewMemoryBlacklist()
|
|
refresh, err := bouncer.CookieRefreshHandler(bouncer.CookieRefreshOptions{
|
|
Cookie: cookieCfg,
|
|
RefreshTTL: refreshTTL,
|
|
Refresh: func(_ context.Context, token string) (string, error) {
|
|
return bouncer.Refresh(secret, token, refreshTTL, bl, 0, issuer)
|
|
},
|
|
Write: func(w http.ResponseWriter, _ *http.Request, res bouncer.CookieResult) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(res.Status)
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": res.Err == nil})
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) {
|
|
r.Post("/api/auth/refresh", refresh.ServeHTTP)
|
|
}}
|
|
h, err := Assemble(app, []party.Plugin{plugin})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
old, oldJTI, err := bouncer.Mint(secret, "42", issuer, time.Hour)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", nil)
|
|
req.Header.Set("Origin", "https://www.example.com")
|
|
req.AddCookie(&http.Cookie{Name: "token", Value: old})
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("refresh status = %d body %s", rec.Code, rec.Body.String())
|
|
}
|
|
setCookie := rec.Header().Get("Set-Cookie")
|
|
c, err := http.ParseSetCookie(setCookie)
|
|
if err != nil {
|
|
t.Fatalf("Set-Cookie %q: %v", setCookie, err)
|
|
}
|
|
if c.Name != "token" || c.Value == "" || c.Value == old {
|
|
t.Fatalf("cookie %s=%q, want a rotated token", c.Name, c.Value)
|
|
}
|
|
for _, want := range []string{"Domain=example.com", "Path=/", "Max-Age=1209600", "HttpOnly", "Secure", "SameSite=Lax"} {
|
|
if !strings.Contains(setCookie, want) {
|
|
t.Errorf("Set-Cookie %q lacks %s", setCookie, want)
|
|
}
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
|
|
t.Errorf("Allow-Origin = %q", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Errorf("Allow-Credentials = %q", got)
|
|
}
|
|
if !varyHas(rec.Header(), "Origin") {
|
|
t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary"))
|
|
}
|
|
if revoked, _ := bl.IsBlacklisted(context.Background(), oldJTI); !revoked {
|
|
t.Error("the old jti is not blacklisted")
|
|
}
|
|
|
|
pre := httptest.NewRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", nil)
|
|
pre.Header.Set("Origin", "https://www.example.com")
|
|
pre.Header.Set("Access-Control-Request-Method", "POST")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, pre)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("preflight status = %d", rec.Code)
|
|
}
|
|
if !varyHas(rec.Header(), "Origin") {
|
|
t.Errorf("preflight Vary = %q, want Origin", rec.Header().Values("Vary"))
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" {
|
|
t.Errorf("preflight Allow-Origin = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCORSCredentialsWildcardRefusedSmoke(t *testing.T) {
|
|
app := transportApp(t, "development", map[string]string{
|
|
"http.yaml": transportBodyLimits + `cors:
|
|
paths: ["api/*"]
|
|
allowed_origins: ["*"]
|
|
supports_credentials: true
|
|
`,
|
|
})
|
|
_, err := BuildRouter(app, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "http.cors") {
|
|
t.Fatalf("BuildRouter error = %v, want one naming http.cors", err)
|
|
}
|
|
}
|
|
|
|
func TestCORSProfileSmoke(t *testing.T) {
|
|
const www = "https://www.example.com"
|
|
files := func() map[string]string {
|
|
return map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
|
|
spa:
|
|
allowed_origins: ["https://www.example.com"]
|
|
allowed_methods: ["GET", "POST"]
|
|
allowed_headers: ["Content-Type"]
|
|
supports_credentials: true
|
|
`}
|
|
}
|
|
plugin := func(profile string) routesPluginFunc {
|
|
return routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
|
|
r.Group("/x", Use("cors:"+profile), func(g pact.Router) {
|
|
g.Get("/me", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
})
|
|
}}
|
|
}
|
|
app := transportApp(t, "development", files())
|
|
router, err := BuildRouter(app, []party.Plugin{plugin("spa")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listed := false
|
|
for _, ri := range router.Routes() {
|
|
if ri.Method == http.MethodOptions && ri.Pattern == "/x/me" && ri.PluginID == "surf" {
|
|
listed = true
|
|
}
|
|
}
|
|
if !listed {
|
|
t.Errorf("route table lacks OPTIONS /x/me owned by surf: %+v", router.Routes())
|
|
}
|
|
h, err := router.compile()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pre := httptest.NewRequest(http.MethodOptions, "/x/me", nil)
|
|
pre.Header.Set("Origin", www)
|
|
pre.Header.Set("Access-Control-Request-Method", "GET")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, pre)
|
|
if rec.Code != http.StatusNoContent {
|
|
t.Fatalf("preflight status = %d", rec.Code)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != www {
|
|
t.Errorf("preflight Allow-Origin = %q", got)
|
|
}
|
|
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
|
|
t.Errorf("preflight Allow-Credentials = %q", got)
|
|
}
|
|
if !varyHas(rec.Header(), "Origin") {
|
|
t.Errorf("preflight Vary = %q", rec.Header().Values("Vary"))
|
|
}
|
|
|
|
get := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
|
get.Header.Set("Origin", www)
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, get)
|
|
if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www {
|
|
t.Errorf("GET = %d, Allow-Origin %q", rec.Code, rec.Header().Get("Access-Control-Allow-Origin"))
|
|
}
|
|
|
|
evil := httptest.NewRequest(http.MethodGet, "/x/me", nil)
|
|
evil.Header.Set("Origin", "https://evil.example.org")
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, evil)
|
|
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
|
t.Errorf("disallowed origin Allow-Origin = %q", got)
|
|
}
|
|
if !varyHas(rec.Header(), "Origin") {
|
|
t.Errorf("disallowed origin Vary = %q", rec.Header().Values("Vary"))
|
|
}
|
|
|
|
_, err = BuildRouter(transportApp(t, "development", files()), []party.Plugin{plugin("missing")})
|
|
if err == nil || !strings.Contains(err.Error(), "missing") {
|
|
t.Fatalf("BuildRouter with cors:missing error = %v", err)
|
|
}
|
|
}
|
|
|
|
// turnstileRoutes declares POST /guest (turnstile:closed) and POST /register
|
|
// (turnstile:open); both echo the body they received.
|
|
func turnstileRoutes(closedMW, openMW string) routesPluginFunc {
|
|
echo := func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(b)
|
|
}
|
|
return routesPluginFunc{id: "acme.guest", routes: func(r pact.Router) {
|
|
r.Post("/guest", echo, closedMW)
|
|
r.Post("/register", echo, openMW)
|
|
}}
|
|
}
|
|
|
|
func TestTurnstileMiddlewareSmoke(t *testing.T) {
|
|
var calls atomic.Int64
|
|
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls.Add(1)
|
|
_ = r.ParseForm()
|
|
ok := r.PostForm.Get("response") == "ok" && r.PostForm.Get("secret") == "test-secret"
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if ok {
|
|
_, _ = io.WriteString(w, `{"success":true}`)
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, `{"success":false,"error-codes":["invalid-input-response"]}`)
|
|
}))
|
|
defer fake.Close()
|
|
files := func() map[string]string { return map[string]string{"http.yaml": transportBodyLimits} }
|
|
post := func(h http.Handler, path, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
// A configured verifier (published, pointed at the fake).
|
|
app := transportApp(t, "development", files())
|
|
verifier := bouncer.NewTurnstile("test-secret", false)
|
|
verifier.Endpoint = fake.URL
|
|
if err := app.Publish[*bouncer.Turnstile](verifier); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := Assemble(app, []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := `{"turnstile_token":"ok","name":"x"}`
|
|
if rec := post(h, "/guest", body); rec.Code != http.StatusOK || rec.Body.String() != body {
|
|
t.Fatalf("guest with a good token = %d %q, want 200 and the full body", rec.Code, rec.Body.String())
|
|
}
|
|
if rec := post(h, "/guest", `{"turnstile_token":"bad","name":"x"}`); rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("guest with a bad token = %d, want 422", rec.Code)
|
|
}
|
|
|
|
// Open mode with no secret passes without a siteverify call.
|
|
before := calls.Load()
|
|
h, err = Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := post(h, "/register", `{"name":"x"}`); rec.Code != http.StatusOK {
|
|
t.Fatalf("open mode without a secret = %d, want 200", rec.Code)
|
|
}
|
|
if calls.Load() != before {
|
|
t.Fatal("open mode without a secret called siteverify")
|
|
}
|
|
|
|
// Closed mode with no secret in production is unavailable.
|
|
h, err = Assemble(transportApp(t, "production", files()), []party.Plugin{turnstileRoutes("turnstile:closed", "turnstile:open")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rec := post(h, "/guest", body); rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("closed mode unconfigured in production = %d, want 503", rec.Code)
|
|
}
|
|
|
|
if _, err := BuildRouter(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile:maybe", "turnstile:open")}); err == nil || !strings.Contains(err.Error(), "turnstile:maybe") {
|
|
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)
|
|
}
|
|
}
|