Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ jobs:
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Start relay
env:
BUZZ_V1_ENABLED: "true"
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
Expand Down Expand Up @@ -547,6 +549,8 @@ jobs:
--archive-file target/ci/backend-integration-tests.tar.zst \
-E 'package(buzz-relay) and test(/workflow_sink/)'
- name: Start relay
env:
BUZZ_V1_ENABLED: "true"
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
Expand Down Expand Up @@ -618,12 +622,16 @@ jobs:
merge-multiple: true
path: target/ci
- name: Start relay
env:
BUZZ_V1_ENABLED: "true"
run: |
chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr
./scripts/start-relay-for-tests.sh --no-build
- name: Relay E2E tests
run: |
cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_thread_roots --test e2e_project -- --ignored --nocapture
cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture
cargo test -p buzz-test-client --test regression_relay_admin_ban_gate -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture
cargo test -p buzz-test-client --test e2e_relay nip29_departure_wire -- --ignored --nocapture
Expand All @@ -636,6 +644,10 @@ jobs:
cargo test -p buzz-test-client --test e2e_relay nip29_relay_rejects_role_change_by_unprivileged_actor -- --ignored --nocapture
env:
RELAY_URL: ws://localhost:3000
RELAY_HTTP_URL: http://localhost:3000
REPRO_RELAY_HTTP: http://localhost:3000
REPRO_HOST: localhost:3000
DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz
GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr
- name: Media read-auth e2e
# Reads require kind:24242 `t=get` auth, so these binaries are the only
Expand Down
2 changes: 2 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,8 @@ test-unit:
#!/usr/bin/env bash
set -euo pipefail
./scripts/test-ensure-local-relay-key.sh
python3 scripts/check-community-ban-route-inventory.py
python3 scripts/check-community-ban-route-inventory.test.py
# Shared Huddle transport, codec, and loopback authentication regressions.
cargo test -p buzz-audio-client
if command -v cargo-nextest &>/dev/null; then
Expand Down
88 changes: 88 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,94 @@ combination space (PR #6807). Scope Playwright locators — unscoped
`getByText` in a required smoke test is a strict-mode flake (PR #6980).
(PRs #6807, #6980, #6996, #7013)

### Community-ban route coverage

`crates/buzz-test-client/tests/fixtures/community-ban-route-inventory.tsv`
classifies the relay's production Axum routes by listener, method, path,
authentication plane, membership behavior, and community-restriction behavior.
The inventory guard finds direct `.route(...)` calls in production functions
named `router` or `*_router`, resolves literal or module-qualified `&str`
constant paths, and applies `.nest(...)` prefixes from `router::build_router`.
It fails when one of those registrations is missing from the table or a stale
row remains, and on any shape it cannot classify: `.route_service`,
`.nest_service`, `.fallback_service`, or `.fallback` (except allow-listed
registrations, each pinned to its builder and exact argument and allowed once),
`.nest` outside `build_router`, a nest target that does not resolve to exactly
one router builder, or a builder nested more than once. New or repeated
`.merge(...)` registrations also fail; existing root aliases and the Git
settings merge are pinned to their exact builder and argument. `scripts/check-community-ban-route-inventory.test.py`
pins each of those shapes with a fixture. Run both directly with:

```bash
python3 scripts/check-community-ban-route-inventory.py
python3 scripts/check-community-ban-route-inventory.test.py
```

`just test-unit` and `just test` run the guard; CI also runs the ignored HTTP
and root-WebSocket behavior matrix in the Relay E2E lane:

```bash
cargo test -p buzz-test-client --test community_ban_routes -- --ignored --nocapture
```

Use a fresh disposable local relay, PostgreSQL, Redis, and MinIO stack for that
command, and set `DATABASE_URL` or `BUZZ_TEST_DATABASE_URL` to the disposable
PostgreSQL database. Start the relay with `BUZZ_V1_ENABLED=true` so the
accessory-route cases exercise their production handlers. The matrix creates
unique tenant hosts and identities. Unset `BUZZ_KLIPY_API_KEY`: GIF controls
assert the exact provider-disabled response without contacting a provider.
The malformed JPEG and invalid Git-push repository-name controls assert their
validation responses; Git reads, workflow and media-read controls assert exact
missing-resource responses. Successful reads
and writes must return their expected JSON shape, not merely avoid a ban.
Ordinary-member routes also run a clear delegated-agent control.
Do not point these tests at a shared, development, or hosted database or relay.

The inventory keeps membership separate from restriction policy. Public NIP-11,
NIP-05, policy documents, and health probes have no authenticated community
principal. Anonymous policy-receipt acceptance is pre-membership and has no
principal to restrict. Invite claim is also pre-membership, but it carries a
verified NIP-98 principal and is classified as restriction-enforced in the
inventory. The integrated invitation regressions cover restricted nonmembers,
verified owners and v1/v2 lifecycle behavior; see
[Invitation compatibility and revocation](#invitation-compatibility-and-revocation)
for the authoritative contract. Relay-operator APIs, deployment-admin APIs,
NIP-FI commands, the localhost Git hook, and the gated mesh test endpoint use
separate auth planes; each exemption is recorded in its route row.
Secret-authenticated workflow webhooks recheck the saved workflow owner's
community ban through the workflow engine. Their PostgreSQL regression covers
owner bans, tenant scope and failed lookups. No sibling coverage remains pending.

Root and huddle admission, final-admission races, fail-closed reads, owner-to-
agent restriction, timeout behavior, and tenant-scoped eviction point to their
existing regression tests in `community-ban-regressions.tsv`. The local socket
eviction tests establish pod-local closure. Delivery to sessions on other pods
depends on the Redis pub/sub path and is not inferred from those local tests.
The moderation-read matrix also checks a moderator-privileged agent against a
fresh signed owner proof and a stored owner link; the bridge harness verifies
that the ban denial uses the NIP-98 signed creation time, with a clear-owner
control that reaches the queue.

This scanner is a source-level guard, not a Rust router interpreter. It reads
direct `.route(...)` calls inside production functions named `router` or ending
in `_router`; a `.route(...)` elsewhere is a scanner error. It resolves a
`.nest(...)` prefix only when the relay's `build_router` passes a literal
or a resolved string constant and a direct call to another recognized router function. It does not
expand macros or interpret dynamic paths, prefixes or arbitrary router
composition. Service-style calls and unsupported nesting or merging are errors,
rather than silently omitted registrations. The existing root merge aliases and
Git settings merge are explicit, single-use exceptions; their builders' direct
routes remain inventoried. It also does not derive auth or
restriction policy from handler code, or prove that the named regression
function contains the expected assertions or is selected by CI. If production
routes use an unrecognized registration shape, extend the scanner and bind
that shape to the inventory before treating the table as complete.

The method column records explicitly declared methods. Axum's `get(...)` helper
also serves `HEAD` through the GET handler unless an explicit `.head(...)`
handler is attached; the scanner does not add that implicit `HEAD` behavior as a
separate inventory row or exercise it separately.

---

## Live Local Relay
Expand Down
187 changes: 183 additions & 4 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2971,13 +2971,13 @@ async fn authorize_moderation_read(
None,
state.config.require_auth_token || nip_fi_active,
)
.map(|auth| auth.proof(auth.event_id_bytes))
.map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at)))
.map_err(|e| e.into_response())
})
.await?;
let pubkey = *admission.proven_pubkey();
let token_owner = admission.token_owner();
let event_id_bytes = admission.into_extra();
let (event_id_bytes, signed_created_at) = admission.into_extra();

