Skip to content

Commit 38f718c

Browse files
bobakemamianclaude
andauthored
fix(webhook): QA review criticals — shutdown drain, readiness token, pending-map leak, tests (#169)
* fix(webhook): address QA review criticals + minimal test coverage Closes four findings from the post-merge review of PR #161: C1 — in-flight drawer presses were abandoned on listen shutdown. h.inFlight counter was incremented/decremented but never read, so Ctrl-C dropped goroutines mid-press. Replaced with sync.WaitGroup + shared pressCtx: shutdown now drains in-flight presses up to 30s, then cancels the shared context so cooperative-cancel goroutines unwind cleanly before runListen returns. C3 — waitForReady over-trusted DNS. A stale DNS record pointing the hostname at some unrelated origin that returns 2xx on /healthz would pass a naive status-only check. Server now mints a per-process random readiness token, embeds it in /healthz JSON, and waitForReady parses + matches it before accepting the tunnel as ready. Closes the loop through edge DNS → CF → local process rather than just testing any leg of it. MintReadyToken() exposed for callers (cmd/serve.go) that run their own HTTP mux. I1 — pending map was an unbounded leak. Dropped entirely. Replaced Wait() semantics with an explicit Register()/Deregister() API; Wait remains as a convenience that handles both. Callers now Register BEFORE publishing the external URL, eliminating the "event arrives before Wait" race that pending was there to cover. webhook test updated to match. P1 — internal/webhook had no tests. Added: - TestNewCorrelationID_Uniqueness (10k ids, no dups) - TestServer_RegisterBeforeReceive (main flow) - TestServer_WaitContextCancelled (no waiter leak) - TestServer_HandleWebhook_RejectsSlashInID (path guard) - TestServer_Healthz_TokenEmbedded (readiness contract) - TestVerifyAuth for none/basic/header/jwt + env-ref + expired token Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(serve): defer cancelPresses to satisfy lostcancel vet check Vet flagged a context-leak path: cancelPresses was assigned but not guaranteed to fire if the readyToken mint failed before the later shutdown call. Added `defer cancelPresses()` immediately after the context is created so every return path closes it. Idempotent — the explicit call in the shutdown drain still works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b9cc497 commit 38f718c

6 files changed

Lines changed: 567 additions & 78 deletions

File tree

cmd/serve.go

Lines changed: 76 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,34 @@ func runListen(cmd *cobra.Command, args []string) error {
9191
}
9292
localURL := fmt.Sprintf("http://%s", ln.Addr().String())
9393

94+
// Press context: shared across every in-flight drawer press so
95+
// shutdown can cancel all of them at once (cooperative cancel
96+
// lands way faster than waiting out the 1h press deadline).
97+
// Defer the cancel so early-return paths don't leak the context —
98+
// vet flags uncalled cancel functions as a leak risk.
99+
pressCtx, cancelPresses := context.WithCancel(context.Background())
100+
defer cancelPresses()
101+
102+
// Readiness token: the tunnel verifier pulls /healthz through the
103+
// public URL and matches this token. Closes the "stale DNS
104+
// pointing elsewhere returns 2xx" gap.
105+
readyToken, err := webhook.MintReadyToken()
106+
if err != nil {
107+
return fmt.Errorf("mint ready token: %w", err)
108+
}
109+
94110
mux := http.NewServeMux()
95-
h := &serveHandler{dsvc: dsvc, routes: routes}
111+
h := &serveHandler{
112+
dsvc: dsvc,
113+
routes: routes,
114+
pressCtx: pressCtx,
115+
cancelAll: cancelPresses,
116+
}
96117
mux.Handle("/", h)
97118
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
119+
w.Header().Set("Content-Type", "application/json")
98120
w.WriteHeader(http.StatusOK)
99-
_, _ = w.Write([]byte(`{"ok":true}`))
121+
_, _ = w.Write([]byte(`{"ok":true,"token":"` + readyToken + `"}`))
100122
})
101123

