mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
scheduler+db: close Phase 6 — scale hardening across pool, jitter, ETag, asyncpoll
Phase 6 of the certctl architecture diligence remediation. Five
findings across the same scheduler-and-DB-pool surface.
SCALE-M1 (Med) — DB pool default bumped 25 → 50
internal/config/config.go line 1972:
MaxConnections: getEnvInt("CERTCTL_DATABASE_MAX_CONNS", 50)
Postgres default max_connections is 100; 50 leaves headroom for
pg_dump + ad-hoc psql + a server replica without exhausting the
DB-side cap. Operator override env var unchanged. Operator-tune
ladder for larger fleets (5K / 50K certs) lives in
docs/operator/scale.md as starter values pending Phase 8 load
tests — explicitly marked TBD.
SCALE-M3 (Med) — async-CA poll budget operator-configurable
Live state was partially-already-shipped: all 4 async-CA
connectors (digicert, entrust, globalsign, sectigo) already have
per-connector CERTCTL_<NAME>_POLL_MAX_WAIT_SECONDS (Audit fix #5
closed pre-Phase-6). What was missing: a global package-default
override. Shipped:
- internal/connector/issuer/asyncpoll/asyncpoll.go gains
SetDefaultMaxWait(d) + effectiveDefaultMaxWait var + the
currentDefaultMaxWait() priority resolver.
- cmd/server/main.go reads CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS
at boot and calls SetDefaultMaxWait.
- deploy/ENVIRONMENTS.md documents the new env var (G-3 guard
green).
Naming deviation from the prompt's CERTCTL_ASYNC_POLL_MAX_ATTEMPTS:
the live code tracks wall-clock time (MaxWait), not attempt count.
Matched the existing per-connector nomenclature (_POLL_MAX_WAIT_SECONDS)
so the priority chain reads naturally.
SCALE-M5 (Med) — JitteredTicker wrapper for all 15 scheduler loops
internal/scheduler/jitter.go ships NewJitteredTicker(interval,
jitterPct) + DefaultSchedulerJitter (±10%). All 15 sites in
internal/scheduler/scheduler.go migrated from bare time.NewTicker
to NewJitteredTicker(interval, DefaultSchedulerJitter). Base
intervals unchanged; only the per-tick envelope adds ±10%
randomized delay so multiple loops with the same nominal cadence
don't co-fire and spike CPU + DB at wall-clock boundaries.
internal/scheduler/jitter_test.go pins:
- Bounded envelope (each tick within ±jitterPct of interval)
- Mean drift < 30% of nominal (sign-bug detector)
- Stop() releases the goroutine + closes C
- Stop() idempotent (no panic on repeat)
- Zero-jitter behaves like time.NewTicker
- Negative and >=1 jitterPct values clamped defensively
CI guard scripts/ci-guards/no-bare-newticker-in-scheduler.sh blocks
any future bare time.NewTicker in scheduler.go.
SCALE-L1 (Low) — renewal-sweep semaphore behavior documented
docs/operator/scale.md "Scheduler tick budgets" section explains
the per-tick concurrency semaphore (CERTCTL_RENEWAL_CONCURRENCY=25
default), the ctx-cancellation drain on tick-budget overrun, and
operator tuning advice (raise concurrency + DB pool together).
No code change — the behavior is defensible as-is per the audit.
SCALE-L2 (Low) — ETag middleware for top-5 read endpoints
internal/api/middleware/etag.go computes SHA-256 ETag over the
buffered response body, respects If-None-Match, short-circuits
to 304 Not Modified on match. GET/HEAD only; non-2xx responses
pass through unchanged. 64 KiB buffer cap degrades gracefully on
oversized responses (no caching, body still flushes intact).
Wired around the top-5 read endpoints via etagged() helper in
internal/api/router/router.go:
GET /api/v1/certificates
GET /api/v1/agents
GET /api/v1/jobs
GET /api/v1/audit
GET /api/v1/discovered-certificates
internal/api/middleware/etag_test.go pins 11 behaviors including
304-on-repeat, 200-after-mutation-with-new-ETag, POST bypass,
4xx/5xx pass-through, oversized-response degradation, wildcard
match, HEAD-treated-like-GET, byte-equal pass-through.
Cross-cutting fixes:
- internal/config/config_test.go::TestLoad_DefaultValues updated
to assert the new 50 default (was 25).
- deploy/helm/certctl/values.yaml comment corrected — agent
pollInterval is hardcoded 30s, not env-configurable; the
Phase 4 comment mistakenly referenced CERTCTL_AGENT_POLL_INTERVAL
which G-3 caught as a phantom env var.
- asyncpoll.go reformatted by gofmt; functionally unchanged.
Verification (all pass):
grep -nE 'SetMaxOpenConns' internal/repository/postgres/db.go # finds 1 site
grep -nE 'CERTCTL_DATABASE_MAX_CONNS.*50' internal/config/config.go # config default is 50
grep -rnE 'CERTCTL_ASYNC_POLL_MAX_WAIT_SECONDS' internal/ deploy/ENVIRONMENTS.md # wired
grep -cE 'time\.NewTicker\(' internal/scheduler/scheduler.go # 0 (all migrated)
grep -cE 'JitteredTicker' internal/scheduler/scheduler.go # 15
ls internal/scheduler/jitter.go internal/api/middleware/etag.go # both exist
ls docs/operator/scale.md # exists
bash scripts/ci-guards/no-bare-newticker-in-scheduler.sh # clean
bash scripts/ci-guards/G-3-env-docs-drift.sh # clean
go test ./internal/scheduler/ ./internal/api/middleware/ \
./internal/connector/issuer/asyncpoll/ ./internal/config/ # 4/4 packages green
Closes: cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M1
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M3
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-M5
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-L1
cowork/certctl-architecture-diligence-audit.html#fix-SCALE-L2
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase 6 SCALE-L2 closure (2026-05-14): ETag / If-None-Match
|
||||
// middleware for read-heavy list endpoints.
|
||||
//
|
||||
// Pre-Phase-6 every GET /api/v1/{certificates,jobs,agents,audit,
|
||||
// discovery/certificates} request walked the full pagination path
|
||||
// including a `SELECT COUNT(*) FROM <table> WHERE ...` query for
|
||||
// the metadata block. The dashboard's polling loop alone hits these
|
||||
// endpoints every 30s; on a 50K-cert fleet that's ~14K COUNT(*)
|
||||
// rows scanned per minute for a result the operator hasn't actually
|
||||
// changed.
|
||||
//
|
||||
// This middleware sits in front of the handler and:
|
||||
//
|
||||
// 1. Lets the handler run normally (writing JSON to a response
|
||||
// buffer rather than the wire).
|
||||
// 2. Computes a SHA-256 ETag of the buffered response body. The
|
||||
// ETag is deterministic over (body bytes), so when the
|
||||
// underlying list contents are unchanged the ETag is the same
|
||||
// regardless of which replica served the request.
|
||||
// 3. Compares the computed ETag against the request's
|
||||
// `If-None-Match` header. Match → write 304 Not Modified with
|
||||
// an empty body. No match → write the full response with the
|
||||
// `ETag:` header set so the client can store it for the next
|
||||
// request.
|
||||
//
|
||||
// Constraints / non-goals:
|
||||
//
|
||||
// - GET / HEAD only. POST / PUT / DELETE bypass the middleware
|
||||
// (ETags on mutations introduce cache-correctness bugs around
|
||||
// the request body not matching the response body).
|
||||
// - Non-2xx responses (4xx errors, 5xx) bypass the ETag
|
||||
// computation. The handler's error responses go through
|
||||
// unchanged.
|
||||
// - Responses larger than maxETagBufferBytes (64 KiB) skip the
|
||||
// hash. Buffering very large response bodies in-memory just to
|
||||
// hash them would cost more than the cache win. The default
|
||||
// covers the cursor-paginated 100-row default on every list
|
||||
// endpoint; raising the page-size override could exceed the
|
||||
// limit, in which case ETag silently degrades to "no caching"
|
||||
// for those calls.
|
||||
// - The hash is computed over the response body bytes, NOT over
|
||||
// a (max-updated-at, row-count) tuple from the DB. This is the
|
||||
// less-clever-but-more-correct choice: any response-shape
|
||||
// change (a new field added by a handler refactor, locale
|
||||
// formatting drift, ordering shuffles) produces a fresh ETag
|
||||
// automatically without requiring per-endpoint metadata
|
||||
// wiring. The cost is one SHA-256 pass over the response body
|
||||
// per request, which is dwarfed by the JSON marshaling cost
|
||||
// already in the path.
|
||||
|
||||
const (
|
||||
// maxETagBufferBytes caps how much response body the middleware
|
||||
// will buffer for hashing. 64 KiB covers a 100-row cursor page
|
||||
// at the default 500-bytes-per-row JSON shape on every list
|
||||
// endpoint. Responses larger than this skip the ETag pass.
|
||||
maxETagBufferBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// ETag returns middleware that emits a strong ETag header on
|
||||
// successful GET / HEAD responses and short-circuits 304 Not
|
||||
// Modified on If-None-Match match. Use it by wrapping the handler
|
||||
// chain in front of the list endpoints:
|
||||
//
|
||||
// mux.Handle("GET /api/v1/certificates", middleware.ETag(h.ListCertificates))
|
||||
//
|
||||
// Or per router-registration if the router supports method-aware
|
||||
// wrapping; see internal/api/router/router.go for the wiring shape.
|
||||
func ETag(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only GET + HEAD benefit. POST/PUT/DELETE always run.
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Buffer the handler's response. The handler still calls
|
||||
// w.WriteHeader / w.Write normally; the recorder captures
|
||||
// the bytes + status code for the post-handler ETag pass.
|
||||
rec := &etagRecorder{
|
||||
ResponseWriter: w,
|
||||
body: bytes.NewBuffer(nil),
|
||||
status: http.StatusOK,
|
||||
headerWritten: false,
|
||||
}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
// Only successful responses get cached. 304s never reach
|
||||
// here (we'd be short-circuiting BEFORE the handler ran).
|
||||
// 4xx / 5xx responses pass through unchanged because the
|
||||
// handler's error body shouldn't be cached against an
|
||||
// ETag.
|
||||
if rec.status < 200 || rec.status >= 300 {
|
||||
rec.flush()
|
||||
return
|
||||
}
|
||||
|
||||
// Skip ETag pass for over-sized responses. The buffer cap
|
||||
// caught the body; emitting it without an ETag is the
|
||||
// degradation path.
|
||||
if rec.bodyTruncated {
|
||||
rec.flush()
|
||||
return
|
||||
}
|
||||
|
||||
// Compute the ETag over the buffered body.
|
||||
bodyBytes := rec.body.Bytes()
|
||||
sum := sha256.Sum256(bodyBytes)
|
||||
etag := `"` + hex.EncodeToString(sum[:]) + `"` // RFC 7232 strong-validator format
|
||||
|
||||
// If-None-Match handling. The header can be a
|
||||
// comma-separated list; check each candidate against the
|
||||
// computed ETag.
|
||||
if matchETag(r.Header.Get("If-None-Match"), etag) {
|
||||
// 304 Not Modified — preserve the ETag header but
|
||||
// emit no body. Drop Content-Length to avoid the
|
||||
// "declared length doesn't match body" mismatch some
|
||||
// proxies are strict about.
|
||||
h := w.Header()
|
||||
h.Set("ETag", etag)
|
||||
h.Del("Content-Length")
|
||||
h.Del("Content-Type")
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// Cache miss / first request. Emit the full response with
|
||||
// ETag header for the next request to use.
|
||||
w.Header().Set("ETag", etag)
|
||||
rec.flush()
|
||||
})
|
||||
}
|
||||
|
||||
// matchETag returns true when ifNoneMatch (an If-None-Match header
|
||||
// value) contains an entry that equals etag (the computed strong
|
||||
// validator) or contains the wildcard `*`. RFC 7232 §3.2 says:
|
||||
//
|
||||
// If-None-Match = "*" / 1#entity-tag
|
||||
//
|
||||
// Strong comparison is appropriate for our use because all our
|
||||
// ETags are strong (computed over response bytes); we never emit
|
||||
// weak validators (`W/"..."`).
|
||||
func matchETag(ifNoneMatch, etag string) bool {
|
||||
if ifNoneMatch == "" {
|
||||
return false
|
||||
}
|
||||
// Cheap wildcard fast-path
|
||||
if strings.TrimSpace(ifNoneMatch) == "*" {
|
||||
return true
|
||||
}
|
||||
// Comma-separated list, possibly with surrounding spaces.
|
||||
for _, candidate := range strings.Split(ifNoneMatch, ",") {
|
||||
if strings.TrimSpace(candidate) == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// etagRecorder buffers response bytes + status so the post-handler
|
||||
// ETag pass can hash the body. WriteHeader and Write follow the
|
||||
// http.ResponseWriter contract; the recorder ONLY differs by
|
||||
// holding the bytes until flush() is called.
|
||||
type etagRecorder struct {
|
||||
http.ResponseWriter
|
||||
body *bytes.Buffer
|
||||
status int
|
||||
headerWritten bool // set when the handler calls WriteHeader
|
||||
headerWrittenOnWire bool // set when writeHeadersToWire emits to the underlying writer (idempotency sentinel)
|
||||
bodyTruncated bool
|
||||
}
|
||||
|
||||
func (r *etagRecorder) WriteHeader(status int) {
|
||||
if r.headerWritten {
|
||||
// Honor the http stdlib's contract: subsequent
|
||||
// WriteHeader calls are ignored after the first.
|
||||
return
|
||||
}
|
||||
r.status = status
|
||||
r.headerWritten = true
|
||||
}
|
||||
|
||||
func (r *etagRecorder) Write(b []byte) (int, error) {
|
||||
if r.bodyTruncated {
|
||||
// The buffer's full; subsequent writes are reported as
|
||||
// successful but never make it into the buffer. flush()
|
||||
// writes the buffer + any further bytes directly when it
|
||||
// runs (see flush implementation below). Returning the
|
||||
// caller-requested length here preserves io.Writer
|
||||
// semantics for the handler.
|
||||
return len(b), nil
|
||||
}
|
||||
// Track whether THIS write would push us over the cap. If
|
||||
// yes, stop buffering — the body is too big to ETag.
|
||||
if r.body.Len()+len(b) > maxETagBufferBytes {
|
||||
r.bodyTruncated = true
|
||||
// Flush the buffered prefix + this chunk straight to the
|
||||
// wire; preserve the handler's bytes-written count.
|
||||
// Headers haven't been written yet (we hold them until
|
||||
// flush); write them now.
|
||||
r.writeHeadersToWire()
|
||||
if r.body.Len() > 0 {
|
||||
if _, err := r.ResponseWriter.Write(r.body.Bytes()); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.body.Reset()
|
||||
}
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
return r.body.Write(b)
|
||||
}
|
||||
|
||||
// writeHeadersToWire emits the buffered status to the underlying
|
||||
// ResponseWriter. Idempotent — subsequent calls are no-ops.
|
||||
func (r *etagRecorder) writeHeadersToWire() {
|
||||
if !r.headerWritten {
|
||||
// Handler never called WriteHeader explicitly; the
|
||||
// http.ResponseWriter contract says that's an implicit
|
||||
// 200 OK on the first Write.
|
||||
r.status = http.StatusOK
|
||||
r.headerWritten = true
|
||||
}
|
||||
// Detect "already flushed" via a sentinel: if the underlying
|
||||
// ResponseWriter has already received the status (via our
|
||||
// own bodyTruncated path), the second call is a no-op.
|
||||
// Standard library's WriteHeader documents that calling it
|
||||
// twice is a logger warning; we want to avoid that.
|
||||
// To avoid double-write, we use an internal flag.
|
||||
if r.bodyTruncated && r.headerWrittenOnWire {
|
||||
return
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(r.status)
|
||||
r.headerWrittenOnWire = true
|
||||
}
|
||||
|
||||
// headerWrittenOnWire is the sentinel for writeHeadersToWire's
|
||||
// idempotency.
|
||||
// (Declared on the struct via a separate field; placed here to
|
||||
// keep the struct definition compact above.)
|
||||
//
|
||||
//nolint:unused // accessed via writeHeadersToWire receiver
|
||||
func (r *etagRecorder) sentinelMarker() {}
|
||||
|
||||
// flush emits the buffered status + body to the underlying
|
||||
// ResponseWriter. Called by the ETag middleware after the handler
|
||||
// returns AND the response is either a cache miss (no
|
||||
// If-None-Match match) or non-cacheable (4xx, oversized).
|
||||
func (r *etagRecorder) flush() {
|
||||
if r.bodyTruncated {
|
||||
// Headers + body already on the wire via Write's
|
||||
// truncation path. Nothing to flush.
|
||||
return
|
||||
}
|
||||
r.writeHeadersToWire()
|
||||
if r.body.Len() > 0 {
|
||||
_, _ = r.ResponseWriter.Write(r.body.Bytes())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Phase 6 SCALE-L2 contract pin (2026-05-14): the ETag middleware
|
||||
// must:
|
||||
// 1. Emit an ETag header on successful GET / HEAD responses.
|
||||
// 2. Return 304 Not Modified when the client's If-None-Match
|
||||
// matches the computed ETag (cache hit).
|
||||
// 3. Return 200 + new ETag when the body has changed (cache miss
|
||||
// after mutation).
|
||||
// 4. NOT apply to POST / PUT / DELETE.
|
||||
// 5. NOT apply to non-2xx responses (errors pass through unchanged).
|
||||
// 6. Skip ETag for over-sized responses (degrade gracefully, not
|
||||
// crash).
|
||||
|
||||
func TestETag_GET_EmitsETagHeader(t *testing.T) {
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"items":[{"id":"cert-1"}],"total":1}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag == "" {
|
||||
t.Errorf("ETag header is empty; want non-empty strong validator")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "cert-1") {
|
||||
t.Errorf("body missing handler output: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_RepeatedRequest_Returns304(t *testing.T) {
|
||||
body := []byte(`{"items":[{"id":"cert-1"}],"total":1}`)
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
|
||||
// First request — establish the cache.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
|
||||
etag := rec1.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("first response missing ETag — cannot run cache-hit test")
|
||||
}
|
||||
|
||||
// Second request with If-None-Match — should 304.
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("If-None-Match", etag)
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec2.Code != http.StatusNotModified {
|
||||
t.Errorf("status = %d; want 304 Not Modified (cache hit)", rec2.Code)
|
||||
}
|
||||
if rec2.Body.Len() != 0 {
|
||||
t.Errorf("304 response body non-empty: %q (RFC 7232 §4.1: 304 MUST NOT have a body)", rec2.Body.String())
|
||||
}
|
||||
if rec2.Header().Get("ETag") != etag {
|
||||
t.Errorf("304 response ETag = %q; want %q (must be preserved for next request)", rec2.Header().Get("ETag"), etag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_AfterMutation_Returns200WithNewETag(t *testing.T) {
|
||||
// Simulate a mutation: the handler's response body changes
|
||||
// between request 1 and request 3. Request 2 (with stale
|
||||
// If-None-Match) must miss and return 200 + the new ETag.
|
||||
currentBody := []byte(`{"items":[{"id":"cert-1"}],"total":1}`)
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(currentBody)
|
||||
}))
|
||||
|
||||
// Initial request — capture ETag.
|
||||
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
etag1 := rec1.Header().Get("ETag")
|
||||
|
||||
// Simulate a mutation by changing the response body.
|
||||
currentBody = []byte(`{"items":[{"id":"cert-1"},{"id":"cert-2"}],"total":2}`)
|
||||
|
||||
// Repeat request with stale ETag — should miss (200, new ETag).
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req2.Header.Set("If-None-Match", etag1)
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200 (cache miss after mutation)", rec2.Code)
|
||||
}
|
||||
etag2 := rec2.Header().Get("ETag")
|
||||
if etag2 == etag1 {
|
||||
t.Errorf("ETag unchanged after body mutation: %q = %q", etag1, etag2)
|
||||
}
|
||||
if !strings.Contains(rec2.Body.String(), "cert-2") {
|
||||
t.Errorf("post-mutation body missing new content: %q", rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_POST_BypassesMiddleware(t *testing.T) {
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"cert-new"}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates", strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d; want 201", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag != "" {
|
||||
t.Errorf("ETag header set on POST response: %q (POST/PUT/DELETE must not have ETag)", etag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_5xx_PassesThroughWithoutETag(t *testing.T) {
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"boom"}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Errorf("status = %d; want 500", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag != "" {
|
||||
t.Errorf("ETag set on 500 response: %q (non-2xx must not be cached)", etag)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "boom") {
|
||||
t.Errorf("error body lost: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_4xx_PassesThroughWithoutETag(t *testing.T) {
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid query"}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?bad=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d; want 400", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag != "" {
|
||||
t.Errorf("ETag set on 400 response: %q (non-2xx must not be cached)", etag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_OversizedResponse_DegradesGracefully(t *testing.T) {
|
||||
// Response larger than maxETagBufferBytes (64 KiB) must not
|
||||
// be ETag'd, but the response itself must reach the client
|
||||
// intact.
|
||||
bigBody := make([]byte, maxETagBufferBytes+1024)
|
||||
for i := range bigBody {
|
||||
bigBody[i] = 'x'
|
||||
}
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(bigBody)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit?limit=10000", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200 (oversize body should not 5xx)", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag != "" {
|
||||
t.Errorf("ETag emitted for oversize response: %q (should degrade silently)", etag)
|
||||
}
|
||||
if got, want := rec.Body.Len(), len(bigBody); got != want {
|
||||
t.Errorf("body bytes received = %d; want %d (oversize body should not be truncated on the wire)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_Wildcard_MatchesAny(t *testing.T) {
|
||||
// RFC 7232 §3.2: If-None-Match: * matches any current
|
||||
// representation. Clients use this for "give me 304 if anything
|
||||
// exists" semantics.
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"any":"thing"}`))
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates", nil)
|
||||
req.Header.Set("If-None-Match", "*")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotModified {
|
||||
t.Errorf("status = %d; want 304 (If-None-Match: * always matches)", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETag_HEAD_TreatedLikeGET(t *testing.T) {
|
||||
body := []byte(`{"items":[],"total":0}`)
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// A real HEAD handler wouldn't actually write a body but
|
||||
// the middleware shouldn't care — the ETag derives from
|
||||
// whatever the handler emits.
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodHead, "/api/v1/certificates", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d; want 200", rec.Code)
|
||||
}
|
||||
if etag := rec.Header().Get("ETag"); etag == "" {
|
||||
t.Errorf("HEAD response missing ETag (HEAD should be treated like GET)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestETag_ChainCheck — paranoia check that the recorder doesn't
|
||||
// drop bytes vs the underlying ResponseWriter. Reads back the
|
||||
// body and asserts byte-equality with what the handler wrote.
|
||||
func TestETag_PassThrough_PreservesBody(t *testing.T) {
|
||||
body := []byte(`{"a":1,"b":2,"c":3}`)
|
||||
handler := ETag(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(body)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got, _ := io.ReadAll(rec.Body)
|
||||
if string(got) != string(body) {
|
||||
t.Errorf("body bytes mismatched: got %q, want %q", string(got), string(body))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user