mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
test(conformance): wire-live workflow trigger community-confinement row
Replace the stubbed workflows::identical_workflow_and_approval_token row with
two honest rows matching readiness:
- workflow_trigger_is_community_confined: wire-live A/B isolation. Define a
workflow under host A (kind:30620, h=channel + name tag), take A's
server-generated workflow_id, fire it under host B (kind:46020) as a caller
who is an owner-member of the *same channel UUID* in B. B must fail closed
with the generic 'workflow not found' because get_workflow(host_community,
id) is community-scoped (command_executor.rs:703, commit c81b89355) — K's
membership of U-in-B is irrelevant. Positive control: A fires its own id and
is accepted, proving the B rejection is community confinement, not an
untriggerable workflow.
- approval_token_is_community_confined: precise pending_lane for WF-08. The
grant fence (get_approval_by_stored_hash(community, hash)) is already landed,
but nothing mints a pending approval over the wire yet (executor approval
gate is an explicit WF-08 TODO; create_approval is test-only), so a green
end-to-end approval-isolation test cannot be exercised today without faking
it. Dep is WF-08, not buzz-db.
Test-only; #[ignore] (needs a live two-host relay). No change to the verified
multi-tenant fix set already on the PR head.
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
parent
08a0399923
commit
16c55a1d46
@@ -1567,15 +1567,280 @@ mod channels_membership {
|
||||
mod workflows {
|
||||
use super::*;
|
||||
|
||||
/// Obligation: identical workflow UUID / approval-token hash in two
|
||||
/// communities are independent; trigger evaluation only sees same-community
|
||||
/// events; schedule execution is isolated (at-most-once per community).
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
/// Workflow definition command (NIP-custom kind 30620). `content` is the
|
||||
/// YAML body; `h` tags the channel. The server *generates* the workflow id
|
||||
/// and returns it in the OK message — it is **not** the `d` tag.
|
||||
const KIND_WORKFLOW_DEF: u16 = 30620;
|
||||
/// Workflow trigger command (kind 46020). `d` tag = the server-generated
|
||||
/// workflow id to fire.
|
||||
const KIND_WORKFLOW_TRIGGER: u16 = 46020;
|
||||
|
||||
/// Convert any base form to `http(s)://` for the REST `POST /events` door.
|
||||
fn to_http(base: &str) -> String {
|
||||
if base.starts_with("http://") || base.starts_with("https://") {
|
||||
base.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
base.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal webhook-triggered workflow YAML. `send_message` is the simplest
|
||||
/// valid action; the trigger type is irrelevant to *this* row (we fire via
|
||||
/// the kind:46020 command door, not the webhook door), but it must parse.
|
||||
fn workflow_yaml(name: &str) -> String {
|
||||
format!(
|
||||
"name: {name}\n\
|
||||
description: conformance trigger-isolation probe\n\
|
||||
trigger:\n\
|
||||
\x20 on: webhook\n\
|
||||
steps:\n\
|
||||
\x20 - id: step1\n\
|
||||
\x20 name: Notify\n\
|
||||
\x20 action: send_message\n\
|
||||
\x20 text: \"conformance\"\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Submit a signed event to the community bound to `http_base`'s host via
|
||||
/// the REST bridge (`POST /events`). In dev mode (`BUZZ_REQUIRE_AUTH_TOKEN
|
||||
/// =false`) the `X-Pubkey` header authenticates. Returns the parsed JSON
|
||||
/// `{accepted, message, ...}` body. The community is derived from the host,
|
||||
/// never from anything in the event — that's row zero.
|
||||
async fn submit_event(http_base: &str, keys: &Keys, event: nostr::Event) -> serde_json::Value {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(format!("{http_base}/events"))
|
||||
.header("X-Pubkey", keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).expect("serialize event"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("POST /events to {http_base} failed: {e}"));
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("read /events body");
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"POST /events to {http_base} returned HTTP {status}: {body}"
|
||||
);
|
||||
serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("parse /events JSON from {http_base}: {e} (body: {body})"))
|
||||
}
|
||||
|
||||
/// Create an `open`-visibility channel with a caller-chosen UUID in the
|
||||
/// community bound to `http_base`'s host (kind:9007, h-tag UUID → the
|
||||
/// `create_channel_with_id` path, which bootstraps the creator as
|
||||
/// owner-member). Using a caller-supplied UUID lets the test reuse the
|
||||
/// *same* channel UUID across both communities: the PK is `(community_id,
|
||||
/// id)`, so the same UUID legitimately co-exists, and `created_by` is
|
||||
/// inserted into `channel_members` as `owner` on **each** side — so the one
|
||||
/// keypair is a member of `U` in A *and* in B. That shared membership is
|
||||
/// load-bearing: it removes "not a member" as an alternate explanation for
|
||||
/// B rejecting the cross-community trigger, leaving the community fence on
|
||||
/// `get_workflow` as the *only* thing that can produce the rejection.
|
||||
async fn create_open_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String {
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &format!("conformance-wf-{channel_uuid}")]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
let body = submit_event(http_base, keys, event).await;
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"create-channel not accepted against {http_base}: {body}"
|
||||
);
|
||||
channel_uuid.to_string()
|
||||
}
|
||||
|
||||
/// Define a workflow in `channel_id` on `http_base`'s community (kind:30620,
|
||||
/// `h`=channel, content=YAML). Returns the **server-generated** workflow id,
|
||||
/// parsed out of the OK message (`response:{"workflow_id":"…"}`). This id is
|
||||
/// the tenant-scoped handle the trigger door confines: defined under A, it
|
||||
/// only resolves under A.
|
||||
async fn define_workflow(http_base: &str, keys: &Keys, channel_id: &str, name: &str) -> String {
|
||||
// `h` binds the channel; `name` is required by `handle_workflow_def`
|
||||
// (it rejects "missing workflow name" before parsing YAML). We use the
|
||||
// `name` tag, not `d`: the server *generates* the workflow id, and that
|
||||
// generated id — not any client-supplied `d` — is the handle this row
|
||||
// confines. A `d` tag here would falsely imply the trigger resolves by
|
||||
// client key.
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_DEF), workflow_yaml(name))
|
||||
.tags(vec![
|
||||
Tag::parse(["h", channel_id]).unwrap(),
|
||||
Tag::parse(["name", name]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
let body = submit_event(http_base, keys, event).await;
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"workflow def not accepted against {http_base}: {body}"
|
||||
);
|
||||
// The command executor returns `message: "response:{json}"` where json
|
||||
// carries `workflow_id`. Extract it.
|
||||
let msg = body["message"].as_str().unwrap_or_default();
|
||||
let json_part = msg.strip_prefix("response:").unwrap_or_else(|| {
|
||||
panic!("workflow def OK message missing `response:` prefix: {msg:?}")
|
||||
});
|
||||
let resp: serde_json::Value = serde_json::from_str(json_part)
|
||||
.unwrap_or_else(|e| panic!("parse workflow def response json: {e} ({json_part:?})"));
|
||||
resp["workflow_id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("workflow def response missing workflow_id: {resp}"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fire a workflow by id on `http_base`'s community (kind:46020, `d`=id).
|
||||
/// Returns the parsed `{accepted, message}` body so the caller can assert on
|
||||
/// the *wire-observable* accept/reject and message. The relay resolves the
|
||||
/// workflow with `get_workflow(host_community, id)` — community-scoped — so a
|
||||
/// foreign-community id fails closed with a generic `invalid: workflow not
|
||||
/// found`, indistinguishable from "no such id at all" (no cross-tenant
|
||||
/// enumeration oracle).
|
||||
async fn trigger_workflow(
|
||||
http_base: &str,
|
||||
keys: &Keys,
|
||||
workflow_id: &str,
|
||||
) -> serde_json::Value {
|
||||
let event = EventBuilder::new(Kind::Custom(KIND_WORKFLOW_TRIGGER), "")
|
||||
.tags(vec![Tag::parse(["d", workflow_id]).unwrap()])
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
submit_event(http_base, keys, event).await
|
||||
}
|
||||
|
||||
/// Obligation (trigger-confinement half): a workflow id defined under
|
||||
/// community A is triggerable only under A. Firing A's id under host B —
|
||||
/// even by a caller who is a legitimate member of the *same channel UUID* in
|
||||
/// B — must fail closed, because trigger resolution is
|
||||
/// `get_workflow(host_community, id)` and the row lives in A's community
|
||||
/// only. The mirror positive (A fires its own id) must succeed, proving the
|
||||
/// rejection is the community fence and not a workflow that is simply
|
||||
/// untriggerable.
|
||||
///
|
||||
/// Wire-observable shape (single keypair K; the fence under test must be
|
||||
/// `community_id`, never `pubkey` or channel membership):
|
||||
/// 1. Create the **same** channel UUID `U` in A and in B. PK is
|
||||
/// `(community_id, id)`, so both inserts succeed and K is bootstrapped
|
||||
/// as owner-member of `U` on *each* side (`create_channel_with_id`).
|
||||
/// This deliberately removes "not a member of U in B" as an alternate
|
||||
/// cause of the B rejection — K *is* a member of U in B.
|
||||
/// 2. Define a workflow in `U` under **A** (kind:30620). The server
|
||||
/// generates `W` and returns it. `W` is an A-community row.
|
||||
/// 3. Fire `W` under host **B** (kind:46020, `d`=W) as K. Must be
|
||||
/// rejected — `accepted == false` and the generic `workflow not found`
|
||||
/// message — because `get_workflow(B_community, W)` finds nothing: `W`
|
||||
/// exists only in A. K's membership of U-in-B is irrelevant; the
|
||||
/// lookup never reaches the membership check.
|
||||
/// 4. Fire `W` under host **A** as K. Must be accepted — the positive
|
||||
/// half proves the rejection in (3) is community confinement, not a
|
||||
/// workflow that can never trigger. (This also exercises the
|
||||
/// same-community happy path through the very fence we're testing.)
|
||||
///
|
||||
/// Mutate-bite (would-it-fail-without-the-fix): drop the community fence on
|
||||
/// the trigger lookup at
|
||||
/// `crates/buzz-relay/src/handlers/command_executor.rs:703`
|
||||
/// (`get_workflow(community_id, workflow_id)` → bare-id lookup, e.g.
|
||||
/// `get_workflow_any(workflow_id)`). Then B's trigger in step 3 loads A's
|
||||
/// workflow row, passes the membership check against B's colliding channel
|
||||
/// `U` (K is a member there), and **accepts** — step 3's `accepted == false`
|
||||
/// assertion goes RED. Restore the `community_id` argument → GREEN. This is
|
||||
/// the exact invariant commit `c81b89355` documents at that call site.
|
||||
///
|
||||
/// NOTE — approval-token isolation is a **separate, not-yet-wire-live**
|
||||
/// obligation, deliberately left as a `pending_lane` below. The grant
|
||||
/// handler (`get_approval_by_stored_hash(community, hash)`) is already
|
||||
/// community-scoped, but nothing *mints* a pending approval over the wire:
|
||||
/// the executor's approval gate is an explicit TODO
|
||||
/// (`crates/buzz-workflow/src/lib.rs` — "approval gates not yet implemented,
|
||||
/// see WF-08") and `create_approval` is only reached from unit tests. A
|
||||
/// green end-to-end approval-isolation test therefore cannot be exercised
|
||||
/// today; writing one would violate this file's contract (a green run can
|
||||
/// never be faked by an empty/DB-only body). It lands with WF-08.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn identical_workflow_and_approval_token_are_isolated() {
|
||||
async fn workflow_trigger_is_community_confined() {
|
||||
let http_a = to_http(&url_a());
|
||||
let http_b = to_http(&url_b());
|
||||
|
||||
// One keypair across both communities — the fence under test must be
|
||||
// community_id, never pubkey.
|
||||
let keys = Keys::generate();
|
||||
|
||||
// (1) Same channel UUID in both communities. (community_id, id) PK
|
||||
// permits this; K becomes owner-member of U on *each* side, so K's
|
||||
// membership of U-in-B cannot explain the B rejection in step (3).
|
||||
let shared_uuid = uuid::Uuid::new_v4();
|
||||
let chan_a = create_open_channel(&http_a, &keys, shared_uuid).await;
|
||||
let chan_b = create_open_channel(&http_b, &keys, shared_uuid).await;
|
||||
assert_eq!(chan_a, chan_b, "channels must share UUID — test design");
|
||||
|
||||
// (2) Define the workflow under A. Server generates W.
|
||||
let name = format!("wfconf_{}", uuid::Uuid::new_v4().simple());
|
||||
let workflow_id = define_workflow(&http_a, &keys, &chan_a, &name).await;
|
||||
assert!(
|
||||
uuid::Uuid::parse_str(&workflow_id).is_ok(),
|
||||
"server-generated workflow_id must be a UUID, got {workflow_id:?}"
|
||||
);
|
||||
|
||||
// (3) Fire W under host B as K. Must fail closed: W is an A-community
|
||||
// row, and get_workflow(B_community, W) finds nothing. K is a member of
|
||||
// U in B, so a leak here is the community fence failing, not membership.
|
||||
let b_resp = trigger_workflow(&http_b, &keys, &workflow_id).await;
|
||||
assert_eq!(
|
||||
b_resp["accepted"].as_bool(),
|
||||
Some(false),
|
||||
"host B accepted a trigger for an A-community workflow id — cross-community \
|
||||
trigger leak. response: {b_resp}"
|
||||
);
|
||||
let b_msg = b_resp["message"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
b_msg.contains("workflow not found"),
|
||||
"host B rejection must be the generic `workflow not found` (no enumeration \
|
||||
oracle); got: {b_msg:?}"
|
||||
);
|
||||
|
||||
// (4) Mirror positive: fire W under host A as K. Must be accepted —
|
||||
// proves the B rejection is community confinement, not an
|
||||
// untriggerable workflow, and exercises the same-community happy path
|
||||
// through the fence under test.
|
||||
let a_resp = trigger_workflow(&http_a, &keys, &workflow_id).await;
|
||||
assert_eq!(
|
||||
a_resp["accepted"].as_bool(),
|
||||
Some(true),
|
||||
"host A rejected a trigger for its own workflow id — positive control failed, \
|
||||
so the B rejection cannot be attributed to community confinement. response: \
|
||||
{a_resp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Obligation (approval-token half): an approval token (its stored hash)
|
||||
/// minted under community A cannot be satisfied by a grant on host B, and
|
||||
/// vice versa. The grant resolution is already community-scoped
|
||||
/// (`get_approval_by_stored_hash(community, hash)`), but this half is
|
||||
/// **not wire-live**: nothing mints a pending approval over the wire yet —
|
||||
/// the executor approval gate is an explicit TODO (WF-08), and
|
||||
/// `create_approval` is reached only from unit tests. Left as a precise
|
||||
/// `pending_lane` so the WF-08 owner fills in *their* row; a green run can
|
||||
/// never be faked by a DB-only/empty body. Depends on WF-08 (approval
|
||||
/// minting), **not** buzz-db — the scoping fence it will exercise is
|
||||
/// already landed.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn approval_token_is_community_confined() {
|
||||
pending_lane(
|
||||
"buzz-db",
|
||||
"same workflow UUID + approval hash in A and B act only within their community",
|
||||
"WF-08 (approval minting)",
|
||||
"an approval token minted under A cannot be satisfied by a grant on host B — \
|
||||
blocked until the executor approval gate (WF-08) mints pending approvals over \
|
||||
the wire; the get_approval_by_stored_hash(community, hash) fence is already landed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user