102124
srv := &http.Server{
@@ -119,7 +141,7 @@ func runListen(cmd *cobra.Command, args []string) error {
119141
return handleWebhookErr(err)
120142
}
121143
tunnelCtx, tunnelCancel := context.WithTimeout(ctx, 90*time.Second)
122-
tunnel, err = webhook.StartTunnel(tunnelCtx, localURL)
144+
tunnel, err = webhook.StartTunnel(tunnelCtx, localURL, readyToken)
123145
tunnelCancel()
124146
if err != nil {
125147
_ = srv.Shutdown(context.Background())
@@ -146,9 +168,40 @@ func runListen(cmd *cobra.Command, args []string) error {
146168
case <-ctx.Done():
147169
}
148170

171+
// Orderly shutdown:
172+
//
173+
// 1. Stop accepting new connections (srv.Shutdown) — 5s grace.
174+
// 2. Wait for in-flight presses up to drainTimeout. Shorter than
175+
// the per-press cap on purpose: operators want Ctrl-C to
176+
// feel responsive, and steps that haven't already observed
177+
// cancellation probably won't.
178+
// 3. Cancel every in-flight press (cancelPresses) so any
179+
// remaining goroutines unwind via context.Done and we don't
180+
// leave zombies after runListen returns.
181+
const drainTimeout = 30 * time.Second
182+
149183
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
150184
defer shutdownCancel()
151185
_ = srv.Shutdown(shutdownCtx)
186+
187+
drained := make(chan struct{})
188+
go func() {
189+
h.wg.Wait()
190+
close(drained)
191+
}()
192+
select {
193+
case <-drained:
194+
// All presses finished cleanly.
195+
case <-time.After(drainTimeout):
196+
fmt.Fprintln(os.Stderr, "draining in-flight presses timed out; cancelling remaining work")
197+
}
198+
cancelPresses()
199+
// Short final wait so cancel-triggered goroutines can clean up
200+
// before runListen's caller returns (history flush, etc.).
201+
select {
202+
case <-drained:
203+
case <-time.After(2 * time.Second):
204+
}
152205
return nil
153206
}
154207

@@ -236,14 +289,18 @@ func printServeBanner(publicBase string, routes []route, tunnel *webhook.Tunnel)
236289
}
237290

238291
// serveHandler dispatches incoming POSTs to the drawer registered for
239-
// the request path. Holds a sync.Mutex around the in-flight count so a
240-
// graceful shutdown can wait for drawer presses rather than dropping
241-
// them mid-run.
292+
// the request path. A WaitGroup tracks in-flight drawer presses so
293+
// graceful shutdown can actually wait for them to finish instead of
294+
// abandoning goroutines the moment the HTTP listener closes. The
295+
// press context is derived from pressCtx so Ctrl-C propagates
296+
// cooperatively — drawer steps honoring ctx cancellation return
297+
// promptly rather than hanging until the 1h press deadline.
242298
type serveHandler struct {
243-
dsvc *drawer.Service
244-
routes []route
245-
mu sync.Mutex
246-
inFlight int
299+
dsvc *drawer.Service
300+
routes []route
301+
wg sync.WaitGroup
302+
pressCtx context.Context
303+
cancelAll context.CancelFunc
247304
}
248305

