test(19.3-06): unit tests for the review fixes

- TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch (D-04)
- TestAPIDocsCSPNoHostSource (D-17)
- TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults (D-03)
- TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (D-10)
This commit is contained in:
Jakub Zych
2026-10-15 22:45:16 +02:00
parent 4d4e37932c
commit 8d80580083
4 changed files with 409 additions and 0 deletions

View File

@@ -442,3 +442,89 @@ func TestCookieHandlerOptionsValidated(t *testing.T) {
t.Fatalf("valid logout options: %v", err)
}
}
func TestLoadCookieConfigProductionInferredSecure(t *testing.T) {
for _, tc := range []struct {
name string
env string
values map[string]any
wantErr bool
secure bool
}{
{"production, http app.url", "production", map[string]any{"app.url": "http://api.example.com"}, true, false},
{"production, unset app.url", "production", map[string]any{}, true, false},
{"production, malformed app.url", "production", map[string]any{"app.url": "://not a url"}, true, false},
{"production, https app.url", "production", map[string]any{"app.url": "https://api.example.com"}, false, true},
{"production, http app.url and secure true", "production", map[string]any{"app.url": "http://api.example.com", "auth.cookie.secure": true}, false, true},
{"development, http app.url", "development", map[string]any{"app.url": "http://localhost:8000"}, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := LoadCookieConfig(cookieSource{values: tc.values, env: tc.env})
if tc.wantErr {
if err == nil {
t.Fatalf("config = %+v, want an error", cfg)
}
for _, key := range []string{"auth.cookie.secure", "app.url"} {
if !strings.Contains(err.Error(), key) {
t.Errorf("error %q does not name %s", err, key)
}
}
return
}
if err != nil {
t.Fatal(err)
}
if cfg.Secure != tc.secure {
t.Fatalf("Secure = %t, want %t", cfg.Secure, tc.secure)
}
})
}
}
func TestZeroCookieConfigDefaults(t *testing.T) {
var zero CookieConfig
for name, c := range map[string]*http.Cookie{
"TokenCookie": zero.TokenCookie("tok", time.Hour),
"ForgetTokenCookie": zero.ForgetTokenCookie(),
} {
if c.Name != DefaultCookieName || c.Path != "/" || c.Domain != "" || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || !c.Secure {
t.Errorf("%s of the zero config = %+v, want token on / with HttpOnly, SameSite=Lax and Secure", name, c)
}
}
partial := CookieConfig{Name: "sid"}.TokenCookie("tok", time.Hour)
if partial.SameSite != http.SameSiteLaxMode || partial.Secure {
t.Errorf("{Name: sid} cookie SameSite %v Secure %t, want Lax and not Secure", partial.SameSite, partial.Secure)
}
write := func(w http.ResponseWriter, _ *http.Request, res CookieResult) { w.WriteHeader(res.Status) }
refresh, err := CookieRefreshHandler(CookieRefreshOptions{
RefreshTTL: time.Hour,
Refresh: func(context.Context, string) (string, error) { return "rotated", nil },
Write: write,
})
if err != nil {
t.Fatal(err)
}
logout, err := CookieLogoutHandler(CookieLogoutOptions{
Revoke: func(context.Context, string) error { return nil },
Write: write,
})
if err != nil {
t.Fatal(err)
}
for name, h := range map[string]http.Handler{"refresh": refresh, "logout": logout} {
req := httptest.NewRequest(http.MethodPost, "/auth/"+name, nil)
req.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "old"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
set := rec.Header().Get("Set-Cookie")
if set == "" {
t.Fatalf("%s: no Set-Cookie (status %d)", name, rec.Code)
}
for _, want := range []string{DefaultCookieName + "=", "Path=/", "HttpOnly", "SameSite=Lax", "Secure"} {
if !strings.Contains(set, want) {
t.Errorf("%s: Set-Cookie %q lacks %s", name, set, want)
}
}
}
}

View File

