diff --git a/tests/common/nip09_helpers.rs b/tests/common/nip09_helpers.rs index 85682fa..2f4bdd3 100644 --- a/tests/common/nip09_helpers.rs +++ b/tests/common/nip09_helpers.rs @@ -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 `//.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. diff --git a/tests/nip09_multi_maintainer.rs b/tests/nip09_multi_maintainer.rs new file mode 100644 index 0000000..efb36f3 --- /dev/null +++ b/tests/nip09_multi_maintainer.rs @@ -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" + ); +} diff --git a/tests/nip09_state_multi_maintainer.rs b/tests/nip09_state_multi_maintainer.rs new file mode 100644 index 0000000..c46ad17 --- /dev/null +++ b/tests/nip09_state_multi_maintainer.rs @@ -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" + ); +}