test(19.3-05): surf CORS, profiles, api-docs and store lookup; cabana reorder hook
- cors_profile_test.go: credentials with * refused (global and profile), pattern errors, anchored wildcard origins, Vary: Origin on dynamic answers, api/www credentialed case, unchanged * answers, profile preflight routes and boot errors, turnstile factory params - apidocs_test.go: off by default, pinned 5.33.1 page with the RESEARCH SRI hashes and nonce CSP, ordered compact JSON, provider count, path validation, escaped title - store_lookup_test.go: MemoryStore fallback, published Store, no dial - reorder_hook_test.go (Postgres): hook sees the new order in the tx, its error rolls back, no hook answers unchanged - gate: surf names in the go stage, cabana names in the docker stage
This commit is contained in:
239
modules/cabana/reorder_hook_test.go
Normal file
239
modules/cabana/reorder_hook_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
294
modules/surf/apidocs_test.go
Normal file
294
modules/surf/apidocs_test.go
Normal file
@@ -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(`<script nonce="([^"]+)">`).FindAllStringSubmatch(page, -1)
|
||||
if len(scripts) != 1 || scripts[0][1] != m[1] {
|
||||
t.Fatalf("inline script nonces %v, CSP nonce %q", scripts, m[1])
|
||||
}
|
||||
if !strings.Contains(page, `url: "/api-docs.json"`) || !strings.Contains(page, "validatorUrl: null") {
|
||||
t.Error("page does not load the local spec with the validator off")
|
||||
}
|
||||
|
||||
// A fresh nonce per request.
|
||||
rec2 := apiGet(h, "/api-docs")
|
||||
if rec2.Header().Get("Content-Security-Policy") == csp {
|
||||
t.Error("nonce reused across requests")
|
||||
}
|
||||
|
||||
spec := apiGet(h, "/api-docs.json")
|
||||
if spec.Code != http.StatusOK || spec.Header().Get("Content-Type") != "application/json" || spec.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("GET /api-docs.json = %d %v", spec.Code, spec.Header())
|
||||
}
|
||||
// Both routes are public: a request without credentials gets them.
|
||||
router, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, ri := range router.Routes() {
|
||||
if ri.PluginID == apiDocsPluginID {
|
||||
n++
|
||||
if ri.Method != http.MethodGet || len(ri.Middleware) != 0 {
|
||||
t.Errorf("api docs route %+v", ri)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("api docs routes = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsSpecJSONOrder(t *testing.T) {
|
||||
want := `{"openapi":"3.0.3","info":{"version":"2","title":"Zoo API"},"paths":{"/zebras":{"get":{"summary":"List zebras"}},"/aardvarks":{"post":{"summary":"Add an aardvark"}}},"components":{}}`
|
||||
got, err := apiDocsJSON([]byte(orderedSpecYAML))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("apiDocsJSON = %s\nwant %s", got, want)
|
||||
}
|
||||
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := apiGet(h, "/api-docs.json"); rec.Body.String() != want {
|
||||
t.Fatalf("served %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsJSONSpecInput(t *testing.T) {
|
||||
got, err := apiDocsJSON([]byte("\n {\n \"openapi\": \"3.1.0\",\n \"paths\": {\"/b\": {}, \"/a\": {}},\n \"info\": {\"title\": \"T\"}\n}\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != `{"openapi":"3.1.0","paths":{"/b":{},"/a":{}},"info":{"title":"T"}}` {
|
||||
t.Fatalf("JSON input = %s", got)
|
||||
}
|
||||
for name, raw := range map[string]string{
|
||||
"empty": " \n",
|
||||
"broken json": `{"openapi": "3.0.3",`,
|
||||
"broken yaml": "openapi: [3.0\n : x",
|
||||
"not an object": "- a\n- b\n",
|
||||
"scalar document": "just text",
|
||||
} {
|
||||
if out, err := apiDocsJSON([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: converted to %s, want an error", name, out)
|
||||
}
|
||||
}
|
||||
|
||||
// A spec that does not convert, or a provider error, fails boot naming the plugin.
|
||||
for name, p := range map[string]specPlugin{
|
||||
"bad spec": {id: "acme.zoo", spec: `{"broken"`},
|
||||
"provider error": {id: "acme.zoo", err: errors.New("embedded file missing")},
|
||||
} {
|
||||
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{p})
|
||||
if err == nil || !strings.Contains(err.Error(), `"acme.zoo"`) {
|
||||
t.Errorf("%s: error = %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsProviderCount(t *testing.T) {
|
||||
plain := routesPluginFunc{id: "acme.plain", routes: func(pact.Router) {}}
|
||||
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{plain})
|
||||
if err == nil || !strings.Contains(err.Error(), "no plugin implements surf.APIDocsProvider") {
|
||||
t.Fatalf("zero providers: %v", err)
|
||||
}
|
||||
_, err = BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{
|
||||
specPlugin{id: "acme.zoo", spec: orderedSpecYAML}, plain, specPlugin{id: "acme.blog", spec: orderedSpecYAML},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.blog, acme.zoo") || !strings.Contains(err.Error(), "exactly one") {
|
||||
t.Fatalf("two providers: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsPathValidation(t *testing.T) {
|
||||
zoo := specPlugin{id: "acme.zoo", spec: orderedSpecYAML}
|
||||
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n path: /docs/api/\n"), []party.Plugin{zoo})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := apiGet(h, "/docs/api"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `url: "/docs/api.json"`) {
|
||||
t.Fatalf("GET /docs/api = %d", rec.Code)
|
||||
}
|
||||
if rec := apiGet(h, "/docs/api.json"); rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /docs/api.json = %d", rec.Code)
|
||||
}
|
||||
if rec := apiGet(h, "/api-docs"); rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("default path still served: %d", rec.Code)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"docs", "https://example.com/docs", "/docs?x=1", "/docs/{rest}", "/docs/../admin", "/docs//api", "/do cs", `/docs\x`} {
|
||||
_, err := BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n path: '"+bad+"'\n"), []party.Plugin{zoo})
|
||||
if err == nil || !strings.Contains(err.Error(), "http.api_docs.path") {
|
||||
t.Errorf("path %q: error = %v", bad, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin route on the docs path collides.
|
||||
clash := routesPluginFunc{id: "acme.clash", routes: func(r pact.Router) {
|
||||
r.Get("/api-docs.json", okHandler)
|
||||
}}
|
||||
_, err = BuildRouter(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{zoo, clash})
|
||||
if err == nil || !strings.Contains(err.Error(), "/api-docs.json") || !strings.Contains(err.Error(), "acme.clash") {
|
||||
t.Fatalf("collision error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocsTitleEscaped(t *testing.T) {
|
||||
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n title: '</title><script>alert(1)</script> & co'\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := apiGet(h, "/api-docs").Body.String()
|
||||
if strings.Contains(page, "<script>alert(1)</script>") || strings.Count(page, "</title>") != 1 {
|
||||
t.Fatalf("title not escaped:\n%s", page)
|
||||
}
|
||||
if !strings.Contains(page, "<title></title><script>alert(1)</script> & co</title>") {
|
||||
t.Fatalf("escaped title missing:\n%s", page)
|
||||
}
|
||||
|
||||
// The default title.
|
||||
h, err = Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page := apiGet(h, "/api-docs").Body.String(); !strings.Contains(page, "<title>API documentation</title>") {
|
||||
t.Fatal("default title missing")
|
||||
}
|
||||
}
|
||||
459
modules/surf/cors_profile_test.go
Normal file
459
modules/surf/cors_profile_test.go
Normal file
@@ -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:<profile> 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user