test(deletion): verify multi-maintainer cascade retention

Add shared Phase-4 helper coverage in tests/common/nip09_helpers.rs for multi-maintainer serving paths, including maintainer-listed first announcements plus second-maintainer same-identifier promotion helpers with strict served assertions.

Add tests/nip09_multi_maintainer.rs and tests/nip09_state_multi_maintainer.rs in the Phase-3 integration style (strict preconditions, relay stop before final assertions, and hard-delete postconditions against the live relay DB).

State coverage verifies 30618 identifier anchoring semantics: state survives while any 30617 remains, and all state is removed when the last maintainer announcement is deleted.

Verification run: cargo test --test nip09_multi_maintainer -- --nocapture; cargo test --test nip09_state_multi_maintainer -- --nocapture; cargo test; cargo clippy --all-targets --all-features.
This commit is contained in:
DanConwayDev
2026-06-17 11:24:18 +00:00
parent 19a88b3db3
commit 816464cffd
3 changed files with 1030 additions and 0 deletions
+435
View File
@@ -137,6 +137,121 @@ pub async fn publish_served_repo(client: &AuditClient, test_name: &str) -> (Even
(announcement, repo_id)
}
/// Publish a served repository announcement while listing additional
/// maintainers in the NIP-34 `maintainers` tag.
///
/// Multi-maintainer cascade tests use this helper so second maintainers can be
/// accepted through the maintainer exception path.
pub async fn publish_served_repo_with_maintainers(
client: &AuditClient,
test_name: &str,
maintainer_pubkeys: &[String],
) -> (Event, String) {
let relay_url = client
.relay_url()
.await
.expect("client should have a relay");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let http_url = format!("http://{}", relay_domain);
let npub = client.public_key().to_bech32().expect("pubkey to bech32");
let repo_id = format!(
"{}-{}",
test_name,
&Keys::generate().public_key().to_hex()[..8]
);
let mut builder = client
.event_builder(Kind::GitRepoAnnouncement, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom("name", vec![repo_id.clone()]))
.tag(Tag::custom(
"clone",
vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
))
.tag(Tag::custom("relays", vec![relay_url.clone()]));
if !maintainer_pubkeys.is_empty() {
builder = builder.tag(Tag::custom("maintainers", maintainer_pubkeys.to_vec()));
}
let announcement = builder.build(client.keys()).expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
let state_event = client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
"refs/heads/main",
vec![DETERMINISTIC_COMMIT_HASH.to_string()],
))
.tag(Tag::custom(
"HEAD",
vec!["ref: refs/heads/main".to_string()],
))
.build(client.keys())
.expect("build state event");
client
.send_event_and_note_purgatory(state_event.clone())
.await
.expect("relay should accept state event");
let clone_path =
clone_repo(&relay_domain, &npub, &repo_id).expect("clone purgatory repo over git http");
let cleanup = |path: &PathBuf| {
let _ = std::fs::remove_dir_all(path);
};
let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
panic!("failed to create deterministic commit: {}", e);
}
};
assert_eq!(
commit_hash, DETERMINISTIC_COMMIT_HASH,
"deterministic commit hash mismatch"
);
let _ = std::process::Command::new("git")
.args(["branch", "main"])
.current_dir(&clone_path)
.output();
let _ = std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(&clone_path)
.output();
let pushed = try_push(&clone_path);
cleanup(&clone_path);
match pushed {
Ok(true) => {}
Ok(false) => panic!("git push rejected while promoting repo out of purgatory"),
Err(e) => panic!("git push error while promoting repo: {}", e),
}
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement"),
"announcement should be served after git data arrives"
);
(announcement, repo_id)
}
/// Publish a served announcement + served state event for one repository.
///
/// Unlike [`publish_served_repo`], this helper returns the exact kind-30618
@@ -211,6 +326,326 @@ pub async fn publish_served_repo_with_state_event(
(announcement, repo_id, state_event)
}
/// Publish a served announcement + served state event while listing additional
/// maintainers in the NIP-34 `maintainers` tag.
pub async fn publish_served_repo_with_state_event_and_maintainers(
client: &AuditClient,
test_name: &str,
maintainer_pubkeys: &[String],
) -> (Event, String, Event) {
let relay_url = client
.relay_url()
.await
.expect("client should have a relay");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let npub = client.public_key().to_bech32().expect("pubkey to bech32");
let repo_id = format!(
"{}-{}",
test_name,
&Keys::generate().public_key().to_hex()[..8]
);
let temp_dir = tempfile::tempdir().expect("create temp dir for repo push");
let commit_hash = create_test_repo_with_commit(temp_dir.path(), CommitVariant::StateTest)
.expect("create deterministic state-test commit");
let mut announcement_builder = client
.event_builder(Kind::GitRepoAnnouncement, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
"clone",
vec![format!("http://{}/{}/{}.git", relay_domain, npub, repo_id)],
))
.tag(Tag::custom("relays", vec![relay_url.clone()]));
if !maintainer_pubkeys.is_empty() {
announcement_builder =
announcement_builder.tag(Tag::custom("maintainers", maintainer_pubkeys.to_vec()));
}
let announcement = announcement_builder
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
let clone_url = format!("http://{}/{}/{}.git", relay_domain, npub, repo_id);
let state_event = create_state_event(
client.keys(),
&repo_id,
&[("main", commit_hash.as_str())],
&[],
&[clone_url.as_str()],
&[relay_url.as_str()],
)
.expect("build repo state event");
client
.send_event_and_note_purgatory(state_event.clone())
.await
.expect("relay should accept state event");
push_to_relay(temp_dir.path(), &relay_domain, &npub, &repo_id)
.expect("git push should promote announcement + state event out of purgatory");
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement"),
"announcement should be served after git data arrives"
);
assert!(
client
.is_event_on_relay(state_event.id)
.await
.expect("query state event"),
"state event should be served after git data arrives"
);
(announcement, repo_id, state_event)
}
/// Publish and serve an additional announcement (kind 30617) for an existing
/// repository identifier using a distinct maintainer client.
///
/// This helper is used by multi-maintainer cascade tests where maintainer A
/// already listed this maintainer in a `maintainers` tag. That allows the
/// second maintainer to independently announce the same identifier and complete
/// git promotion for their own `/<npub>/<identifier>.git` repository.
pub async fn publish_served_announcement_for_identifier(
client: &AuditClient,
identifier: &str,
) -> Event {
let relay_url = client
.relay_url()
.await
.expect("client should have a relay");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let http_url = format!("http://{}", relay_domain);
let npub = client.public_key().to_bech32().expect("pubkey to bech32");
let announcement = client
.event_builder(Kind::GitRepoAnnouncement, "")
.tag(Tag::identifier(identifier))
.tag(Tag::custom("name", vec![identifier.to_string()]))
.tag(Tag::custom(
"clone",
vec![format!("{}/{}/{}.git", http_url, npub, identifier)],
))
.tag(Tag::custom("relays", vec![relay_url.clone()]))
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
let state_event = client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(identifier))
.tag(Tag::custom(
"refs/heads/main",
vec![DETERMINISTIC_COMMIT_HASH.to_string()],
))
.tag(Tag::custom(
"HEAD",
vec!["ref: refs/heads/main".to_string()],
))
.build(client.keys())
.expect("build state event");
client
.send_event_and_note_purgatory(state_event)
.await
.expect("relay should accept state event");
tokio::time::sleep(Duration::from_millis(300)).await;
if client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement")
{
return announcement;
}
let clone_path =
clone_repo(&relay_domain, &npub, identifier).expect("clone purgatory repo over git http");
let cleanup = |path: &PathBuf| {
let _ = std::fs::remove_dir_all(path);
};
let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
panic!("failed to create deterministic commit: {}", e);
}
};
assert_eq!(
commit_hash, DETERMINISTIC_COMMIT_HASH,
"deterministic commit hash mismatch"
);
let _ = std::process::Command::new("git")
.args(["branch", "main"])
.current_dir(&clone_path)
.output();
let _ = std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(&clone_path)
.output();
let pushed = try_push(&clone_path);
cleanup(&clone_path);
match pushed {
Ok(true) => {}
Ok(false) => panic!("git push rejected while promoting repo out of purgatory"),
Err(e) => panic!("git push error while promoting repo: {}", e),
}
assert!(
client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement"),
"announcement should be served after git data arrives"
);
announcement
}
/// Publish and serve an additional announcement+state pair for an existing
/// repository identifier using a distinct maintainer client.
///
/// Returns the served announcement and the exact served state event for strict
/// by-id assertions in multi-maintainer 30618 cascade tests.
pub async fn publish_served_announcement_with_state_for_identifier(
client: &AuditClient,
identifier: &str,
) -> (Event, Event) {
let relay_url = client
.relay_url()
.await
.expect("client should have a relay");
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let npub = client.public_key().to_bech32().expect("pubkey to bech32");
let http_url = format!("http://{}", relay_domain);
let announcement = client
.event_builder(Kind::GitRepoAnnouncement, "")
.tag(Tag::identifier(identifier))
.tag(Tag::custom("name", vec![identifier.to_string()]))
.tag(Tag::custom(
"clone",
vec![format!("{}/{}/{}.git", http_url, npub, identifier)],
))
.tag(Tag::custom("relays", vec![relay_url.clone()]))
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
let clone_url = format!("{}/{}/{}.git", http_url, npub, identifier);
let state_event = create_state_event(
client.keys(),
identifier,
&[("main", DETERMINISTIC_COMMIT_HASH)],
&[],
&[clone_url.as_str()],
&[relay_url.as_str()],
)
.expect("build repo state event");
client
.send_event_and_note_purgatory(state_event.clone())
.await
.expect("relay should accept state event");
tokio::time::sleep(Duration::from_millis(300)).await;
let announcement_served = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement");
let state_served = client
.is_event_on_relay(state_event.id)
.await
.expect("query state event");
if announcement_served && state_served {
return (announcement, state_event);
}
let clone_path =
clone_repo(&relay_domain, &npub, identifier).expect("clone purgatory repo over git http");
let cleanup = |path: &PathBuf| {
let _ = std::fs::remove_dir_all(path);
};
let commit_hash = match create_deterministic_commit(&clone_path, "Initial commit") {
Ok(h) => h,
Err(e) => {
cleanup(&clone_path);
panic!("failed to create deterministic commit: {}", e);
}
};
assert_eq!(
commit_hash, DETERMINISTIC_COMMIT_HASH,
"deterministic commit hash mismatch"
);
let _ = std::process::Command::new("git")
.args(["branch", "main"])
.current_dir(&clone_path)
.output();
let _ = std::process::Command::new("git")
.args(["checkout", "main"])
.current_dir(&clone_path)
.output();
let pushed = try_push(&clone_path);
cleanup(&clone_path);
match pushed {
Ok(true) => {}
Ok(false) => panic!("git push rejected while promoting repo out of purgatory"),
Err(e) => panic!("git push error while promoting repo: {}", e),
}
assert!(
client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement"),
"announcement should be served after git data arrives"
);
assert!(
client
.is_event_on_relay(state_event.id)
.await
.expect("query state event"),
"state event should be served after git data arrives"
);
(announcement, state_event)
}
/// Query the relay for a served repo announcement using a real client's access
/// pattern: author + kind + `d` identifier (a NIP-01 addressable-event query),
/// rather than a by-id lookup. Returns whether the relay serves it.
+402
View File
@@ -0,0 +1,402 @@
//! NIP-09 multi-maintainer announcement-cascade integration tests.
//!
//! These tests verify hard-delete cascade behaviour in the relay's main DB when
//! multiple maintainers announce the same repository identifier.
//!
//! Covered here:
//! - Same-identifier announcements by different maintainers
//! - Dependent event retention when at least one referenced announcement survives
//! - Dependent event hard-deletion when all referenced announcements are deleted
//!
//! Serving note: these tests use the maintainer-exception path for the second
//! and third announcement (`maintainers` tag + non-local `relays` tag), because
//! independent second-announcement git promotion is not currently reliable in
//! this fixture setup.
//!
//! Not covered here: PR-chain kinds (1618/1619/1631/1632), which need additional
//! PR-specific test wiring outside this Phase 4 scope.
mod common;
use common::{
announcement_coordinate, announcement_served_by_coordinate, build_deletion,
publish_served_announcement_for_identifier, publish_served_repo_with_maintainers, TestRelay,
};
use grasp_audit::{AuditClient, AuditConfig};
use nostr_sdk::prelude::*;
use std::time::Duration;
#[tokio::test]
async fn test_two_maintainers_one_deletes_shared_event_survives() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let (announcement_a, repo_id) = publish_served_repo_with_maintainers(
&client_a,
"multi-maint-a",
&[client_b.public_key().to_hex()],
)
.await;
let announcement_b = publish_served_announcement_for_identifier(&client_b, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
let coordinate_b = announcement_coordinate(&announcement_b, &repo_id);
let patch = client_a
.event_builder(Kind::from(1617), "shared patch")
.tag(Tag::custom("a", vec![coordinate_a.clone()]))
.tag(Tag::custom("a", vec![coordinate_b.clone()]))
.build(client_a.keys())
.expect("build patch");
client_a
.send_event(patch.clone())
.await
.expect("relay should accept patch");
tokio::time::sleep(Duration::from_millis(300)).await;
for (label, id) in [
("announcement_a", announcement_a.id),
("announcement_b", announcement_b.id),
("patch", patch.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
assert!(
announcement_served_by_coordinate(&client_a, &announcement_a, &repo_id).await,
"announcement A must be served by coordinate before deletion"
);
assert!(
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await,
"announcement B must be served by coordinate before deletion"
);
let deletion_a = build_deletion(&client_a, &[], std::slice::from_ref(&coordinate_a));
client_a
.send_event(deletion_a)
.await
.expect("relay should accept announcement A deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let patch_survived = client_a
.is_event_on_relay(patch.id)
.await
.expect("query patch after deletion");
let announcement_a_by_coordinate =
announcement_served_by_coordinate(&client_a, &announcement_a, &repo_id).await;
let announcement_b_by_coordinate =
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await;
relay.stop().await;
assert!(
!announcement_a_survived,
"announcement A must be hard-deleted"
);
assert!(announcement_b_survived, "announcement B must survive");
assert!(
patch_survived,
"patch anchored to announcement B must survive"
);
assert!(
!announcement_a_by_coordinate,
"announcement A coordinate must not be served after deletion"
);
assert!(
announcement_b_by_coordinate,
"announcement B coordinate must remain served after deleting A"
);
}
#[tokio::test]
async fn test_event_anchored_only_to_deleted_maintainer_is_removed() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let (announcement_a, repo_id) = publish_served_repo_with_maintainers(
&client_a,
"multi-anchored-only-a",
&[client_b.public_key().to_hex()],
)
.await;
let announcement_b = publish_served_announcement_for_identifier(&client_b, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
let patch = client_a
.event_builder(Kind::from(1617), "patch anchored only to A")
.tag(Tag::custom("a", vec![coordinate_a.clone()]))
.build(client_a.keys())
.expect("build patch");
client_a
.send_event(patch.clone())
.await
.expect("relay should accept patch");
tokio::time::sleep(Duration::from_millis(300)).await;
for (label, id) in [
("announcement_a", announcement_a.id),
("announcement_b", announcement_b.id),
("patch", patch.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion_a = build_deletion(&client_a, &[], std::slice::from_ref(&coordinate_a));
client_a
.send_event(deletion_a)
.await
.expect("relay should accept announcement A deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let patch_survived = client_a
.is_event_on_relay(patch.id)
.await
.expect("query patch after deletion");
relay.stop().await;
assert!(
!announcement_a_survived,
"announcement A must be hard-deleted"
);
assert!(announcement_b_survived, "announcement B must survive");
assert!(
!patch_survived,
"patch anchored only to deleted announcement A must be hard-deleted"
);
}
#[tokio::test]
async fn test_two_maintainers_both_delete_unanchored_events_removed() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let (announcement_a, repo_id) = publish_served_repo_with_maintainers(
&client_a,
"multi-both-delete",
&[client_b.public_key().to_hex()],
)
.await;
let announcement_b = publish_served_announcement_for_identifier(&client_b, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
let coordinate_b = announcement_coordinate(&announcement_b, &repo_id);
let patch = client_a
.event_builder(Kind::from(1617), "shared patch doomed")
.tag(Tag::custom("a", vec![coordinate_a.clone()]))
.tag(Tag::custom("a", vec![coordinate_b.clone()]))
.build(client_a.keys())
.expect("build patch");
client_a
.send_event(patch.clone())
.await
.expect("relay should accept patch");
tokio::time::sleep(Duration::from_millis(300)).await;
for (label, id) in [
("announcement_a", announcement_a.id),
("announcement_b", announcement_b.id),
("patch", patch.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion_a = build_deletion(&client_a, &[], std::slice::from_ref(&coordinate_a));
let deletion_b = build_deletion(&client_b, &[], std::slice::from_ref(&coordinate_b));
client_a
.send_event(deletion_a)
.await
.expect("relay should accept announcement A deletion");
client_b
.send_event(deletion_b)
.await
.expect("relay should accept announcement B deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let patch_survived = client_a
.is_event_on_relay(patch.id)
.await
.expect("query patch after deletion");
let announcement_a_by_coordinate =
announcement_served_by_coordinate(&client_a, &announcement_a, &repo_id).await;
let announcement_b_by_coordinate =
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await;
relay.stop().await;
assert!(
!announcement_a_survived && !announcement_b_survived,
"both announcements must be hard-deleted"
);
assert!(
!patch_survived,
"patch must be hard-deleted after both anchoring announcements are deleted"
);
assert!(
!announcement_a_by_coordinate && !announcement_b_by_coordinate,
"neither announcement coordinate should remain served"
);
}
#[tokio::test]
async fn test_three_maintainers_middle_deletes_event_survives() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let client_c =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer C client");
let (announcement_a, repo_id) = publish_served_repo_with_maintainers(
&client_a,
"multi-three-a",
&[
client_b.public_key().to_hex(),
client_c.public_key().to_hex(),
],
)
.await;
let announcement_b = publish_served_announcement_for_identifier(&client_b, &repo_id).await;
let announcement_c = publish_served_announcement_for_identifier(&client_c, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
let coordinate_b = announcement_coordinate(&announcement_b, &repo_id);
let coordinate_c = announcement_coordinate(&announcement_c, &repo_id);
let patch = client_a
.event_builder(Kind::from(1617), "triple-anchored patch")
.tag(Tag::custom("a", vec![coordinate_a]))
.tag(Tag::custom("a", vec![coordinate_b.clone()]))
.tag(Tag::custom("a", vec![coordinate_c]))
.build(client_a.keys())
.expect("build patch");
client_a
.send_event(patch.clone())
.await
.expect("relay should accept patch");
tokio::time::sleep(Duration::from_millis(300)).await;
for (label, id) in [
("announcement_a", announcement_a.id),
("announcement_b", announcement_b.id),
("announcement_c", announcement_c.id),
("patch", patch.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion_b = build_deletion(&client_b, &[], std::slice::from_ref(&coordinate_b));
client_b
.send_event(deletion_b)
.await
.expect("relay should accept announcement B deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let announcement_c_survived = client_c
.is_event_on_relay(announcement_c.id)
.await
.expect("query announcement C after deletion");
let patch_survived = client_a
.is_event_on_relay(patch.id)
.await
.expect("query patch after deletion");
relay.stop().await;
assert!(announcement_a_survived, "announcement A must survive");
assert!(
!announcement_b_survived,
"announcement B must be hard-deleted"
);
assert!(announcement_c_survived, "announcement C must survive");
assert!(
patch_survived,
"patch must survive because it remains anchored to announcements A and C"
);
}
+193
View File
@@ -0,0 +1,193 @@
//! NIP-09 multi-maintainer repository-state (30618) cascade integration tests.
//!
//! These tests verify identifier-level 30618 deletion rules in the relay's
//! single-node hard-delete path (main DB only; no holding DB/archival/recovery).
//!
//! Covered here:
//! - state survives while any kind-30617 announcement for the identifier remains
//! - all state for an identifier is deleted when the last announcement is deleted
//!
//! Serving note: second-maintainer announcements are served via the maintainer
//! exception path in this suite (`maintainers` tag + non-local `relays` tag).
mod common;
use common::{
announcement_coordinate, announcement_served_by_coordinate, build_deletion,
publish_served_announcement_with_state_for_identifier,
publish_served_repo_with_state_event_and_maintainers, TestRelay,
};
use grasp_audit::{AuditClient, AuditConfig};
use nostr_sdk::prelude::*;
use std::time::Duration;
#[tokio::test]
async fn test_state_survives_while_any_maintainer_announcement_remains() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let (announcement_a, repo_id, state_a) = publish_served_repo_with_state_event_and_maintainers(
&client_a,
"state-multi-a",
&[client_b.public_key().to_hex()],
)
.await;
let (announcement_b, state_b) =
publish_served_announcement_with_state_for_identifier(&client_b, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
for (label, id) in [
("announcement_a", announcement_a.id),
("state_a", state_a.id),
("announcement_b", announcement_b.id),
("state_b", state_b.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
assert!(
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await,
"announcement B must be served by coordinate before deletion"
);
let deletion_a = build_deletion(&client_a, &[], std::slice::from_ref(&coordinate_a));
client_a
.send_event(deletion_a)
.await
.expect("relay should accept announcement A deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let state_b_survived = client_b
.is_event_on_relay(state_b.id)
.await
.expect("query state B after deletion");
let announcement_b_by_coordinate =
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await;
relay.stop().await;
assert!(
!announcement_a_survived,
"announcement A must be hard-deleted"
);
assert!(
announcement_b_survived,
"announcement B must survive, keeping identifier anchored"
);
assert!(
state_b_survived,
"state B must survive while identifier still has a live announcement"
);
assert!(
announcement_b_by_coordinate,
"announcement B must still be served by coordinate"
);
}
#[tokio::test]
async fn test_state_deleted_when_last_maintainer_deletes() {
let relay = TestRelay::start().await;
let client_a = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create maintainer A client");
let client_b =
AuditClient::new_with_keys(relay.url(), AuditConfig::isolated(), Keys::generate())
.await
.expect("create maintainer B client");
let (announcement_a, repo_id, state_a) = publish_served_repo_with_state_event_and_maintainers(
&client_a,
"state-multi-last",
&[client_b.public_key().to_hex()],
)
.await;
let (announcement_b, state_b) =
publish_served_announcement_with_state_for_identifier(&client_b, &repo_id).await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_id);
let coordinate_b = announcement_coordinate(&announcement_b, &repo_id);
for (label, id) in [
("announcement_a", announcement_a.id),
("state_a", state_a.id),
("announcement_b", announcement_b.id),
("state_b", state_b.id),
] {
assert!(
client_a
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion_a = build_deletion(&client_a, &[], std::slice::from_ref(&coordinate_a));
let deletion_b = build_deletion(&client_b, &[], std::slice::from_ref(&coordinate_b));
client_a
.send_event(deletion_a)
.await
.expect("relay should accept announcement A deletion");
client_b
.send_event(deletion_b)
.await
.expect("relay should accept announcement B deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client_a
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement A after deletion");
let announcement_b_survived = client_b
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement B after deletion");
let state_a_survived = client_a
.is_event_on_relay(state_a.id)
.await
.expect("query state A after deletion");
let state_b_survived = client_b
.is_event_on_relay(state_b.id)
.await
.expect("query state B after deletion");
let announcement_a_by_coordinate =
announcement_served_by_coordinate(&client_a, &announcement_a, &repo_id).await;
let announcement_b_by_coordinate =
announcement_served_by_coordinate(&client_b, &announcement_b, &repo_id).await;
relay.stop().await;
assert!(
!announcement_a_survived && !announcement_b_survived,
"all announcements for identifier must be hard-deleted"
);
assert!(
!state_a_survived && !state_b_survived,
"all 30618 state events for identifier must be hard-deleted when unanchored"
);
assert!(
!announcement_a_by_coordinate && !announcement_b_by_coordinate,
"identifier must no longer be served by announcement coordinates"
);
}