@@ -717,3 +717,162 @@ func TestSiteCachePurgeAsyncCoalesced(t *testing.T) {
}
assertNoToken(t, logs)
}
// blockingReceiver answers once release is closed (or the request ends).
func blockingReceiver(t *testing.T) (*httptest.Server, func() []purgeHit, func()) {
t.Helper()
release := make(chan struct{})
var once sync.Once
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
_, _ = w.Write([]byte(`{"purged":1}`))
case <-r.Context().Done():
}
})
free := func() { once.Do(func() { close(release) }) }
t.Cleanup(free)
return srv, hits, free
}
func TestSiteCachePurgeWaitAndClose(t *testing.T) {
t.Run("nil and idle purgers return at once", func(t *testing.T) {
var nilPurger *SiteCachePurger
if err := nilPurger.Wait(context.Background()); err != nil {
t.Fatalf("nil Wait = %v", err)
}
srv, _ := purgeReceiver(t, func(http.ResponseWriter, *http.Request) {})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := newTestPurger(t, srv).Wait(ctx); err != nil {
t.Fatalf("idle Wait under a cancelled context = %v, want nil", err)
}
})
t.Run("Wait gives up with the context and the purge is still sent", func(t *testing.T) {
captureLogs(t)
srv, hits, free := blockingReceiver(t)
p := newTestPurger(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "pending")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := p.Wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Wait = %v, want context.DeadlineExceeded", err)
}
free()
waitPurges(t, p)
if got := hits(); len(got) != 1 || got[0].body["slug"] != "pending" {
t.Fatalf("requests = %#v, want the queued purge", got)
}
})
install := func(t *testing.T, srv *httptest.Server) (*Handle, *SiteCachePurger) {
t.Helper()
cfg := testConfig(t)
if err := cfg.Set(SiteCachePurgeURLKey, srv.URL+"/api/__site__/cache/purge"); err != nil {
t.Fatal(err)
}
if err := cfg.Set(SiteCachePurgeTokenKey, purgeToken40); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
h, err := Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
p := SiteCacheFromApp(app)
if p == nil {
t.Fatal("Install published no purger")
}
return h, p
}
t.Run("Handle.Close waits for a queued purge", func(t *testing.T) {
captureLogs(t)
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte(`{"purged":1}`))
})
h, p := install(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "at-shutdown")
if err := h.Close(); err != nil {
t.Fatal(err)
}
if got := hits(); len(got) != 1 || got[0].body["slug"] != "at-shutdown" {
t.Fatalf("requests after Close = %#v, want the queued purge", got)
}
})
t.Run("Handle.Close is bounded when the site hangs", func(t *testing.T) {
captureLogs(t)
srv, _, _ := blockingReceiver(t)
h, p := install(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "hung")
start := time.Now()
if err := h.Close(); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed > SiteCachePurgeTimeout+time.Second+500*time.Millisecond {
t.Fatalf("Close took %s, want at most %s plus a second", elapsed, SiteCachePurgeTimeout)
}
waitPurges(t, p)
})
}
func TestSiteCacheBulkCommitCoalesced(t *testing.T) {
gdb := purgeDB(t)
captureLogs(t)
ctx := context.Background()
run := func(t *testing.T, save func(p *SiteCachePurger) error, last string) {
t.Helper()
srv, hits, free := blockingReceiver(t)
p := newTestPurger(t, srv)
start := time.Now()
if err := save(p); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("the saves took %s while the site was blocked, want under 500ms", elapsed)
}
free()
waitPurges(t, p)
got := hits()
if len(got) != 2 {
t.Fatalf("%d requests, want 2 (one in flight, one coalesced follow-up)", len(got))
}
if got[1].body["slug"] != last {
t.Fatalf("follow-up slug = %q, want the last, %q", got[1].body["slug"], last)
}
}
t.Run("25 purges in one transaction", func(t *testing.T) {
run(t, func(p *SiteCachePurger) error {
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
for i := range 25 {
slug := "bulk-" + strconv.Itoa(i)
if err := tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error; err != nil {
return err
}
PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug)
}
return nil
})
}, "bulk-24")
})
t.Run("10 separate transactions", func(t *testing.T) {
run(t, func(p *SiteCachePurger) error {
for i := range 10 {
slug := "each-" + strconv.Itoa(i)
if err := lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug)
return tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error
}); err != nil {
return err
}
}
return nil
}, "each-9")
})
}

View File

@@ -305,3 +305,67 @@ func TestAPIDocsTitleEscaped(t *testing.T) {
t.Fatal("default title missing")
}
}
// cspDirectives splits a Content-Security-Policy value into its directives.
func cspDirectives(csp string) map[string][]string {
out := map[string][]string{}
for _, part := range strings.Split(csp, ";") {
fields := strings.Fields(part)
if len(fields) == 0 {
continue
}
out[fields[0]] = fields[1:]
}
return out
}
func TestAPIDocsCSPNoHostSource(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)
}
nonces := map[string]bool{}
for range 2 {
rec := apiGet(h, "/api-docs")
if rec.Code != http.StatusOK {
t.Fatalf("GET /api-docs = %d", rec.Code)
}
dirs := cspDirectives(rec.Header().Get("Content-Security-Policy"))
script := dirs["script-src"]
if len(script) != 1 || !strings.HasPrefix(script[0], "'nonce-") || !strings.HasSuffix(script[0], "'") {
t.Fatalf("script-src = %q, want exactly one nonce source", script)
}
nonce := strings.TrimSuffix(strings.TrimPrefix(script[0], "'nonce-"), "'")
if nonce == "" {
t.Fatal("empty nonce")
}
for _, d := range []string{"object-src", "base-uri", "frame-ancestors"} {
if got := dirs[d]; len(got) != 1 || got[0] != "'none'" {
t.Errorf("%s = %q, want 'none'", d, got)
}
}
page := rec.Body.String()
tags := regexp.MustCompile(`<script[^>]*>`).FindAllString(page, -1)
if len(tags) != 3 {
t.Fatalf("script tags = %q, want 3", tags)
}
for _, tag := range tags {
if !strings.Contains(tag, ` nonce="`+nonce+`"`) {
t.Errorf("script tag %q lacks nonce %q", tag, nonce)
}
}
for _, sri := range []string{
"sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW",
"sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw",
"sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF",
} {
if !strings.Contains(page, `integrity="`+sri+`"`) {
t.Errorf("page lacks integrity %s", sri)
}
}
nonces[nonce] = true
}
if len(nonces) != 2 {
t.Fatal("two requests got the same nonce")
}
}

