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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
492 lines
16 KiB
Rust
492 lines
16 KiB
Rust
//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP).
|
|
//!
|
|
//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation.
|
|
//! These tests cover the three behaviors that only exist once an event reaches
|
|
//! storage, plus proof that the envelope validator is actually wired into the
|
|
//! live write path:
|
|
//! - a valid cross-owner project round-trips through its NIP-33 coordinate;
|
|
//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author,
|
|
//! and two authors sharing a `d` hold two independent projects (this is what
|
|
//! makes owner-only editing free rather than a relay permission check);
|
|
//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every
|
|
//! referenced kind:30617 announcement untouched, because membership is an
|
|
//! assertion about repositories and never authority over them;
|
|
//! - malformed envelopes are refused by the relay, not merely by the validator.
|
|
//!
|
|
//! See `docs/nips/NIP-MP.md` for the normative contract.
|
|
//!
|
|
//! # Running
|
|
//!
|
|
//! Start the relay, then run:
|
|
//!
|
|
//! ```text
|
|
//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored
|
|
//! ```
|
|
|
|
use std::time::Duration;
|
|
|
|
use buzz_test_client::BuzzTestClient;
|
|
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
|
|
|
|
const PROJECT_KIND: u16 = 30621;
|
|
const REPO_ANNOUNCEMENT_KIND: u16 = 30617;
|
|
|
|
fn relay_url() -> String {
|
|
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
|
}
|
|
|
|
fn sub_id(name: &str) -> String {
|
|
format!("e2e-project-{name}-{}", uuid::Uuid::new_v4())
|
|
}
|
|
|
|
/// A short unique suffix so concurrent runs never collide on a `d` tag.
|
|
fn unique(prefix: &str) -> String {
|
|
format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8])
|
|
}
|
|
|
|
fn member_coord(owner: &Keys, repo_d: &str) -> String {
|
|
format!(
|
|
"{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}",
|
|
owner.public_key().to_hex()
|
|
)
|
|
}
|
|
|
|
/// Build a project event. `members` are canonical `30617:<owner>:<d>`
|
|
/// coordinates; `created_at` defaults to now when `None`.
|
|
fn project_event(
|
|
keys: &Keys,
|
|
d_tag: &str,
|
|
name: &str,
|
|
members: &[String],
|
|
created_at: Option<u64>,
|
|
) -> nostr::Event {
|
|
let mut tags = vec![
|
|
Tag::parse(["d", d_tag]).unwrap(),
|
|
Tag::parse(["name", name]).unwrap(),
|
|
];
|
|
tags.extend(
|
|
members
|
|
.iter()
|
|
.map(|m| Tag::parse(["a", m.as_str()]).unwrap()),
|
|
);
|
|
let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags);
|
|
match created_at {
|
|
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
|
None => builder,
|
|
}
|
|
.sign_with_keys(keys)
|
|
.unwrap()
|
|
}
|
|
|
|
/// Announce a repository so a project has a real coordinate to reference.
|
|
fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event {
|
|
EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "")
|
|
.tags(vec![
|
|
Tag::parse(["d", repo_d]).unwrap(),
|
|
Tag::parse(["name", repo_d]).unwrap(),
|
|
])
|
|
.sign_with_keys(keys)
|
|
.unwrap()
|
|
}
|
|
|
|
/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the
|
|
/// relay takes the coordinate-delete path rather than the event-id path.
|
|
/// `created_at` defaults to now when `None`.
|
|
fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option<u64>) -> nostr::Event {
|
|
let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex());
|
|
let builder =
|
|
EventBuilder::new(Kind::Custom(5), "")
|
|
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]);
|
|
match created_at {
|
|
Some(ts) => builder.custom_created_at(Timestamp::from(ts)),
|
|
None => builder,
|
|
}
|
|
.sign_with_keys(keys)
|
|
.unwrap()
|
|
}
|
|
|
|
fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter {
|
|
Filter::new()
|
|
.kind(Kind::Custom(kind))
|
|
.author(author.public_key())
|
|
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
|
|
}
|
|
|
|
/// Subscribe with `filter` and drain to EOSE.
|
|
async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec<nostr::Event> {
|
|
let sid = sub_id(name);
|
|
client
|
|
.subscribe(&sid, vec![filter])
|
|
.await
|
|
.expect("subscribe");
|
|
client
|
|
.collect_until_eose(&sid, Duration::from_secs(5))
|
|
.await
|
|
.expect("collect events")
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_project_publish_and_query_returns_cross_owner_members() {
|
|
let url = relay_url();
|
|
let owner = Keys::generate();
|
|
let other = Keys::generate();
|
|
let d_tag = unique("project");
|
|
|
|
let members = vec![
|
|
member_coord(&owner, "buzz"),
|
|
member_coord(&other, "buzz-infra"),
|
|
];
|
|
|
|
let mut client = BuzzTestClient::connect(&url, &owner)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let event = project_event(&owner, &d_tag, "Platform", &members, None);
|
|
let ok = client.send_event(event).await.expect("send project");
|
|
assert!(ok.accepted, "relay rejected project event: {}", ok.message);
|
|
|
|
let events = query(
|
|
&mut client,
|
|
"query",
|
|
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(events.len(), 1, "expected exactly one project event");
|
|
let stored: Vec<&str> = events[0]
|
|
.tags
|
|
.iter()
|
|
.filter_map(|t| {
|
|
let parts = t.as_slice();
|
|
(parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str())
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
stored, members,
|
|
"both members must survive the round trip, including the one owned by another pubkey"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() {
|
|
let url = relay_url();
|
|
let owner = Keys::generate();
|
|
let d_tag = unique("project-replace");
|
|
let now = Timestamp::now().as_secs();
|
|
|
|
let mut client = BuzzTestClient::connect(&url, &owner)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100));
|
|
let ok = client.send_event(first).await.expect("send old");
|
|
assert!(ok.accepted, "relay rejected old project: {}", ok.message);
|
|
|
|
let members = vec![member_coord(&owner, "buzz")];
|
|
let second = project_event(&owner, &d_tag, "New", &members, Some(now));
|
|
let ok = client.send_event(second).await.expect("send new");
|
|
assert!(ok.accepted, "relay rejected new project: {}", ok.message);
|
|
|
|
let events = query(
|
|
&mut client,
|
|
"replace",
|
|
addressable_filter(PROJECT_KIND, &owner, &d_tag),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
events.len(),
|
|
1,
|
|
"NIP-33: only the newest head should remain"
|
|
);
|
|
let name = events[0]
|
|
.tags
|
|
.iter()
|
|
.find_map(|t| {
|
|
let parts = t.as_slice();
|
|
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
|
})
|
|
.expect("name tag");
|
|
assert_eq!(name, "New", "the newer head must win");
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// Owner-only editing is a property of the addressable model, not a relay
|
|
/// permission check: two authors publishing the same `d` occupy two coordinates,
|
|
/// so neither can overwrite the other. This is the test that would fail if the
|
|
/// kind were ever classified as plain-replaceable or keyed on `d` alone.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_project_same_d_under_two_authors_are_independent() {
|
|
let url = relay_url();
|
|
let alice = Keys::generate();
|
|
let bob = Keys::generate();
|
|
let d_tag = unique("project-shared-d");
|
|
|
|
let mut alice_client = BuzzTestClient::connect(&url, &alice)
|
|
.await
|
|
.expect("connect");
|
|
let ok = alice_client
|
|
.send_event(project_event(&alice, &d_tag, "Alice", &[], None))
|
|
.await
|
|
.expect("send alice");
|
|
assert!(
|
|
ok.accepted,
|
|
"relay rejected alice's project: {}",
|
|
ok.message
|
|
);
|
|
|
|
let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect");
|
|
let ok = bob_client
|
|
.send_event(project_event(&bob, &d_tag, "Bob", &[], None))
|
|
.await
|
|
.expect("send bob");
|
|
assert!(ok.accepted, "relay rejected bob's project: {}", ok.message);
|
|
|
|
for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] {
|
|
let events = query(
|
|
&mut alice_client,
|
|
label,
|
|
addressable_filter(PROJECT_KIND, keys, &d_tag),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
events.len(),
|
|
1,
|
|
"{label} should still hold their own project at the shared `d`"
|
|
);
|
|
let name = events[0]
|
|
.tags
|
|
.iter()
|
|
.find_map(|t| {
|
|
let parts = t.as_slice();
|
|
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
|
})
|
|
.expect("name tag");
|
|
assert_eq!(name, expected_name, "{label}'s project was overwritten");
|
|
}
|
|
|
|
alice_client.disconnect().await.expect("disconnect");
|
|
bob_client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// Deleting a project must delete only the grouping. A project is metadata about
|
|
/// repositories; if a tombstone at the project coordinate cascaded to the
|
|
/// referenced kind:30617s, adding a repo to someone's project would become a way
|
|
/// to destroy it.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_project_tombstone_deletes_coordinate_and_spares_members() {
|
|
let url = relay_url();
|
|
let owner = Keys::generate();
|
|
let repo_d = unique("repo");
|
|
let project_d = unique("project-tombstone");
|
|
|
|
let mut client = BuzzTestClient::connect(&url, &owner)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let ok = client
|
|
.send_event(repo_announcement(&owner, &repo_d))
|
|
.await
|
|
.expect("send announcement");
|
|
assert!(ok.accepted, "relay rejected announcement: {}", ok.message);
|
|
|
|
let members = vec![member_coord(&owner, &repo_d)];
|
|
let ok = client
|
|
.send_event(project_event(&owner, &project_d, "Doomed", &members, None))
|
|
.await
|
|
.expect("send project");
|
|
assert!(ok.accepted, "relay rejected project: {}", ok.message);
|
|
|
|
let before = query(
|
|
&mut client,
|
|
"tombstone-pre",
|
|
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
|
)
|
|
.await;
|
|
assert_eq!(before.len(), 1, "project should be live before deletion");
|
|
|
|
let ok = client
|
|
.send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None))
|
|
.await
|
|
.expect("send tombstone");
|
|
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
|
|
|
let after = query(
|
|
&mut client,
|
|
"tombstone-post",
|
|
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
|
)
|
|
.await;
|
|
assert!(
|
|
after.is_empty(),
|
|
"tombstone should remove the project coordinate, got {} event(s)",
|
|
after.len()
|
|
);
|
|
|
|
let repo = query(
|
|
&mut client,
|
|
"member-after",
|
|
addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d),
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
repo.len(),
|
|
1,
|
|
"deleting a project must not touch the repositories it referenced"
|
|
);
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own
|
|
/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or
|
|
/// replayed by a third party — must therefore retire V1 only and leave the newer
|
|
/// V2 head live. Before the timestamp predicate landed in
|
|
/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and
|
|
/// this sequence silently destroyed V2.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_stale_tombstone_between_versions_leaves_newer_project_live() {
|
|
let url = relay_url();
|
|
let owner = Keys::generate();
|
|
let project_d = unique("project-stale-tombstone");
|
|
let now = Timestamp::now().as_secs();
|
|
|
|
let mut client = BuzzTestClient::connect(&url, &owner)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let ok = client
|
|
.send_event(project_event(
|
|
&owner,
|
|
&project_d,
|
|
"V1",
|
|
&[],
|
|
Some(now - 100),
|
|
))
|
|
.await
|
|
.expect("send v1");
|
|
assert!(ok.accepted, "relay rejected V1: {}", ok.message);
|
|
|
|
let ok = client
|
|
.send_event(project_event(&owner, &project_d, "V2", &[], Some(now)))
|
|
.await
|
|
.expect("send v2");
|
|
assert!(ok.accepted, "relay rejected V2: {}", ok.message);
|
|
|
|
// Timestamped strictly between V1 and V2: valid for V1, stale for V2.
|
|
let ok = client
|
|
.send_event(coordinate_delete(
|
|
&owner,
|
|
PROJECT_KIND,
|
|
&project_d,
|
|
Some(now - 50),
|
|
))
|
|
.await
|
|
.expect("send stale tombstone");
|
|
assert!(
|
|
ok.accepted,
|
|
"a well-formed tombstone is still an acceptable event: {}",
|
|
ok.message
|
|
);
|
|
|
|
let after = query(
|
|
&mut client,
|
|
"stale-tombstone",
|
|
addressable_filter(PROJECT_KIND, &owner, &project_d),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
after.len(),
|
|
1,
|
|
"a tombstone older than the live head must not delete it, got {} event(s)",
|
|
after.len()
|
|
);
|
|
let name = after[0]
|
|
.tags
|
|
.iter()
|
|
.find_map(|t| {
|
|
let parts = t.as_slice();
|
|
(parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str())
|
|
})
|
|
.expect("surviving head must carry its name tag");
|
|
assert_eq!(name, "V2", "the surviving head must be the newer version");
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|
|
|
|
/// Proves the envelope validator is reachable from the live write path — a unit
|
|
/// test of `validate_project_envelope` cannot show that ingest calls it.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn test_project_malformed_envelope_rejected_by_relay() {
|
|
let url = relay_url();
|
|
let owner = Keys::generate();
|
|
let mut client = BuzzTestClient::connect(&url, &owner)
|
|
.await
|
|
.expect("connect");
|
|
|
|
let duplicate = member_coord(&owner, "buzz");
|
|
// Each case pairs a malformed event with the substring its rejection must
|
|
// carry, so a refusal for an unrelated reason cannot satisfy the assertion.
|
|
let cases: Vec<(&str, nostr::Event, &str)> = vec![
|
|
(
|
|
"duplicate member coordinate",
|
|
project_event(
|
|
&owner,
|
|
&unique("project-dup"),
|
|
"Dup",
|
|
&[duplicate.clone(), duplicate],
|
|
None,
|
|
),
|
|
"duplicate member coordinate",
|
|
),
|
|
(
|
|
"member coordinate naming the wrong kind",
|
|
project_event(
|
|
&owner,
|
|
&unique("project-badkind"),
|
|
"Bad kind",
|
|
&[format!("30618:{}:buzz", owner.public_key().to_hex())],
|
|
None,
|
|
),
|
|
"member `a` tag must be",
|
|
),
|
|
(
|
|
"member coordinate with an uppercase-hex owner",
|
|
project_event(
|
|
&owner,
|
|
&unique("project-upper"),
|
|
"Uppercase",
|
|
&[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))],
|
|
None,
|
|
),
|
|
"member `a` tag must be",
|
|
),
|
|
];
|
|
|
|
for (label, event, expected) in cases {
|
|
let ok = client.send_event(event).await.expect("send");
|
|
assert!(
|
|
!ok.accepted,
|
|
"relay must reject a project with a {label}, got OK: {}",
|
|
ok.message
|
|
);
|
|
assert!(
|
|
ok.message.contains(expected),
|
|
"rejection for {label} must name the rule that fired, got: {}",
|
|
ok.message
|
|
);
|
|
}
|
|
|
|
client.disconnect().await.expect("disconnect");
|
|
}
|