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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user