Files
buzz/crates/buzz-test-client/tests/conformance_multitenant.rs
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

2750 lines
134 KiB
Rust

//! Multi-tenant conformance harness.
//!
//! This file mirrors the obligation table in `docs/multi-tenant-conformance.md`
//! **one row per module**. It is the executable form of the conformance
//! contract: the current single-community relay is the wire-parity *oracle*, and
//! these tests prove two things the rewrite must never break:
//!
//! 1. **N=1 parity** — with one configured host → one default community, every
//! existing client observes byte-identical behavior. This is asserted by the
//! *existing* e2e suites (`e2e_relay`, `e2e_media`, `e2e_git`, …) run
//! with `RELAY_URL` pointed at the new relay; no new test is needed here,
//! only the documented obligation that those suites stay green unchanged.
//!
//! 2. **A/B isolation** — with two hosts → two communities on the *same* relay
//! deployment, no tenant-observable state crosses the boundary. These are
//! the new tests below. They require a running multi-tenant relay with two
//! host mappings, so they are `#[ignore]` by default and selected with
//! `--ignored`.
//!
//! # Running the A/B isolation suite
//!
//! ```text
//! RELAY_URL_A=http://a.localhost:3000 \
//! RELAY_URL_B=http://b.localhost:3000 \
//! cargo test -p buzz-test-client --test conformance_multitenant -- --ignored
//! ```
//!
//! Both URLs MUST address the same relay process (same pod, same Postgres, same
//! Redis); only the `Host` header differs. That is the whole point: one binary,
//! one DB, two communities, provably isolated by `community_id` derived from the
//! host — never from caller input.
//!
//! # Status of each row
//!
//! A row is `todo!()`-stubbed until the lane it depends on lands on the
//! integration branch. The stub is intentional and load-bearing: it names the
//! exact obligation so the lane owner fills in *their* row, and a green run can
//! never be faked by an empty body. Lane ownership is noted per module.
#![allow(clippy::todo, unused)]
use std::time::Duration;
/// Host A's base URL (community A). Defaults to a local two-host relay.
fn url_a() -> String {
std::env::var("RELAY_URL_A").unwrap_or_else(|_| "http://a.localhost:3000".to_string())
}
/// Host B's base URL (community B), same relay process, different host.
fn url_b() -> String {
std::env::var("RELAY_URL_B").unwrap_or_else(|_| "http://b.localhost:3000".to_string())
}
/// An unmapped/unknown host on the same relay process. No community row maps to
/// it, so the relay must fail closed (404) rather than fall through to a default
/// tenant. `*.localhost` resolves to 127.0.0.1, so this addresses the same relay
/// as `url_a`/`url_b` but presents a `Host` no community is bound to.
fn url_unknown() -> String {
std::env::var("RELAY_URL_UNKNOWN")
.unwrap_or_else(|_| "http://unknown.localhost:3000".to_string())
}
/// Marker for a conformance obligation whose lane has not yet landed on the
/// integration branch. Centralizes the "not yet wired" panic so the harvest of
/// remaining work is one grep: `rg pending_lane conformance_multitenant.rs`.
#[track_caller]
fn pending_lane(lane: &str, obligation: &str) -> ! {
todo!("conformance pending [{lane}]: {obligation}");
}
// ---------------------------------------------------------------------------
// Row zero: request community binding (Eva — relay-wiring)
// ---------------------------------------------------------------------------
mod row_zero_host_binding {
use super::*;
/// Obligation: an unknown/unmapped host fails closed with a *generic*
/// rejection and never falls through to a default tenant.
///
/// This is the wire complement promised by [`super::nip11_relay_info`]:
/// NIP-11 is deliberately host-agnostic and serves the *identical* static
/// document to every host (mapped or not) so a 404-vs-200 status difference
/// cannot become an enumeration oracle on the `nostr+json` path. The
/// fail-closed binding instead lives on the **WebSocket-upgrade / non-
/// `nostr+json`** door (`router.rs::nip11_or_ws_handler` →
/// `tenant::bind_community`), and *that* is what this row asserts.
///
/// Three properties, all wire-observable:
/// 1. **Fails closed** — an unmapped host does not fall through to a
/// default tenant. A non-`nostr+json` request to the unknown host is
/// rejected `404`, where a mapped host is *not* 404 (it serves the SPA
/// / NIP-11 fallback, or upgrades to WS). The status *difference*
/// between mapped and unmapped on this door is the proof the unmapped
/// host got no tenant. NOTE: this mapped-200/unmapped-404 status
/// *difference* is an intentional, door-scoped distinguisher — it
/// exists on the non-`nostr+json` (SPA/WS) door only, where a 404 is
/// how an unbound host is signalled. The `nostr+json` door
/// deliberately does *not* expose it (see `nip11_relay_info`), which is
/// why an unauthenticated NIP-11 probe cannot enumerate communities. A
/// future reader must not "fix" this into a 404-everywhere: that would
/// break the SPA fallback that legitimately serves mapped hosts here.
/// 2. **Generic** — the rejection body is the fixed string the relay uses
/// for *both* "unmapped" and "lookup error" (`router.rs:181`); it must
/// not echo the host or otherwise distinguish the failure mode, so an
/// unauthenticated caller cannot probe which communities exist.
/// 3. **The WS door fails too** — a raw WebSocket handshake to the unknown
/// host is rejected at the upgrade (before any frame is read), not
/// accepted-then-bound-to-a-default.
///
/// Mutate-bite (would-it-fail-without-the-fix): make `bind_community` fall
/// through to a default tenant on the unmapped host (e.g. `Err(_) =>` returns
/// a real `TenantContext` instead of the 404) → the unmapped host stops
/// 404'ing and the status-difference / WS-rejected assertions go red.
#[tokio::test]
#[ignore]
async fn unmapped_host_fails_closed_generically() {
// (2) Generic body + (1) fails-closed status, both on the non-
// `nostr+json` HTTP door where the body is fully observable.
let client = reqwest::Client::builder()
.build()
.expect("build reqwest client");
// Default Accept (NOT `application/nostr+json`): a mapped host serves
// the SPA / NIP-11 fallback (non-404); an unmapped host fails closed.
let unknown_resp = client
.get(url_unknown())
.send()
.await
.unwrap_or_else(|e| panic!("GET {} failed: {e}", url_unknown()));
let unknown_status = unknown_resp.status();
let unknown_body = unknown_resp.text().await.expect("read unmapped body");
let mapped_resp = client
.get(url_a())
.send()
.await
.unwrap_or_else(|e| panic!("GET {} failed: {e}", url_a()));
let mapped_status = mapped_resp.status();
// (1) Fails closed: unmapped is 404, mapped is not. The *difference* is
// the proof — the unmapped host bound to no tenant, while the mapped one
// proceeded past the bind. If an unmapped host silently fell through to a
// default tenant, it would return the same non-404 as the mapped host.
assert_eq!(
unknown_status,
reqwest::StatusCode::NOT_FOUND,
"unmapped host must fail closed with 404, not fall through to a \
default tenant (got {unknown_status})"
);
assert_ne!(
mapped_status,
reqwest::StatusCode::NOT_FOUND,
"a mapped host must NOT 404 on this door — otherwise the 404 above \
is not evidence the unmapped host was singled out as unbound"
);
// (2) Generic: the body must not echo the host or any tenant-
// distinguishing fragment. The host authority `unknown.localhost[:port]`
// (and the bare label) must be absent, so the rejection cannot be used
// to confirm a host the relay does not serve.
let unknown_url = url_unknown();
let unknown_authority = unknown_url
.strip_prefix("http://")
.or_else(|| unknown_url.strip_prefix("https://"))
.unwrap_or(&unknown_url);
assert!(
!unknown_body.contains(unknown_authority),
"unmapped-host rejection echoed the host authority \
{unknown_authority:?} in its body: {unknown_body:?} — the \
rejection must be generic and reveal nothing host-specific"
);
assert!(
!unknown_body.contains("unknown.localhost"),
"unmapped-host rejection echoed the host label in its body: \
{unknown_body:?} — the rejection must be generic"
);
// (3) The WS-upgrade door fails closed too: a raw WebSocket handshake to
// the unknown host is rejected AT the upgrade, never accepted and then
// bound to a default tenant. `bind_community` runs before
// `WebSocketUpgrade::from_request`, so the 404 is returned in place of
// the `101 Switching Protocols` and the handshake errors out.
let ws_url = url_unknown().replacen("http://", "ws://", 1);
let ws_result = tokio_tungstenite::connect_async(&ws_url).await;
assert!(
ws_result.is_err(),
"WebSocket upgrade to an unmapped host must be rejected at the \
handshake (fail-closed before any frame), but it succeeded — the \
connection bound to a tenant it should not have"
);
}
/// Obligation: a client-supplied `h` tag / token community stamp can never
/// override the host-derived community; a disagreeing stamp is rejected.
///
/// # What this row asserts, and how it is *distinct* from its siblings
///
/// Per `NOSTR.md`: "The Nostr wire format does not grow a tenant tag.
/// Client-supplied `#h` tags still name channels/groups and are checked
/// against the host-derived community." So the only client-supplied
/// community-ish signal on the EVENT wire is the `#h` channel tag, and the
/// row-zero contract is that it is resolved *within* the host-derived
/// community (`tenant.community()`), never honored as a cross-community
/// override.
///
/// This is the **override-attempt** scenario, deliberately partitioned from
/// two siblings that share the same scope branch but assert different
/// properties of it (see channel: `buzz-relay-rewrite`, 2026-06-27):
///
/// * [`super::channels_membership::same_channel_uuid_in_two_communities_is_isolated`]
/// (Mari, `buzz-db`) asserts **coexistence**: a channel UUID that exists
/// in *both* A and B; a post in A's instance never touches B's. Two
/// legitimate channels, non-interference.
/// * [`super::api_tokens_nip98_replay`] / Sami's
/// `verify_nip42_rejects_event_signed_for_wrong_communitys_host`
/// (`nip42_host_binding_live.rs`) assert the **AUTH `relay` tag** and
/// **token / NIP-98 `u`-host** override signals on their own paths.
///
/// row_zero (b) asserts the **`#h` override-attempt**: a channel that exists
/// *only in B*; an A connection `#h`-tagging it is **rejected** — the host
/// binding wins over the claim. Sibling-not-replacement: this shares the
/// `ingest::check_channel_membership` → `is_member_cached(tenant.community(),
/// ch_id)` scope branch with Mari's row, but bites the *override* property,
/// not coexistence.
///
/// # Why the channel is `visibility=open` (isolating override from membership)
///
/// The B channel is created **open**, so in B itself a non-member can post to
/// it. That is load-bearing: it means the A-connection post can fail for
/// **exactly one** reason — the channel does not exist in A's community
/// (`get_channel(A, b_ch_id)` is `None` → not open → not member). If the
/// channel were restricted, the rejection would be the ordinary
/// "not a member" gate and would *not* prove the override property. The
/// positive control (the same post succeeds against B) confirms the channel
/// is genuinely postable, so the A-side rejection is the override-rejection
/// and nothing else — the red comes from the override assertion, not a setup
/// or shared-membership failure.
///
/// Mutate-bite (would-it-fail-without-the-fix): make
/// `ingest::check_channel_membership` resolve the channel against the
/// *claimed* `#h` community instead of `tenant.community()` (honor the
/// override) → the A-connection post of B's open channel is accepted and
/// this row's "A must reject" assertion goes red.
///
/// # Bite-specificity: the rejection is pinned to the override branch
///
/// "A rejected" alone is not enough — `bind_community` (404), bridge-auth
/// (403), NIP-98 replay, relay-membership (403), and JSON parse (400) all
/// run *before* the channel-scope branch, so any of them could red this row
/// while the override path was never reached. To rule that out, the
/// override assertion below also pins the reason string
/// `"restricted: not a channel member"` — the exact
/// `IngestError::Rejected` the override path emits. That makes the red mean
/// "A rejected *because* the host-derived community refused the `#h` claim,"
/// not merely "A rejected." (Dawn + Mari converged on this independently
/// from the sanitization and channels-membership sides, 2026-06-27.)
#[tokio::test]
#[ignore]
async fn client_supplied_community_cannot_override_host() {
use nostr::Keys;
let keys = Keys::generate();
// Create an OPEN channel that lives ONLY in community B (host B).
let channel = create_open_channel(&url_b(), &keys).await;
// Positive control: the channel is genuinely postable in B — a kind:9
// message to B's host succeeds. This proves the A-side rejection below
// is the cross-community override-rejection, not a broken/unpostable
// channel or a membership gate.
let (status_b, body_b) =
post_kind9(&url_b(), &keys, &channel, "row-zero-b: legit post in B").await;
assert!(
status_b.is_success() && accepted(&body_b),
"control failed: kind:9 to B's own open channel must be accepted \
(status {status_b}, body {body_b}) — without this the A-side \
rejection does not isolate the override property"
);
// The override attempt: on an A connection (host A → community A), post a
// kind:9 `#h`-tagging the channel UUID that exists only in B. The
// client-supplied `#h` community signal disagrees with
// `resolve_host(A)`; row zero requires the host to win, so A must reject.
let (status_a, body_a) = post_kind9(
&url_a(),
&keys,
&channel,
"row-zero-b: override attempt from A",
)
.await;
// The override assertion itself: A rejects. `get_channel(A, b_ch_id)`
// finds nothing in A's community, so the open-channel bypass cannot
// apply and the host-resolved community refuses the claim. (A 2xx +
// accepted:true here would mean A honored the B `#h` claim — the exact
// override this row forbids.)
assert!(
!status_a.is_success() || !accepted(&body_a),
"row zero violated: an A connection posting to a channel that exists \
only in community B was ACCEPTED (status {status_a}, body {body_a}) \
— the client-supplied `#h` community overrode the host-derived \
community"
);
// Bite-specificity: the rejection above must come from the
// channel-scope/override branch, not an incidental earlier gate
// (`bind_community` 404, bridge-auth 403, NIP-98 replay, relay
// membership 403, JSON parse 400) that would red this row while the
// override path was never reached. The override path emits exactly
// `IngestError::Rejected("restricted: not a channel member")`:
// `get_channel(A, b_ch_id)` returns None against A's community, the
// open-channel bypass cannot apply, and the host-resolved community
// refuses the claim. Pinning the reason string converts "A rejected
// for *some* reason" into "A rejected *because the host-derived
// community refused the `#h` claim*" — the property this row exists to
// prove. (Two cold reviewers, Dawn + Mari, converged on this
// independently from the sanitization and channels-membership sides.)
assert!(
body_a.contains("restricted: not a channel member"),
"row zero violated: A rejected, but not via the channel-scope \
override branch — body {body_a:?} does not carry the \
\"restricted: not a channel member\" reason, so the red could be \
an incidental earlier gate (auth/parse/relay-membership) rather \
than the host-binding refusing the client-supplied `#h` claim"
);
// And the rejection must not leak B's existence: the generic
// channel-scope rejection ("restricted: not a channel member") reveals
// nothing about whether the channel exists elsewhere. The B channel UUID
// appearing in A's rejection body would itself be a cross-community
// existence oracle.
assert!(
!body_a.contains(&channel),
"A's rejection echoed the B-only channel UUID {channel:?} in its \
body: {body_a:?} — the rejection must not confirm cross-community \
existence"
);
}
}
/// Create an `open`-visibility channel (kind:9007) in the community bound to
/// `base_url`'s host, via the NIP-98 HTTP bridge (`POST /events`). Returns the
/// channel UUID. The `Host` header is implied by `base_url`, so the relay
/// derives the community from the host — the channel lands in exactly that
/// community and no other.
async fn create_open_channel(base_url: &str, keys: &nostr::Keys) -> String {
use nostr::{EventBuilder, Kind, Tag};
let channel_uuid = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid]).expect("h tag"),
Tag::parse(["name", &format!("row-zero-{channel_uuid}")]).expect("name tag"),
Tag::parse(["channel_type", "stream"]).expect("channel_type tag"),
Tag::parse(["visibility", "open"]).expect("visibility tag"),
])
.sign_with_keys(keys)
.expect("sign create-channel event");
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/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!("create-channel POST to {base_url} failed: {e}"));
let status = resp.status();
let body = resp.text().await.expect("read create-channel body");
assert!(
status.is_success() && body.contains("\"accepted\":true"),
"create-channel in {base_url} must succeed (status {status}, body {body})"
);
channel_uuid
}
/// Post a kind:9 group message `#h`-tagging `channel` to the community bound to
/// `base_url`'s host. Returns `(status, body)` so callers can assert on the
/// wire-observable accept/reject. The relay derives the community from the
/// `Host` (implied by `base_url`); `channel` is the client-supplied `#h` claim.
async fn post_kind9(
base_url: &str,
keys: &nostr::Keys,
channel: &str,
content: &str,
) -> (reqwest::StatusCode, String) {
use nostr::{EventBuilder, Kind, Tag};
let event = EventBuilder::new(Kind::Custom(9), content)
.tags(vec![Tag::parse(["h", channel]).expect("h tag")])
.sign_with_keys(keys)
.expect("sign kind:9 event");
let client = reqwest::Client::new();
let resp = client
.post(format!("{base_url}/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!("kind:9 POST to {base_url} failed: {e}"));
let status = resp.status();
let body = resp.text().await.expect("read kind:9 body");
(status, body)
}
/// Whether a `POST /events` JSON body reports the event as accepted.
fn accepted(body: &str) -> bool {
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("accepted").and_then(|a| a.as_bool()))
.unwrap_or(false)
}
// ---------------------------------------------------------------------------
// NIP-11 relay info and relay `self` (Eva — relay-wiring)
// ---------------------------------------------------------------------------
mod nip11_relay_info {
use super::*;
/// Fetch the NIP-11 relay information document from `base_url`'s root with
/// `Accept: application/nostr+json`. Returns `(status, body)`; `body` is the
/// raw response text (parsed by callers as needed).
///
/// The `Host` header is implied by `base_url` — `a.localhost`/`b.localhost`
/// both resolve to 127.0.0.1, so reqwest addresses the same relay process
/// and the relay derives the community from the host. That host-derivation
/// is exactly what this row exercises; nothing here is caller-supplied.
async fn fetch_nip11(base_url: &str) -> (reqwest::StatusCode, String) {
let client = reqwest::Client::builder()
.build()
.expect("build reqwest client");
let resp = client
.get(base_url)
.header(reqwest::header::ACCEPT, "application/nostr+json")
.send()
.await
.unwrap_or_else(|e| panic!("NIP-11 GET {base_url} failed: {e}"));
let status = resp.status();
let body = resp.text().await.expect("read NIP-11 body");
(status, body)
}
/// Obligation: unauthenticated NIP-11 reads must not become an enumeration
/// oracle for other communities; `RelayInfo::build` takes only static +
/// host-scoped inputs (the static-input lint backs this at compile/CI time).
///
/// This is the *black-box* complement to that compile-time fence
/// (`crates/buzz-relay/src/nip11.rs::_RELAY_INFO_BUILD_STATIC_INPUT_FENCE`):
/// the fence proves `RelayInfo::build` *cannot* take an unscoped DB/search
/// input; this test proves the *observable wire behavior* — that the served
/// document carries nothing that distinguishes one community from another.
///
/// One field is deliberately host-scoped: `icon` (the NIP-WP workspace
/// icon, per-community state served in the standard NIP-11 field, fetched
/// through `bind_community` so it can only ever be the requesting host's
/// own community state — intentionally public presentation, exactly what
/// upstream NIP-11 `icon` is). So the
/// oracle proof is: the documents are identical *modulo each community's
/// own `icon`*, and an unmapped host's document carries no `icon` at all.
/// The moment any *other* per-community value leaks into the doc, the
/// icon-stripped bodies diverge and this assertion fails — that is the
/// mutate-bite this row guards (seed a community-distinguishing field into
/// the served doc → the A≡B assertion goes red).
#[tokio::test]
#[ignore]
async fn nip11_is_not_a_cross_community_enumeration_oracle() {
let (status_a, body_a) = fetch_nip11(&url_a()).await;
let (status_b, body_b) = fetch_nip11(&url_b()).await;
assert_eq!(
status_a,
reqwest::StatusCode::OK,
"host A must serve its NIP-11 document"
);
assert_eq!(
status_b,
reqwest::StatusCode::OK,
"host B must serve its NIP-11 document"
);
// Both bodies must be valid NIP-11 JSON — a relay-info object, not an
// error page or a host echo.
let mut json_a: serde_json::Value =
serde_json::from_str(&body_a).expect("host A NIP-11 is valid JSON");
let mut json_b: serde_json::Value =
serde_json::from_str(&body_b).expect("host B NIP-11 is valid JSON");
assert!(
json_a.get("supported_nips").is_some(),
"host A NIP-11 must be a relay-info document (has supported_nips)"
);
assert!(
json_b.get("supported_nips").is_some(),
"host B NIP-11 must be a relay-info document (has supported_nips)"
);
// `icon` is the one deliberately host-scoped field (the NIP-WP
// workspace icon; each host sees only its own community's icon).
// Strip it before the equality proof — every OTHER field must still
// be community-agnostic.
json_a.as_object_mut().map(|o| o.remove("icon"));
json_b.as_object_mut().map(|o| o.remove("icon"));
// The enumeration-oracle obligation: no other field of the served
// document varies by community. Identical icon-stripped bodies are the
// proof that the doc cannot be used to distinguish or probe another
// tenant.
assert_eq!(
json_a, json_b,
"NIP-11 from host A and host B must be identical apart from each \
community's own `icon`: any other community-distinguishing field \
would make the unauthenticated relay-info document an enumeration \
oracle for other tenants"
);
// An *unmapped* host must get the SAME document too — not a 404. NIP-11
// is intentionally host-agnostic (served from static facts BEFORE host
// binding; see `router.rs::nip11_or_ws_handler`). If an unknown host
// 404'd here while a mapped host returned 200, that status difference
// would itself be the enumeration oracle — a caller could probe which
// hosts are configured by watching for 404-vs-200. Serving the identical
// static doc to every host, mapped or not, is precisely what denies that
// oracle. (Fail-closed host binding lives on the WS-upgrade / non-
// `nostr+json` path and is asserted by `row_zero_host_binding`.)
let (status_unknown, body_unknown) = fetch_nip11(&url_unknown()).await;
assert_eq!(
status_unknown,
reqwest::StatusCode::OK,
"an unmapped host must still receive the static NIP-11 document, not \
a 404 — a status difference between mapped and unmapped hosts would \
itself be a community-enumeration oracle"
);
let json_unknown: serde_json::Value =
serde_json::from_str(&body_unknown).expect("unmapped-host NIP-11 is valid JSON");
assert!(
json_unknown.get("icon").is_none(),
"an unmapped host binds to no community, so its NIP-11 document must \
carry no `icon` — leaking any community's icon to an unmapped host \
would cross the tenant boundary"
);
assert_eq!(
json_a, json_unknown,
"NIP-11 served to an unmapped host must match a mapped host's \
icon-stripped document: apart from the host's own `icon`, the \
relay-info doc carries no host-derived field, so it cannot reveal \
whether a given host is configured"
);
}
}
// ---------------------------------------------------------------------------
// API tokens and NIP-98 replay (Sami — buzz-auth)
// ---------------------------------------------------------------------------
mod api_tokens_nip98_replay {
use super::*;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use nostr::{EventBuilder, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
/// Obligation: token hash uniqueness/lookup is `(community_id, token_hash)`;
/// a token minted in A does not authorize the same hash in B.
///
/// **This row is doc-only — and that is the honest answer.**
///
/// Every other wire-driven row in this file proves a black-box property: a
/// client addresses the relay over HTTP/WS and observes that the response
/// denies a cross-community oracle. This row's obligation cannot be tested
/// that way because **the api_token mint surface does not exist on the wire
/// in `buzz-relay`** — there is no route by which a client can bind a token
/// to a community over HTTP, so the "mint in A, present to B" precondition
/// has no entry point.
///
/// Verified on PR head:
/// * `crates/buzz-relay/src/router.rs:52-79` — full route list is `/`,
/// `/info`, `/.well-known/nostr.json`, `/health`, `/_liveness`,
/// `/_readiness`, `/events`, `/query`, `/count`, `/hooks/{id}`, plus
/// the media, git, and git-policy sub-routers. **No `/tokens` route.**
/// * `crates/buzz-relay/src/api/` directory is `{bridge, events, media,
/// nip05, git/, mod}` — no `tokens` module. The 792-line
/// `crates/sprout-relay/src/api/tokens.rs` self-service-minting
/// endpoint that existed pre-rewrite (PR #37, commit `f84da74d3`) was
/// deliberately not ported. Porting it to enable this row would be a
/// product + security-surface decision, not a test-enablement task.
/// * Api_tokens are consumed (not minted) by the Blossom upload path at
/// `crates/buzz-relay/src/api/media.rs:638`, which extracts the
/// `X-Auth-Token: buzz_*` header and looks up
/// `state.db.get_api_token_by_hash_including_revoked(tenant.community(),
/// &hash)`. The comment immediately above that call names the row-44
/// fence explicitly: *"A token minted in community A presented to a
/// host that resolves to community B must not authorize."*
///
/// So where other rows prove *the oracle is denied*, api_tokens proves
/// *the oracle's input surface does not exist* — a sibling of the
/// `audit_log` row (whose proof is that the *output* surface does not
/// exist). Both are strictly stronger than a wire-denied assertion, and
/// the honest way to state it is to cite the facts, not to invent an
/// out-of-band mint path that would break the conformance file's black-box
/// contract (its deps are `buzz-ws-client`/`reqwest`/`tokio-tungstenite`/
/// `s3` — no `sqlx`, no `buzz-db`).
///
/// The `(community_id, token_hash)` fence itself is proven directly at the
/// storage layer, where direct Postgres access is in-convention:
///
/// 1. `crates/buzz-db/src/api_token.rs:425
/// lookup_by_hash_is_scoped_to_community` — inserts two rows with
/// **identical 32-byte hash** in communities A and B (legal under
/// `UNIQUE(community_id, token_hash)`), then asserts A-scoped lookup
/// returns A's row only, B-scoped lookup returns B's row only, and a
/// third (unrelated) community returns None. The mutate-bite handle
/// is named in the test's doc-comment: strip `AND community_id = $1`
/// from `get_api_token_by_hash_including_revoked` and the lookup
/// becomes hash-only, returning whichever row Postgres picks
/// first — the cross-tenancy assertion fails. Sharp row-44 shape.
/// 2. `crates/buzz-db/src/api_token.rs:488
/// active_lookup_by_hash_is_scoped_to_community` — mirror for the
/// `revoked_at IS NULL` variant `Db::get_api_token_by_hash`. Same
/// shape: same hash, distinct communities, scoped lookup returns the
/// caller's row only.
///
/// And the consumer fence — `media.rs:638` — calls the scoped DB lookup
/// with `tenant.community()` derived from the request host *before* token
/// resolution (`media.rs:97` comment: *"This MUST run before scope
/// resolution so the API-token lookup is keyed on (community_id,
/// token_hash). Resolving scopes without a tenant in hand would query
/// api_tokens by hash alone, defeating the cross-community fence."*).
///
/// Substrate on PR head: `api_tokens` table has UNIQUE index
/// `(community_id, token_hash)`; `CommunityId` is a server-resolved type
/// (never client input); `tenant.community()` is bound from the request
/// host by `bind_community` before any tenant-scoped DB read or write.
#[test]
fn token_minted_in_a_does_not_authorize_in_b() {
// Compile-time anchor: this row is doc-only by design. The proof lives
// in the cited storage-layer unit tests; the wire surface for minting
// does not exist (see module doc-comment for the verified route list).
// If anyone adds a `/tokens` route to `buzz-relay`, this row's shape
// should be revisited and a wire-driven body added.
}
/// Obligation: NIP-98 replay seen-set is shared (any-pod) AND community
/// scoped: a nonce spent in A is still spendable in B, but a replay within
/// A is rejected from any pod.
///
/// # Wire-observable claim and what bites it
///
/// The load-bearing wire-observable property is **within-community replay
/// rejection**: a NIP-98 event posted twice to the same community must be
/// rejected on the second attempt. This is the proof that the shared
/// (cross-pod) seen-set is in the request path at all — without it, any
/// pod would happily re-honor a spent NIP-98 event.
///
/// Mutate-bite for this assertion: turn `check_nip98_replay` into a no-op
/// in `crates/buzz-relay/src/api/bridge.rs:79` (return `Ok(())` before
/// consulting the guard). Under the mutation, the second POST goes 200
/// instead of 401 → the within-community replay assertion fails RED. The
/// bite fires on the replay check itself, not on a sibling fence.
///
/// # Why the cross-community independence arm is a tripwire, not a bite
///
/// The obligation's cross-community arm — "a nonce spent in A is still
/// spendable in B" — IS asserted by this test, but as a **positive
/// control** rather than a mutate-bite. Reasoning:
///
/// The replay key shape is `buzz:{community}:nip98:{event_id_hex}` (see
/// `crates/buzz-auth/src/nip98_replay.rs:103 nip98_replay_key`). The
/// community prefix is what makes the key per-community; the
/// `event_id_hex` is what makes it per-event. **On natural wire traffic
/// the event_id is already community-distinct**, because the NIP-98 `u`
/// tag is part of the signed canonical bytes and the per-tenant host
/// binding (see `verify_bridge_auth` + `nip98_expected_url` after the
/// row-44 sibling fix `bf8a1a4fa`) forces the `u` to differ per community.
/// So two events posted to A and B respectively are signed against
/// different `u` values → they have different event_ids → their seen-set
/// keys have different suffixes → they do not collide *regardless of
/// whether the community prefix is present*.
///
/// This means the community prefix is **structurally redundant for
/// natural wire traffic**. It is load-bearing only against an artificial
/// "same event_id surfaces in two communities" scenario, which content-
/// addressing makes implausible. The substrate's own doc-comment for the
/// unit test that proves the prefix isolates such artificial collisions
/// names the property as exactly that: "**Belt-and-suspenders**: even if a
/// same-id event surfaces in two communities (which content-addressing
/// makes implausible), the seen-set MUST consult two distinct rows."
/// (`crates/buzz-auth/src/nip98_replay.rs:163
/// key_isolates_communities_for_same_event_id`.)
///
/// Why u-host can't be bypassed to manufacture a wire collision: the
/// bridge processes requests in this order (`bridge.rs:242-259`):
/// 1. `bind_community` from request `Host` header (row-zero fence)
/// 2. `nip98_expected_url(state.config.relay_url, &tenant, "/events")`
/// builds the per-tenant expected `u`
/// 3. `verify_bridge_auth` rejects with 401 unless the signed event's
/// `u` tag matches the per-tenant expected URL
/// 4. **Only then** is `check_nip98_replay` called
///
/// A same-physical-event posted to both A and B would be rejected at step
/// 3 (u-host mismatch) for one of the two hosts, so it never reaches the
/// replay check from a wire test. The replay-prefix-drop mutation
/// proposed in earlier design rounds turned out to be vacuous against
/// natural wire traffic; a wire-driven bite on the prefix's load-
/// bearingness against the artificial-collision case is not constructible
/// from this file's black-box vantage point. The unit test cited above
/// proves it at the layer where the artificial construction is possible.
///
/// What the tripwire DOES catch: a future regression that globalizes the
/// seen-set namespace by truncating or normalizing the key (e.g.,
/// "simplifying" the key to just `buzz:nip98:{event_id}`, or
/// canonicalizing `u` in a way that collapses cross-tenant `u` values
/// into the same event_id) would break the "spend in A doesn't burn the
/// slot in B" arm even though u-tags differ. The tripwire assertion gives
/// such a regression somewhere to land at the wire layer, on top of the
/// unit-layer same-id-collision proof.
///
/// # Test layout
///
/// Two distinct keypairs per community (Eva's setup-equivalence vacuity
/// scar: distinct values make any leak surface as wrong-pubkey-spent /
/// wrong-content-stored, not silent-absent). The wire surface is `POST
/// /events` with `Authorization: Nostr <base64-NIP-98-event>`. Bodies are
/// minimal valid kind:1 nostr events authored by the same NIP-98 signer
/// (relay-membership is open under `BUZZ_REQUIRE_AUTH_TOKEN=false`).
#[tokio::test]
#[ignore]
async fn nip98_replay_seenset_is_shared_and_community_scoped() {
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// Distinct keypairs per community — if the seen-set ever leaked into
// a globalized namespace via a regression in u-canonicalization, the
// tripwire assertion below catches it because B's post would 401 as
// "already spent" using A's key's slot.
let keys_a = Keys::generate();
let keys_b = Keys::generate();
assert_ne!(
keys_a.public_key().to_hex(),
keys_b.public_key().to_hex(),
"test design requires distinct keys per community"
);
// (1) Within-community replay rejection — the load-bearing wire bite.
//
// Sign a NIP-98 event E with u = A's /events URL, post it to A → must
// 200. Post the exact same NIP-98 event again to A → must 401 with a
// body that names replay detection. The mutate-bite is `check_nip98_
// replay → noop` in `bridge.rs:79`; under that mutation the second
// post goes 200 because the seen-set is not consulted, and this
// assertion fails RED on the within-community arm.
let events_url_a = format!("{http_a}/events");
let body_a = build_kind1_event_json(&keys_a, "A within-community replay test");
let auth_a_first = build_nip98_header(&keys_a, &events_url_a, "POST", body_a.as_bytes());
let client = reqwest::Client::new();
let first_a = client
.post(&events_url_a)
.header("Authorization", &auth_a_first)
.header("Content-Type", "application/json")
.body(body_a.clone())
.send()
.await
.unwrap_or_else(|e| panic!("first POST to A failed: {e}"));
assert!(
first_a.status().is_success(),
"first NIP-98 post to A must succeed, got {} (body: {})",
first_a.status(),
first_a.text().await.unwrap_or_default(),
);
// Repost the SAME NIP-98 event (byte-identical Authorization header,
// byte-identical body — same canonical event id).
let second_a = client
.post(&events_url_a)
.header("Authorization", &auth_a_first)
.header("Content-Type", "application/json")
.body(body_a)
.send()
.await
.unwrap_or_else(|e| panic!("second POST to A failed: {e}"));
// The assertion below pins the status code (401), not just rejection,
// because the system has defense-in-depth across two layers with
// distinct rejection signatures:
// * auth-layer replay check (`check_nip98_replay`) — rejects with
// 401 + body "NIP-98: replay detected".
// * storage-layer dedup (`events` PK `ON CONFLICT DO NOTHING` in
// `ingest_event`) — accepts with 200 + body `accepted: false,
// message: "duplicate"`.
// Both reject a duplicate, but only the 401 path proves the seen-set
// is in the request path. A body-only check like `!accepted` would
// pass under a noop'd `check_nip98_replay` because storage-dedup
// still 200-accepted-false's the second post — the bite would go
// vacuous against the layer the obligation actually names. Status-
// code is the load-bearing-layer discriminator; do not weaken this
// to `!accepted` for "simpler reading."
assert_eq!(
second_a.status(),
reqwest::StatusCode::UNAUTHORIZED,
"second POST to A with the same NIP-98 event MUST be rejected as \
replay (got {}) — if this returns 200, the shared seen-set is \
not in the request path (storage-dedup at `ingest_event` would \
return 200-accepted-false on the same input; only the 401 from \
`check_nip98_replay` proves the auth-layer replay fence). \
Mutate-bite handle: `check_nip98_replay` in bridge.rs:79.",
second_a.status(),
);
let second_a_body = second_a.text().await.unwrap_or_default();
assert!(
second_a_body.contains("replay"),
"second POST to A's 401 body should name replay detection, got: \
{second_a_body:?}",
);
// (2) Cross-community spend-spread — tripwire, no mutate-bite.
//
// Sign an independent NIP-98 event E' for B's /events URL (different
// u → different event_id by signed-canonical divergence). Post E' to
// B → must 200 even though E was already spent in A. This catches a
// future namespace-globalization regression in the seen-set (e.g.,
// key truncation, u-normalization collapse) by giving such a
// regression somewhere to land at the wire layer. The cross-
// community-prefix isolation property against an artificial same-id
// collision is proven at the unit layer by
// `nip98_replay.rs:163 key_isolates_communities_for_same_event_id`.
let events_url_b = format!("{http_b}/events");
let body_b = build_kind1_event_json(&keys_b, "B cross-community spread test");
let auth_b = build_nip98_header(&keys_b, &events_url_b, "POST", body_b.as_bytes());
let first_b = client
.post(&events_url_b)
.header("Authorization", &auth_b)
.header("Content-Type", "application/json")
.body(body_b)
.send()
.await
.unwrap_or_else(|e| panic!("POST to B failed: {e}"));
assert!(
first_b.status().is_success(),
"POST of an independent NIP-98 event to B must succeed (200) — \
A's spent nonce must not burn B's seen-set slot. Got {} (body: \
{}). Tripwire: if this fails, the seen-set namespace has been \
globalized and the per-community scope is broken. The artificial \
same-event_id-different-community case is proven separately at \
`crates/buzz-auth/src/nip98_replay.rs:163 \
key_isolates_communities_for_same_event_id`.",
first_b.status(),
first_b.text().await.unwrap_or_default(),
);
}
/// Build a `Authorization: Nostr <base64>` header value for NIP-98 HTTP
/// auth (kind 27235 `HttpAuth` with `u`/`method`/`payload` tags). Mirrors
/// the pattern in `crates/buzz-auth/src/nip98.rs`; kept local to this
/// row so the conformance file's rows stay self-contained.
fn build_nip98_header(keys: &Keys, url: &str, method: &str, body: &[u8]) -> String {
let payload_hash = hex::encode(Sha256::digest(body));
let tags = vec![
Tag::parse(["u", url]).expect("u tag"),
Tag::parse(["method", method]).expect("method tag"),
Tag::parse(["payload", &payload_hash]).expect("payload tag"),
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(keys)
.expect("sign NIP-98 event");
let json = nostr::JsonUtil::as_json(&event);
let encoded = BASE64.encode(json.as_bytes());
format!("Nostr {encoded}")
}
/// Build a minimal kind:1 nostr event JSON for the bridge ingest path.
/// The body is just a valid signed event — the test does not care about
/// the content beyond it surviving relay-side parsing.
fn build_kind1_event_json(keys: &Keys, content: &str) -> String {
let event = EventBuilder::new(Kind::TextNote, content)
.sign_with_keys(keys)
.expect("sign kind:1");
nostr::JsonUtil::as_json(&event)
}
/// Convert any base form to `http(s)://` for REST.
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()
}
}
}
// ---------------------------------------------------------------------------
// Membership / allowlist / archived identities (Sami — buzz-auth)
// ---------------------------------------------------------------------------
mod membership_allowlist {
use super::*;
/// Obligation: membership/allowlist/archive keyed `(community_id, pubkey)`;
/// archiving a key in A cannot hide/archive it in B; errors stay generic.
#[tokio::test]
#[ignore]
async fn archive_in_a_does_not_affect_b() {
pending_lane(
"buzz-auth",
"archived_identities (community_id, pubkey) — A's archive invisible to B",
);
}
}
// ---------------------------------------------------------------------------
// Users / profiles / NIP-05 / user search (Sami+Quinn — auth+search)
// ---------------------------------------------------------------------------
mod users_profiles_nip05 {
use super::*;
use buzz_test_client::BuzzTestClient;
use nostr::{EventBuilder, Keys, Kind};
/// Convert any base form to `ws(s)://` for WS connect.
fn to_ws(base: &str) -> String {
if base.starts_with("ws://") || base.starts_with("wss://") {
base.trim_end_matches('/').to_string()
} else {
base.replace("https://", "wss://")
.replace("http://", "ws://")
.trim_end_matches('/')
.to_string()
}
}
/// Convert any base form to `http(s)://` for REST.
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()
}
}
/// Publish a kind:0 (Metadata) event via WS for `keys`, with the supplied
/// JSON content. Returns the event id hex. Latest kind:0 per
/// `(community_id, pubkey)` wins under NIP-01 replaceable semantics; the
/// relay's ingest side-effect at `crates/buzz-relay/src/handlers/side_effects.rs::handle_kind0_profile`
/// also syncs the parsed fields into `users` via
/// `update_user_profile(tenant.community(), pubkey, ...)`.
async fn publish_kind0(client: &mut BuzzTestClient, keys: &Keys, content_json: &str) -> String {
let event = EventBuilder::new(Kind::Metadata, content_json)
.sign_with_keys(keys)
.unwrap();
let id_hex = event.id.to_hex();
let ok = client.send_event(event).await.expect("send kind:0");
assert!(ok.accepted, "kind:0 not accepted: {}", ok.message);
id_hex
}
/// Query latest kind:0 for `pubkey_hex` via REST `POST /query` (relay's
/// bridge endpoint; with `BUZZ_REQUIRE_AUTH_TOKEN=false` the dev-mode
/// `X-Pubkey` header is sufficient — no NIP-98 mint needed). Returns the
/// list of event JSON values (typically 0 or 1 since kind:0 is
/// NIP-01-replaceable).
async fn query_kind0(http_base: &str, pubkey_hex: &str) -> Vec<serde_json::Value> {
let client = reqwest::Client::new();
let filters = serde_json::json!([{
"kinds": [0],
"authors": [pubkey_hex],
"limit": 1,
}]);
let resp = client
.post(format!("{http_base}/query"))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&filters).unwrap())
.send()
.await
.unwrap_or_else(|e| panic!("POST /query against {http_base} failed: {e}"));
assert!(
resp.status().is_success(),
"POST /query against {http_base} returned {}",
resp.status()
);
resp.json().await.expect("parse /query JSON")
}
/// Extract the NIP-11 / extract_domain form of a host. Mirrors
/// `crates/buzz-relay/src/api/nip05.rs::extract_domain` — strips the
/// scheme prefix and any port, returning the bare hostname. The relay's
/// `canonicalize_nip05` requires a kind:0 `nip05` value to end in
/// `@<this domain>`. In multi-tenant mode the expected domain is the bound
/// request host (`tenant.host()`), not the global `RELAY_URL` host.
fn extract_relay_domain(relay_url_env: &str) -> String {
// Match the relay's logic exactly: strip wss/ws, then take everything
// before the first ':' or '/' (port and path).
let s = relay_url_env
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.trim_start_matches("https://")
.trim_start_matches("http://");
s.split([':', '/']).next().unwrap_or(s).to_string()
}
/// Obligation: same pubkey can hold a different profile per community; kind:0
/// replacement is scoped by `(community_id, pubkey)`.
///
/// Wire-observable shape: the same keypair publishes kind:0 with
/// community-distinct content (`{"display_name":"A profile"}` vs
/// `{"display_name":"B profile"}`) on each host's WS-AUTH'd connection.
/// NIP-01 replaceable semantics mean the latest kind:0 per `(community_id,
/// pubkey)` is what subsequent queries return — REST `POST /query` on each
/// host returns the kind:0 *its* community has, never the other's.
///
/// Sibling-not-replacement framing: this row asserts the *per-community*
/// view of the profile (the obligation text). The underlying SQL fence at
/// `crates/buzz-db/src/user.rs:140` (`UPDATE users SET ... WHERE
/// community_id = $N AND pubkey = $M`) AND the event-storage fence at
/// `crates/buzz-db/src/event.rs::get_events_by_ids` (or the equivalent
/// REQ-path filter) both contribute — same defense-in-depth shape Quinn
/// caught in `search_fts` (see that row's doc-comment). The mutate-bite
/// here drops the read-side community fence on kind:0 retrieval (the
/// query.rs / event.rs path that materialises `POST /query` results); a
/// dropped-fence leak surfaces as the other community's content in the
/// response. Distinct content per community is what makes the leak
/// observable on the wire (different content → different Nostr event id;
/// without distinct content, the symmetric setup-equivalence vacuity Dawn
/// caught in `audit_log` would collapse the leak into a same-id
/// indistinguishable wire return).
///
/// Same pubkey on both connections is required by the obligation itself
/// ("same pubkey ... different profile per community"); it is what proves
/// the fence is `community_id`, not `pubkey`.
#[tokio::test]
#[ignore]
async fn same_pubkey_distinct_profiles_in_two_communities() {
let ws_a = to_ws(&url_a());
let ws_b = to_ws(&url_b());
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// Same key on both connections — that's the property under test.
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Community-distinct content. Same JSON shape, different display_name:
// the difference is what makes a cross-community leak observable on
// the wire (a leak that returns the *other* community's kind:0 shows
// up as a different display_name string in the response).
let content_a = serde_json::json!({"display_name": "A profile"}).to_string();
let content_b = serde_json::json!({"display_name": "B profile"}).to_string();
// Connect each side, publish each side's kind:0.
let mut client_a = BuzzTestClient::connect(&ws_a, &keys)
.await
.expect("connect A");
let _id_a = publish_kind0(&mut client_a, &keys, &content_a).await;
let mut client_b = BuzzTestClient::connect(&ws_b, &keys)
.await
.expect("connect B");
let _id_b = publish_kind0(&mut client_b, &keys, &content_b).await;
// Let the ingest side-effect settle (handle_kind0_profile is async on
// the DB write path).
tokio::time::sleep(Duration::from_millis(500)).await;
// (1) Query on A: must return exactly one kind:0, whose content is
// A's. A leak that surfaces B's row would show up as content_b in this
// response.
let hits_a = query_kind0(&http_a, &pubkey_hex).await;
assert_eq!(
hits_a.len(),
1,
"A's /query for kind:0 by pubkey returned {} events; expected exactly 1. \
contents: {:?}",
hits_a.len(),
hits_a
.iter()
.map(|e| e["content"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
);
let a_content = hits_a[0]["content"].as_str().unwrap_or("");
assert_eq!(
a_content, content_a,
"A's kind:0 content is not A's profile — B's profile leaked through. \
got: {a_content:?}"
);
// (2) Mirror: query on B returns B's profile, not A's.
let hits_b = query_kind0(&http_b, &pubkey_hex).await;
assert_eq!(
hits_b.len(),
1,
"B's /query for kind:0 by pubkey returned {} events; expected exactly 1. \
contents: {:?}",
hits_b.len(),
hits_b
.iter()
.map(|e| e["content"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
);
let b_content = hits_b[0]["content"].as_str().unwrap_or("");
assert_eq!(
b_content, content_b,
"B's kind:0 content is not B's profile — A's profile leaked through. \
got: {b_content:?}"
);
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
}
/// Obligation: the same NIP-05 local part can exist on two hosts; lookup only
/// resolves handles for the requested host/community.
///
/// Wire-observable shape: same local-part `alice` registered in BOTH
/// communities, but owned by **distinct** pubkeys (one per community).
/// `GET /.well-known/nostr.json?name=alice` against host A returns A's
/// pubkey; same query against host B returns B's pubkey. A cross-community
/// leak surfaces on the wire as wrong-pubkey-returned (not just
/// count-changes) — exactly the setup-equivalence vacuity defense Dawn
/// established in `audit_log` (distinct keys per community = the wrong
/// answer is observable in the response, not just absent from it).
///
/// Registration mechanism: kind:0 (Metadata) publish over WS with
/// `{"nip05":"alice@<domain>"}`. The relay's side-effect handler at
/// `crates/buzz-relay/src/handlers/side_effects.rs::handle_kind0_profile`
/// validates the handle via `crates/buzz-relay/src/api/nip05.rs::canonicalize_nip05`
/// against `extract_domain(state.config.relay_url)` (the relay's configured
/// host) and then calls `update_user_profile(tenant.community(), pubkey, ...,
/// Some(handle))`. The domain piece is config-pinned (e.g. `"localhost"`
/// for `RELAY_URL=ws://localhost:3100`); the **community** scope is what
/// isolates, and that's what this row exercises.
///
/// Mutate-bite (would-it-fail-without-the-fix): drop `community_id = $1`
/// from `crates/buzz-db/src/user.rs::get_user_by_nip05` (line 185 form
/// `WHERE community_id = $1 AND LOWER(nip05_handle) = LOWER($2)`). With
/// the community fence dropped, `LOWER(handle) = LOWER('alice@localhost')`
/// matches BOTH communities' rows; the LIMIT 1 picks one
/// non-deterministically — either A's or B's pubkey. From either host the
/// `GET /.well-known/nostr.json?name=alice` response will sometimes
/// resolve to the *other* community's pubkey (≠ the registered local
/// pubkey). The assertion `resolved_pubkey == own_community_pubkey` reds
/// because the resolved hex no longer matches the host's own user.
#[tokio::test]
#[ignore]
async fn same_nip05_local_part_on_two_hosts_is_independent() {
let ws_a = to_ws(&url_a());
let ws_b = to_ws(&url_b());
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// Distinct keys per community — the same local-part must resolve to
// each community's *own* pubkey. With identical keys, a leak would
// return the same pubkey from either host (indistinguishable on the
// wire). Distinct keys make the leak observable as
// wrong-pubkey-returned.
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let pk_a_hex = keys_a.public_key().to_hex();
let pk_b_hex = keys_b.public_key().to_hex();
assert_ne!(pk_a_hex, pk_b_hex, "test design requires distinct pubkeys");
// Same local-part `alice` in both communities, but each registered
// under its own host's domain. Post-fix, `canonicalize_nip05` runs
// against `tenant.host()` (not `config.relay_url`), so `alice@
// a.localhost` is canonical on host A and `alice@b.localhost` is
// canonical on host B. Pre-fix, both hosts only accept the global
// `config.relay_url` domain; this registration would have been
// silently dropped and the well-known lookup would return empty.
// Deriving each handle from its own host URL ties the test to the
// canonicalize-against-`tenant.host()` fix at the registration step.
let domain_a = extract_relay_domain(&url_a());
let domain_b = extract_relay_domain(&url_b());
let local = format!("alice_{}", uuid::Uuid::new_v4().simple());
let handle_a = format!("{local}@{domain_a}");
let handle_b = format!("{local}@{domain_b}");
let content_a = serde_json::json!({"display_name": local, "nip05": handle_a}).to_string();
let content_b = serde_json::json!({"display_name": local, "nip05": handle_b}).to_string();
// Register each pubkey under the same local-part in its own community.
let mut client_a = BuzzTestClient::connect(&ws_a, &keys_a)
.await
.expect("connect A");
let _ = publish_kind0(&mut client_a, &keys_a, &content_a).await;
let mut client_b = BuzzTestClient::connect(&ws_b, &keys_b)
.await
.expect("connect B");
let _ = publish_kind0(&mut client_b, &keys_b, &content_b).await;
// Let the side-effect (handle_kind0_profile → update_user_profile)
// settle.
tokio::time::sleep(Duration::from_millis(500)).await;
// (1) NIP-05 lookup on A: must resolve `alice` to A's pubkey, not B's.
let nip05_url_a = format!("{http_a}/.well-known/nostr.json?name={local}");
let resp_a = reqwest::get(&nip05_url_a)
.await
.unwrap_or_else(|e| panic!("GET {nip05_url_a} failed: {e}"));
assert_eq!(
resp_a.status(),
reqwest::StatusCode::OK,
"NIP-05 lookup on A must return 200"
);
let body_a: serde_json::Value = resp_a.json().await.expect("parse NIP-05 JSON from A");
let resolved_a = body_a["names"][&local].as_str();
assert_eq!(
resolved_a,
Some(pk_a_hex.as_str()),
"NIP-05 lookup on A for local-part {local:?} must resolve to A's pubkey \
({pk_a_hex}); got {resolved_a:?}. If this is B's pubkey ({pk_b_hex}), \
the community fence on `get_user_by_nip05` has been dropped and B's \
user leaked through A's lookup."
);
// Host-local NIP-05 identity (Mari's product bar): the relays
// map for A's user must advertise A's own host, never the global
// config.relay_url. Pre-fix, line 52 of `api/nip05.rs` cloned
// `state.config.relay_url` here regardless of bound tenant, so a
// client on host A would have been pointed back at the global URL
// (e.g. `ws://localhost:3100`) instead of `ws://a.localhost:3000`.
let relays_a = body_a["relays"][&pk_a_hex]
.as_array()
.expect("NIP-05 response from A must include a relays map entry for A's pubkey");
let relay_a_url = relays_a
.first()
.and_then(|v| v.as_str())
.expect("relays[] must contain at least one URL string");
assert!(
relay_a_url.contains(&domain_a),
"NIP-05 relays map on host A must advertise A's own host \
(expected to contain {domain_a:?}); got {relay_a_url:?}. If \
this advertises the global config.relay_url, the line-52 \
tenant-host drift in api/nip05.rs has regressed."
);
// (2) Mirror: same local-part on B resolves to B's pubkey, not A's.
let nip05_url_b = format!("{http_b}/.well-known/nostr.json?name={local}");
let resp_b = reqwest::get(&nip05_url_b)
.await
.unwrap_or_else(|e| panic!("GET {nip05_url_b} failed: {e}"));
assert_eq!(
resp_b.status(),
reqwest::StatusCode::OK,
"NIP-05 lookup on B must return 200"
);
let body_b: serde_json::Value = resp_b.json().await.expect("parse NIP-05 JSON from B");
let resolved_b = body_b["names"][&local].as_str();
assert_eq!(
resolved_b,
Some(pk_b_hex.as_str()),
"NIP-05 lookup on B for local-part {local:?} must resolve to B's pubkey \
({pk_b_hex}); got {resolved_b:?}. If this is A's pubkey ({pk_a_hex}), \
the community fence on `get_user_by_nip05` has been dropped and A's \
user leaked through B's lookup."
);
let relays_b = body_b["relays"][&pk_b_hex]
.as_array()
.expect("NIP-05 response from B must include a relays map entry for B's pubkey");
let relay_b_url = relays_b
.first()
.and_then(|v| v.as_str())
.expect("relays[] must contain at least one URL string");
assert!(
relay_b_url.contains(&domain_b),
"NIP-05 relays map on host B must advertise B's own host \
(expected to contain {domain_b:?}); got {relay_b_url:?}. \
Mirror of A's host-local identity check."
);
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
}
}
// ---------------------------------------------------------------------------
// Channel-less global events and DMs (Mari+Max — db+pubsub)
// ---------------------------------------------------------------------------
mod channelless_global_events_dms {
use super::*;
/// Obligation: same event id / d-tag / pubkey can co-exist in two
/// communities; direct `GET /api/events/{id}` and REQ filter by community
/// first; NIP-33 uniqueness is `(community_id, kind, pubkey, d_tag)`.
#[tokio::test]
#[ignore]
async fn same_event_id_and_dtag_coexist_across_communities() {
pending_lane(
"buzz-db",
"same id/d-tag in A and B both retrievable, each scoped; no cross-fetch",
);
}
/// Obligation: a DM `#p` in A does not cross-deliver to B.
#[tokio::test]
#[ignore]
async fn dm_does_not_cross_deliver_between_communities() {
pending_lane(
"buzz-pubsub",
"DM addressed in A never fans out to the same pubkey's B subscription",
);
}
}
// ---------------------------------------------------------------------------
// Feed read-side isolation (Perci — buzz-db/relay bridge)
// ---------------------------------------------------------------------------
mod feed_read_side_isolation {
use super::*;
/// Obligation: two-host feed reads are scoped by the request host's
/// community. A mention/feed item for the same pubkey in A must never be
/// returned by B's feed endpoint, including the empty-accessible-channel
/// case where SQL must mean "community-global only" rather than "all
/// channels".
///
/// DB-level adversarial regressions for this exact leak shape landed in
/// `crates/buzz-db/src/feed.rs`; this row is the wire-level conformance
/// lane left pending until the two-host feed client harness is wired.
#[tokio::test]
#[ignore]
async fn feed_mentions_do_not_cross_communities_over_the_wire() {
pending_lane(
"feed read-side conformance",
concat!(
"two-host feed mentions query for the same pubkey returns only the ",
"host-derived community's events; empty accessible-channel lists ",
"remain community-global only",
),
);
}
}
// ---------------------------------------------------------------------------
// Channels and channel membership (Mari — buzz-db)
// ---------------------------------------------------------------------------
mod channels_membership {
use super::*;
use buzz_test_client::BuzzTestClient;
use nostr::{EventBuilder, Keys, Kind, Tag};
/// Convert any base form to `ws(s)://` for WS connect.
fn to_ws(base: &str) -> String {
if base.starts_with("ws://") || base.starts_with("wss://") {
base.trim_end_matches('/').to_string()
} else {
base.replace("https://", "wss://")
.replace("http://", "ws://")
.trim_end_matches('/')
.to_string()
}
}
/// Convert any base form to `http(s)://` for REST.
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()
}
}
/// Create a `visibility=open`, `channel_type=stream` channel with a
/// caller-chosen UUID in the community resolved by `http_base` (the relay
/// derives community from the request host; UUID is client-supplied per
/// kind:9007 semantics, and the community is server-resolved). Returns
/// the channel UUID hex (== `channel_uuid` input).
///
/// Using a caller-supplied UUID is the load-bearing setup for this row:
/// the obligation asserts the *same UUID* in two communities, which the
/// channels PK `(community_id, id)` permits. The relay accepts the UUID
/// because it is paired with the community-from-host, not with a
/// client-supplied tenant — verify by reading the create-channel path in
/// `crates/buzz-relay/src/handlers/side_effects.rs::handle_create_group`.
async fn create_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String {
let client = reqwest::Client::new();
let pubkey_hex = keys.public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &format!("conformance-channels-{channel_uuid}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let resp = client
.post(format!("{http_base}/events"))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&event).unwrap())
.send()
.await
.expect("submit create-channel");
assert!(
resp.status().is_success(),
"create-channel HTTP failed against {http_base}: {}",
resp.status()
);
let body: serde_json::Value = resp.json().await.expect("parse create-channel response");
assert!(
body["accepted"].as_bool().unwrap_or(false),
"create-channel not accepted against {http_base}: {body}"
);
channel_uuid.to_string()
}
/// Post a kind:9 message to `channel_id` over a WS-AUTH'd connection,
/// returning the event id hex. The post succeeds because the channel is
/// `visibility=open` in its host-derived community; the `#h: channel_id`
/// tag is checked against `tenant.community()` (the host-derived
/// community), so a post to A's channel via A's connection resolves to
/// A's channel row, never B's.
async fn post_kind9(
client: &mut BuzzTestClient,
keys: &Keys,
channel_id: &str,
content: &str,
) -> String {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let event = EventBuilder::new(Kind::Custom(9), content)
.tags([h_tag])
.sign_with_keys(keys)
.unwrap();
let id_hex = event.id.to_hex();
let ok = client.send_event(event).await.expect("send kind:9");
assert!(ok.accepted, "kind:9 not accepted: {}", ok.message);
id_hex
}
/// Query kind:9 events on a given channel via REST `POST /query`
/// (dev-mode `X-Pubkey` auth — no NIP-98 mint needed under the
/// `BUZZ_REQUIRE_AUTH_TOKEN=false` recipe). Returns the events as their
/// raw JSON values (typically 0 or more depending on what the
/// host-derived community has stored against that channel id).
async fn query_kind9_in_channel(
http_base: &str,
pubkey_hex: &str,
channel_id: &str,
) -> Vec<serde_json::Value> {
let client = reqwest::Client::new();
let filters = serde_json::json!([{
"kinds": [9],
"#h": [channel_id],
"limit": 100,
}]);
let resp = client
.post(format!("{http_base}/query"))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&filters).unwrap())
.send()
.await
.unwrap_or_else(|e| panic!("POST /query against {http_base} failed: {e}"));
assert!(
resp.status().is_success(),
"POST /query against {http_base} returned {}",
resp.status()
);
resp.json().await.expect("parse /query JSON")
}
/// Obligation: the same channel UUID legitimately co-exists in two
/// communities (DB PK `(community_id, id)`); an `h` tag resolving to a
/// channel in another community is rejected generically.
///
/// # What this row asserts: the **coexistence** half of the obligation
///
/// This row's scope is the **positive arm** of the same `is_member_cached`
/// scope branch that [`super::row_zero_host_binding::client_supplied_community_cannot_override_host`]
/// exercises as the **negative arm**. Sibling-not-replacement, per the
/// frame Dawn established when reading row_zero (b):
///
/// * **row_zero (b) (override-attempt)**: channel U exists *only in B*;
/// an A connection `#h`-tagging it is rejected (the negative arm of
/// `is_member_cached(A, U)`, which returns `false` because
/// `get_channel(A, U)` is `None`). Proves: client-supplied claim
/// never overrides host-derived community.
/// * **this row (coexistence)**: channel U exists in *both* A and B
/// as distinct rows (legal under the `(community_id, id)` PK);
/// posts in A land only in A's instance; posts in B land only in
/// B's instance. The positive arm of `is_member_cached`: when U
/// exists in the tenant-derived community, the membership/permission
/// check finds the *right* row, not the other community's.
///
/// A bug that resolves `get_channel`/`is_member_cached` against the
/// claimed community instead of the host-derived one would pass row_zero
/// (b)'s negative-arm test (both sides would still reject A's post
/// against B-only's U for some reason) but would fail this row's
/// positive-arm test (A's post might land in B's channel, or vice versa).
/// So this row catches a class of bugs row_zero (b) structurally cannot,
/// even though both share the `is_member_cached` scope branch.
///
/// # Wire-observable shape
///
/// One keypair shared across both communities — that's load-bearing for
/// the test: it proves the fence is `community_id`, not `pubkey`. Same
/// channel UUID `U` created in both A and B. Same pubkey posts kind:9 to
/// U in A (`"A message"`) and to U in B (`"B message"`).
///
/// REST `POST /query` with `{kinds:[9], #h:[U]}` against A's host
/// returns *exactly* A's message; same query against B's host returns
/// *exactly* B's message. Each side's response has count == 1 and
/// content == its own community's. A cross-community leak surfaces as
/// either count == 2 OR a hit whose content is the *other* community's.
///
/// # Distinct content per community: the setup-equivalence-vacuity defense
///
/// Per the named lesson in `landed-on-head-discipline` (Quinn's
/// `search_fts` + Dawn's `audit_log`), identical event content collapses
/// distinct rows into colliding Nostr event ids (id = hash(pubkey,
/// created_at, kind, tags, content); community is server-side provenance,
/// not in the id hash). Distinct content per community produces distinct
/// event ids and makes the leak observable as
/// wrong-content-on-the-wire, not just same-id-different-row (which
/// looks identical to the honest path).
///
/// # Mutate-bite (would-it-fail-without-the-fix)
///
/// Drop `WHERE community_id = $1` from
/// `crates/buzz-db/src/event.rs::query_events` (the non-p-tag branch,
/// currently lines 266-270 form
/// `FROM events WHERE community_id = $1` → `FROM events WHERE TRUE`).
/// With distinct content per community + the shared `#h: U` filter, A's
/// `/query` now returns BOTH events (different ids, both matching
/// `channel_id = U` because U exists as both A's and B's channel rows
/// under the same UUID). Either `hits_a.len() == 1` fails (count==2) or
/// the content assertion fails (the leaked row substitutes for A's).
/// The bite is observable on the wire either way. Restore → GREEN.
///
/// # Single-fence-per-path topology
///
/// Unlike `search_fts`, which had defense-in-depth (FTS predicate +
/// `get_events_by_ids` re-filter), `query_events`'s `WHERE community_id`
/// is the only community fence on the `POST /query` read path. Single-
/// fence mutation will bite immediately. The `get_channel` fence at
/// `channel.rs:275` is the *write/permission-check* fence (exercised by
/// `is_member_cached` on the post path), not the read fence; together
/// they're the two-layer scoping of the *whole channel system*, but for
/// this row's wire-observable read property the load-bearing fence is
/// `query_events`.
#[tokio::test]
#[ignore]
async fn same_channel_uuid_in_two_communities_is_isolated() {
let ws_a = to_ws(&url_a());
let ws_b = to_ws(&url_b());
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// One keypair shared across both communities — proves the fence is
// community, not pubkey.
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Same channel UUID `U` in both communities. The `(community_id, id)`
// PK permits this; this row's whole obligation rests on it.
let shared_uuid = uuid::Uuid::new_v4();
let chan_a = create_channel(&http_a, &keys, shared_uuid).await;
let chan_b = create_channel(&http_b, &keys, shared_uuid).await;
assert_eq!(
chan_a, chan_b,
"channels must share UUID — test design (the PK `(community_id, id)` makes this legal)"
);
// Community-distinct content. The leak between A's and B's instances
// of the same UUID becomes wire-observable only when each side's
// message has identifying content; identical content collapses both
// events to the same Nostr event id and a leak becomes
// indistinguishable from the honest path on the wire.
let content_a = "A message in shared-UUID channel".to_string();
let content_b = "B message in shared-UUID channel".to_string();
// Connect each side, post each side's kind:9.
let mut client_a = BuzzTestClient::connect(&ws_a, &keys)
.await
.expect("connect A");
let _id_a = post_kind9(&mut client_a, &keys, &chan_a, &content_a).await;
let mut client_b = BuzzTestClient::connect(&ws_b, &keys)
.await
.expect("connect B");
let _id_b = post_kind9(&mut client_b, &keys, &chan_b, &content_b).await;
// Let the relay's write-path settle. `events` is committed
// synchronously with the OK response, but `received_at`/Redis fan-out
// and the channel materialised-row updates may race a fast follow-up
// read; the same 500ms wait used by every other row in this file.
tokio::time::sleep(Duration::from_millis(500)).await;
// (1) A's query: shared UUID `U` resolves to A's channel row; the
// events returned must be A's message, count == 1.
let hits_a = query_kind9_in_channel(&http_a, &pubkey_hex, &chan_a).await;
assert_eq!(
hits_a.len(),
1,
"A's /query for kind:9 #h:{chan_a} returned {} events; expected exactly 1. \
cross-community leak suspected. hit contents: {:?}",
hits_a.len(),
hits_a
.iter()
.map(|e| e["content"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
);
let a_content = hits_a[0]["content"].as_str().unwrap_or("");
assert_eq!(
a_content, content_a,
"A's kind:9 content is not A's message — B's message leaked through. \
got: {a_content:?}"
);
// (2) Mirror: B's query returns B's message, count == 1.
let hits_b = query_kind9_in_channel(&http_b, &pubkey_hex, &chan_b).await;
assert_eq!(
hits_b.len(),
1,
"B's /query for kind:9 #h:{chan_b} returned {} events; expected exactly 1. \
cross-community leak suspected. hit contents: {:?}",
hits_b.len(),
hits_b
.iter()
.map(|e| e["content"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>()
);
let b_content = hits_b[0]["content"].as_str().unwrap_or("");
assert_eq!(
b_content, content_b,
"B's kind:9 content is not B's message — A's message leaked through. \
got: {b_content:?}"
);
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
}
}
// ---------------------------------------------------------------------------
// Workflows / runs / approvals / webhooks / schedules (Mari+Max)
// ---------------------------------------------------------------------------
mod workflows {
use super::*;
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 a normalized `{accepted, message}` body so the caller can assert
/// on the *wire-observable* accept/reject and message. The HTTP bridge maps
/// `IngestError::Rejected` to HTTP 400 + `{error}` while the WS door maps the
/// same condition to `OK false`; for this conformance row either envelope is
/// acceptable. The safety property is the scoped lookup and generic message.
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();
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 workflow trigger to {http_base} failed: {e}"));
let status = resp.status();
let body = resp.text().await.expect("read workflow trigger body");
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_else(|e| {
panic!("parse workflow trigger JSON from {http_base}: {e} (body: {body})")
});
if status.is_success() {
return parsed;
}
if status == reqwest::StatusCode::BAD_REQUEST {
return serde_json::json!({
"accepted": false,
"message": parsed["error"].as_str().unwrap_or_default(),
});
}
panic!("POST workflow trigger to {http_base} returned HTTP {status}: {body}");
}
/// 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 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(
"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",
);
}
}
// ---------------------------------------------------------------------------
// Search / FTS (Quinn — buzz-search)
// ---------------------------------------------------------------------------
mod search_fts {
use super::*;
use buzz_test_client::{BuzzTestClient, RelayMessage};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
/// Convert an `http(s)://host[:port]` base into the `ws(s)://` form the
/// websocket client needs. The conformance docstring documents URLs as
/// `http://` for human/REST clarity; the WS upgrade still happens on the
/// same host:port.
fn to_ws(base: &str) -> String {
if base.starts_with("ws://") || base.starts_with("wss://") {
base.trim_end_matches('/').to_string()
} else {
base.replace("https://", "wss://")
.replace("http://", "ws://")
.trim_end_matches('/')
.to_string()
}
}
/// Convert any base form to `http(s)://` for REST calls.
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()
}
}
/// Create a visibility=open channel with a caller-chosen UUID in the
/// community resolved by `http_base` (the relay derives community from the
/// request host, never from caller input — that's row zero). Using a
/// caller-supplied UUID lets the test reuse the *same* channel UUID across
/// two communities, which is the load-bearing shape: PK is
/// `(community_id, id)`, so the same UUID legitimately co-exists, and the
/// only thing keeping A's events from surfacing under B's `#h:UUID` search
/// is the FTS `community_id` predicate.
async fn create_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String {
let client = reqwest::Client::new();
let pubkey_hex = keys.public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &format!("conformance-fts-{channel_uuid}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let resp = client
.post(format!("{http_base}/events"))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&event).unwrap())
.send()
.await
.expect("submit create-channel");
assert!(
resp.status().is_success(),
"create-channel HTTP failed against {http_base}: {}",
resp.status()
);
let body: serde_json::Value = resp.json().await.expect("parse create-channel response");
assert!(
body["accepted"].as_bool().unwrap_or(false),
"create-channel not accepted against {http_base}: {body}"
);
channel_uuid.to_string()
}
/// Post a kind:9 with `content` to `channel_id` over the WS connection
/// `client`. Returns the event id hex (so we can target it with NIP-09).
async fn post_kind9(
client: &mut BuzzTestClient,
keys: &Keys,
channel_id: &str,
content: &str,
) -> String {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let event = EventBuilder::new(Kind::Custom(9), content)
.tags([h_tag])
.sign_with_keys(keys)
.unwrap();
let id_hex = event.id.to_hex();
let ok = client.send_event(event).await.expect("send kind:9");
assert!(ok.accepted, "kind:9 not accepted: {}", ok.message);
id_hex
}
/// Run a one-shot NIP-50 search for `token` scoped to `channel_id` and
/// return the events received before EOSE.
async fn search_for(
client: &mut BuzzTestClient,
channel_id: &str,
token: &str,
) -> Vec<nostr::Event> {
let sub_id = format!("fts-{}", uuid::Uuid::new_v4());
let filter = Filter::new()
.kind(Kind::Custom(9))
.search(token)
.custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel_id]);
client
.subscribe(&sub_id, vec![filter])
.await
.expect("subscribe");
client
.collect_until_eose(&sub_id, Duration::from_secs(10))
.await
.expect("collect until EOSE")
}
/// Obligation: every search `filter` includes `community_id`; same
/// channel/token in A and B return only same-community hits; deleting in A
/// does not delete the B document. Enforced in Postgres FTS
/// (search_tsv/GIN), not a separate search engine.
///
/// Shape (designed so the wire-observable property is a *single*
/// per-community row whose content is the community's own):
/// 1. Pick one keypair (same author across both communities — proves the
/// fence is `community_id`, not `pubkey`).
/// 2. Create the *same* channel UUID `U` in A and in B (PK is
/// `(community_id, id)`, so this is legitimate). Each side's
/// `accessible_channels` therefore contains `U`, so a search REQ with
/// `#h: U` from either side passes the `accessible_channels`
/// intersect.
/// 3. Post kind:9 with the shared FTS token but **community-distinct
/// content** (`"A community probe {token}"` vs `"B community probe
/// {token}"`) to `U` in A and to `U` in B. Distinct content → distinct
/// Nostr event ids (id is a hash of (pubkey, created_at, kind, tags,
/// content), so different content hashes to different ids); the
/// shared token still makes both rows match the FTS predicate. A
/// cross-community leak therefore shows up on the wire as either a
/// second hit (count == 2) OR a hit whose content is the *other*
/// community's — earlier iterations of this test used identical
/// content and discovered the hard way that identical content
/// collapses both leak modes into "indistinguishable on the wire"
/// (defense-in-depth at the FTS + `get_events_by_ids` layers ate the
/// mutation by returning the live row regardless of which community
/// claimed it).
/// 4. NIP-50 search on A with `#h: U` for that token: returns exactly
/// one hit whose content is `content_a`.
/// 5. Mirror: NIP-50 search on B with `#h: U` returns exactly one hit
/// whose content is `content_b`.
/// 6. NIP-09 kind:5 deletion against A's event over the A connection
/// (`#h: U`, `#e: id_a`).
/// 7. Re-search on A: zero hits (search excludes `deleted_at IS NOT
/// NULL`).
/// 8. Re-search on B: B's hit unchanged (delete did not cross), content
/// still equals `content_b`.
///
/// Mutate-bite to confirm load-bearing: drop the community fences at
/// BOTH layers of the search read path simultaneously:
/// - `crates/buzz-search/src/query.rs::search` lines 160-161 (the FTS
/// `WHERE community_id = $ctx` predicate); and
/// - `crates/buzz-db/src/event.rs::get_events_by_ids` lines 870-872
/// (the read-side `WHERE community_id = $1` predicate).
/// Replace each with `WHERE TRUE` to keep SQL syntax valid; the second
/// fence is reached via `state.db.get_events_by_ids(tenant.community(),
/// ...)` from `handlers/req.rs::handle_search_req` at line ~590. With
/// distinct content per community (see step 3), A's search now returns
/// *both* communities' rows (different ids, both matching `#h: U` via the
/// shared channel UUID, both matching FTS via the shared token). Step 4's
/// `hits_a.len() == 1` assertion goes RED with the failure message
/// listing both contents — explicitly "B community probe …" surfacing
/// inside A's wire response. Restore both fences → GREEN.
///
/// Defense-in-depth: each layer alone catches the leak. Mutating only the
/// FTS fence leaves the read fence to filter to A's community; mutating
/// only the read fence leaves FTS to never emit B's id in the first
/// place. Both must drop for the wire-observable property to fail. The
/// two non-negotiable predicates the obligation text names live at these
/// two layers; the test's red-with-both-down evidence is that the union
/// of those fences is what makes the property wire-observable, and each
/// fence individually is non-vacuous (single-fence mutation keeps the
/// test green because the other defends). This is the correct,
/// honest mutate-bite for an obligation defended by redundant layers.
#[tokio::test]
#[ignore]
async fn search_results_and_deletes_are_community_scoped() {
let ws_a = to_ws(&url_a());
let ws_b = to_ws(&url_b());
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// One keypair shared across both communities: every cross-community
// filter we apply must be community_id, never pubkey.
let keys = Keys::generate();
// Same channel UUID in both communities. The (community_id, id) PK
// permits this and the test depends on it: see module docstring above.
let shared_uuid = uuid::Uuid::new_v4();
let chan_a = create_channel(&http_a, &keys, shared_uuid).await;
let chan_b = create_channel(&http_b, &keys, shared_uuid).await;
assert_eq!(chan_a, chan_b, "channels must share UUID — test design");
// Unique token that cannot match anything else in the DB.
let token = format!("ftsconf_{}", uuid::Uuid::new_v4().simple());
// Distinct content per community. The shared token is what FTS
// matches; the per-community label makes each row uniquely
// identifiable on the wire AND produces distinct Nostr event ids
// (the id hash includes content, so different content → different
// id → no PK collision games). This is load-bearing for the
// mutate-bite: with identical content the two communities' rows
// would hash to the same Nostr id and a leak that returns "the
// other community's row" would be indistinguishable on the wire
// from "the right community's row." Distinct content makes the
// leak observable.
let content_a = format!("A community probe {token}");
let content_b = format!("B community probe {token}");
// Connect to A, post in A.
let mut client_a = BuzzTestClient::connect(&ws_a, &keys)
.await
.expect("connect A");
let id_a = post_kind9(&mut client_a, &keys, &chan_a, &content_a).await;
// Connect to B, post in B (same key, same channel UUID, same token —
// only the community label in the content + the community itself
// differ).
let mut client_b = BuzzTestClient::connect(&ws_b, &keys)
.await
.expect("connect B");
let _id_b = post_kind9(&mut client_b, &keys, &chan_b, &content_b).await;
// Let FTS write-path settle. `search_tsv` is a generated column, so it
// commits with the row; this matches the existing e2e search test's
// wait.
tokio::time::sleep(Duration::from_millis(500)).await;
// (4) Search on A: must return exactly one hit, and that hit must be
// the row written via the A connection (the relay returns the event
// *body* with the canonical id, but the row provenance is community
// A). Crucially: count==1 — a missing community fence would surface 2
// (A's row + B's row through the shared #h:U filter).
let hits_a = search_for(&mut client_a, &chan_a, &token).await;
assert_eq!(
hits_a.len(),
1,
"A's search returned {} hits; expected exactly 1. cross-community leak suspected. \
hit contents: {:?}",
hits_a.len(),
hits_a.iter().map(|e| e.content.clone()).collect::<Vec<_>>()
);
assert_eq!(
hits_a[0].content, content_a,
"A's hit content is not A's row — B's content leaked through the wire \
(id-equality is irrelevant; content distinguishes the communities). \
got: {:?}",
hits_a[0].content,
);
// (5) Mirror: search on B must also return exactly one hit, and that
// hit must carry B's content (not A's).
let hits_b = search_for(&mut client_b, &chan_b, &token).await;
assert_eq!(
hits_b.len(),
1,
"B's search returned {} hits; expected exactly 1. cross-community leak suspected. \
hit contents: {:?}",
hits_b.len(),
hits_b.iter().map(|e| e.content.clone()).collect::<Vec<_>>()
);
assert_eq!(
hits_b[0].content, content_b,
"B's hit content is not B's row — A's content leaked through the wire. got: {:?}",
hits_b[0].content,
);
// (6) NIP-09 delete in A targeting A's event.
let delete_a = EventBuilder::new(Kind::Custom(5), "conformance delete")
.tags([
Tag::parse(["h", &chan_a]).unwrap(),
Tag::parse(["e", &id_a]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok_del = client_a.send_event(delete_a).await.expect("send delete");
assert!(ok_del.accepted, "A delete rejected: {}", ok_del.message);
// Soft-delete may flow through indexing async. Same wait as above.
tokio::time::sleep(Duration::from_millis(500)).await;
// (7) Re-search on A: A's hit gone. The search SQL has both
// `community_id = $ctx` AND `deleted_at IS NULL`; the latter excludes
// A's now-soft-deleted row. Count==0 in A's community.
let hits_a_post = search_for(&mut client_a, &chan_a, &token).await;
assert_eq!(
hits_a_post.len(),
0,
"A's deleted event still returned by search. hit ids={:?}",
hits_a_post
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>()
);
// (8) Re-search on B: B's hit unchanged — delete in A did not cross
// into B (NIP-09 `soft_delete_event` is keyed on `(community_id,
// event_id)`).
let hits_b_post = search_for(&mut client_b, &chan_b, &token).await;
assert_eq!(
hits_b_post.len(),
1,
"B's hit count changed after A's delete; cross-community deletion suspected. \
hit ids={:?}",
hits_b_post
.iter()
.map(|e| e.id.to_hex())
.collect::<Vec<_>>()
);
assert_eq!(
hits_b_post[0].content, content_b,
"B's hit content drifted after A's delete"
);
// Drain any trailing live events so disconnect is clean. NIP-50 is
// one-shot; we don't expect any.
let _ = client_a.recv_event(Duration::from_millis(50)).await;
let _ = client_b.recv_event(Duration::from_millis(50)).await;
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
// `RelayMessage` is imported for documentation of `search_for`'s
// return path; reference it so the unused-import lint doesn't fire.
let _ = std::any::type_name::<RelayMessage>();
}
}
// ---------------------------------------------------------------------------
// Redis pub/sub, presence, typing, cache invalidation (Max — buzz-pubsub)
// ---------------------------------------------------------------------------
mod pubsub_presence_typing {
use super::*;
use buzz_test_client::{BuzzTestClient, RelayMessage};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
const KIND_PRESENCE_UPDATE: u16 = 20001;
const KIND_TYPING_INDICATOR: u16 = 20002;
/// Convert any base form to `ws(s)://` for WS connect.
fn to_ws(base: &str) -> String {
if base.starts_with("ws://") || base.starts_with("wss://") {
base.trim_end_matches('/').to_string()
} else {
base.replace("https://", "wss://")
.replace("http://", "ws://")
.trim_end_matches('/')
.to_string()
}
}
/// Convert any base form to `http(s)://` for REST.
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()
}
}
/// Create the same visibility=open channel UUID in the community resolved by
/// `http_base`. Open visibility keeps the typing half focused on pub/sub
/// fan-out scoping rather than membership setup: either host can post to its
/// own tenant-local instance of the UUID, and the only live-delivery fence
/// left is the server-resolved community in the subscription/fan-out index.
async fn create_open_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String {
let client = reqwest::Client::new();
let pubkey_hex = keys.public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &format!("conformance-pubsub-{channel_uuid}")]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let resp = client
.post(format!("{http_base}/events"))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&event).unwrap())
.send()
.await
.expect("submit create-channel");
assert!(
resp.status().is_success(),
"create-channel HTTP failed against {http_base}: {}",
resp.status()
);
let body: serde_json::Value = resp.json().await.expect("parse create-channel response");
assert!(
body["accepted"].as_bool().unwrap_or(false),
"create-channel not accepted against {http_base}: {body}"
);
channel_uuid.to_string()
}
async fn publish_presence(client: &mut BuzzTestClient, keys: &Keys, status: &str) {
let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE), status)
.sign_with_keys(keys)
.unwrap();
let ok = client.send_event(event).await.expect("send presence");
assert!(ok.accepted, "presence not accepted: {}", ok.message);
}
/// Query synthesized presence through REST `POST /query`. This exercises the
/// bridge intercept at `api/bridge.rs::synthesize_presence`, which reads
/// Redis via `get_presence_bulk(tenant, authors)`; there is no DB fallback
/// for this ephemeral state.
async fn query_presence(http_base: &str, pubkey_hex: &str) -> Vec<serde_json::Value> {
let client = reqwest::Client::new();
let filters = serde_json::json!([{
"kinds": [KIND_PRESENCE_UPDATE],
"authors": [pubkey_hex],
"limit": 1,
}]);
let resp = client
.post(format!("{http_base}/query"))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&filters).unwrap())
.send()
.await
.unwrap_or_else(|e| panic!("POST /query against {http_base} failed: {e}"));
assert!(
resp.status().is_success(),
"POST /query against {http_base} returned {}",
resp.status()
);
resp.json().await.expect("parse /query JSON")
}
async fn subscribe_typing(client: &mut BuzzTestClient, sub_id: &str, channel_id: &str) {
let filter = Filter::new()
.kind(Kind::Custom(KIND_TYPING_INDICATOR))
.custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel_id]);
client
.subscribe(sub_id, vec![filter])
.await
.expect("subscribe to typing");
let historical = client
.collect_until_eose(sub_id, Duration::from_secs(10))
.await
.expect("collect typing EOSE");
assert!(
historical.is_empty(),
"typing is ephemeral and should not return historical events; got {}",
historical.len()
);
}
async fn publish_typing(
client: &mut BuzzTestClient,
keys: &Keys,
channel_id: &str,
content: &str,
) -> String {
let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR), content)
.tags([Tag::parse(["h", channel_id]).unwrap()])
.sign_with_keys(keys)
.unwrap();
let id_hex = event.id.to_hex();
let ok = client.send_event(event).await.expect("send typing");
assert!(ok.accepted, "typing not accepted: {}", ok.message);
id_hex
}
/// Drain live events for `sub_id` until `quiet_for` elapses, returning only
/// EVENT frames for that subscription. This deliberately keeps listening
/// after the expected local event so a cross-community leak has a window to
/// surface as a second/wrong-content live delivery.
async fn drain_live_events(
client: &mut BuzzTestClient,
sub_id: &str,
quiet_for: Duration,
) -> Vec<nostr::Event> {
let mut events = Vec::new();
loop {
match client.recv_event(quiet_for).await {
Ok(RelayMessage::Event {
subscription_id,
event,
}) if subscription_id == sub_id => events.push(*event),
Ok(_) => {}
Err(_) => return events,
}
}
}
fn assert_one_content(events: &[nostr::Event], expected: &str, forbidden: &str, side: &str) {
let contents = events.iter().map(|e| e.content.clone()).collect::<Vec<_>>();
assert_eq!(
events.len(),
1,
"{side}'s typing subscription received {} live events; expected exactly its own. \
contents: {contents:?}",
events.len()
);
assert_eq!(
events[0].content, expected,
"{side}'s typing event content is not its own — cross-community content leaked. \
got: {:?}, expected: {:?}, forbidden other-community content: {:?}",
events[0].content, expected, forbidden
);
assert!(
!contents.iter().any(|content| content == forbidden),
"{side}'s typing subscription saw other-community content {forbidden:?}; \
subscription/fan-out community scoping leaked"
);
}
/// Obligation: keys are `buzz:{community}:…`; cross-node fan-out never
/// delivers an A event to a B subscription, even for the same channel UUID;
/// the same pubkey can be online in A and away in B independently.
///
/// Wire-observable shape, split across the two load-bearing pub/sub fences:
///
/// 1. **Presence / Redis key fence.** The same keypair publishes kind:20001
/// presence with distinct statuses in A and B. Presence is ephemeral, so
/// `/query` is synthesized from Redis (`api/bridge.rs::synthesize_presence`
/// → `buzz_pubsub::get_presence_bulk`). A's query must return only A's
/// status and B's only B's. This bites the Redis key format in
/// `crates/buzz-pubsub/src/presence.rs::presence_key`, which must include
/// `ctx.community()` (`buzz:{community}:presence:{pubkey}`). Same pubkey
/// is required: it proves the isolation coordinate is community, not key.
///
/// 2. **Typing / subscription fan-out fence.** The same channel UUID is
/// created in both communities, both sides subscribe to kind:20002 + `#h`
/// for that UUID, then each side publishes distinct typing content.
/// Because the channel UUID and kind are intentionally identical, the only
/// in-memory live-delivery discriminator is the server-resolved community
/// in `SubscriptionRegistry`'s `(CommunityId, channel/kind)` indexes and
/// `fan_out_scoped(community_id, event)`. Distinct content makes a leak a
/// wrong-answer-returned failure instead of setup-equivalence vacuity.
///
/// Mutate-bites:
/// - Presence: drop/neutralize `ctx.community()` in
/// `buzz-pubsub/src/presence.rs::presence_key` (shared Redis key). The B
/// publish overwrites A's status for the same pubkey, so A's `/query`
/// returns `status_b` and the presence assertion reds.
/// - Typing: drop both live-delivery tenant fences at once: ignore
/// `community_id` in `SubscriptionRegistry::fan_out_scoped`'s
/// channel-kind lookup AND remove the receiver-side
/// `community_for_conn(conn_id) == Some(event_community)` check in
/// `handlers/event.rs::filter_fanout_by_access`. With the same channel
/// UUID + kind in both tenants, A's live typing event is delivered to B's
/// subscription (and/or vice versa); the exact-one-content assertions red
/// with the other community's content in the drain. Either fence alone is
/// defense-in-depth: the index prevents cross-tenant candidates, and the
/// send chokepoint drops any stale/injected cross-tenant candidate.
#[tokio::test]
#[ignore]
async fn fanout_and_presence_do_not_cross_communities() {
let ws_a = to_ws(&url_a());
let ws_b = to_ws(&url_b());
let http_a = to_http(&url_a());
let http_b = to_http(&url_b());
// Same key across both communities is load-bearing for presence: the
// Redis key must be `(community, pubkey)`, not just `pubkey`.
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let mut client_a = BuzzTestClient::connect(&ws_a, &keys)
.await
.expect("connect A");
let mut client_b = BuzzTestClient::connect(&ws_b, &keys)
.await
.expect("connect B");
let suffix = uuid::Uuid::new_v4().simple().to_string();
let status_a = format!("online-a-{suffix}");
let status_b = format!("away-b-{suffix}");
publish_presence(&mut client_a, &keys, &status_a).await;
publish_presence(&mut client_b, &keys, &status_b).await;
// Let Redis writes and the bridge's subsequent read see a stable value.
tokio::time::sleep(Duration::from_millis(250)).await;
let presence_a = query_presence(&http_a, &pubkey_hex).await;
assert_eq!(
presence_a.len(),
1,
"A's synthesized presence query returned {} events; expected exactly 1. \
contents: {:?}",
presence_a.len(),
presence_a
.iter()
.map(|e| e["content"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>()
);
let got_status_a = presence_a[0]["content"].as_str().unwrap_or_default();
assert_eq!(
got_status_a, status_a,
"A's synthesized presence is not A's status — B's status leaked/overwrote it. \
got: {got_status_a:?}, expected: {status_a:?}, B status: {status_b:?}"
);
let presence_b = query_presence(&http_b, &pubkey_hex).await;
assert_eq!(
presence_b.len(),
1,
"B's synthesized presence query returned {} events; expected exactly 1. \
contents: {:?}",
presence_b.len(),
presence_b
.iter()
.map(|e| e["content"].as_str().unwrap_or_default().to_string())
.collect::<Vec<_>>()
);
let got_status_b = presence_b[0]["content"].as_str().unwrap_or_default();
assert_eq!(
got_status_b, status_b,
"B's synthesized presence is not B's status — A's status leaked/overwrote it. \
got: {got_status_b:?}, expected: {status_b:?}, A status: {status_a:?}"
);
// Same channel UUID in both communities. This makes `(channel_id, kind)`
// identical on both sides; community_id is the only correct fan-out
// partition key.
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");
let sub_a = format!("typing-a-{suffix}");
let sub_b = format!("typing-b-{suffix}");
subscribe_typing(&mut client_a, &sub_a, &chan_a).await;
subscribe_typing(&mut client_b, &sub_b, &chan_b).await;
let content_a = format!("typing from A {suffix}");
let content_b = format!("typing from B {suffix}");
let _id_a = publish_typing(&mut client_a, &keys, &chan_a, &content_a).await;
let _id_b = publish_typing(&mut client_b, &keys, &chan_b, &content_b).await;
let live_a = drain_live_events(&mut client_a, &sub_a, Duration::from_millis(500)).await;
let live_b = drain_live_events(&mut client_b, &sub_b, Duration::from_millis(500)).await;
assert_one_content(&live_a, &content_a, &content_b, "A");
assert_one_content(&live_b, &content_b, &content_a, "B");
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
}
}
// ---------------------------------------------------------------------------
// Media / Blossom / S3 (Perci — buzz-media)
// ---------------------------------------------------------------------------
mod media_blossom {
use super::*;
/// Obligation: blob `GET/HEAD /media/{sha256.ext}` requires Blossom read auth
/// scoped to the serving host or the blob hash, and the request is bound to the
/// tenant resolved from the request headers. A bare read is rejected before any
/// storage lookup, so the endpoint does not leak blob existence.
///
/// CAS bytes are still deduplicated across communities, so the boundary is not
/// the bytes: it is the metadata/descriptor/upload-auth/quota/audit layer plus
/// the per-tenant read binding. B's private upload metadata and errors must not
/// be observable from A even when the underlying blob is shared.
///
/// Known limitation, deferred: relay membership plus knowledge of a hash is
/// sufficient to read a blob. Read auth binds host and tenant, not the channel
/// ACL of the message the blob was attached to.
#[tokio::test]
#[ignore]
async fn media_metadata_boundary_holds_while_blob_bytes_shared() {
pending_lane(
"buzz-media",
"reads require host/hash-scoped Blossom auth and bind to the header tenant; \
bare reads 401 before storage; shared SHA bytes OK; A cannot read B's upload \
metadata/quota/audit; errors generic",
);
}
}
// ---------------------------------------------------------------------------
// Git hosting / NIP-34 / object storage (Perci — buzz-media/git)
// ---------------------------------------------------------------------------
mod git_hosting {
use super::*;
/// Obligation: pointer/name keys include community; same owner/repo in two
/// communities are independent; a push in A does not advance B's pointer.
#[tokio::test]
#[ignore]
async fn same_owner_repo_isolated_push_does_not_cross() {
pending_lane(
"buzz-media",
"repos/{community}/{owner}/{repo}/pointer; push in A leaves B pointer unchanged",
);
}
}
// ---------------------------------------------------------------------------
// Mesh / agents / ACP / MCP / CLI (Eva — relay-wiring smoke)
// ---------------------------------------------------------------------------
mod mesh_agents_cli {
use super::*;
/// Obligation: one portable key may join multiple communities, but
/// memberships, DMs, profiles, jobs, and presence do not bleed across them.
#[tokio::test]
#[ignore]
async fn one_key_two_communities_no_bleed() {
pending_lane(
"relay-wiring",
"same key joins A and B; CLI/ACP smoke shows distinct memberships/profiles",
);
}
}
// ---------------------------------------------------------------------------
// Audit log and observability (Dawn — buzz-audit)
// ---------------------------------------------------------------------------
mod audit_log {
//! Obligation: audit reads verify exactly one community chain
//! (`(community_id, seq)` / `(community_id, hash)`); error strings must not
//! leak cross-community IDs, constraint names, or existence facts.
//!
//! **This row is doc-only — and that is the strongest statement in the file.**
//!
//! Every other row here proves a black-box property: the relay serves a wire
//! response, and the test asserts that response denies a cross-community
//! oracle. The audit log has no such response to assert against — it has **no
//! client-reachable wire surface at all**. There is no `/audit` route in
//! `crates/buzz-relay/src/router.rs` (the route list is `/`, `/info`,
//! `/.well-known/nostr.json`, the health probes, `/events`, `/query`,
//! `/count`, `/hooks`, the media and git sub-routers, and the audio WS — no
//! audit endpoint). Audit is written as an ingest side-effect
//! (`handlers/event.rs`, `dispatch_persistent_event`) and read only via
//! `buzz_audit::AuditService::{verify_chain, get_entries}`, which are
//! operator-internal (consumed by `buzz-admin`). `crates/buzz-audit/src/
//! error.rs` states it directly: `AuditError` is "never relayed to a client
//! on the wire," and "no variant embeds a `community_id`."
//!
//! So where other rows prove *the oracle is denied*, audit proves *the
//! oracle's surface does not exist* — a strictly stronger isolation claim,
//! and the honest way to state it is to cite the facts, not to invent a wire
//! observation that the architecture does not offer. Reaching behind the
//! wire into Postgres here would also break this file's black-box contract
//! (its deps are `buzz-ws-client`/`reqwest`/`tokio-tungstenite`/`s3` — no
//! `sqlx`, no `buzz-audit`), and a DB-direct read can never catch a
//! wire-layer bug because it never traverses the wire read path.
//!
//! The two halves of the obligation are proven in their proper homes, where
//! direct Postgres access is in-convention:
//!
//! 1. **One chain per community** —
//! `buzz_audit::service::tests::chains_are_independent_per_community`
//! (direct `AuditService::log`) proves interleaved A/B writes keep
//! independent `(community_id, seq)` chains, each starting at seq 1 with
//! its own `prev_hash`, and that `verify_chain`/`get_entries` scoped to
//! one community never traverse another. The *integrated* path — that a
//! community resolved from the request's `TenantContext` at relay ingest
//! lands in the correct chain and stays isolated — is proven by
//! `buzz_relay::handlers::event::tests::
//! audit_chain_is_isolated_per_tenant_through_relay_ingest`, driving
//! `dispatch_persistent_event` under two tenants against a shared
//! Postgres (no WS-AUTH dependency).
//! 2. **Errors don't leak** —
//! `buzz_audit::error::tests::audit_error_text_carries_no_community_id_or_constraint`
//! asserts no `AuditError` variant's rendered text embeds a
//! `community_id`, constraint name, or cross-community object id.
//!
//! Substrate on PR head: `crates/buzz-audit/src/entry.rs` keys `AuditEntry`
//! `(community_id, seq)` with per-community `prev_hash`; `NewAuditEntry.
//! community_id` is typed `CommunityId` (server-resolved, never client
//! input).
}
// ---------------------------------------------------------------------------
// Migration gate 5: N=1 conformance (Eva — orchestration)
// ---------------------------------------------------------------------------
mod n1_parity {
//! N=1 parity is asserted by the *existing* e2e suites run against the new
//! relay with one configured host → one default community. The obligation:
//! no existing client needs new tags, paths, event fields, CLI flags, or
//! protocol messages. This module documents the gate; the assertion lives in
//! the unchanged `e2e_*` suites passing green under `RELAY_URL=<new relay>`.
//!
//! Parity runner (driven from the integration job, not a unit test):
//! 1. Boot new relay, one host → default community, backfill existing data.
//! 2. Run the full `e2e_*` ignored suite with `RELAY_URL` at the new relay.
//! 3. Every suite green, unchanged === N=1 parity proven.
}