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) } }