249306
func (h *serveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -353,19 +410,17 @@ func (h *serveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
353410
// Press asynchronously: respond to the webhook sender right away
354411
// with a run id. Most services retry aggressively on slow webhook
355412
// responses, and a long-running drawer would block them needlessly.
356-
h.mu.Lock()
357-
h.inFlight++
358-
h.mu.Unlock()
413+
//
414+
// WaitGroup lets runListen's shutdown path drain in-flight presses
415+
// instead of abandoning them.
416+
h.wg.Add(1)
359417

360418
go func() {
361-
defer func() {
362-
h.mu.Lock()
363-
h.inFlight--
364-
h.mu.Unlock()
365-
}()
366-
// Fresh context — not tied to the request (which is about to
367-
// close). 1h cap so a hung drawer can't leak forever.
368-
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
419+
defer h.wg.Done()
420+
// Derive from h.pressCtx (cancelled on shutdown) with a 1h
421+
// cap so a wedged drawer can't leak forever. Steps that honor
422+
// ctx observe cancellation and return cleanly.
423+
ctx, cancel := context.WithTimeout(h.pressCtx, time.Hour)
369424
defer cancel()
370425

371426
exec := drawer.NewExecutor()

cmd/webhook.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ func runWebhookTest(cmd *cobra.Command, args []string) error {
253253
if !jsonOutput {
254254
fmt.Fprintf(os.Stderr, " Starting tunnel (this takes ~15s)…\n")
255255
}
256-
t, err := webhook.StartTunnel(ctx, srv.LocalURL())
256+
t, err := webhook.StartTunnel(ctx, srv.LocalURL(), srv.ReadyToken())
257257
if err != nil {
258258
return handleWebhookErr(err)
259259
}
@@ -263,24 +263,36 @@ func runWebhookTest(cmd *cobra.Command, args []string) error {
263263
if err != nil {
264264
return handleWebhookErr(err)
265265
}
266+
// Register the waiter BEFORE publishing the URL (spawning the
267+
// POST goroutine). Eliminates the "event arrives before Wait
268+
// registers" race — no pending-map needed server-side.
269+
waiterCh := srv.Register(corr)
270+
defer srv.Deregister(corr)
271+
266272
postURL := fmt.Sprintf("%s/webhook/%s", t.URL, corr)
267273

268274
if !jsonOutput {
269275
fmt.Fprintf(os.Stderr, " Tunnel up: %s\n", t.URL)
270276
fmt.Fprintf(os.Stderr, " POSTing to %s …\n", postURL)
271277
}
272278

273-
// Fire the test POST in the background; cloudflared sometimes
274-
// needs a beat before the first edge request routes, so we retry
275-
// with a short backoff.
279+
// Fire the test POST in the background. Tunnel readiness is
280+
// already verified by StartTunnel's /healthz + token check.
276281
delivery := make(chan error, 1)
277282
go func() {
278283
delivery <- selfPost(ctx, postURL)
279284
}()
280285

281286
waitCtx, waitCancel := context.WithTimeout(ctx, 60*time.Second)
282287
defer waitCancel()
283-
ev, waitErr := srv.Wait(waitCtx, corr)
288+
289+
var ev webhook.Event
290+
var waitErr error
291+
select {
292+
case ev = <-waiterCh:
293+
case <-waitCtx.Done():
294+
waitErr = waitCtx.Err()
295+
}
284296
if waitErr != nil {
285297
postErr := <-delivery
286298
return handleWebhookErr(fmt.Errorf("no webhook received within 60s (post err: %v, wait err: %w)", postErr, waitErr))

internal/webhook/auth_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package webhook
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"encoding/base64"
7+
"encoding/json"
8+
"net/http"
9+
"net/http/httptest"
10+
"strings"
11+
"testing"
12+
"time"
13+
)
14+
15+
// Guards the four n8n-style auth paths end-to-end. Each test uses a
16+
// plain httptest request (no tunnel/server) and feeds directly into
17+
// VerifyAuth so these stay fast and deterministic.
18+
19+
func TestVerifyAuth_NoneAndNil(t *testing.T) {
20+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
21+
for _, cfg := range []*TriggerAuthConfig{nil, {Type: ""}, {Type: "none"}} {
22+
if res := VerifyAuth(cfg, req); !res.OK {
23+
t.Errorf("nil/empty/none cfg should pass, got %+v", res)
24+
}
25+
}
26+
}
27+
28+
func TestVerifyAuth_BasicHappyPath(t *testing.T) {
29+
cfg := &TriggerAuthConfig{Type: "basic", Username: "u", Password: "p"}
30+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
31+
req.SetBasicAuth("u", "p")
32+
if res := VerifyAuth(cfg, req); !res.OK {
33+
t.Fatalf("basic should pass with matching creds: %+v", res)
34+
}
35+
}
36+
37+
func TestVerifyAuth_BasicRejects(t *testing.T) {
38+
cfg := &TriggerAuthConfig{Type: "basic", Username: "u", Password: "p"}
39+
cases := []struct {
40+
name string
41+
apply func(*http.Request)
42+
wantCode string
43+
wantStatus int
44+
}{
45+
{"missing", func(r *http.Request) {}, "AUTH_MISSING", http.StatusUnauthorized},
46+
{"wrong_user", func(r *http.Request) { r.SetBasicAuth("x", "p") }, "AUTH_INVALID", http.StatusUnauthorized},
47+
{"wrong_pass", func(r *http.Request) { r.SetBasicAuth("u", "x") }, "AUTH_INVALID", http.StatusUnauthorized},
48+
}
49+
for _, tc := range cases {
50+
t.Run(tc.name, func(t *testing.T) {
51+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
52+
tc.apply(req)
53+
res := VerifyAuth(cfg, req)
54+
if res.OK {
55+
t.Fatalf("should have failed")
56+
}
57+
if res.Code != tc.wantCode || res.Status != tc.wantStatus {
58+
t.Errorf("got %s/%d, want %s/%d", res.Code, res.Status, tc.wantCode, tc.wantStatus)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestVerifyAuth_HeaderHappyAndMismatch(t *testing.T) {
65+
cfg := &TriggerAuthConfig{Type: "header", HeaderName: "X-Token", HeaderValue: "s3cret"}
66+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
67+
req.Header.Set("X-Token", "s3cret")
68+
if res := VerifyAuth(cfg, req); !res.OK {
69+
t.Fatalf("should pass with matching header: %+v", res)
70+
}
71+
req2, _ := http.NewRequest(http.MethodPost, "/x", nil)
72+
req2.Header.Set("X-Token", "wrong")
73+
if res := VerifyAuth(cfg, req2); res.OK {
74+
t.Fatal("should reject on mismatch")
75+
}
76+
req3, _ := http.NewRequest(http.MethodPost, "/x", nil)
77+
// no header at all
78+
res := VerifyAuth(cfg, req3)
79+
if res.Code != "AUTH_MISSING" {
80+
t.Errorf("expected AUTH_MISSING on header absent, got %s", res.Code)
81+
}
82+
}
83+
84+
func TestVerifyAuth_EnvRefResolution(t *testing.T) {
85+
t.Setenv("BUTTONS_TEST_SECRET", "env-val")
86+
cfg := &TriggerAuthConfig{Type: "header", HeaderName: "X-Token", HeaderValue: "$ENV{BUTTONS_TEST_SECRET}"}
87+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
88+
req.Header.Set("X-Token", "env-val")
89+
if res := VerifyAuth(cfg, req); !res.OK {
90+
t.Fatalf("env-backed header should match its resolved value, got %+v", res)
91+
}
92+
}
93+
94+
// TestVerifyAuth_JWT runs through a full HS256 signing flow against
95+
// our verifier using only stdlib primitives so the test doesn't
96+
// depend on a JWT library the production code deliberately avoids.
97+
func TestVerifyAuth_JWT(t *testing.T) {
98+
secret := "test-secret"
99+
token := signHS256Token(t, secret, map[string]any{
100+
"iss": "test-issuer",
101+
"aud": "test-aud",
102+
"exp": time.Now().Add(1 * time.Hour).Unix(),
103+
})
104+
105+
cfg := &TriggerAuthConfig{Type: "jwt", JWTSecret: secret, JWTIssuer: "test-issuer", JWTAudience: "test-aud"}
106+
req, _ := http.NewRequest(http.MethodPost, "/x", nil)
107+
req.Header.Set("Authorization", "Bearer "+token)
108+
if res := VerifyAuth(cfg, req); !res.OK {
109+
t.Fatalf("valid JWT should pass: %+v", res)
110+
}
111+
112+
t.Run("wrong_issuer", func(t *testing.T) {
113+
cfg2 := &TriggerAuthConfig{Type: "jwt", JWTSecret: secret, JWTIssuer: "other"}
114+
if res := VerifyAuth(cfg2, req); res.OK {
115+
t.Fatal("should reject issuer mismatch")
116+
}
117+
})
118+
t.Run("bad_signature", func(t *testing.T) {
119+
cfg2 := &TriggerAuthConfig{Type: "jwt", JWTSecret: "different-secret"}
120+
if res := VerifyAuth(cfg2, req); res.OK {
121+
t.Fatal("should reject signature mismatch")
122+
}
123+
})
124+
t.Run("expired", func(t *testing.T) {
125+
expired := signHS256Token(t, secret, map[string]any{
126+
"exp": time.Now().Add(-2 * time.Hour).Unix(),
127+
})
128+
req2, _ := http.NewRequest(http.MethodPost, "/x", nil)
129+
req2.Header.Set("Authorization", "Bearer "+expired)
130+
cfg2 := &TriggerAuthConfig{Type: "jwt", JWTSecret: secret}
131+
res := VerifyAuth(cfg2, req2)
132+
if res.OK {
133+
t.Fatal("should reject expired")
134+
}
135+
if !strings.Contains(res.Detail, "expired") {
136+
t.Errorf("expected 'expired' in detail, got %q", res.Detail)
137+
}
138+
})
139+
t.Run("missing_bearer", func(t *testing.T) {
140+
req2, _ := http.NewRequest(http.MethodPost, "/x", nil)
141+
cfg2 := &TriggerAuthConfig{Type: "jwt", JWTSecret: secret}
142+
if res := VerifyAuth(cfg2, req2); res.Code != "AUTH_MISSING" {
143+
t.Errorf("expected AUTH_MISSING, got %s", res.Code)
144+
}
145+
})
146+
}
147+
148+
// signHS256Token builds a minimal JWT using only stdlib so our tests
149+
// don't rely on golang-jwt/jwt — that's a deliberate avoidance in
150+
// production code we don't want to undo just for tests.
151+
func signHS256Token(t *testing.T, secret string, claims map[string]any) string {
152+
t.Helper()
153+
header := map[string]any{"alg": "HS256", "typ": "JWT"}
154+
hb, _ := json.Marshal(header)
155+
cb, _ := json.Marshal(claims)
156+
h := base64.RawURLEncoding.EncodeToString(hb)
157+
c := base64.RawURLEncoding.EncodeToString(cb)
158+
signingInput := h + "." + c
159+
mac := hmac.New(sha256.New, []byte(secret))
160+
mac.Write([]byte(signingInput))
161+
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
162+
return signingInput + "." + sig
163+
}
164+
165+
// ensure httptest import used — avoids unused-import break under
166+
// future refactors that might trim this file.
167+
var _ = httptest.NewServer

0 commit comments

Comments
 (0)