Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1091ead
test(relay): map community ban routes and regressions
bradseiler Oct 5, 2026
b0b40fe
docs(testing): clarify invite ban regression contract
bradseiler Oct 5, 2026
d1add80
docs(testing): clarify webhook ban classification
bradseiler Oct 5, 2026
7cb9312
test(relay): align timeout admin regression
bradseiler Oct 6, 2026
fba01dd
test(relay): cover current community entry points
bradseiler Oct 9, 2026
3a2947d
test(relay): enable accessory routes in the ban matrix
bradseiler Oct 9, 2026
29a3447
docs(testing): distinguish pending sibling ban contracts
Oct 12, 2026
1d1dd58
docs(testing): distinguish invite policy from implementation
Oct 12, 2026
43e1c24
fix(test): fail the route inventory on every shape it cannot classify
TheSentinel454 Oct 26, 2026
aaf6336
fix(test): pin unrouted registrations and reject repeated nests
TheSentinel454 Oct 26, 2026
9c6aa98
fix(test): pass relay_keypair to add_relay_member in ban-matrix tests
TheSentinel454 Oct 26, 2026
a6e1223
fix(test): consume MembershipWrite in ban-matrix seeds
TheSentinel454 Oct 26, 2026
0ffb134
test(auth): bind route controls to exact outcomes and merged routers
bradseiler Oct 30, 2026
1579157
test(auth): match Git controls to handler response contracts
bradseiler Oct 30, 2026
bf765db
fix(test): reject nested Git settings merges
bradseiler Nov 4, 2026
cac5501
test(relay): classify merged FI upgrade routes
bradseiler Nov 4, 2026
3f2fd75
fix(test): reject unclassified route method chains
bradseiler Nov 5, 2026
f1e4300
docs(testing): wrap scanner limitations
bradseiler Nov 5, 2026
73755ca
docs(testing): reflow scanner limitations paragraph
bradseiler Nov 5, 2026
7ff87dd
test(relay): classify the current FI policy routes
bradseiler Nov 6, 2026
47e15f7
test(relay): classify operator invite mint restrictions
bradseiler Nov 9, 2026
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
11 changes: 11 additions & 0 deletions .github/workflows/_ci-relay.yml
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,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 @@ -592,6 +594,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 @@ -665,12 +669,15 @@ jobs:
- name: Start relay
env:
BUZZ_MEDIA_READ_MODE: enforce
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 @@ -683,6 +690,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
98 changes: 98 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,104 @@ 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. Route method expressions must start with a known HTTP helper
and contain only known method helpers or `.with_state`, `.layer`, and
`.route_layer` modifiers. Unclassified chains, including `on`, `on_service`,
and `*_service` helpers, fail even when mixed with a known method.
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.
Operator-mediated invite minting is restriction-enforced: the operator attests
the actor, and the relay checks that actor's community role and restrictions.
Its PostgreSQL route regression mints for clear owners and admins, then rejects
a banned admin and actors without authority.
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
185 changes: 182 additions & 3 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3201,7 +3201,7 @@ async fn authorize_moderation_read(
None,
state.config.require_auth_token || nip_fi_active,
))
.map(|auth| auth.proof((auth.event_id_bytes, None)))
.map(|auth| auth.proof((auth.event_id_bytes, auth.signed_created_at)))
.map_err(|e| e.into_response())
})
.await?;
Expand All @@ -3218,7 +3218,7 @@ async fn authorize_moderation_read(
&tenant,
&pubkey_bytes,
super::relay_members::extract_auth_tag_header(headers),
None,
caller.signed_at,
caller.token_owner,
)
.await;
Expand Down Expand Up @@ -3462,7 +3462,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() -> buzz_pubsub::RedisConnection {
Expand Down Expand Up @@ -5985,6 +5985,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 @@ -5996,6 +6006,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 @@ -8752,6 +8763,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