check_nip98_replay(state, &tenant, event_id_bytes)
.await
Expand All @@ -2990,7 +2990,7 @@ async fn authorize_moderation_read(
&tenant,
&pubkey_bytes,
super::relay_members::extract_auth_tag_header(headers),
None,
signed_created_at,
token_owner,
)
.await
Expand Down Expand Up @@ -3191,7 +3191,7 @@ mod typing_postgres_tests;
#[cfg(test)]
pub(crate) mod postgres_tests {
use super::*;
use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag};
use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag, Timestamp};
use std::sync::Mutex;

fn redis_pool() -> deadpool_redis::Pool {
Expand Down Expand Up @@ -5603,6 +5603,16 @@ pub(crate) mod postgres_tests {
url: &str,
method: &str,
body: &[u8],
) -> axum::http::HeaderMap {
make_nip98_headers_with_created_at(keys, url, method, body, Timestamp::now().as_secs())
}

fn make_nip98_headers_with_created_at(
keys: &Keys,
url: &str,
method: &str,
body: &[u8],
created_at: u64,
) -> axum::http::HeaderMap {
use base64::engine::general_purpose::STANDARD as BASE64;
use sha2::{Digest, Sha256};
Expand All @@ -5614,6 +5624,7 @@ pub(crate) mod postgres_tests {
];
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.expect("sign NIP-98 event");
let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event");
Expand Down Expand Up @@ -7596,6 +7607,174 @@ pub(crate) mod postgres_tests {
);
}

/// A moderator-privileged agent remains subject to its verified NIP-OA
/// owner's ban on moderation reads. The first request has no stored owner
/// link and uses a time-bounded auth tag; the second uses the durable owner
/// link. A clear fresh-owner control reaches the queue.
/// Mutation: discard `signed_created_at` in `authorize_moderation_read` →
/// the fresh-owner ban is missed and the moderator agent receives 200 → RED.
#[test]
#[ignore = "requires Postgres and disposable Redis via REDIS_URL"]
fn moderation_read_honors_fresh_and_stored_banned_agent_owners() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");

let Some(mut state) = rt.block_on(nip_fi_off_test_state()) else {
panic!("local Postgres and Redis must be available");
};
let mut config = state.config.as_ref().clone();
config.require_relay_membership = true;
Arc::get_mut(&mut state)
.expect("test state has one owner")
.config = Arc::new(config);

rt.block_on(async {
let issuer = Keys::generate();
for scenario in ["fresh-banned-owner", "stored-banned-owner", "fresh-clear-owner"] {
let host = format!(
"moderation-owner-{scenario}-{}.local",
uuid::Uuid::new_v4().simple()
);
let community = state
.db
.ensure_configured_community(&host)
.await
.expect("ensure community")
.id;
let owner = Keys::generate();
let agent = Keys::generate();
for keys in [&owner, &agent] {
state
.db
.ensure_user(community, keys.public_key().as_bytes())
.await
.expect("ensure owner and moderator-agent users");
}
state
.db
.add_relay_member(
community,
&owner.public_key().to_hex(),
"member",
None,
&nostr::Keys::generate(),
)
.await
.map(|write| write.outcome)
.expect("seed owner as a community member");
state
.db
.add_relay_member(
community,
&agent.public_key().to_hex(),
"admin",
None,
&nostr::Keys::generate(),
)
.await
.map(|write| write.outcome)
.expect("seed agent's own moderator role");

let stored_owner = scenario == "stored-banned-owner";
if stored_owner {
assert!(state
.db
.set_agent_owner_for_authorization(
community,
agent.public_key().as_bytes(),
owner.public_key().as_bytes(),
)
.await
.expect("store agent owner link"));
}
let linked_owner = state
.db
.get_agent_channel_policy(community, agent.public_key().as_bytes())
.await
.expect("read agent owner link")
.and_then(|(_, owner)| owner);
assert_eq!(
linked_owner,
stored_owner.then(|| owner.public_key().to_bytes().to_vec()),
"fresh and stored owner variants must be distinct"
);

let banned_owner = scenario != "fresh-clear-owner";
if banned_owner {
state
.db
.ban_community_member(
community,
owner.public_key().as_bytes(),
issuer.public_key().as_bytes(),
None,
None,
)
.await
.expect("seed owner ban");
}

// The signed event is older than the NIP-OA upper bound while
// remaining fresh for NIP-98. Passing `Utc::now()` or dropping
// the signed time would therefore fail to resolve this owner.
let auth_created_at = Timestamp::now().as_secs().saturating_sub(5);
let owner_tag = buzz_sdk::nip_oa::compute_auth_tag(
&owner,
&agent.public_key(),
&format!("created_at<{}", auth_created_at + 1),
)
.expect("sign time-bounded owner proof");
let url = format!("https://{host}/moderation/reports");
let mut headers = make_nip98_headers_with_created_at(
&agent,
&url,
"GET",
b"",
auth_created_at,
);
if !stored_owner {
headers.insert(
"x-auth-tag",
owner_tag.parse().expect("valid NIP-OA auth header"),
);
}

let (status, _, body) = oneshot_request_full(
Arc::clone(&state),
"GET",
"/moderation/reports",
&host,
headers,
b"",
)
.await;
if banned_owner {
assert_eq!(
status,
axum::http::StatusCode::FORBIDDEN,
"{scenario}: an agent with its own admin role must still inherit its owner's ban; body: {body:?}"
);
let body: serde_json::Value =
serde_json::from_slice(&body).expect("ban response JSON");
assert_eq!(
body["error"],
"blocked: you are banned from this community",
"{scenario}: denial must come from the ban gate, not the moderator role gate"
);
} else {
assert_eq!(
status,
axum::http::StatusCode::OK,
"a moderator agent with an unbanned owner must reach the queue"
);
assert_eq!(body.as_ref(), b"[]", "fresh clear-owner control");
}
}
});
}

// ── T2-seam: admitted malformed query through real handler → 400 ─────────
//
// Thufir's required seam test: one admitted malformed-query request through
Expand Down
Loading
Loading