diff --git a/modules/cabana/reorder_hook_test.go b/modules/cabana/reorder_hook_test.go new file mode 100644 index 0000000..9315409 --- /dev/null +++ b/modules/cabana/reorder_hook_test.go @@ -0,0 +1,239 @@ +package cabana_test + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net/http" + "strings" + "sync" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "gorm.io/gorm" +) + +// Unit tests (plan 05 of phase 19.3) for pact.ReorderAfterSave (D-11): the +// hook runs inside the reorder transaction after the new order is written, +// receives the partition, and its error rolls the reorder back. They reuse +// the partitioned flat acme.shelf fixture of catalog_smoke_test.go. + +// hookShelfPlugin is acme.shelf with a pins controller that implements +// pact.ReorderAfterSave through hook. +type hookShelfPlugin struct { + hook func(ctx context.Context, partition *uint64) error +} + +func (hookShelfPlugin) ID() string { return "acme.shelf" } +func (hookShelfPlugin) Requires() []string { return nil } +func (hookShelfPlugin) Register(*backpack.App) error { return nil } +func (hookShelfPlugin) Boot(*backpack.App) error { return nil } +func (hookShelfPlugin) AdminFS() fs.FS { return shelfFS } +func (p hookShelfPlugin) AdminControllers() []pact.AdminController { + return []pact.AdminController{hookedPins{hook: p.hook}} +} +func (hookShelfPlugin) Permissions() []pact.Permission { + return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}} +} + +// hookedPins is shelfPins with the reorder hook. +type hookedPins struct { + shelfPins + hook func(ctx context.Context, partition *uint64) error +} + +func (h hookedPins) ReorderAfterSave(ctx context.Context, partition *uint64) error { + return h.hook(ctx, partition) +} + +var _ pact.ReorderAfterSave = hookedPins{} + +// shelfHookEnv is a logged-in admin over plugin with fresh acme.shelf +// tables, two shelves and three pins (A=1 and B=2 on top, X=7 on low). +type shelfHookEnv struct { + *conformEnv + top, low shelfShelf + a, b, x uint +} + +func newShelfHookEnv(t *testing.T, plugin party.Plugin) *shelfHookEnv { + t.Helper() + gdb := adminGorm(t) + models := []any{&shelfPin{}, &shelfShelf{}} + if err := gdb.Migrator().DropTable(models...); err != nil { + t.Fatal(err) + } + if err := gdb.AutoMigrate(models...); err != nil { + t.Fatal(err) + } + login := fmt.Sprintf("shelf-hook-%d", time.Now().UnixNano()) + insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) + h, err := paymentsAssemble(t, gdb, []party.Plugin{plugin}) + if err != nil { + t.Fatal(err) + } + env := &shelfHookEnv{conformEnv: &conformEnv{h: h, db: gdb, login: login}, top: shelfShelf{Title: "Top"}, low: shelfShelf{Title: "Low"}} + if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { + t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) + } + for _, s := range []*shelfShelf{&env.top, &env.low} { + if err := gdb.Create(s).Error; err != nil { + t.Fatal(err) + } + } + pins := []shelfPin{{Name: "A", ShelfID: &env.top.ID, SortOrder: 1}, {Name: "B", ShelfID: &env.top.ID, SortOrder: 2}, {Name: "X", ShelfID: &env.low.ID, SortOrder: 7}} + for i := range pins { + if err := gdb.Create(&pins[i]).Error; err != nil { + t.Fatal(err) + } + } + env.a, env.b, env.x = pins[0].ID, pins[1].ID, pins[2].ID + return env +} + +// swap posts B=1, A=2 for the top shelf and returns the status and body. +func (e *shelfHookEnv) swap(t *testing.T) (int, string) { + t.Helper() + rec := e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{ + "partition": e.top.ID, + "nodes": []map[string]any{ + {"id": e.b, "parent_id": nil, "sort_order": 1}, + {"id": e.a, "parent_id": nil, "sort_order": 2}, + }, + }, true) + return rec.Code, rec.Body.String() +} + +// sorts reads the sort values of every pin through db. +func sorts(t *testing.T, db *gorm.DB) map[uint]int { + t.Helper() + var all []shelfPin + if err := db.Order("id").Find(&all).Error; err != nil { + t.Fatal(err) + } + out := map[uint]int{} + for _, p := range all { + out[p.ID] = p.SortOrder + } + return out +} + +func TestReorderHookRunsInTx(t *testing.T) { + var ( + mu sync.Mutex + calls int + gotPart *uint64 + inTx map[uint]int + outside map[uint]int + hasTx bool + afterCommit map[uint]int + ) + var env *shelfHookEnv + env = newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, partition *uint64) error { + mu.Lock() + defer mu.Unlock() + calls++ + gotPart = partition + tx, ok := cabana.TxFromContext(ctx) + hasTx = ok + if !ok { + return errors.New("no transaction in the hook context") + } + inTx = sorts(t, tx.WithContext(ctx)) + // Another connection still sees the committed order. + outside = sorts(t, env.db) + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) { + mu.Lock() + defer mu.Unlock() + afterCommit = sorts(t, db.WithContext(ctx)) + }) + return nil + }}) + + code, body := env.swap(t) + if code != http.StatusOK { + t.Fatalf("POST status=%d body=%s", code, body) + } + mu.Lock() + defer mu.Unlock() + if calls != 1 || !hasTx { + t.Fatalf("hook calls=%d tx=%t", calls, hasTx) + } + if gotPart == nil || *gotPart != uint64(env.top.ID) { + t.Fatalf("partition = %v, want %d", gotPart, env.top.ID) + } + if inTx[env.b] != 1 || inTx[env.a] != 2 || inTx[env.x] != 7 { + t.Fatalf("hook saw %v through the transaction, want the new order", inTx) + } + if outside[env.a] != 1 || outside[env.b] != 2 { + t.Fatalf("outside the transaction %v, want the old order before commit", outside) + } + if afterCommit == nil || afterCommit[env.b] != 1 || afterCommit[env.a] != 2 { + t.Fatalf("AfterCommit work saw %v, want the committed new order", afterCommit) + } + if got := sorts(t, env.db); got[env.b] != 1 || got[env.a] != 2 || got[env.x] != 7 { + t.Fatalf("stored = %v", got) + } +} + +func TestReorderHookErrorRollsBack(t *testing.T) { + for _, tc := range []struct { + name string + err error + status int + }{ + {"plain error", errors.New("purge registry unavailable"), http.StatusInternalServerError}, + {"validation error", &cabana.ValidationError{Details: map[string]any{"nodes": []string{"Refused."}}}, http.StatusUnprocessableEntity}, + {"forbidden", &cabana.ForbiddenError{Message: "Locked."}, http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + var afterCommitRan bool + env := newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, _ *uint64) error { + tx, _ := cabana.TxFromContext(ctx) + lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { afterCommitRan = true }) + return tc.err + }}) + before := sorts(t, env.db) + code, body := env.swap(t) + if code != tc.status { + t.Fatalf("status=%d want %d body=%s", code, tc.status, body) + } + if after := sorts(t, env.db); fmt.Sprint(after) != fmt.Sprint(before) { + t.Fatalf("sort values changed: before %v after %v", before, after) + } + if afterCommitRan { + t.Fatal("AfterCommit work ran after a rolled-back reorder") + } + if tc.status == http.StatusInternalServerError && (strings.Contains(body, "purge registry") || !strings.Contains(body, `"error"`)) { + t.Fatalf("500 body = %s", body) + } + }) + } +} + +func TestReorderWithoutHookUnchanged(t *testing.T) { + plain := newShelfHookEnv(t, shelfPlugin{}) + code, plainBody := plain.swap(t) + if code != http.StatusOK { + t.Fatalf("POST status=%d body=%s", code, plainBody) + } + if got := sorts(t, plain.db); got[plain.b] != 1 || got[plain.a] != 2 || got[plain.x] != 7 { + t.Fatalf("stored = %v", got) + } + + // A hook that does nothing answers the same tree. + hooked := newShelfHookEnv(t, hookShelfPlugin{hook: func(context.Context, *uint64) error { return nil }}) + code, hookedBody := hooked.swap(t) + if code != http.StatusOK { + t.Fatalf("hooked POST status=%d body=%s", code, hookedBody) + } + if plainBody != hookedBody { + t.Fatalf("answers differ:\nwithout hook %s\nwith hook %s", plainBody, hookedBody) + } +} diff --git a/modules/surf/apidocs_test.go b/modules/surf/apidocs_test.go new file mode 100644 index 0000000..c58ec75 --- /dev/null +++ b/modules/surf/apidocs_test.go @@ -0,0 +1,294 @@ +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_-]+)' https://cdn\.jsdelivr\.net;`).FindStringSubmatch(csp) + if m == nil { + t.Fatalf("CSP %q", 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") + } +} diff --git a/modules/surf/cors_profile_test.go b/modules/surf/cors_profile_test.go new file mode 100644 index 0000000..7a623bc --- /dev/null +++ b/modules/surf/cors_profile_test.go @@ -0,0 +1,459 @@ +package surf + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" +) + +// Unit tests for the CORS hardening (D-04), the cors: policies +// (D-05) and the turnstile factory parameters (D-18) of phase 19.3. + +func corsRequest(method, target, origin string, preflight bool) *http.Request { + r := httptest.NewRequest(method, target, nil) + if origin != "" { + r.Header.Set("Origin", origin) + } + if preflight { + r.Header.Set("Access-Control-Request-Method", "POST") + r.Header.Set("Access-Control-Request-Headers", "content-type") + } + return r +} + +func serveCORS(h http.Handler, r *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + return rec +} + +func okHandler(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } + +func TestCORSCredentialsWildcardRefused(t *testing.T) { + _, err := LoadCORSConfig(writeHTTPConfig(t, ` +cors: + paths: ["api/*"] + allowed_origins: ["https://www.example.com", "*"] + supports_credentials: true +`)) + if err == nil || !strings.Contains(err.Error(), "http.cors.supports_credentials") || !strings.Contains(err.Error(), "http.cors.allowed_origins") { + t.Fatalf("global error = %v", err) + } + + _, err = LoadCORSProfiles(writeHTTPConfig(t, ` +cors_profiles: + spa: + allowed_origins: ["*"] + supports_credentials: true +`)) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.supports_credentials") { + t.Fatalf("profile error = %v", err) + } + + // The same origins without credentials load. + if _, err := LoadCORSConfig(writeHTTPConfig(t, "cors:\n paths: [\"api/*\"]\n allowed_origins: [\"*\"]\n")); err != nil { + t.Fatalf("* without credentials: %v", err) + } + // Credentials with exact origins and patterns load. + if _, err := LoadCORSConfig(writeHTTPConfig(t, ` +cors: + paths: ["api/*"] + allowed_origins: ["https://www.example.com", "https://*.example.com"] + allowed_origins_patterns: ['^https://[a-z]+\.example\.org$'] + supports_credentials: true +`)); err != nil { + t.Fatalf("credentials with exact origins: %v", err) + } +} + +func TestCORSPatternErrors(t *testing.T) { + for _, tc := range []struct { + name, pattern, want string + }{ + {"uncompilable", `^https://(www\.example\.com$`, "does not compile"}, + {"hash delimited", `#^https://www\.example\.com$#`, "PHP delimiters"}, + {"slash delimited with flags", `/^https://www\.example\.com$/i`, "PHP delimiters"}, + } { + t.Run(tc.name, func(t *testing.T) { + yaml := "cors:\n paths: [\"api/*\"]\n allowed_origins_patterns:\n - 'https://ok\\.example\\.com'\n - '" + tc.pattern + "'\n" + _, err := LoadCORSConfig(writeHTTPConfig(t, yaml)) + if err == nil || !strings.Contains(err.Error(), "http.cors.allowed_origins_patterns[1]") || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("global error = %v", err) + } + pyaml := "cors_profiles:\n spa:\n allowed_origins_patterns:\n - '" + tc.pattern + "'\n" + _, err = LoadCORSProfiles(writeHTTPConfig(t, pyaml)) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.allowed_origins_patterns[0]") { + t.Fatalf("profile error = %v", err) + } + }) + } + // A pattern that merely contains # or / later is not delimited. + if phpDelimited(`^https://x\.example\.com/#$`) || !phpDelimited(`#x#`) || phpDelimited(`#x#1`) { + t.Fatal("phpDelimited misclassifies") + } +} + +func TestCORSWildcardOriginEntry(t *testing.T) { + h := pathScopedCORS(CORSConfig{ + Paths: []string{"api/*"}, + AllowedOrigins: []string{"https://*.example.com"}, + AllowedMethods: []string{"GET"}, + SupportsCredentials: true, + }, http.HandlerFunc(okHandler)) + for origin, allowed := range map[string]bool{ + "https://www.example.com": true, + "https://a.b.example.com": true, + "https://example.com": false, + "https://example.com.evil.org": false, + "https://wwwexample.com": false, + "http://www.example.com": false, + "https://www.example.com.": false, + } { + rec := serveCORS(h, corsRequest(http.MethodGet, "/api/x", origin, false)) + got := rec.Header().Get("Access-Control-Allow-Origin") + if allowed && got != origin { + t.Errorf("%s: Allow-Origin = %q, want the origin", origin, got) + } + if !allowed && got != "" { + t.Errorf("%s: Allow-Origin = %q, want none", origin, got) + } + } + if got := wildcardOriginPattern("https://*.example.com"); got != `^https://.*\.example\.com$` { + t.Fatalf("pattern = %q", got) + } +} + +func TestCORSVaryOnDynamicAnswers(t *testing.T) { + for _, tc := range []struct { + name string + cfg CORSConfig + }{ + {"credentials", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com"}, AllowedMethods: []string{"GET", "POST"}, SupportsCredentials: true}}, + {"patterns", CORSConfig{Paths: []string{"api/*"}, AllowedOriginsPatterns: []string{`^https://www\.example\.com$`}, AllowedMethods: []string{"*"}}}, + {"several origins", CORSConfig{Paths: []string{"api/*"}, AllowedOrigins: []string{"https://www.example.com", "https://admin.example.com"}, AllowedMethods: []string{"*"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := pathScopedCORS(tc.cfg, http.HandlerFunc(okHandler)) + for _, req := range []struct { + label string + r *http.Request + allow bool + }{ + {"allowed GET", corsRequest(http.MethodGet, "/api/x", "https://www.example.com", false), true}, + {"disallowed GET", corsRequest(http.MethodGet, "/api/x", "https://evil.example.org", false), false}, + {"no origin GET", corsRequest(http.MethodGet, "/api/x", "", false), false}, + {"allowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://www.example.com", true), true}, + {"disallowed preflight", corsRequest(http.MethodOptions, "/api/x", "https://evil.example.org", true), false}, + } { + rec := serveCORS(h, req.r) + if !varyHas(rec.Header(), "Origin") { + t.Errorf("%s: Vary = %q, want Origin", req.label, rec.Header().Values("Vary")) + } + got := rec.Header().Get("Access-Control-Allow-Origin") + if req.allow && got != "https://www.example.com" { + t.Errorf("%s: Allow-Origin = %q", req.label, got) + } + if !req.allow && got != "" { + t.Errorf("%s: Allow-Origin = %q, want none", req.label, got) + } + if !req.allow && rec.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Errorf("%s: credentials header on a refused origin", req.label) + } + } + }) + } + + // Vary is added once, next to an existing Vary value. + h := http.Header{} + h.Add("Vary", "Accept-Encoding, origin") + addVary(h, "Origin") + if got := h.Values("Vary"); len(got) != 1 { + t.Fatalf("Vary = %q", got) + } +} + +func TestCORSSubdomainCredentialed(t *testing.T) { + const www = "https://www.example.com" + app := transportApp(t, "production", map[string]string{ + "app.yaml": "name: transport-test\nurl: https://api.example.com\n", + "http.yaml": transportBodyLimits + `cors: + paths: ["api/*"] + allowed_origins: ["https://www.example.com"] + allowed_methods: ["GET", "POST"] + allowed_headers: ["Content-Type", "X-Requested-With"] + exposed_headers: ["X-Request-Id"] + max_age: 600 + supports_credentials: true +`, + }) + plugin := routesPluginFunc{id: "acme.auth", routes: func(r pact.Router) { + r.Get("/api/me", okHandler) + r.Post("/api/auth/refresh", okHandler) + }} + h, err := Assemble(app, []party.Plugin{plugin}) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + label string + r *http.Request + code int + }{ + {"GET", corsRequest(http.MethodGet, "https://api.example.com/api/me", www, false), http.StatusOK}, + {"POST", corsRequest(http.MethodPost, "https://api.example.com/api/auth/refresh", www, false), http.StatusOK}, + {"preflight", corsRequest(http.MethodOptions, "https://api.example.com/api/auth/refresh", www, true), http.StatusNoContent}, + } { + rec := serveCORS(h, tc.r) + hd := rec.Header() + if rec.Code != tc.code { + t.Fatalf("%s: status %d", tc.label, rec.Code) + } + if hd.Get("Access-Control-Allow-Origin") != www || hd.Get("Access-Control-Allow-Credentials") != "true" { + t.Errorf("%s: Allow-Origin %q Allow-Credentials %q", tc.label, hd.Get("Access-Control-Allow-Origin"), hd.Get("Access-Control-Allow-Credentials")) + } + if !varyHas(hd, "Origin") { + t.Errorf("%s: Vary = %q", tc.label, hd.Values("Vary")) + } + if hd.Get("Access-Control-Allow-Methods") != "GET, POST" || hd.Get("Access-Control-Allow-Headers") != "Content-Type, X-Requested-With" { + t.Errorf("%s: methods %q headers %q", tc.label, hd.Get("Access-Control-Allow-Methods"), hd.Get("Access-Control-Allow-Headers")) + } + if hd.Get("Access-Control-Expose-Headers") != "X-Request-Id" || hd.Get("Access-Control-Max-Age") != "600" { + t.Errorf("%s: expose %q max-age %q", tc.label, hd.Get("Access-Control-Expose-Headers"), hd.Get("Access-Control-Max-Age")) + } + } + // The apex host and another site get nothing. + for _, origin := range []string{"https://example.com", "https://www.example.org"} { + rec := serveCORS(h, corsRequest(http.MethodGet, "https://api.example.com/api/me", origin, false)) + if rec.Header().Get("Access-Control-Allow-Origin") != "" || rec.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Errorf("%s: answered with CORS headers %v", origin, rec.Header()) + } + } +} + +func TestCORSStarUnchanged(t *testing.T) { + cfg := CORSConfig{ + Paths: []string{"api/*"}, + AllowedMethods: []string{"*"}, + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"*"}, + } + h := pathScopedCORS(cfg, http.HandlerFunc(okHandler)) + + rec := serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "https://app.example.test", false)) + want := http.Header{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Allow-Methods": {"*"}, + "Access-Control-Allow-Headers": {"*"}, + } + assertHeaders(t, "GET", rec.Header(), want) + + // Without an Origin the * answer is the same. + rec = serveCORS(h, corsRequest(http.MethodGet, "/api/genres", "", false)) + assertHeaders(t, "GET without Origin", rec.Header(), want) + + rec = serveCORS(h, corsRequest(http.MethodOptions, "/api/genres", "https://app.example.test", true)) + assertHeaders(t, "OPTIONS", rec.Header(), http.Header{ + "Access-Control-Allow-Origin": {"*"}, + "Access-Control-Allow-Methods": {"POST"}, + "Access-Control-Allow-Headers": {"content-type"}, + "Cache-Control": {"no-cache, private"}, + "Content-Type": {"text/html; charset=UTF-8"}, + "Vary": {"Access-Control-Request-Method, Access-Control-Request-Headers"}, + }) + if rec.Code != http.StatusNoContent { + t.Fatalf("OPTIONS status %d", rec.Code) + } +} + +// assertHeaders requires got to carry exactly the header set want. +func assertHeaders(t *testing.T, label string, got, want http.Header) { + t.Helper() + for k, v := range want { + if g := got.Values(k); strings.Join(g, "|") != strings.Join(v, "|") { + t.Errorf("%s: %s = %q, want %q", label, k, g, v) + } + } + for k := range got { + if _, ok := want[k]; !ok { + t.Errorf("%s: unexpected header %s: %q", label, k, got.Values(k)) + } + } +} + +const spaProfileYAML = `cors_profiles: + spa: + allowed_origins: ["https://www.example.com"] + allowed_methods: ["GET", "POST"] + allowed_headers: ["Content-Type"] + supports_credentials: true + open: + allowed_origins: ["*"] + allowed_methods: ["GET"] +` + +func TestCORSProfilePreflightRoute(t *testing.T) { + const www = "https://www.example.com" + plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Group("/x", Use("cors:spa"), func(g pact.Router) { + g.Get("/me", okHandler) + g.Post("/me", okHandler) + g.Get("/items/{id}", okHandler) + }) + r.Get("/public", okHandler, "cors:open") + r.Get("/plain", okHandler) + }} + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}) + router, err := BuildRouter(app, []party.Plugin{plugin}) + if err != nil { + t.Fatal(err) + } + options := map[string]int{} + for _, ri := range router.Routes() { + if ri.Method == http.MethodOptions { + if ri.PluginID != corsPreflightOwner { + t.Errorf("OPTIONS %s owned by %q", ri.Pattern, ri.PluginID) + } + options[ri.Pattern]++ + } + } + if len(options) != 3 || options["/x/me"] != 1 || options["/x/items/{id}"] != 1 || options["/public"] != 1 { + t.Fatalf("OPTIONS routes = %v, want one per profiled path", options) + } + h, err := router.compile() + if err != nil { + t.Fatal(err) + } + + rec := serveCORS(h, corsRequest(http.MethodOptions, "/x/items/7", www, true)) + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Methods") != "GET, POST" { + t.Fatalf("spa preflight = %d %v", rec.Code, rec.Header()) + } + rec = serveCORS(h, corsRequest(http.MethodOptions, "/x/me", "https://evil.example.org", true)) + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" || !varyHas(rec.Header(), "Origin") { + t.Fatalf("refused preflight = %d %v", rec.Code, rec.Header()) + } + rec = serveCORS(h, corsRequest(http.MethodOptions, "/public", "https://any.example.net", true)) + if rec.Code != http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "*" || rec.Header().Get("Access-Control-Allow-Credentials") != "" { + t.Fatalf("open preflight = %d %v", rec.Code, rec.Header()) + } + // A path without a profile gets no preflight route. + rec = serveCORS(h, corsRequest(http.MethodOptions, "/plain", www, true)) + if rec.Code == http.StatusNoContent || rec.Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatalf("plain OPTIONS = %d %v", rec.Code, rec.Header()) + } + // The profile also applies to the real request. + rec = serveCORS(h, corsRequest(http.MethodPost, "/x/me", www, false)) + if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != www || rec.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatalf("POST = %d %v", rec.Code, rec.Header()) + } +} + +func TestCORSProfileUnknown(t *testing.T) { + plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Get("/x/me", okHandler, "cors:missing") + }} + _, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) + if err == nil || !strings.Contains(err.Error(), `"missing"`) || !strings.Contains(err.Error(), "http.cors_profiles.missing") { + t.Fatalf("error = %v", err) + } + // No profiles configured at all. + _, err = Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}), []party.Plugin{plugin}) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("error without profiles = %v", err) + } + // A profile name outside the allowed characters. + _, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles:\n Spa.App:\n allowed_origins: [\"https://x.example.com\"]\n")) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.") { + t.Fatalf("bad name error = %v", err) + } + // cors_profiles that is not a map. + _, err = LoadCORSProfiles(writeHTTPConfig(t, "cors_profiles: [spa]\n")) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles") { + t.Fatalf("list error = %v", err) + } +} + +func TestCORSProfileWithPathsRefused(t *testing.T) { + _, err := LoadCORSProfiles(writeHTTPConfig(t, `cors_profiles: + spa: + paths: ["api/*"] + allowed_origins: ["https://www.example.com"] +`)) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") || !strings.Contains(err.Error(), "cors:spa") { + t.Fatalf("error = %v", err) + } + _, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles: + spa: + paths: ["x/*"] +`}), nil) + if err == nil || !strings.Contains(err.Error(), "http.cors_profiles.spa.paths") { + t.Fatalf("BuildRouter error = %v", err) + } +} + +func TestCORSProfileOverlapGlobal(t *testing.T) { + plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Get("/api/me", okHandler, "cors:spa") + }} + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML + `cors: + paths: ["api/*"] + allowed_origins: ["*"] +`}) + _, err := BuildRouter(app, []party.Plugin{plugin}) + if err == nil || !strings.Contains(err.Error(), "http.cors.paths") || !strings.Contains(err.Error(), "/api/me") { + t.Fatalf("error = %v", err) + } + + // An OPTIONS route already registered on a profiled path collides. + r := New(nil) + r.corsProfiles = map[string]*corsPolicy{"spa": newCORSPolicy(CORSConfig{AllowedOrigins: []string{"https://www.example.com"}})} + r.add("acme.spa", "", nil, http.MethodGet, "/x/me", okHandler, []string{"cors:spa"}, false) + r.add("acme.other", "", nil, http.MethodOptions, "/x/me", okHandler, nil, false) + err = r.addCORSPreflights() + if err == nil || !strings.Contains(err.Error(), "OPTIONS /x/me") || !strings.Contains(err.Error(), "acme.other") { + t.Fatalf("own OPTIONS error = %v", err) + } +} + +func TestCORSProfileConflictingProfilesSamePath(t *testing.T) { + plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Get("/x/me", okHandler, "cors:spa") + r.Post("/x/me", okHandler, "cors:open") + }} + _, err := BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin}) + if err == nil || !strings.Contains(err.Error(), "/x/me") || !strings.Contains(err.Error(), "cors:spa") || !strings.Contains(err.Error(), "cors:open") { + t.Fatalf("error = %v", err) + } + + two := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) { + r.Group("/x", Use("cors:spa"), func(g pact.Router) { + g.Get("/me", okHandler, "cors:open") + }) + }} + _, err = BuildRouter(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{two}) + if err == nil || !strings.Contains(err.Error(), "two CORS profiles") { + t.Fatalf("two profiles on one route error = %v", err) + } +} + +func TestTurnstileFactoryParams(t *testing.T) { + files := func() map[string]string { return map[string]string{"http.yaml": transportBodyLimits} } + for _, mw := range []string{"turnstile:open", "turnstile:closed"} { + if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")}); err != nil { + t.Fatalf("%s: %v", mw, err) + } + } + for _, mw := range []string{"turnstile:maybe", "turnstile:Closed", "turnstile:"} { + _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes(mw, "turnstile:open")}) + if err == nil || !strings.Contains(err.Error(), "turnstile:open or turnstile:closed") { + t.Fatalf("%s: error = %v", mw, err) + } + } + if _, err := Assemble(transportApp(t, "development", files()), []party.Plugin{turnstileRoutes("turnstile", "turnstile:open")}); err == nil || !strings.Contains(err.Error(), `"turnstile"`) { + t.Fatalf("bare turnstile: error = %v", err) + } +} diff --git a/modules/surf/store_lookup_test.go b/modules/surf/store_lookup_test.go index 1e9202d..f59075e 100644 --- a/modules/surf/store_lookup_test.go +++ b/modules/surf/store_lookup_test.go @@ -3,6 +3,7 @@ package surf import ( "context" "fmt" + "net" "net/http" "net/http/httptest" "os" @@ -149,3 +150,107 @@ func TestRateLimitAcrossInstancesSmoke(t *testing.T) { } } } + +// Unit tests (plan 05 of phase 19.3) for BuildRouter's rate-limit store +// lookup (D-09). + +func TestBuildRouterStoreFallback(t *testing.T) { + r, err := BuildRouter(nil, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := r.limiter.store.(*MemoryStore); !ok { + t.Fatalf("nil app: store = %T", r.limiter.store) + } + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}) + r, err = BuildRouter(app, nil) + if err != nil { + t.Fatal(err) + } + mem, ok := r.limiter.store.(*MemoryStore) + if !ok { + t.Fatalf("no published store: store = %T", r.limiter.store) + } + // The fallback store counts: two attempts, max 1. + if allowed, _, _ := mem.Attempt("k", 1, time.Minute); !allowed { + t.Fatal("first attempt refused") + } + if allowed, _, _ := mem.Attempt("k", 1, time.Minute); allowed { + t.Fatal("second attempt admitted") + } +} + +func TestBuildRouterUsesPublishedStore(t *testing.T) { + app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}) + fake := &countingStore{} + if err := app.Publish[Store](fake); err != nil { + t.Fatal(err) + } + r, err := BuildRouter(app, []party.Plugin{assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}}) + if err != nil { + t.Fatal(err) + } + if r.limiter.store != Store(fake) { + t.Fatalf("limiter store = %T, want the published store", r.limiter.store) + } + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + for i, want := range []int{http.StatusOK, http.StatusOK, http.StatusTooManyRequests} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil)) + if rec.Code != want { + t.Fatalf("request %d: status %d, want %d", i+1, rec.Code, want) + } + } + if fake.n.Load() != 3 { + t.Fatalf("published store saw %d attempts, want 3", fake.n.Load()) + } +} + +func TestBuildRouterNeverDials(t *testing.T) { + // A listener standing in for Redis counts every connection. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + var accepted atomic.Int64 + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + accepted.Add(1) + _ = c.Close() + } + }() + t.Cleanup(func() { _ = ln.Close() }) + + app := redisStoreApp(t, "redis://"+ln.Addr().String()+"/0") + start := time.Now() + r, err := BuildRouter(app, []party.Plugin{assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:5,1"}}}) + if err != nil { + t.Fatal(err) + } + h, err := r.compile() + if err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if d := time.Since(start); d > time.Second { + t.Fatalf("BuildRouter and one request took %s", d) + } + if _, ok := r.limiter.store.(*MemoryStore); !ok { + t.Fatalf("store = %T, want *MemoryStore without install", r.limiter.store) + } + time.Sleep(50 * time.Millisecond) + if n := accepted.Load(); n != 0 { + t.Fatalf("Redis was dialled %d times without installCooler", n) + } +} diff --git a/scripts/check-phase19.3.sh b/scripts/check-phase19.3.sh index 99630d6..09ed45e 100755 --- a/scripts/check-phase19.3.sh +++ b/scripts/check-phase19.3.sh @@ -65,7 +65,21 @@ BOUNCER_TESTS=( TestTurnstileMiddlewareStatuses TestTurnstileNeverLogsSecret ) +SURF_TESTS=( + TestCORSCredentialsWildcardRefused TestCORSPatternErrors TestCORSWildcardOriginEntry + TestCORSVaryOnDynamicAnswers TestCORSSubdomainCredentialed TestCORSStarUnchanged + TestCORSOptionsMatchesLaravel + TestCORSProfilePreflightRoute TestCORSProfileUnknown TestCORSProfileWithPathsRefused + TestCORSProfileOverlapGlobal TestCORSProfileConflictingProfilesSamePath + TestTurnstileFactoryParams + TestAPIDocsOffByDefault TestAPIDocsServesPinnedPage TestAPIDocsSpecJSONOrder + TestAPIDocsJSONSpecInput TestAPIDocsProviderCount TestAPIDocsPathValidation + TestAPIDocsTitleEscaped + TestBuildRouterStoreFallback TestBuildRouterUsesPublishedStore TestBuildRouterNeverDials +) + # docker stage: testcontainers suites. +DOCKER_CABANA_TESTS=(TestReorderHookRunsInTx TestReorderHookErrorRollsBack TestReorderWithoutHookUnchanged) usage() { cat >&2 <<'EOF' @@ -329,6 +343,7 @@ run_go() { echo "vet -tags summercms_s3: $ROOT/modules/lagoon/..." go_short "$ROOT" go_named "$ROOT" ./modules/bouncer -short "${BOUNCER_TESTS[@]}" + go_named "$ROOT" ./modules/surf -short "${SURF_TESTS[@]}" echo "phase19.3 go passed" } @@ -341,7 +356,8 @@ run_docker() { echo "refuse: the docker stage needs a reachable Docker daemon (testcontainers); run it on a Docker host" >&2 return 1 fi - not_wired docker + go_named "$ROOT" ./modules/cabana "-timeout 20m" "${DOCKER_CABANA_TESTS[@]}" + echo "phase19.3 docker passed" } run_plugins() {