View File

@@ -718,3 +718,103 @@ func TestTurnstileEmptySecretWarnsAtBoot(t *testing.T) {
t.Fatalf("warnings without turnstile routes = %v", got)
}
}
func TestCORSCredentialedOriginRules(t *testing.T) {
// Each block lists a good entry first, so a refusal names index [1].
blocks := []struct{ name, prefix, yamlHead, indent string }{
{"global", "http.cors", "cors:\n paths: [\"api/*\"]\n", " "},
{"profile", "http.cors_profiles.spa", "cors_profiles:\n spa:\n", " "},
}
load := func(t *testing.T, block int, body string) error {
t.Helper()
b := blocks[block]
cfg := writeHTTPConfig(t, b.yamlHead+body)
if b.name == "global" {
_, err := LoadCORSConfig(cfg)
return err
}
_, err := LoadCORSProfiles(cfg)
return err
}
entry := func(indent, key, good, value string, credentials bool) string {
out := indent + key + ": ['" + good + "', '" + value + "']\n"
if credentials {
out += indent + "supports_credentials: true\n"
}
return out
}
const goodOrigin, goodPattern = "https://www.example.com", `^https://www\.example\.com$`
accepted := []struct{ key, value string }{
{"allowed_origins", "https://www.example.com"},
{"allowed_origins", "https://*.example.com"},
{"allowed_origins", "https://*.api.example.com:8443"},
{"allowed_origins_patterns", `^https://[a-z]+\.example\.org$`},
}
refused := []struct{ key, value string }{
{"allowed_origins", "https://*"},
{"allowed_origins", "*://*"},
{"allowed_origins", "https://*example.com"},
{"allowed_origins", "https://*.com"},
{"allowed_origins", "http*://www.example.com"},
{"allowed_origins_patterns", `https://.*\.example\.com`},
{"allowed_origins_patterns", `^https://.*\.example\.com`},
{"allowed_origins_patterns", `^https?://.*$`},
{"allowed_origins_patterns", `^.*$`},
}
for i, b := range blocks {
for _, tc := range accepted {
good := goodOrigin
if tc.key == "allowed_origins_patterns" {
good = goodPattern
}
if err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)); err != nil {
t.Errorf("%s %s %q with credentials: %v", b.name, tc.key, tc.value, err)
}
}
for _, tc := range refused {
good := goodOrigin
if tc.key == "allowed_origins_patterns" {
good = goodPattern
}
want := b.prefix + "." + tc.key + "[1]"
err := load(t, i, entry(b.indent, tc.key, good, tc.value, true))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s %q with credentials: error = %v, want one naming %s", b.name, tc.value, err, want)
}
if err := load(t, i, entry(b.indent, tc.key, good, tc.value, false)); err != nil {
t.Errorf("%s %q without credentials: %v", b.name, tc.value, err)
}
}
}
}
func TestCORSAnchoredPatternNoSuffixMatch(t *testing.T) {
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
r.Get("/x/me", okHandler, "cors:spa")
}}
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
spa:
allowed_origins_patterns: ['^https://[a-z]+\.example\.com$']
allowed_methods: ["GET"]
supports_credentials: true
`})
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com", false))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" || rec.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("allowed origin: Allow-Origin %q Allow-Credentials %q", got, rec.Header().Get("Access-Control-Allow-Credentials"))
}
rec = serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com.attacker.net", false))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("suffixed origin: Allow-Origin = %q, want none", got)
}
if rec.Header().Get("Access-Control-Allow-Credentials") != "" {
t.Fatal("suffixed origin: Allow-Credentials set")
}
if !varyHas(rec.Header(), "Origin") {
t.Fatalf("suffixed origin: Vary = %q, want Origin", rec.Header().Values("Vary"))
}
}