ryu_webhook_ingress/dispatch.rs
1//! Path-aware inbound webhook dispatch — the "route ANY inbound path" seam
2//! (webhook-unify).
3//!
4//! # Why this exists
5//!
6//! Before this module the managed [`super::RyuRelaySource`] fanned out **only**
7//! `composio.webhook` frames and dispatched them straight to the composio
8//! triggers store. A per-workflow webhook trigger
9//! (`POST /api/workflows/<id>/webhook`) was therefore *unreachable* on a default
10//! laptop node: the tunnel/relay knew a single hardcoded path
11//! ([`super::WEBHOOK_PATH`]) and no other. The tunnel backends
12//! (Cloudflared/Funnel/OwnRelay) forward every path to Core's real router, so
13//! they already worked — the gap was RyuRelay's in-process dispatch.
14//!
15//! This module closes it by making dispatch **path-routed**: given
16//! `(path, raw_body, signature)` it delivers to the correct handler — the composio
17//! webhook receiver, a per-workflow webhook trigger, or (future) a registered
18//! channel — instead of composio-only. The concrete handlers are kernel; they are
19//! reached through [`super::WebhookIngressHost`].
20//!
21//! # Auth is re-verified here (the correctness crux)
22//!
23//! A per-workflow webhook's HMAC secret lives **only** in Core (the workflow's
24//! `Webhook` trigger); `apps/server` cannot know it. So a workflow frame that
25//! arrives over the relay MUST be re-verified here with the *same*
26//! `verify_workflow_webhook_signature` the HTTP handler uses (both go through the
27//! host) — dispatching it unverified would be an auth bypass that fires real side
28//! effects. Composio, by contrast, is a trust-relay: the server verifies the
29//! global secret before fan-out, so the legacy `composio.webhook` frame path stays
30//! as-is (see [`super::ryu_relay`]). The fail-closed ladder below stays in this
31//! crate; the host only performs the leaf secret lookup + crypto + run.
32//!
33//! Placement (CLAUDE.md §1): choosing which handler runs for an inbound event is
34//! *what runs* → Core. No policy here; the outbound governance the sibling
35//! `channel_send` node performs is the Gateway's job and lives there.
36
37use std::collections::BTreeMap;
38use std::sync::{Mutex, OnceLock, RwLock};
39
40use serde_json::Value;
41
42use super::host::{host, WorkflowWebhookSecret};
43use super::ryu_relay::SeenDeliveries;
44use super::WEBHOOK_PATH;
45
46/// The URL-path prefix/suffix bracketing a per-workflow webhook trigger route
47/// (`/api/workflows/<id>/webhook`). Kept in lockstep with the axum route in
48/// `server/mod.rs` so [`workflow_webhook_path`] and [`parse_workflow_webhook_path`]
49/// round-trip.
50const WORKFLOW_WEBHOOK_PREFIX: &str = "/api/workflows/";
51const WORKFLOW_WEBHOOK_SUFFIX: &str = "/webhook";
52
53/// Replay-staleness window for inbound webhooks that carry a timestamp header
54/// (seconds). A delivery whose declared timestamp is more than this far from
55/// "now" (either direction — clock skew or a replayed capture) is rejected.
56const REPLAY_WINDOW_SECS: i64 = 300;
57
58/// Build the canonical per-workflow webhook path for `id`. The registry
59/// (`GET /api/webhooks`) and the delivery recorder use this so the stored
60/// last-delivery key and the advertised URL never drift.
61pub fn workflow_webhook_path(id: &str) -> String {
62 format!("{WORKFLOW_WEBHOOK_PREFIX}{id}{WORKFLOW_WEBHOOK_SUFFIX}")
63}
64
65/// Parse a `/api/workflows/<id>/webhook` path back to its `<id>`, or `None` when
66/// the path is not a workflow-webhook route. Rejects an empty id and a nested
67/// path (an `id` may not itself contain a `/`).
68fn parse_workflow_webhook_path(path: &str) -> Option<String> {
69 let inner = path
70 .strip_prefix(WORKFLOW_WEBHOOK_PREFIX)?
71 .strip_suffix(WORKFLOW_WEBHOOK_SUFFIX)?;
72 if inner.is_empty() || inner.contains('/') {
73 return None;
74 }
75 Some(inner.to_owned())
76}
77
78// ── Last-delivery tracking (the registry's per-endpoint metadata) ─────────────
79
80/// Process-global map of `webhook path → last-delivery unix seconds`. Populated
81/// on every successful dispatch (relay *and* direct-HTTP), read by the
82/// `GET /api/webhooks` registry so each endpoint can show when it last fired.
83static LAST_DELIVERY: RwLock<BTreeMap<String, i64>> = RwLock::new(BTreeMap::new());
84
85fn now_unix() -> i64 {
86 std::time::SystemTime::now()
87 .duration_since(std::time::UNIX_EPOCH)
88 .map(|d| d.as_secs() as i64)
89 .unwrap_or(0)
90}
91
92/// Record that `path` just received (and accepted) a delivery, stamping "now".
93/// Called from both the relay dispatcher and the direct HTTP handlers so the
94/// registry reflects every source.
95pub fn record_delivery(path: &str) {
96 if let Ok(mut guard) = LAST_DELIVERY.write() {
97 guard.insert(path.to_owned(), now_unix());
98 }
99}
100
101/// The unix-seconds timestamp of the last accepted delivery for `path`, if any.
102pub fn last_delivery(path: &str) -> Option<i64> {
103 LAST_DELIVERY.read().ok().and_then(|g| g.get(path).copied())
104}
105
106// ── Direct-HTTP delivery dedup (relay parity) ─────────────────────────────────
107
108/// Process-global dedup set for DIRECT-HTTP deliveries. The relay transport
109/// keeps its own per-subscription set (ryu_relay.rs); this one covers the
110/// public HTTP handlers, which face the same at-least-once retry semantics.
111/// Returns true when `id` is new (dispatch) — false when already seen (skip).
112/// An empty id is always "new": deliveries without a delivery-id header are
113/// not dedupable and pass through unchanged.
114pub fn first_http_delivery(id: &str) -> bool {
115 static SEEN: OnceLock<Mutex<SeenDeliveries>> = OnceLock::new();
116 let lock = SEEN.get_or_init(|| Mutex::new(SeenDeliveries::default()));
117 let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner());
118 guard.insert(id)
119}
120
121// ── Replay window (acceptance #5) ─────────────────────────────────────────────
122
123/// Whether an inbound delivery is fresh enough to accept, given the value of a
124/// timestamp header (e.g. Svix/Composio `webhook-timestamp`) if present.
125///
126/// **Back-compat, low-risk posture**: a delivery with *no* timestamp header, or
127/// an unparseable one, is treated as fresh (returns `true`) — many callers
128/// (including Core's own tests and simple integrations) do not sign a timestamp,
129/// and rejecting them would break existing flows. Only a *present, parseable*
130/// timestamp that is outside [`REPLAY_WINDOW_SECS`] of `now_unix` is rejected.
131/// This adds replay protection for callers that opt in without a hard cutover.
132pub fn timestamp_fresh(ts_header: Option<&str>, now: i64) -> bool {
133 let Some(raw) = ts_header.map(str::trim).filter(|s| !s.is_empty()) else {
134 return true;
135 };
136 // Accept a bare unix-seconds value or an `t=<secs>` (Stripe-style) token.
137 let parsed = raw
138 .split(',')
139 .find_map(|tok| tok.trim().strip_prefix("t=").or(Some(tok.trim())))
140 .and_then(|v| v.parse::<i64>().ok());
141 match parsed {
142 Some(ts) => (now - ts).abs() <= REPLAY_WINDOW_SECS,
143 None => true,
144 }
145}
146
147// ── Shared per-workflow webhook delivery (reused by HTTP + relay) ─────────────
148
149/// The outcome of delivering a per-workflow webhook. Rich enough that both the
150/// axum `workflow_webhook` handler (→ HTTP status) and the relay dispatcher
151/// (→ log line) map from the *same* decision, so their auth can never drift.
152#[derive(Debug)]
153pub enum WorkflowWebhookOutcome {
154 /// The signature verified and the workflow run started; carries its run id.
155 Ran(String),
156 /// No workflow with this id exists.
157 NotFound,
158 /// The workflow exists but declares no `Webhook` trigger.
159 NoWebhookTrigger,
160 /// The webhook trigger exists but has no (non-empty) secret configured —
161 /// fail-closed: an unauthenticated public trigger is a forgery vector.
162 NoSecret,
163 /// The signature was missing or did not match.
164 BadSignature,
165 /// The body was not valid UTF-8 / JSON. Carries a human-readable reason.
166 BadBody(String),
167 /// The signature verified but starting the run failed. Carries the error.
168 RunError(String),
169 /// The signature verified but `delivery_id` was already seen (at-least-once
170 /// retry) — the run already fired on the first delivery, so this one is a
171 /// no-op. Relay parity: mirrors `ryu_relay.rs`'s `SeenDeliveries` dedup for
172 /// the direct-HTTP path.
173 Duplicate,
174}
175
176/// Verify and (on success) fire a per-workflow webhook trigger. This is the
177/// single source of truth for the workflow-webhook auth + run path — the HTTP
178/// handler and the relay dispatcher both call it, guaranteeing identical
179/// fail-closed semantics.
180///
181/// `delivery_id` is checked against the process-global HTTP seen-set
182/// ([`first_http_delivery`]) immediately AFTER the signature verifies (never
183/// before — an unauthenticated caller must not be able to poison the seen-set
184/// with a forged id and suppress a later legitimate delivery) and BEFORE the
185/// run fires. An empty `delivery_id` (no id header, or a caller — such as the
186/// relay — that already deduped upstream) is always treated as new, so passing
187/// `""` is a safe no-op.
188///
189/// On success it records the delivery against [`workflow_webhook_path`] so the
190/// registry reflects relay-delivered firings too.
191pub async fn deliver_workflow_webhook(
192 id: &str,
193 raw_body: &[u8],
194 signature: Option<&str>,
195 delivery_id: &str,
196) -> WorkflowWebhookOutcome {
197 let Ok(host) = host() else {
198 // No host installed → treat as unresolvable (fail-closed, never fires).
199 return WorkflowWebhookOutcome::NotFound;
200 };
201 // The host does the raw lookup; the empty-secret → NoSecret decision stays
202 // here (the crate owns the fail-closed ladder, not the host).
203 let secret = match host.workflow_webhook_secret(id) {
204 WorkflowWebhookSecret::NotFound => return WorkflowWebhookOutcome::NotFound,
205 WorkflowWebhookSecret::NoTrigger => return WorkflowWebhookOutcome::NoWebhookTrigger,
206 WorkflowWebhookSecret::Secret(s) => match s.filter(|s| !s.trim().is_empty()) {
207 Some(secret) => secret,
208 None => return WorkflowWebhookOutcome::NoSecret,
209 },
210 };
211 if !host.verify_workflow_webhook_signature(&secret, raw_body, signature) {
212 return WorkflowWebhookOutcome::BadSignature;
213 }
214 // Dedup AFTER auth so an unauthenticated caller cannot poison the seen-set
215 // with a forged id and suppress a legitimate delivery.
216 if !first_http_delivery(delivery_id) {
217 return WorkflowWebhookOutcome::Duplicate;
218 }
219 // The raw JSON body becomes the run's trigger payload; validate it parses so
220 // a malformed body fails fast rather than seeding unusable trigger state.
221 let Ok(body_str) = std::str::from_utf8(raw_body) else {
222 return WorkflowWebhookOutcome::BadBody("body is not valid UTF-8".to_owned());
223 };
224 if serde_json::from_str::<Value>(body_str).is_err() {
225 return WorkflowWebhookOutcome::BadBody("body must be valid JSON".to_owned());
226 }
227 match host.run_workflow_for_trigger(id, body_str).await {
228 Ok(run_id) => {
229 record_delivery(&workflow_webhook_path(id));
230 WorkflowWebhookOutcome::Ran(run_id)
231 }
232 Err(e) => WorkflowWebhookOutcome::RunError(e.to_string()),
233 }
234}
235
236// ── The path router (any inbound path) ────────────────────────────────────────
237
238/// The outcome of routing one inbound webhook by path.
239#[derive(Debug)]
240pub enum InboundOutcome {
241 /// Delivered to a handler; `detail` is a short human summary for logs.
242 Delivered { detail: String },
243 /// Recognised the path but refused the delivery (bad signature, no secret,
244 /// store unavailable, …). Carries the reason.
245 Rejected(String),
246 /// No handler is registered for this path.
247 Unhandled,
248}
249
250/// Route an inbound webhook to the correct in-process handler by `path`.
251///
252/// This is the unified replacement for the composio-only relay dispatch: it
253/// matches the composio path and every per-workflow webhook path (and is the one
254/// place a future registered-channel path would gain an arm). It re-verifies the
255/// signature for the workflow arm (the secret lives only in Core) and for the
256/// composio arm (defense-in-depth even though the relay server also verifies).
257///
258/// `signature` is the pre-extracted signature-header value (the relay frame /
259/// HTTP handler picks the right header spelling). `raw_body` is the exact bytes
260/// the signature was computed over.
261pub async fn deliver_inbound(
262 path: &str,
263 raw_body: &[u8],
264 signature: Option<&str>,
265) -> InboundOutcome {
266 if path == WEBHOOK_PATH {
267 let Ok(host) = host() else {
268 return InboundOutcome::Rejected("webhook-ingress host unavailable".to_owned());
269 };
270 // Composio: verify the global secret, then hand to the composio store.
271 if !host.verify_webhook_signature(raw_body, signature) {
272 return InboundOutcome::Rejected(
273 "composio webhook: invalid or missing signature".to_owned(),
274 );
275 }
276 let Ok(payload) = serde_json::from_slice::<Value>(raw_body) else {
277 return InboundOutcome::Rejected("composio webhook: invalid JSON body".to_owned());
278 };
279 return match host.composio_handle_webhook(&payload).await {
280 Some(fired) => {
281 record_delivery(path);
282 InboundOutcome::Delivered {
283 detail: format!("composio webhook fired {fired} run(s)"),
284 }
285 }
286 None => InboundOutcome::Rejected("composio triggers store unavailable".to_owned()),
287 };
288 }
289
290 if let Some(id) = parse_workflow_webhook_path(path) {
291 // "" for `delivery_id`: every `deliver_inbound` caller (the relay's
292 // `Inbound` arm, and Core's real-wiring test) already deduped by the
293 // frame/delivery id upstream — see `ryu_relay.rs`'s `dispatch_frame` —
294 // so a second dedup here would be redundant, and "" keeps it a no-op.
295 return match deliver_workflow_webhook(&id, raw_body, signature, "").await {
296 WorkflowWebhookOutcome::Ran(run_id) => InboundOutcome::Delivered {
297 detail: format!("workflow '{id}' run {run_id}"),
298 },
299 WorkflowWebhookOutcome::NotFound => {
300 InboundOutcome::Rejected(format!("workflow '{id}' not found"))
301 }
302 WorkflowWebhookOutcome::NoWebhookTrigger => {
303 InboundOutcome::Rejected(format!("workflow '{id}' has no webhook trigger"))
304 }
305 WorkflowWebhookOutcome::NoSecret => InboundOutcome::Rejected(format!(
306 "workflow '{id}' webhook has no secret configured"
307 )),
308 WorkflowWebhookOutcome::BadSignature => {
309 InboundOutcome::Rejected(format!("workflow '{id}': invalid or missing signature"))
310 }
311 WorkflowWebhookOutcome::BadBody(e) => {
312 InboundOutcome::Rejected(format!("workflow '{id}': {e}"))
313 }
314 WorkflowWebhookOutcome::RunError(e) => {
315 InboundOutcome::Rejected(format!("workflow '{id}' run failed: {e}"))
316 }
317 // "" is always treated as new by `first_http_delivery`, so this arm
318 // is unreachable from this call site — kept exhaustive for the enum.
319 WorkflowWebhookOutcome::Duplicate => {
320 InboundOutcome::Rejected(format!("workflow '{id}': duplicate delivery"))
321 }
322 };
323 }
324
325 InboundOutcome::Unhandled
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::host::{set_global_host, WebhookIngressHost};
332 use std::sync::Arc;
333
334 /// A single deterministic mock host installed for the crate test process
335 /// (`set_global_host` is a `OnceLock` → one host per process, so behaviour is
336 /// keyed purely on inputs). Drives the router's branches without any kernel:
337 /// - `workflow_webhook_secret`: keyed on an `-notfound` / `-notrigger` /
338 /// `-nosecret` marker in the id, else a real secret.
339 /// - `verify_workflow_webhook_signature`: true iff the signature is `"good"`.
340 /// - `run_workflow_for_trigger`: succeeds with a synthetic run id.
341 struct MockHost;
342
343 #[async_trait::async_trait]
344 impl WebhookIngressHost for MockHost {
345 fn composio_is_configured(&self) -> bool {
346 true
347 }
348 fn has_webhook_trigger(&self) -> bool {
349 false
350 }
351 fn verify_webhook_signature(&self, _raw_body: &[u8], signature: Option<&str>) -> bool {
352 signature == Some("good")
353 }
354 fn verify_workflow_webhook_signature(
355 &self,
356 _secret: &str,
357 _raw_body: &[u8],
358 signature: Option<&str>,
359 ) -> bool {
360 signature == Some("good")
361 }
362 async fn composio_handle_webhook(&self, _payload: &Value) -> Option<usize> {
363 Some(1)
364 }
365 async fn run_workflow_for_trigger(
366 &self,
367 workflow_id: &str,
368 _payload_json: &str,
369 ) -> anyhow::Result<String> {
370 Ok(format!("trigrun_{workflow_id}"))
371 }
372 fn workflow_webhook_secret(&self, workflow_id: &str) -> WorkflowWebhookSecret {
373 if workflow_id.contains("-notfound") {
374 WorkflowWebhookSecret::NotFound
375 } else if workflow_id.contains("-notrigger") {
376 WorkflowWebhookSecret::NoTrigger
377 } else if workflow_id.contains("-nosecret") {
378 WorkflowWebhookSecret::Secret(None)
379 } else {
380 WorkflowWebhookSecret::Secret(Some("s3cr3t".to_owned()))
381 }
382 }
383 fn auth_token(&self) -> Option<String> {
384 None
385 }
386 fn data_dir(&self) -> std::path::PathBuf {
387 std::env::temp_dir()
388 }
389 async fn ensure_funnel(&self, _port: u16) -> anyhow::Result<String> {
390 anyhow::bail!("mock: no funnel")
391 }
392 async fn funnel_url(&self, _port: u16) -> Option<String> {
393 None
394 }
395 }
396
397 /// Install the shared mock host (idempotent). Any host-driven test calls this
398 /// first, so install order across the parallel test threads is irrelevant.
399 fn ensure_mock_host() {
400 set_global_host(Arc::new(MockHost));
401 }
402
403 #[test]
404 fn workflow_path_round_trips() {
405 let p = workflow_webhook_path("wf-123");
406 assert_eq!(p, "/api/workflows/wf-123/webhook");
407 assert_eq!(parse_workflow_webhook_path(&p).as_deref(), Some("wf-123"));
408 }
409
410 #[test]
411 fn parse_rejects_non_workflow_and_nested_paths() {
412 assert_eq!(parse_workflow_webhook_path("/api/composio/webhook"), None);
413 assert_eq!(parse_workflow_webhook_path("/api/workflows//webhook"), None);
414 assert_eq!(
415 parse_workflow_webhook_path("/api/workflows/a/b/webhook"),
416 None
417 );
418 assert_eq!(parse_workflow_webhook_path("/nope"), None);
419 }
420
421 #[test]
422 fn timestamp_fresh_accepts_absent_and_recent_rejects_stale() {
423 let now = 1_000_000i64;
424 // Absent / unparseable → fresh (back-compat).
425 assert!(timestamp_fresh(None, now));
426 assert!(timestamp_fresh(Some(" "), now));
427 assert!(timestamp_fresh(Some("not-a-number"), now));
428 // Within the window → fresh; the `t=` form is accepted too.
429 assert!(timestamp_fresh(Some("1000000"), now));
430 assert!(timestamp_fresh(Some(&format!("{}", now - 299)), now));
431 assert!(timestamp_fresh(Some("t=1000000"), now));
432 // Outside the window (either direction) → stale.
433 assert!(!timestamp_fresh(Some(&format!("{}", now - 301)), now));
434 assert!(!timestamp_fresh(Some(&format!("{}", now + 301)), now));
435 }
436
437 #[tokio::test]
438 async fn last_delivery_round_trips() {
439 let path = format!(
440 "/api/workflows/ld-{}/webhook",
441 uuid::Uuid::new_v4().simple()
442 );
443 assert!(last_delivery(&path).is_none());
444 record_delivery(&path);
445 assert!(last_delivery(&path).is_some());
446 }
447
448 #[tokio::test]
449 async fn unknown_path_is_unhandled() {
450 let outcome = deliver_inbound("/api/does/not/exist", b"{}", None).await;
451 assert!(matches!(outcome, InboundOutcome::Unhandled));
452 }
453
454 #[tokio::test]
455 async fn workflow_path_with_bad_signature_is_rejected_not_composio() {
456 // A workflow path routes to the WORKFLOW arm, not composio: a bad/missing
457 // signature yields a workflow-scoped rejection (or NotFound), never the
458 // composio "invalid signature" message. This proves the router split.
459 ensure_mock_host();
460 let id = format!("wf-{}", uuid::Uuid::new_v4().simple());
461 let path = workflow_webhook_path(&id);
462 let outcome = deliver_inbound(&path, b"{}", Some("deadbeef")).await;
463 match outcome {
464 InboundOutcome::Rejected(msg) => {
465 assert!(
466 msg.contains(&id),
467 "expected a workflow-scoped rejection, got: {msg}"
468 );
469 assert!(
470 !msg.contains("composio"),
471 "workflow path must not route to composio: {msg}"
472 );
473 }
474 other => panic!("expected Rejected, got {other:?}"),
475 }
476 }
477
478 /// The router acceptance test (crate-side, mock host): a **workflow** webhook
479 /// delivered through the unified ingress (`deliver_inbound`, the same entry the
480 /// relay uses) routes to the WORKFLOW arm, re-verifies the trigger secret via
481 /// the host, and on success reaches `run_workflow_for_trigger` — producing a
482 /// run id and recording the delivery. A tampered/bad signature is rejected
483 /// fail-closed. The *real-wiring* variant (real `save_workflow` + run against
484 /// `CoreWebhookIngressHost`) lives in core (`apps/core/src/webhook_ingress.rs`).
485 #[tokio::test]
486 async fn workflow_webhook_reaches_run_through_unified_ingress() {
487 ensure_mock_host();
488
489 let id = format!("wf-unify-{}", uuid::Uuid::new_v4().simple());
490 let body = br#"{"event":"unify","value":42}"#;
491 let path = workflow_webhook_path(&id);
492
493 // A valid signature ("good" per the mock) reaches the run.
494 let outcome = deliver_inbound(&path, body, Some("good")).await;
495 match &outcome {
496 InboundOutcome::Delivered { detail } => {
497 assert!(
498 detail.contains(&id) && detail.contains("run"),
499 "expected a workflow run delivery, got: {detail}"
500 );
501 }
502 other => panic!("expected Delivered (reaching the workflow run), got {other:?}"),
503 }
504 // And it is recorded for the registry.
505 assert!(
506 last_delivery(&path).is_some(),
507 "delivery should be recorded for the registry"
508 );
509
510 // A bad signature is rejected fail-closed (never fires the run).
511 let rejected = deliver_inbound(&path, br#"{"event":"tampered"}"#, Some("bad")).await;
512 assert!(matches!(rejected, InboundOutcome::Rejected(_)));
513 }
514
515 // ── first_http_delivery (Plan 013) ─────────────────────────────────────────
516 //
517 // `first_http_delivery` backs a process-global `OnceLock<Mutex<SeenDeliveries>>`
518 // shared by every test in this (and any other) process. cargo runs tests as
519 // parallel threads in one process, so ids are prefixed per-test to avoid
520 // cross-test interference — mirrors `last_delivery_round_trips`'s uuid-suffix
521 // pattern above.
522
523 #[test]
524 fn first_http_delivery_dedups_repeats() {
525 let id = format!("http-dlv-{}", uuid::Uuid::new_v4().simple());
526 assert!(first_http_delivery(&id), "first sight is new");
527 assert!(!first_http_delivery(&id), "second sight is a duplicate");
528 }
529
530 #[test]
531 fn first_http_delivery_empty_id_always_new() {
532 // No id to dedup on → never suppress dispatch (matches SeenDeliveries).
533 assert!(first_http_delivery(""));
534 assert!(first_http_delivery(""));
535 }
536
537 #[test]
538 fn first_http_delivery_distinct_ids_do_not_interfere() {
539 let a = format!("http-dlv-a-{}", uuid::Uuid::new_v4().simple());
540 let b = format!("http-dlv-b-{}", uuid::Uuid::new_v4().simple());
541 assert!(first_http_delivery(&a));
542 assert!(
543 first_http_delivery(&b),
544 "a different id is unaffected by a's insert"
545 );
546 assert!(!first_http_delivery(&a), "a is still deduped");
547 assert!(!first_http_delivery(&b), "b is still deduped");
548 }
549
550 /// `deliver_workflow_webhook` acceptance (Plan 013): a valid signature with a
551 /// repeated `delivery_id` yields `Ran` on the first call and `Duplicate` on
552 /// the second — the dedup sits after auth (a bad-signature call never
553 /// consumes the seen-set, proven by the second assertion below) and before
554 /// the run.
555 #[tokio::test]
556 async fn deliver_workflow_webhook_dedups_by_delivery_id() {
557 ensure_mock_host();
558
559 let id = format!("wf-dedup-{}", uuid::Uuid::new_v4().simple());
560 let delivery_id = format!("dlv-{}", uuid::Uuid::new_v4().simple());
561 let body = br#"{"event":"first"}"#;
562
563 // First delivery: valid signature, fresh id → runs.
564 let first = deliver_workflow_webhook(&id, body, Some("good"), &delivery_id).await;
565 assert!(matches!(first, WorkflowWebhookOutcome::Ran(_)));
566
567 // Retried delivery: same id → duplicate, no second run.
568 let retried = deliver_workflow_webhook(&id, body, Some("good"), &delivery_id).await;
569 assert!(matches!(retried, WorkflowWebhookOutcome::Duplicate));
570
571 // An unauthenticated forged id never reaches (and so never poisons) the
572 // seen-set: a fresh id with a bad signature is rejected, not deduped —
573 // and that same id is still usable afterwards for a real delivery.
574 let forged_id = format!("dlv-forged-{}", uuid::Uuid::new_v4().simple());
575 let bad = deliver_workflow_webhook(&id, body, Some("bad"), &forged_id).await;
576 assert!(matches!(bad, WorkflowWebhookOutcome::BadSignature));
577 let now_valid = deliver_workflow_webhook(&id, body, Some("good"), &forged_id).await;
578 assert!(
579 matches!(now_valid, WorkflowWebhookOutcome::Ran(_)),
580 "a forged-signature attempt must not poison the seen-set for the real id"
581 );
582 }
583}