From b3795236ccde4cefaebfd3da06ce4c838b76e78a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 14 Oct 2026 17:18:38 -0400 Subject: [PATCH] fix(relay): reject unreadable write targets as missing (#8249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 A sender who cannot use or read a referenced event, artifact or workflow now gets exactly the response it would get for an ID that does not exist. #8197 (merged) set this rule for deletions; this applies it to the other writes that look up a target, plus the workflow read and webhook endpoints. Artifact `create` and workflow definition saves are the exceptions, described below. Access is checked before any message that depends on the target, readability goes through `event_visible_to_reader` (including #8197's private-deletion rule), and soft-deleted targets keep matching missing ones. | Path | Checks, in order | Response for any unusable target | |---|---|---| | Reaction (kind 7) | target lookup → `event_visible_to_reader` → every channel gate for the derived channel, including archived | `invalid: reaction target event not found` | | Edit (kind 40003) | target in the edit's `h` channel and readable → author / agent-owner check, including an author since removed from a private channel | `invalid: edit target not found or not editable by you` | | Forum vote (kind 45002) | target in the vote's `h` channel and readable → kind check | `invalid: vote target event not found` | | Report by event (kind 1984 `e`) | `event_visible_to_reader` → token channel scope → channel membership or open | `invalid: report target event not found` | | Reply (NIP-10 and workflow `send_message` replies) | parent in the reply's channel and readable | `reply parent not found` | | Artifact update / delete / restore / move | head exists and is homed in `h` (move: in the source the sender can write) → revision, type, deleted and root checks | `conflict: artifact head unavailable` | | Workflow trigger (kind 46020) | workflow exists and sender is its owner | `invalid: workflow not found or not triggerable by you` | | `GET /workflows/{id}/runs` and `/runs/{run_id}/approvals` | workflow exists, has a channel, and the caller can read it | `404 {"error":"workflow not found"}` | | `POST /hooks/{id}` | workflow exists and the provided secret matches its stored secret → definition and trigger-type checks | `404 {"error":"workflow not found"}` | Two of these reject writes that used to be accepted: a reaction to an event the sender cannot read (such as someone else's author-only event), and a report on an event the reporter cannot read. Reports by pubkey and by blob are unchanged. In an archived channel, writes whose channel comes from their target (reactions and deletions) get that target's not-found response, and an artifact move out of an archived source channel gets `conflict: artifact head unavailable` like any other source the sender cannot write; every other write keeps `invalid: channel is archived`. A webhook call with an absent or wrong secret, or to a workflow without a webhook secret, now gets `404` instead of `401`/`400`. Artifact `create` and workflow definition saves still report a taken ID, as NIP-AR allows for artifacts. Removed messages, none of which any client depends on from these endpoints: `edit target event not found`, `must be event author to edit`, `target event belongs to a different channel`, `target event has no channel`, `parent event belongs to a different channel`, `parent event has no channel association`, `artifact home changed`, `workflow is not accessible`, `workflow is not channel-scoped`, the webhook's `authentication failed` and `webhook secret required but not configured`, `forbidden: not authorized to trigger this workflow` for non-owners (it remains for an owner whose channel authority lapsed), and `restricted: not a channel member` for an edit by an author removed from a private channel. `e2e_existence_oracle` checks each event write over HTTP and WS, and the workflow read and webhook endpoints over HTTP, against missing, soft-deleted, other-channel, private-channel, archived, author-only and wrong-kind targets as applicable, with a positive control per path. It runs in the Backend Integration job alongside `e2e_author_only_deletion`. `removed_author_edit_does_not_reveal_target` checks that a removed author's edits of their remaining message, their deleted message and a missing ID get identical answers. In the PostgreSQL suite, `head_outside_authorized_channel_matches_missing_head` covers the artifact transaction, `workflow_reply_to_unusable_parent_matches_missing` covers workflow replies through `RelayActionSink::send_message`, `scoped_token_report_does_not_reveal_target` and `scoped_token_reaction_does_not_reveal_target` cover channel-scoped tokens, and `channelless_workflow_runs_read_as_missing` covers the runs read for a workflow with no channel. --------- Signed-off-by: Will Pfleger Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- .github/workflows/_ci-relay.yml | 3 +- crates/buzz-db/src/store/artifact.rs | 21 +- .../src/store/artifact_postgres_tests.rs | 101 +++ .../src/api/artifact_postgres_tests.rs | 23 +- crates/buzz-relay/src/api/bridge.rs | 109 ++- crates/buzz-relay/src/api/workflows.rs | 28 +- crates/buzz-relay/src/handlers/artifact.rs | 14 +- .../src/handlers/command_executor.rs | 11 +- crates/buzz-relay/src/handlers/ingest.rs | 339 ++++++-- crates/buzz-relay/src/handlers/report.rs | 48 +- crates/buzz-relay/src/workflow_sink.rs | 121 +++ .../tests/e2e_existence_oracle.rs | 823 ++++++++++++++++++ 12 files changed, 1501 insertions(+), 140 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_existence_oracle.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index dff24c5f75..a0fa0a4ae8 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -111,6 +111,7 @@ jobs: --lib \ --test e2e_event_reminder \ --test e2e_author_only_deletion \ + --test e2e_existence_oracle \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Build PostgreSQL test archive if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && matrix.artifact == 'postgres-tests' @@ -581,7 +582,7 @@ jobs: run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion)' \ + -E 'binary(e2e_event_reminder) | binary(e2e_author_only_deletion) | binary(e2e_existence_oracle)' \ --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 diff --git a/crates/buzz-db/src/store/artifact.rs b/crates/buzz-db/src/store/artifact.rs index 988fb86135..a8a49ac602 100644 --- a/crates/buzz-db/src/store/artifact.rs +++ b/crates/buzz-db/src/store/artifact.rs @@ -25,6 +25,10 @@ pub enum ArtifactOutcome { Rejected(&'static str), } +/// Conflict for a non-create revision whose artifact is missing or lives +/// outside every channel the sender was authorized for. +pub const HEAD_UNAVAILABLE: &str = "artifact head unavailable"; + fn invalid(message: &str) -> DbError { DbError::InvalidData(message.into()) } @@ -133,7 +137,19 @@ impl Db { return Ok(ArtifactOutcome::Conflict("artifact identity is taken")) } (None, ArtifactOp::Create) => {} - (None, _) => return Ok(ArtifactOutcome::Conflict("artifact head unavailable")), + (None, _) => return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)), + // The caller authorized writes only in `h`, or for a move in the + // source it read before this transaction. A head anywhere else + // answers like a missing one, before any check that describes it. + (Some(_), op) + if source + != match op { + ArtifactOp::Move => authorized_source, + _ => Some(env.home), + } => + { + return Ok(ArtifactOutcome::Conflict(HEAD_UNAVAILABLE)) + } (Some(head), op) => { let current: Vec = head.get("event_id"); if env.prev.as_deref() != Some(current.as_slice()) { @@ -152,9 +168,6 @@ impl Db { "only move changes home and move must change home", )); } - if op == ArtifactOp::Move && authorized_source != source { - return Ok(ArtifactOutcome::Conflict("artifact home changed")); - } if op == ArtifactOp::Delete && env.root != old_root { return Ok(ArtifactOutcome::Rejected("delete preserves root")); } diff --git a/crates/buzz-db/src/store/artifact_postgres_tests.rs b/crates/buzz-db/src/store/artifact_postgres_tests.rs index 4ac03a06f4..f1b0af4362 100644 --- a/crates/buzz-db/src/store/artifact_postgres_tests.rs +++ b/crates/buzz-db/src/store/artifact_postgres_tests.rs @@ -436,3 +436,104 @@ async fn quiescing_community_rejects_artifact_at_admission_before_coordinate_loc "a rejected artifact must persist nothing" ); } + +/// A non-create revision whose head lives outside the channel the caller +/// authorized answers exactly like a missing artifact, before the revision, +/// type, deleted and root checks could describe the head. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn head_outside_authorized_channel_matches_missing_head() { + let f = Fixture::new().await; + let d = Uuid::new_v4(); + let create = f.revision(d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&create, None).await, + ArtifactOutcome::Accepted(_) + )); + let gone = Uuid::new_v4(); + let deleted_d = Uuid::new_v4(); + let deleted_create = f.revision(deleted_d, "create", f.a, None, &f.owner, vec![]); + assert!(matches!( + f.accept(&deleted_create, None).await, + ArtifactOutcome::Accepted(_) + )); + let deleted = f.revision( + deleted_d, + "delete", + f.a, + Some(&deleted_create), + &f.owner, + vec![], + ); + assert!(matches!( + f.accept(&deleted, None).await, + ArtifactOutcome::Accepted(_) + )); + let retyped = { + let mut tags: Vec> = vec![ + vec!["ar".into(), "1".into()], + vec!["d".into(), d.to_string()], + vec!["h".into(), f.b.to_string()], + vec!["type".into(), "buzz.note".into()], + vec!["op".into(), "update".into()], + vec!["title".into(), "Test".into()], + ]; + tags.push(vec!["prev".into(), create.id.to_hex()]); + EventBuilder::new(Kind::Custom(45010), "secret") + .tags(tags.into_iter().map(|t| Tag::parse(t).unwrap())) + .sign_with_keys(&f.peer) + .unwrap() + }; + let cases = [ + ( + "missing", + f.revision(gone, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "current prev", + f.revision(d, "update", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "stale prev", + f.revision(d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ("other type", retyped, None), + ( + "delete", + f.revision(d, "delete", f.b, Some(&create), &f.peer, vec![]), + None, + ), + ( + "restore deleted", + f.revision(deleted_d, "restore", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "update deleted", + f.revision(deleted_d, "update", f.b, Some(&deleted), &f.peer, vec![]), + None, + ), + ( + "move from unauthorized source", + f.revision(d, "move", f.b, Some(&create), &f.peer, vec![]), + Some(f.b), + ), + ]; + for (case, event, source) in cases { + match f.accept(&event, source).await { + ArtifactOutcome::Conflict(reason) => { + assert_eq!(reason, super::artifact::HEAD_UNAVAILABLE, "{case}") + } + other => panic!("{case}: {other:?}"), + } + } + // Positive control: the same revision homed where the head lives. + let update = f.revision(d, "update", f.a, Some(&create), &f.peer, vec![]); + assert!(matches!( + f.accept(&update, None).await, + ArtifactOutcome::Accepted(_) + )); +} diff --git a/crates/buzz-relay/src/api/artifact_postgres_tests.rs b/crates/buzz-relay/src/api/artifact_postgres_tests.rs index e60010cc8a..b962d3f1c7 100644 --- a/crates/buzz-relay/src/api/artifact_postgres_tests.rs +++ b/crates/buzz-relay/src/api/artifact_postgres_tests.rs @@ -311,21 +311,36 @@ async fn writes_use_channel_gates_for_home_and_move_source() { .as_array() .unwrap() .contains(&json!(["prev", peer_edit.id.to_hex()]))); + // An unwritable source answers exactly like a move of a missing artifact. + let missing_move = + |key: &Keys| f.revision_as(key, f.home, Uuid::new_v4(), "move", Some(&moved)); + let (status, body) = f.try_publish(&missing_move(&f.peer)).await; + let peer_missing = (status, without_event_id(body)); + assert!(!peer_missing.0.is_success(), "{peer_missing:?}"); // The peer can write the destination but not the private source. let back = f.revision_as(&f.peer, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&back).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("not a channel member"), "{body}"); + assert_eq!((status, without_event_id(body)), peer_missing); sqlx::query("UPDATE channels SET archived_at=now() WHERE community_id=$1 AND id=$2") .bind(f.community.as_uuid()) .bind(f.private) .execute(&f.pool) .await .unwrap(); + let (status, body) = f.try_publish(&missing_move(&f.owner)).await; + let owner_missing = (status, without_event_id(body)); + assert!(!owner_missing.0.is_success(), "{owner_missing:?}"); let archived_source = f.revision_as(&f.owner, f.home, d, "move", Some(&moved)); let (status, body) = f.try_publish(&archived_source).await; - assert!(!status.is_success() || body["accepted"] == false, "{body}"); - assert!(body.to_string().contains("archived"), "{body}"); + assert_eq!((status, without_event_id(body)), owner_missing); +} + +/// Drop the per-event id so rejections of different events compare exactly. +fn without_event_id(mut body: Value) -> Value { + if let Some(object) = body.as_object_mut() { + object.remove("event_id"); + } + body } /// Re-sign an artifact revision with extra tags and an explicit timestamp. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index be6743599b..4e90a20e15 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2449,6 +2449,24 @@ pub async fn workflow_webhook( .await .map_err(|_| not_found("workflow not found"))?; + // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. + // Until the caller proves the secret, every failure answers like a missing + // workflow, so the response never reveals that the workflow exists, its + // trigger type, or whether a secret was configured. + let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); + let provided_secret = headers + .get("x-webhook-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| query.secret.clone()) + .unwrap_or_default(); + if !stored_secret + .is_some_and(|secret| crate::webhook_secret::verify_secret(&provided_secret, &secret)) + { + tracing::warn!("webhook: missing or invalid secret for workflow {id}"); + return Err(not_found("workflow not found")); + } + let def: buzz_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) .map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?; @@ -2459,30 +2477,6 @@ pub async fn workflow_webhook( )); } - // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. - let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); - let provided_secret = headers - .get("x-webhook-secret") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .or_else(|| query.secret.clone()) - .unwrap_or_default(); - - match &stored_secret { - Some(secret) => { - if !crate::webhook_secret::verify_secret(&provided_secret, secret) { - tracing::warn!("webhook: invalid secret for workflow {id}"); - return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); - } - } - None => { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "webhook secret required but not configured — re-save the workflow to generate one", - )); - } - } - // Parse optional JSON body as trigger context. let body_json: Option = if body.is_empty() { @@ -4441,6 +4435,73 @@ pub(crate) mod postgres_tests { (status, String::from_utf8_lossy(&bytes).into_owned()) } + /// A workflow row without a channel has no readers, so even its owner + /// reads its runs exactly like a workflow that does not exist. Ingest + /// refuses channelless definitions, so the row is seeded directly. + /// Mutation: let a `None` channel through `authorize_workflow_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channelless_workflow_runs_read_as_missing() { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let state = bridge_handler_test_state() + .await + .expect("local Postgres not reachable"); + let host = format!("channelless-{}.local", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let owner = Keys::generate(); + state + .db + .ensure_user(community, owner.public_key().as_bytes()) + .await + .expect("ensure user"); + let workflow = state + .db + .create_workflow( + community, + None, + owner.public_key().as_bytes(), + "channelless", + "{}", + &[0u8; 32], + ) + .await + .expect("seed workflow"); + let read = |id: uuid::Uuid| { + let state = state.clone(); + let host = host.clone(); + let owner = owner.public_key().to_hex(); + async move { + let resp = crate::router::build_router(state) + .oneshot( + Request::builder() + .uri(format!("/workflows/{id}/runs")) + .header(header::HOST, host) + .header("x-pubkey", owner) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("oneshot"); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + (status, body) + } + }; + let missing = read(uuid::Uuid::new_v4()).await; + assert_eq!(missing.0, axum::http::StatusCode::NOT_FOUND, "{missing:?}"); + assert_eq!(read(workflow).await, missing); + } + /// Collect buzz_events_rejected_total with (transport, reason) labels from /// a DebuggingRecorder snapshot. fn http_reject_counts( diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index d957965bca..b6a1f98e00 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -102,19 +102,19 @@ async fn authorize_workflow_read( .await .map_err(|e| e.into_response())?; - let workflow = state - .db - .get_workflow(tenant.community(), workflow_id) - .await - .map_err(|error| match error { - buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow not found").into_response() - } - other => internal_error(&format!("get workflow for run read: {other}")).into_response(), - })?; - let channel_id = workflow.channel_id.ok_or_else(|| { - api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response() - })?; + // A workflow the caller cannot read answers exactly like a missing one, so + // the response never reveals which workflow IDs exist. + let not_found = || api_error(StatusCode::NOT_FOUND, "workflow not found").into_response(); + let workflow = match state.db.get_workflow(tenant.community(), workflow_id).await { + Ok(workflow) => workflow, + Err(buzz_db::error::DbError::NotFound(_)) => return Err(not_found()), + Err(other) => { + return Err( + internal_error(&format!("get workflow for run read: {other}")).into_response(), + ) + } + }; + let channel_id = workflow.channel_id.ok_or_else(not_found)?; let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await @@ -122,7 +122,7 @@ async fn authorize_workflow_read( internal_error(&format!("workflow channel access lookup: {error}")).into_response() })?; if !accessible.contains(&channel_id) { - return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response()); + return Err(not_found()); } Ok(tenant) diff --git a/crates/buzz-relay/src/handlers/artifact.rs b/crates/buzz-relay/src/handlers/artifact.rs index 3e186f9338..772e032e75 100644 --- a/crates/buzz-relay/src/handlers/artifact.rs +++ b/crates/buzz-relay/src/handlers/artifact.rs @@ -24,10 +24,14 @@ pub(crate) async fn accept( .artifact_home(tenant.community(), env.id) .await .map_err(|e| IngestError::Internal(format!("artifact storage: {e}")))? - .ok_or_else(|| { - IngestError::CanvasConflict("conflict: artifact head unavailable".into()) + .ok_or_else(head_unavailable)?; + // A source the sender cannot write answers like a missing artifact. + super::ingest::check_channel_write(tenant, state, auth, source) + .await + .map_err(|e| match e { + IngestError::Internal(_) => e, + _ => head_unavailable(), })?; - super::ingest::check_channel_write(tenant, state, auth, source).await?; Some(source) } else { None @@ -64,6 +68,10 @@ pub(crate) async fn accept( }) } +fn head_unavailable() -> IngestError { + IngestError::CanvasConflict(format!("conflict: {}", buzz_db::artifact::HEAD_UNAVAILABLE)) +} + /// Live delivery only; unlike kind 9 there are no audit, workflow, or thread /// side effects. Missed deliveries are recovered by replaying stored events. async fn publish(state: &Arc, tenant: &TenantContext, stored: &StoredEvent) { diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 460323882a..bab57a3809 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -809,6 +809,9 @@ async fn handle_workflow_def( }) } +/// Trigger rejection for a missing workflow and for one the sender does not own. +const WORKFLOW_NOT_FOUND: &str = "invalid: workflow not found or not triggerable by you"; + async fn handle_workflow_trigger( tenant: &TenantContext, state: &Arc, @@ -835,15 +838,15 @@ async fn handle_workflow_trigger( .db .get_workflow(community_id, workflow_id) .await - .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; + .map_err(|_| IngestError::Rejected(WORKFLOW_NOT_FOUND.into()))?; // 3. Manual triggers execute with the workflow owner's authority, so only // the owner may start them. Channel membership alone is insufficient: a // member could otherwise invoke another user's webhook or message actions. + // Anyone else is told the workflow is not found, so the response never + // reveals whether a workflow ID exists. if workflow.owner_pubkey != self_bytes { - return Err(IngestError::Rejected( - "forbidden: not authorized to trigger this workflow".into(), - )); + return Err(IngestError::Rejected(WORKFLOW_NOT_FOUND.into())); } // SEC-006: manual triggers must honor the workflow's lifecycle state and diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 0324d6f2cc..9e8311e8f6 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -628,16 +628,24 @@ pub(crate) fn extract_channel_id(event: &Event) -> Option { pub(crate) enum ReactionChannelResult { Channel(Uuid), NoChannel, + /// Missing, soft-deleted, or not readable by the reactor. NotFound, NoTarget, DbError(String), } -/// Derive channel_id from the target event for NIP-25 reactions. +/// Rejection for a reaction whose target is missing or unusable by the sender. +/// Every such target gets this text, so it never reveals whether, or where, +/// the target exists. +const REACTION_TARGET_NOT_FOUND: &str = "reaction target event not found"; + +/// Derive channel_id from the target event for NIP-25 reactions. A target +/// `reader` may not read resolves as `NotFound`. pub(crate) async fn derive_reaction_channel( community_id: CommunityId, db: &buzz_db::Db, event: &Event, + reader: &[u8], ) -> ReactionChannelResult { let target_hex = match event.tags.iter().rev().find_map(|tag| { if tag.kind().to_string() == "e" { @@ -665,6 +673,9 @@ pub(crate) async fn derive_reaction_channel( .get_event_by_id_for_event_write(community_id, &id_bytes) .await { + Ok(Some(target)) if !super::req::event_visible_to_reader(&target.event, reader) => { + ReactionChannelResult::NotFound + } Ok(Some(target)) => match target.channel_id { Some(ch_id) => ReactionChannelResult::Channel(ch_id), None => ReactionChannelResult::NoChannel, @@ -921,6 +932,18 @@ impl ThreadMetadataOwned { } } +/// Whether `target` is in `channel_id` and readable by `reader`. Write paths +/// that reference an event reject a target failing this exactly as a missing +/// one, so the rejection never reveals whether, or where, the target exists. +fn target_readable_in_channel( + target: &buzz_core::StoredEvent, + channel_id: Uuid, + reader: &[u8], +) -> bool { + target.channel_id == Some(channel_id) + && super::req::event_visible_to_reader(&target.event, reader) +} + /// Resolve NIP-10 thread ancestry from e-tags. pub(crate) async fn resolve_nip10_thread_meta( community_id: CommunityId, @@ -947,18 +970,12 @@ pub(crate) async fn resolve_nip10_thread_meta( .get_thread_metadata_by_event(community_id, &parent_bytes), ); + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, &reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1130,6 +1147,7 @@ pub(crate) async fn resolve_relay_reply_thread_meta( community_id: CommunityId, parent_hex: &str, channel_id: Uuid, + reader: &[u8], state: &AppState, ) -> Result { let parent_bytes = @@ -1146,16 +1164,9 @@ pub(crate) async fn resolve_relay_reply_thread_meta( let parent_event = parent_event_result .map_err(|e| format!("db error looking up parent: {e}"))? + .filter(|parent| target_readable_in_channel(parent, channel_id, reader)) .ok_or_else(|| "reply parent not found".to_string())?; - match parent_event.channel_id { - Some(parent_ch) if parent_ch != channel_id => { - return Err("parent event belongs to a different channel".to_string()); - } - None => return Err("parent event has no channel association".to_string()), - _ => {} - } - let parent_created = chrono::DateTime::from_timestamp(parent_event.event.created_at.as_secs() as i64, 0) .unwrap_or_else(Utc::now); @@ -1253,6 +1264,10 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ event.pubkey.to_bytes().to_vec() } +/// Rejection for an edit whose target is missing or not editable by the +/// sender, so the error never reveals whether, or where, the target exists. +const EDIT_TARGET_DENIED: &str = "edit target not found or not editable by you"; + /// Validate kind:40003 edit ownership — event.pubkey must match target's effective author, /// or the actor must be the owning human of the agent that authored the target message. async fn validate_edit_ownership( @@ -1280,27 +1295,21 @@ async fn validate_edit_ownership( let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; + // Kind 40003 skips the generic membership gate, so this validator is the + // only authority: a target outside the edit's `h` channel, unreadable, or + // not the actor's to edit is denied exactly like a missing one. + let edit_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for edit".to_string())?; + let actor = event.pubkey.to_bytes().to_vec(); let target_event = state .db .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? - .ok_or_else(|| "edit target event not found".to_string())?; - - // Verify target belongs to the same channel as the edit event. - let edit_channel_id = extract_channel_id(event); - match (edit_channel_id, target_event.channel_id) { - (Some(edit_ch), Some(target_ch)) if edit_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} // Same channel or no channel context — OK - } + .filter(|target| target_readable_in_channel(target, edit_channel_id, &actor)) + .ok_or_else(|| EDIT_TARGET_DENIED.to_string())?; let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key()); - let actor = event.pubkey.to_bytes().to_vec(); if author == actor { // Author editing their own message: re-gate on membership/open visibility so that // a removed private-channel member cannot mutate old messages after access is revoked. @@ -1317,7 +1326,7 @@ async fn validate_edit_ownership( .map(|ch| ch.visibility == "open") .unwrap_or(false); if !is_open { - return Err("restricted: not a channel member".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } } @@ -1329,7 +1338,7 @@ async fn validate_edit_ownership( .await .map_err(|e| format!("db error checking agent ownership: {e}"))?; if !is_owner { - return Err("must be event author to edit".to_string()); + return Err(EDIT_TARGET_DENIED.to_string()); } } Ok(()) @@ -1358,6 +1367,9 @@ async fn validate_forum_vote_target( } }) .ok_or_else(|| "missing e tag for vote target".to_string())?; + let vote_channel_id = + extract_channel_id(event).ok_or_else(|| "missing h tag for vote".to_string())?; + let reader = effective_message_author(event, &state.relay_keypair.public_key()); let target_bytes = hex::decode(&target_hex).map_err(|_| "invalid target event ID".to_string())?; @@ -1366,24 +1378,15 @@ async fn validate_forum_vote_target( .get_event_by_id_for_event_write(community_id, &target_bytes) .await .map_err(|e| format!("db error: {e}"))? + // Readability first: the kind diagnostic below would otherwise reveal + // an inaccessible event's existence and kind. + .filter(|target| target_readable_in_channel(target, vote_channel_id, &reader)) .ok_or_else(|| "vote target event not found".to_string())?; let target_kind = event_kind_u32(&target_event.event); if target_kind != KIND_FORUM_POST && target_kind != KIND_FORUM_COMMENT { return Err("vote target must be a forum post or comment".to_string()); } - - // Verify target belongs to the same channel as the vote event. - let vote_channel_id = extract_channel_id(event); - match (vote_channel_id, target_event.channel_id) { - (Some(vote_ch), Some(target_ch)) if vote_ch != target_ch => { - return Err("target event belongs to a different channel".to_string()); - } - (Some(_), None) => { - return Err("target event has no channel".to_string()); - } - _ => {} - } Ok(()) } @@ -2569,7 +2572,7 @@ async fn ingest_event_inner( // report; that is tolerated because reports are non-actioning signals and // remain visible only to moderators. if kind_u32 == KIND_REPORT { - super::report::handle_report_event(tenant, &event, state) + super::report::handle_report_event(tenant, &event, state, auth.channel_ids()) .await .map_err(IngestError::Rejected)?; return Ok(IngestResult { @@ -2614,25 +2617,27 @@ async fn ingest_event_inner( } } - // A kind 5 targeting an event derives its channel from that target, so - // every channel gate below would reveal the target's existence or - // location. Their rejections all collapse to the shared denial. - let deletion_targets_event = kind_u32 == KIND_DELETION - && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty(); - let deletion_denied = || { - IngestError::Rejected(format!( - "invalid: {}", - crate::handlers::side_effects::DELETION_TARGET_DENIED - )) + // A reaction, or a kind 5 targeting an event, derives its channel from + // that target, so every channel gate below would reveal the target's + // existence or location. Their rejections all collapse to the kind's + // not-found denial. + let target_denial = if kind_u32 == KIND_REACTION { + Some(REACTION_TARGET_NOT_FOUND) + } else if kind_u32 == KIND_DELETION + && !crate::handlers::side_effects::extract_target_event_ids(&event).is_empty() + { + Some(crate::handlers::side_effects::DELETION_TARGET_DENIED) + } else { + None }; + let target_denied = |reason: &str| IngestError::Rejected(format!("invalid: {reason}")); let mut channel_id = if kind_u32 == KIND_REACTION { - match derive_reaction_channel(tenant.community(), &state.db, &event).await { + let reader = effective_message_author(&event, &state.relay_keypair.public_key()); + match derive_reaction_channel(tenant.community(), &state.db, &event, &reader).await { ReactionChannelResult::Channel(ch_id) => Some(ch_id), ReactionChannelResult::NoChannel => None, ReactionChannelResult::NotFound => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(target_denied(REACTION_TARGET_NOT_FOUND)); } ReactionChannelResult::NoTarget => { return Err(IngestError::Rejected( @@ -2663,7 +2668,9 @@ async fn ingest_event_inner( .map_err(|e| { IngestError::Internal(format!("error: looking up deletion target: {e}")) })? - .ok_or_else(deletion_denied)? + .ok_or_else(|| { + target_denied(crate::handlers::side_effects::DELETION_TARGET_DENIED) + })? .channel_id } None => None, @@ -2683,15 +2690,12 @@ async fn ingest_event_inner( } if let Some(ch_id) = channel_id { - check_token_channel_access(&auth, ch_id).map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::AuthFailed(e) - } + check_token_channel_access(&auth, ch_id).map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::AuthFailed(e), })?; - } else if deletion_targets_event && auth.channel_ids().is_some() { - return Err(deletion_denied()); + } else if let (Some(reason), Some(_)) = (target_denial, auth.channel_ids()) { + return Err(target_denied(reason)); } else if auth.channel_ids().is_some() { // Channel-scoped tokens cannot publish global events — that would bypass // the token's channel restriction. This covers kind:1 (global text notes), @@ -2774,12 +2778,9 @@ async fn ingest_event_inner( }, state_for_request(tenant, auth.pubkey()), ); - auth_result.map_err(|e| { - if deletion_targets_event { - deletion_denied() - } else { - IngestError::Rejected(e) - } + auth_result.map_err(|e| match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected(e), })?; } } @@ -2920,12 +2921,13 @@ async fn ingest_event_inner( if kind_u32 == KIND_DELETION { // The target's archived channel blocks the deletion, so it must // reject before the validator's target-kind diagnostics. - if deletion_targets_event - && channel_row + if let Some(reason) = target_denial { + if channel_row .as_ref() .is_some_and(|ch| ch.archived_at.is_some()) - { - return Err(deletion_denied()); + { + return Err(target_denied(reason)); + } } crate::handlers::side_effects::validate_standard_deletion_event(tenant, &event, state) .await @@ -2943,7 +2945,10 @@ async fn ingest_event_inner( if !is_unarchive { if let Some(channel) = &channel_row { if channel.archived_at.is_some() { - return Err(IngestError::Rejected("invalid: channel is archived".into())); + return Err(match target_denial { + Some(reason) => target_denied(reason), + None => IngestError::Rejected("invalid: channel is archived".into()), + }); } } } @@ -3327,9 +3332,9 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Internal(format!("error: {e}")))? { buzz_db::ReactionEventInsertOutcome::TargetMissing => { - return Err(IngestError::Rejected( - "invalid: reaction target event not found".into(), - )); + return Err(IngestError::Rejected(format!( + "invalid: {REACTION_TARGET_NOT_FOUND}" + ))); } buzz_db::ReactionEventInsertOutcome::Duplicate => { return Ok(IngestResult { @@ -6917,6 +6922,174 @@ mod postgres_tests { assert_eq!(stored.channel_id, Some(channels[0])); } + /// A community with `author`'s live message in an open channel, an + /// author global note, and a second open channel for out-of-scope tokens. + struct ScopedTargetFixture { + state: Arc, + tenant: TenantContext, + tracer: Arc, + author: nostr::Keys, + sender: nostr::Keys, + target_channel: Uuid, + other_channel: Uuid, + live: Event, + global: Event, + } + + impl ScopedTargetFixture { + async fn new(label: &str) -> Self { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let state = ingest_state().await; + let host = format!("{label}-{}.test", Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + let tracer: Arc = Arc::new(VecTracer::default()); + let (author, sender) = (nostr::Keys::generate(), nostr::Keys::generate()); + let mut channels = Vec::new(); + for creator in [&author, &sender] { + let channel_id = Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + channel_id, + &format!("{label}-{}", channel_id.simple()), + ChannelType::Stream, + ChannelVisibility::Open, + None, + creator.public_key().to_bytes().as_slice(), + None, + ) + .await + .expect("create test channel"); + channels.push(channel_id); + } + let publish = |kind: u16, tags: Vec| { + EventBuilder::new(Kind::Custom(kind), Uuid::new_v4().to_string()) + .tags(tags) + .sign_with_keys(&author) + .expect("sign") + }; + let live = publish( + 9, + vec![nostr::Tag::parse(["h", &channels[0].to_string()]).unwrap()], + ); + let global = publish(1, vec![]); + for event in [live.clone(), global.clone()] { + let auth = IngestAuth::Http { + pubkey: author.public_key(), + scopes: vec![Scope::MessagesWrite], + auth_method: HttpAuthMethod::Nip98, + }; + ingest_event_inner(&state, &tracer, &tenant, event, auth) + .await + .expect("store target"); + } + Self { + state, + tenant, + tracer, + author, + sender, + target_channel: channels[0], + other_channel: channels[1], + live, + global, + } + } + + fn scoped(&self, channel: Uuid) -> IngestAuth { + IngestAuth::Nip42 { + pubkey: self.sender.public_key(), + scopes: vec![Scope::MessagesWrite], + channel_ids: Some(vec![channel]), + conn_id: Uuid::new_v4(), + } + } + + fn sign(&self, kind: u16, tags: Vec>) -> Event { + EventBuilder::new(Kind::Custom(kind), "+") + .tags(tags.into_iter().map(|t| nostr::Tag::parse(t).unwrap())) + .sign_with_keys(&self.sender) + .expect("sign") + } + + async fn submit( + &self, + event: Event, + auth: IngestAuth, + ) -> Result { + ingest_event_inner(&self.state, &self.tracer, &self.tenant, event, auth).await + } + } + + /// A report sent with a token scoped to another channel answers exactly + /// like a report on a missing event. + /// Mutation: drop the `token_channels` check in `reporter_can_read` → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_report_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-report").await; + let author = f.author.public_key().to_hex(); + let report = |target: String| { + f.sign( + KIND_REPORT as u16, + vec![ + vec!["e".into(), target, "spam".into()], + vec!["p".into(), author.clone()], + ], + ) + }; + for target in ["a".repeat(64), f.live.id.to_hex()] { + match f + .submit(report(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => { + assert_eq!(msg, "invalid: report target event not found", "{target}") + } + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(report(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reports the message"); + } + + /// A reaction sent with a token scoped to another channel, to a channel + /// message or to a global note, answers exactly like one to a missing event. + /// Mutation: drop the `(Some(reason), Some(_))` arm, or map the token + /// check to its own error → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn scoped_token_reaction_does_not_reveal_target() { + let f = ScopedTargetFixture::new("scoped-reaction").await; + let react = |target: String| f.sign(KIND_REACTION as u16, vec![vec!["e".into(), target]]); + for target in ["a".repeat(64), f.live.id.to_hex(), f.global.id.to_hex()] { + match f + .submit(react(target.clone()), f.scoped(f.other_channel)) + .await + { + Err(IngestError::Rejected(msg)) => assert_eq!( + msg, + format!("invalid: {REACTION_TARGET_NOT_FOUND}"), + "{target}" + ), + Err(other) => panic!("target {target}: {other:?}"), + Ok(_) => panic!("target {target}: accepted"), + } + } + f.submit(react(f.live.id.to_hex()), f.scoped(f.target_channel)) + .await + .expect("in-scope token reacts to the message"); + } + // ── Owner-aware ban/timeout coverage ───────────────────────────────────── /// Fresh community plus an agent owned by `owner` (users.agent_owner_pubkey). diff --git a/crates/buzz-relay/src/handlers/report.rs b/crates/buzz-relay/src/handlers/report.rs index fccf8eb42b..2e9e1aa4d7 100644 --- a/crates/buzz-relay/src/handlers/report.rs +++ b/crates/buzz-relay/src/handlers/report.rs @@ -20,8 +20,10 @@ use std::sync::Arc; use buzz_core::tenant::TenantContext; +use buzz_core::StoredEvent; use buzz_db::moderation::{NewReport, ReportTarget}; use nostr::Event; +use uuid::Uuid; use crate::state::AppState; @@ -41,10 +43,13 @@ pub const REPORT_TYPES: &[&str] = &[ /// Rejections use client-safe `invalid:`/`restricted:` reasons. On success /// the report is queued (idempotently, keyed by the signed event id) and the /// event itself is **not** stored or broadcast as a regular event. +/// +/// `token_channels` is the sender's channel-scoped token restriction, if any. pub async fn handle_report_event( tenant: &TenantContext, event: &Event, state: &Arc, + token_channels: Option<&[Uuid]>, ) -> Result<(), String> { let parsed = parse_report(event)?; let reporter_pubkey = event.pubkey.to_bytes(); @@ -55,9 +60,20 @@ pub async fn handle_report_event( .db .get_event_by_id(tenant.community(), &event_id) .await - .map_err(|e| format!("error: database error resolving report target: {e}"))? - .ok_or_else(|| "invalid: report target event not found".to_string())?; - (ReportTarget::Event(event_id), stored.channel_id) + .map_err(|e| format!("error: database error resolving report target: {e}"))?; + // A target the reporter cannot read is rejected exactly like a + // missing one, so the response never reveals whether it exists. + let readable = match &stored { + Some(stored) => { + reporter_can_read(tenant, state, stored, &reporter_pubkey, token_channels) + .await? + } + None => false, + }; + match stored { + Some(stored) if readable => (ReportTarget::Event(event_id), stored.channel_id), + _ => return Err("invalid: report target event not found".to_string()), + } } ParsedReportTarget::Blob { sha256, .. } => { let sha_hex = hex::encode(&sha256); @@ -93,6 +109,32 @@ pub async fn handle_report_event( Ok(()) } +/// Whether `reporter` may read `target`: the event-level read gate, plus the +/// channel's membership-or-open rule and the token's channel scope for a +/// channel event. +async fn reporter_can_read( + tenant: &TenantContext, + state: &AppState, + target: &StoredEvent, + reporter: &[u8], + token_channels: Option<&[Uuid]>, +) -> Result { + if !super::req::event_visible_to_reader(&target.event, reporter) { + return Ok(false); + } + let Some(channel_id) = target.channel_id else { + return Ok(true); + }; + if token_channels.is_some_and(|allowed| !allowed.contains(&channel_id)) { + return Ok(false); + } + match super::ingest::check_channel_membership(tenant, state, channel_id, reporter, None).await { + Ok(()) => Ok(true), + Err(reason) if reason.starts_with("error:") => Err(reason), + Err(_) => Ok(false), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ParsedReport<'a> { target: ParsedReportTarget, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index e069d7a37d..e757b49d80 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -320,6 +320,7 @@ impl ActionSink for RelayActionSink { tenant.community(), parent_hex, channel_uuid, + &author_pubkey_bytes, &state, ) .await @@ -1401,4 +1402,124 @@ mod postgres_tests { "expected InvalidInput, got {err:?}" ); } + + /// A workflow reply to a parent the owner cannot use, in another channel + /// or unreadable to them, fails exactly like a reply to a missing parent. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn workflow_reply_to_unusable_parent_matches_missing() { + let state = test_state().await; + let owner = nostr::Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let stranger = nostr::Keys::generate(); + let host = format!("wf-unusable-{}.example", uuid::Uuid::new_v4().simple()); + let community = match state + .db + .create_community_with_owner(&host, &owner_hex) + .await + .expect("create community") + { + CreateCommunityWithOwnerResult::Created(rec) => rec.id, + other => panic!("expected fresh community, got {other:?}"), + }; + let open_channel = |name: &'static str| { + let state = Arc::clone(&state); + let owner = owner.public_key().to_bytes(); + async move { + state + .db + .create_channel( + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + None, + &owner, + None, + ) + .await + .expect("create channel") + } + }; + let channel = open_channel("wf-unusable").await; + let elsewhere = open_channel("wf-elsewhere").await; + let insert = |keys: &nostr::Keys, kind: u32, channel_id: Uuid, extra: Vec| { + let state = Arc::clone(&state); + let mut tags = vec![Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]; + tags.extend(extra); + let event = EventBuilder::new(Kind::from(kind as u16), "parent") + .tags(tags) + .sign_with_keys(keys) + .expect("sign parent"); + async move { + state + .db + .insert_event(community, &event, Some(channel_id)) + .await + .expect("insert parent"); + event.id.to_hex() + } + }; + // A kind 5 whose `k` tag names an author-only kind is readable only by + // its author. Production ingest never stores one channel-scoped (its + // target is global), so this is the closest state that isolates the + // resolver's visibility check and the reader identity it is given. + let author_only_deletion = |keys: &nostr::Keys| { + let target = nostr::Keys::generate().public_key().to_hex(); + insert( + keys, + buzz_core::kind::KIND_DELETION, + channel.id, + vec![ + Tag::parse(["e", &target]).expect("e tag"), + Tag::parse(["k", "30300"]).expect("k tag"), + ], + ) + }; + let other_channel = insert(&owner, KIND_STREAM_MESSAGE, elsewhere.id, vec![]).await; + let unreadable = author_only_deletion(&stranger).await; + let owner_only = author_only_deletion(&owner).await; + + let sink = RelayActionSink::new(&state); + let reply = |author_hex: String, parent: String| { + let sink = &sink; + let channel = channel.id.to_string(); + async move { + sink.send_message( + community, + &channel, + "reply", + "reply", + &author_hex, + Some(&parent), + ) + .await + } + }; + let missing = nostr::Keys::generate().public_key().to_hex(); + let other_owner_hex = nostr::Keys::generate().public_key().to_hex(); + for (case, author, parent) in [ + ("missing", &owner_hex, missing), + ("other channel", &owner_hex, other_channel), + ("unreadable", &owner_hex, unreadable), + ( + "readable only by another owner", + &other_owner_hex, + owner_only.clone(), + ), + ] { + match reply(author.clone(), parent).await { + Err(ActionSinkError::InvalidInput(msg)) => { + assert_eq!(msg, "reply parent not found", "{case}") + } + other => panic!("{case}: expected the missing-parent error, got {other:?}"), + } + } + + // Positive control: the resolver reads as the workflow owner, who + // alone can see their author-only parent. + reply(owner_hex.clone(), owner_only) + .await + .expect("owner replies to a parent only they can read"); + } } diff --git a/crates/buzz-test-client/tests/e2e_existence_oracle.rs b/crates/buzz-test-client/tests/e2e_existence_oracle.rs new file mode 100644 index 0000000000..762d6fe472 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_existence_oracle.rs @@ -0,0 +1,823 @@ +//! End-to-end tests: a write that references an event, artifact or workflow +//! the sender cannot use or read gets exactly the response it would get for +//! an ID that does not exist. +//! +//! Each test sends the same write as an unauthorized sender against targets +//! that are missing, soft-deleted, in another channel, in a private channel +//! the sender has not joined, author-only, or of the wrong kind, over HTTP and +//! WS, and requires one identical HTTP status, HTTP error and WS `OK` +//! message. A positive control shows the legitimate write still succeeds. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3001 cargo test -p buzz-test-client --test e2e_existence_oracle -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, EventId, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +const KIND_EVENT_REMINDER: u16 = 30300; +const MISSING: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) +} + +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.to_vec()).expect("valid tag") +} + +fn sign(keys: &Keys, kind: u16, content: &str, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(event) + .send() + .await + .expect("submit event"); + let ok = resp.status().is_success(); + let body: Value = resp.json().await.expect("parse response"); + if ok { + ( + body["accepted"].as_bool().unwrap_or(false), + body["message"].as_str().unwrap_or("").to_string(), + ) + } else { + (false, body["error"].as_str().unwrap_or("").to_string()) + } +} + +async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) { + let (accepted, msg) = submit(client, keys, event).await; + assert!(accepted, "setup event rejected: {msg}"); +} + +/// Submit `event` as `sender` over HTTP and WS and return how each rejected +/// it: the HTTP status and error, and the WS `OK` message. +async fn rejection(client: &Client, sender: &Keys, event: nostr::Event) -> (u16, String, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", sender.public_key().to_hex()) + .json(&event) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + let http_msg = body["error"] + .as_str() + .or_else(|| body["message"].as_str()) + .unwrap_or("") + .to_string(); + let mut ws = BuzzTestClient::connect(&relay_url(), sender) + .await + .expect("connect"); + let ok = ws.send_event(event).await.expect("OK"); + ws.disconnect().await.expect("disconnect"); + assert!(!ok.accepted, "WS must reject the event"); + (status, http_msg, ok.message) +} + +/// Every case must be rejected over HTTP and WS with `expected`, and with +/// one HTTP status across all cases. +async fn assert_uniform( + client: &Client, + sender: &Keys, + expected: &str, + cases: Vec<(&str, nostr::Event)>, +) { + let mut status_seen = None; + let mut mismatches = Vec::new(); + for (case, event) in cases { + let (status, http_msg, ws_msg) = rejection(client, sender, event).await; + if status < 400 && http_msg.is_empty() { + mismatches.push(format!("{case}: HTTP accepted")); + } + if http_msg != expected { + mismatches.push(format!("{case}: HTTP {http_msg:?}")); + } + if ws_msg != expected { + mismatches.push(format!("{case}: WS {ws_msg:?}")); + } + if *status_seen.get_or_insert(status) != status { + mismatches.push(format!("{case}: HTTP status {status}")); + } + } + assert!( + mismatches.is_empty(), + "expected {expected:?}:\n{}", + mismatches.join("\n") + ); +} + +async fn create_channel(client: &Client, owner: &Keys, visibility: &str) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let event = sign( + owner, + 9007, + "", + vec![ + tag(&["h", &id]), + tag(&["name", &format!("eo-{id}")]), + tag(&["channel_type", "stream"]), + tag(&["visibility", visibility]), + ], + ); + submit_ok(client, owner, &event).await; + id +} + +async fn add_member(client: &Client, owner: &Keys, channel: &str, member: &Keys) { + let event = sign( + owner, + 9000, + "", + vec![ + tag(&["h", channel]), + tag(&["p", &member.public_key().to_hex()]), + ], + ); + submit_ok(client, owner, &event).await; +} + +async fn post(client: &Client, author: &Keys, kind: u16, channel: &str) -> nostr::Event { + let event = sign( + author, + kind, + &uuid::Uuid::new_v4().to_string(), + vec![tag(&["h", channel])], + ); + submit_ok(client, author, &event).await; + event +} + +async fn delete(client: &Client, author: &Keys, target: &nostr::Event) { + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["e", &target.id.to_hex()])]) + .sign_with_keys(author) + .unwrap(); + submit_ok(client, author, &deletion).await; +} + +/// The author's targets in every place an outsider cannot use them. +struct Targets { + /// An open channel the outsider has not joined. + open: String, + live: nostr::Event, + deleted: nostr::Event, + hidden: nostr::Event, + reminder: nostr::Event, +} + +async fn targets(client: &Client, author: &Keys, kind: u16) -> Targets { + let open = create_channel(client, author, "open").await; + let private = create_channel(client, author, "private").await; + let live = post(client, author, kind, &open).await; + let deleted = post(client, author, kind, &open).await; + delete(client, author, &deleted).await; + let hidden = post(client, author, kind, &private).await; + let reminder = sign( + author, + KIND_EVENT_REMINDER, + "nip44-ciphertext-placeholder", + vec![ + tag(&["d", &uuid::Uuid::new_v4().to_string()]), + tag(&["alt", "Encrypted reminder"]), + ], + ); + submit_ok(client, author, &reminder).await; + Targets { + open, + live, + deleted, + hidden, + reminder, + } +} + +fn hex(event: &nostr::Event) -> String { + event.id.to_hex() +} + +/// Reactions derive their channel from the target, so a target the reactor +/// cannot use, including someone else's author-only event, is rejected as +/// missing. Reacting to another user's author-only event used to be stored. +#[tokio::test] +#[ignore] +async fn reaction_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + // A live message the reactor could read, in an archived channel they belong to. + let archived = create_channel(&client, &author, "open").await; + add_member(&client, &author, &archived, &other).await; + let in_archive = post(&client, &author, 9, &archived).await; + let archive = sign( + &author, + 9002, + "", + vec![tag(&["h", &archived]), tag(&["archived", "true"])], + ); + submit_ok(&client, &author, &archive).await; + let react = |target: &str| sign(&other, 7, "+", vec![tag(&["e", target])]); + assert_uniform( + &client, + &other, + "invalid: reaction target event not found", + vec![ + ("missing", react(MISSING)), + ("soft-deleted", react(&hex(&t.deleted))), + ("private channel", react(&hex(&t.hidden))), + ("author-only", react(&hex(&t.reminder))), + ("archived channel", react(&hex(&in_archive))), + ], + ) + .await; + + // Positive controls: the author reacts to their reminder, and a member + // reacts in the open channel. + submit_ok( + &client, + &author, + &sign(&author, 7, "+", vec![tag(&["e", &hex(&t.reminder)])]), + ) + .await; + add_member(&client, &author, &t.open, &other).await; + submit_ok(&client, &other, &react(&hex(&t.live))).await; +} + +/// An edit of a target that is not the sender's, or not in the edit's +/// channel, or unreadable, is rejected exactly like a missing target. +#[tokio::test] +#[ignore] +async fn edit_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + add_member(&client, &other, &own, &author).await; + let not_yours = post(&client, &author, 9, &own).await; + let edit = |target: &str| { + sign( + &other, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&t.deleted))), + ("other channel", edit(&hex(&t.live))), + ("private channel", edit(&hex(&t.hidden))), + ("no channel / author-only", edit(&hex(&t.reminder))), + ("not yours", edit(&hex(¬_yours))), + ], + ) + .await; + + // Positive control: the sender edits their own message. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &edit(&hex(&mine))).await; +} + +/// An author removed from a private channel cannot tell whether their old +/// message still exists: editing it is rejected exactly like editing a +/// missing or soft-deleted one. +#[tokio::test] +#[ignore] +async fn removed_author_edit_does_not_reveal_target() { + let client = http_client(); + let owner = Keys::generate(); + let removed = Keys::generate(); + let private = create_channel(&client, &owner, "private").await; + add_member(&client, &owner, &private, &removed).await; + let kept = post(&client, &removed, 9, &private).await; + let deleted = post(&client, &removed, 9, &private).await; + delete(&client, &removed, &deleted).await; + let remove = sign( + &owner, + 9001, + "", + vec![ + tag(&["h", &private]), + tag(&["p", &removed.public_key().to_hex()]), + ], + ); + submit_ok(&client, &owner, &remove).await; + let edit = |target: &str| { + sign( + &removed, + 40003, + "edited", + vec![tag(&["e", target]), tag(&["h", &private])], + ) + }; + assert_uniform( + &client, + &removed, + "invalid: edit target not found or not editable by you", + vec![ + ("missing", edit(MISSING)), + ("soft-deleted", edit(&hex(&deleted))), + ("existing, author removed", edit(&hex(&kept))), + ], + ) + .await; +} + +/// A vote's channel and readability check runs before its kind check, so an +/// unusable target never reveals its kind. +#[tokio::test] +#[ignore] +async fn forum_vote_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 45001).await; + let wrong_kind = post(&client, &author, 9, &t.open).await; + let own = create_channel(&client, &other, "open").await; + let vote = |target: &str| { + sign( + &other, + 45002, + "+", + vec![tag(&["e", target]), tag(&["h", &own])], + ) + }; + assert_uniform( + &client, + &other, + "invalid: vote target event not found", + vec![ + ("missing", vote(MISSING)), + ("soft-deleted", vote(&hex(&t.deleted))), + ("other channel", vote(&hex(&t.live))), + ("private channel", vote(&hex(&t.hidden))), + ("author-only", vote(&hex(&t.reminder))), + ("other channel, wrong kind", vote(&hex(&wrong_kind))), + ], + ) + .await; + + // Positive control: a vote on a forum post in the vote's channel. + let mine = post(&client, &other, 45001, &own).await; + submit_ok(&client, &other, &vote(&hex(&mine))).await; +} + +/// A report on an event the reporter cannot read is rejected exactly like a +/// report on a missing event. Such reports used to be queued. +#[tokio::test] +#[ignore] +async fn report_does_not_reveal_target() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let report = |target: &str| { + sign( + &other, + 1984, + "", + vec![ + tag(&["e", target, "spam"]), + tag(&["p", &author.public_key().to_hex()]), + ], + ) + }; + assert_uniform( + &client, + &other, + "invalid: report target event not found", + vec![ + ("missing", report(MISSING)), + ("soft-deleted", report(&hex(&t.deleted))), + ("private channel", report(&hex(&t.hidden))), + ("author-only", report(&hex(&t.reminder))), + ], + ) + .await; + + // Positive controls: a readable event, and a report by pubkey. + submit_ok(&client, &other, &report(&hex(&t.live))).await; + let by_pubkey = sign( + &other, + 1984, + "", + vec![tag(&["p", &author.public_key().to_hex(), "spam"])], + ); + submit_ok(&client, &other, &by_pubkey).await; +} + +/// A reply whose parent is outside the reply's channel or unreadable is +/// rejected exactly like a reply to a missing parent. +#[tokio::test] +#[ignore] +async fn reply_does_not_reveal_parent() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let t = targets(&client, &author, 9).await; + let own = create_channel(&client, &other, "open").await; + let reply = |parent: &str| { + sign( + &other, + 9, + "reply", + vec![tag(&["e", parent, "", "reply"]), tag(&["h", &own])], + ) + }; + let (_, expected, _) = rejection(&client, &other, reply(MISSING)).await; + assert!( + expected.contains("reply parent not found"), + "missing parent: {expected:?}" + ); + assert_uniform( + &client, + &other, + &expected, + vec![ + ("missing", reply(MISSING)), + ("soft-deleted", reply(&hex(&t.deleted))), + ("other channel", reply(&hex(&t.live))), + ("private channel", reply(&hex(&t.hidden))), + ("no channel / author-only", reply(&hex(&t.reminder))), + ], + ) + .await; + + // Positive control: a reply in the parent's channel. + let mine = post(&client, &other, 9, &own).await; + submit_ok(&client, &other, &reply(&hex(&mine))).await; +} + +fn artifact_revision( + author: &Keys, + artifact: &str, + channel: &str, + op: &str, + kind_type: &str, + prev: Option<&EventId>, +) -> nostr::Event { + let mut tags = vec![ + tag(&["ar", "1"]), + tag(&["d", artifact]), + tag(&["h", channel]), + tag(&["type", kind_type]), + tag(&["op", op]), + ]; + if op != "delete" { + tags.push(tag(&["title", "Task"])); + } + tags.extend(prev.map(|p| tag(&["prev", &p.to_hex()]))); + sign(author, 45010, "", tags) +} + +fn d_tag(event: &nostr::Event) -> String { + event + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + (s.first().map(String::as_str) == Some("d")).then(|| s[1].clone()) + }) + .expect("d tag") +} + +/// Any artifact head outside a channel the sender can write answers +/// `head unavailable`, before the revision, type and deleted checks. +#[tokio::test] +#[ignore] +async fn artifact_revision_does_not_reveal_head() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let open = create_channel(&client, &author, "open").await; + let private = create_channel(&client, &author, "private").await; + let own = create_channel(&client, &other, "open").await; + let create = |channel: &str| { + artifact_revision( + &author, + &uuid::Uuid::new_v4().to_string(), + channel, + "create", + "buzz.task", + None, + ) + }; + let live = create(&open); + submit_ok(&client, &author, &live).await; + let hidden = create(&private); + submit_ok(&client, &author, &hidden).await; + let gone = create(&open); + submit_ok(&client, &author, &gone).await; + let gone_delete = artifact_revision( + &author, + &d_tag(&gone), + &open, + "delete", + "buzz.task", + Some(&gone.id), + ); + submit_ok(&client, &author, &gone_delete).await; + + let rev = |head: &nostr::Event, channel: &str, op: &str, ty: &str, prev: &EventId| { + artifact_revision(&other, &d_tag(head), channel, op, ty, Some(prev)) + }; + let missing = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "update", + "buzz.task", + Some(&live.id), + ); + assert_uniform( + &client, + &other, + "conflict: artifact head unavailable", + vec![ + ("missing", missing), + ( + "other channel, current prev", + rev(&live, &own, "update", "buzz.task", &live.id), + ), + ( + "other channel, stale prev", + rev(&live, &own, "update", "buzz.task", &hidden.id), + ), + ( + "other channel, wrong type", + rev(&live, &own, "update", "buzz.note", &live.id), + ), + ( + "other channel, delete", + rev(&live, &own, "delete", "buzz.task", &live.id), + ), + ( + "private channel, update", + rev(&hidden, &own, "update", "buzz.task", &hidden.id), + ), + ( + "private channel, move", + rev(&hidden, &own, "move", "buzz.task", &hidden.id), + ), + ( + "deleted, restore", + rev(&gone, &own, "restore", "buzz.task", &gone_delete.id), + ), + ( + "deleted, update", + rev(&gone, &own, "update", "buzz.task", &gone_delete.id), + ), + ], + ) + .await; + + // Positive control: the sender revises an artifact in their channel. + let mine = artifact_revision( + &other, + &uuid::Uuid::new_v4().to_string(), + &own, + "create", + "buzz.task", + None, + ); + submit_ok(&client, &other, &mine).await; + submit_ok( + &client, + &other, + &rev(&mine, &own, "update", "buzz.task", &mine.id), + ) + .await; +} + +/// Triggering someone else's workflow is rejected exactly like triggering a +/// workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_trigger_does_not_reveal_workflow() { + let client = http_client(); + let author = Keys::generate(); + let other = Keys::generate(); + let channel = create_channel(&client, &author, "open").await; + add_member(&client, &author, &channel, &other).await; + let define = |id: &str| { + sign( + &author, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", &channel])], + ) + }; + let live = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&live)).await; + let deleted = uuid::Uuid::new_v4().to_string(); + submit_ok(&client, &author, &define(&deleted)).await; + let coordinate = format!("30620:{}:{deleted}", author.public_key().to_hex()); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tags(vec![tag(&["a", &coordinate])]) + .sign_with_keys(&author) + .unwrap(); + submit_ok(&client, &author, &deletion).await; + + let trigger = |keys: &Keys, id: &str| sign(keys, 46020, "{}", vec![tag(&["d", id])]); + assert_uniform( + &client, + &other, + "invalid: workflow not found or not triggerable by you", + vec![ + ( + "missing", + trigger(&other, &uuid::Uuid::new_v4().to_string()), + ), + ("soft-deleted", trigger(&other, &deleted)), + ("someone else's", trigger(&other, &live)), + ], + ) + .await; + + // Positive control: the owner triggers their workflow. + submit_ok(&client, &author, &trigger(&author, &live)).await; +} + +/// GET `path` as `reader` and return the HTTP status and parsed body. +async fn read_as(client: &Client, reader: &Keys, path: &str) -> (u16, Value) { + let resp = client + .get(format!("{}{path}", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .send() + .await + .expect("read workflow"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Reading the runs or approvals of a workflow in a channel the caller cannot +/// read answers exactly like reading a workflow that does not exist. +#[tokio::test] +#[ignore] +async fn workflow_read_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let outsider = Keys::generate(); + let channel = create_channel(&client, &owner, "private").await; + let live = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &live).await; + let run = start_webhook_run(&client, &live, &secret).await; + let missing = uuid::Uuid::new_v4().to_string(); + let paths = |id: &str| { + [ + format!("/workflows/{id}/runs"), + format!("/workflows/{id}/runs/{run}/approvals"), + ] + }; + + for (missing_path, live_path) in paths(&missing).iter().zip(paths(&live).iter()) { + let expected = read_as(&client, &outsider, missing_path).await; + assert_eq!(expected.0, 404, "{missing_path}: {:?}", expected.1); + assert_eq!( + read_as(&client, &outsider, live_path).await, + expected, + "{live_path} must answer like a missing workflow" + ); + } + + // Positive control: the owner reads their workflow's run and its approvals. + let [runs_path, approvals_path] = paths(&live); + let (status, body) = read_as(&client, &owner, &runs_path).await; + assert_eq!(status, 200, "owner runs read failed: {body}"); + assert!( + body["runs"] + .as_array() + .is_some_and(|runs| runs.iter().any(|r| r["id"] == run.as_str())), + "owner runs must include {run}: {body}" + ); + let (status, body) = read_as(&client, &owner, &approvals_path).await; + assert_eq!(status, 200, "owner approvals read failed: {body}"); + assert!(body["approvals"].is_array()); +} + +/// Save a webhook-triggered workflow `id` in `channel` and return its secret. +async fn save_webhook_workflow(client: &Client, owner: &Keys, channel: &str, id: &str) -> String { + let definition = sign( + owner, + 30620, + "name: existence-oracle\ntrigger:\n on: webhook\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", id]), tag(&["h", channel])], + ); + let (accepted, message) = submit(client, owner, &definition).await; + assert!(accepted, "webhook workflow rejected: {message}"); + let response: Value = serde_json::from_str( + message + .strip_prefix("response:") + .expect("workflow save response"), + ) + .expect("parse workflow save response"); + response["webhook_secret"] + .as_str() + .expect("webhook secret") + .to_string() +} + +/// Call webhook `id` with the right secret, assert it starts a run exactly as +/// documented, and return the run id. +async fn start_webhook_run(client: &Client, id: &str, secret: &str) -> String { + let (status, body) = call_webhook(client, id, Some(secret)).await; + assert_eq!(status, 202, "valid webhook call failed: {body}"); + body["run_id"] + .as_str() + .and_then(|run| uuid::Uuid::parse_str(run).ok()) + .unwrap_or_else(|| panic!("webhook response lacks a run_id: {body}")) + .to_string() +} + +/// POST the public webhook endpoint for `id` with an optional secret header and +/// return the HTTP status and parsed body. +async fn call_webhook(client: &Client, id: &str, secret: Option<&str>) -> (u16, Value) { + let mut request = client.post(format!("{}/hooks/{id}", relay_http_url())); + if let Some(secret) = secret { + request = request.header("x-webhook-secret", secret); + } + let resp = request.send().await.expect("call webhook"); + let status = resp.status().as_u16(); + (status, resp.json().await.expect("parse response")) +} + +/// Until a caller proves the webhook secret, the unauthenticated webhook +/// endpoint answers every workflow exactly like a missing one, so it never +/// reveals that a workflow exists or what its trigger is. +#[tokio::test] +#[ignore] +async fn webhook_does_not_reveal_workflow() { + let client = http_client(); + let owner = Keys::generate(); + let channel = create_channel(&client, &owner, "open").await; + let webhook = uuid::Uuid::new_v4().to_string(); + let secret = save_webhook_workflow(&client, &owner, &channel, &webhook).await; + let other_trigger = uuid::Uuid::new_v4().to_string(); + let message_posted = sign( + &owner, + 30620, + "name: existence-oracle\ntrigger:\n on: message_posted\nsteps:\n - id: pause\n action: delay\n duration: 1s\n", + vec![tag(&["d", &other_trigger]), tag(&["h", &channel])], + ); + submit_ok(&client, &owner, &message_posted).await; + + let missing = uuid::Uuid::new_v4().to_string(); + let expected = call_webhook(&client, &missing, Some(&secret)).await; + assert_eq!(expected.0, 404, "missing workflow: {:?}", expected.1); + for (case, id, provided) in [ + ( + "non-webhook workflow", + &other_trigger, + Some(secret.as_str()), + ), + ("absent secret", &webhook, None), + ("wrong secret", &webhook, Some("wrong-secret")), + ] { + assert_eq!( + call_webhook(&client, id, provided).await, + expected, + "{case} must answer like a missing workflow" + ); + } + + // Positive control: the right secret starts a run. + start_webhook_run(&client, &webhook, &secret).await; +}