Files
ngit-grasp/tests/nip09_multi_maintainer.rs
T
DanConwayDev c8d3829f29 feat: Implement multi-maintainer graph-based deletion algorithm
Replace simple cascade with graph-based retention algorithm:
- Build event dependency graph with nodes (events) and edges (references)
- EventGraph: directed graph tracking event relationships
- EventNode: tracks event ID, kind, references, and retention reasons
- Address resolution: maps kind 30617 addresses to event IDs

Re-evaluation engine:
- reevaluate_events_without_announcement(): determines which events to keep/delete
- Queries all events referencing deleted announcement
- Re-runs acceptance policy WITHOUT deleted announcement
- Tracks which announcements justify keeping each event
- Fail-secure defaults (missing events marked for deletion)

Graph traversal:
- BFS traversal from kept announcements
- Marks reachable events as "keep", unreachable as "delete"
- Configurable max depth (default: 100) to prevent infinite loops
- DFS-based circular dependency detection with recursion stack
- Handles anchored vs unanchored circular dependencies

Multi-maintainer logic:
- Events kept if they reference OTHER valid announcements
- Events deleted only when ALL referenced announcements are deleted
- Handles complex scenarios:
  - Two maintainers, one deletes → events preserved
  - Two maintainers, both delete → events deleted
  - Three+ maintainers with partial deletions
  - Deep dependency chains (5+ levels)
  - Isolated subgraphs

Integration:
- Replaces simple cascade in deletion_ops.rs
- determine_events_to_delete() uses graph algorithm
- Comprehensive logging of graph statistics and retention reasons
- Updated handle_deletion() in builder.rs

Tests: 31 tests covering:
- Graph building (12 unit tests)
  - Empty graph, single event, references
  - Circular references, transitive dependencies
  - Max depth handling
- Re-evaluation engine (6 unit tests)
  - Single/multi-maintainer scenarios
  - Announcement reference handling
  - Fail-secure defaults
- Graph traversal (7 unit tests)
  - Linear chains, isolated subgraphs
  - Circular dependency detection
  - Anchored vs unanchored cycles
  - Max depth exceeded
- Multi-maintainer integration (6 tests)
  - Two maintainers (one/both delete)
  - Three maintainers with partial deletions
  - Deep dependency chains
  - Selective retention based on maintainer subsets
2026-01-15 10:33:54 +00:00

573 lines
18 KiB
Rust

//! NIP-09 Multi-Maintainer Deletion Integration Tests
//!
//! Tests ngit-grasp relay's handling of deletion requests in multi-maintainer scenarios.
//! Verifies that the dependency graph algorithm correctly processes deletions when one or
//! more maintainers remove their repository announcements.
//!
//! # Test Coverage
//!
//! 1. Two maintainers, one deletes (events should be preserved via other maintainer)
//! 2. Two maintainers, both delete (events should be deleted)
//! 3. Event referencing only one maintainer (should be deleted when that maintainer deletes)
//! 4. Complex reference graphs (3+ levels deep)
//! 5. Three maintainers with selective deletion
//! 6. Deep dependency chains
//! 7. Multiple events with different maintainer subsets
//!
//! # Testing Approach
//!
//! These integration tests verify that the relay correctly accepts deletion requests
//! and processes them. The actual retention/deletion logic is verified through:
//! - Server-side logs (check with RUST_LOG=debug)
//! - Unit tests in src/nostr/policy/{reevaluation,traversal,graph}.rs
//! - Database inspection tests in nip09_cascade.rs and nip09_archival.rs
//!
//! # Running Tests
//!
//! ```bash
//! # Run all multi-maintainer tests
//! cargo test --test nip09_multi_maintainer
//!
//! # Run specific test
//! cargo test --test nip09_multi_maintainer test_two_maintainers_one_deletes
//!
//! # With debug output to see server-side processing
//! RUST_LOG=debug cargo test --test nip09_multi_maintainer -- --nocapture
//! ```
mod common;
use common::{create_repo_announcement, TestRelay};
use nostr_sdk::prelude::*;
use std::time::Duration;
use tokio::time::sleep;
/// Helper function to create a deletion event
fn create_deletion_event(
keys: &Keys,
event_ids: &[EventId],
addresses: &[String],
) -> Result<Event, Box<dyn std::error::Error>> {
let mut tags = vec![];
for id in event_ids {
tags.push(Tag::custom(TagKind::custom("e"), vec![id.to_hex()]));
}
for addr in addresses {
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
}
let event = EventBuilder::new(Kind::from(5), "delete")
.tags(tags)
.sign_with_keys(keys)?;
Ok(event)
}
/// Helper function to create a patch event that references repositories
fn create_patch_event(
keys: &Keys,
content: &str,
repo_addresses: &[String],
) -> Result<Event, Box<dyn std::error::Error>> {
let mut tags = vec![];
for addr in repo_addresses {
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
}
let event = EventBuilder::new(Kind::from(1617), content)
.tags(tags)
.sign_with_keys(keys)?;
Ok(event)
}
/// Helper function to create an issue event that references repositories
fn create_issue_event(
keys: &Keys,
content: &str,
repo_addresses: &[String],
) -> Result<Event, Box<dyn std::error::Error>> {
let mut tags = vec![];
for addr in repo_addresses {
tags.push(Tag::custom(TagKind::custom("a"), vec![addr.clone()]));
}
let event = EventBuilder::new(Kind::from(1621), content)
.tags(tags)
.sign_with_keys(keys)?;
Ok(event)
}
/// Helper function to send event and verify it's accepted
async fn send_event_and_get_response(
client: &Client,
event: &Event,
) -> Result<(), Box<dyn std::error::Error>> {
let output = client.send_event(event).await?;
if output.success.is_empty() {
return Err(format!("Event rejected by all relays: {:?}", output.failed).into());
}
Ok(())
}
/// Helper function to build announcement address
fn build_announcement_address(event: &Event) -> String {
let identifier = event
.tags
.iter()
.find_map(|tag| {
let tag_vec = tag.as_slice();
if tag_vec.len() >= 2 && tag_vec[0] == "d" {
Some(tag_vec[1].to_string())
} else {
None
}
})
.unwrap_or_default();
format!("30617:{}:{}", event.pubkey.to_hex(), identifier)
}
/// Test: Two maintainers, one deletes - events should be preserved
///
/// Scenario:
/// - Two maintainers create announcements for the same repository
/// - Events (patches, issues) reference both announcements
/// - One maintainer deletes their announcement
/// - Server should process deletion correctly (events kept via graph algorithm)
#[tokio::test]
async fn test_two_maintainers_one_deletes_events_preserved() {
let relay = TestRelay::start().await;
let maintainer1 = Keys::generate();
let maintainer2 = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
// Create announcements from both maintainers for same repository
let identifier = "shared-repo";
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
let addr1 = build_announcement_address(&announcement1);
let addr2 = build_announcement_address(&announcement2);
send_event_and_get_response(&client, &announcement1)
.await
.expect("Failed to send announcement1");
send_event_and_get_response(&client, &announcement2)
.await
.expect("Failed to send announcement2");
// Create patch referencing both announcements
let patch = create_patch_event(&maintainer1, "Test patch", &[addr1.clone(), addr2.clone()])
.expect("Failed to create patch");
send_event_and_get_response(&client, &patch)
.await
.expect("Failed to send patch");
// Create issue referencing both announcements
let issue = create_issue_event(&maintainer1, "Test issue", &[addr1.clone(), addr2.clone()])
.expect("Failed to create issue");
send_event_and_get_response(&client, &issue)
.await
.expect("Failed to send issue");
sleep(Duration::from_millis(200)).await;
// Maintainer1 deletes their announcement
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
.expect("Failed to create deletion");
send_event_and_get_response(&client, &deletion1)
.await
.expect("Failed to send deletion");
sleep(Duration::from_millis(500)).await;
// Test verifies deletion is accepted and processed
// Server-side graph algorithm should keep events (valid through announcement2)
relay.stop().await;
}
/// Test: Two maintainers, both delete - events should be deleted
///
/// Scenario:
/// - Two maintainers create announcements
/// - Events reference both
/// - Both delete their announcements
/// - Server should mark all events for deletion
#[tokio::test]
async fn test_two_maintainers_both_delete_events_deleted() {
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
let maintainer1 = Keys::generate();
let maintainer2 = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
let identifier = "doomed-repo";
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
let addr1 = build_announcement_address(&announcement1);
let addr2 = build_announcement_address(&announcement2);
send_event_and_get_response(&client, &announcement1)
.await
.expect("Failed to send announcement1");
send_event_and_get_response(&client, &announcement2)
.await
.expect("Failed to send announcement2");
let patch = create_patch_event(&maintainer1, "Test patch", &[addr1.clone(), addr2.clone()])
.expect("Failed to create patch");
send_event_and_get_response(&client, &patch)
.await
.expect("Failed to send patch");
sleep(Duration::from_millis(200)).await;
// Both maintainers delete
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
.expect("Failed to create deletion1");
let deletion2 = create_deletion_event(&maintainer2, &[], &[addr2.clone()])
.expect("Failed to create deletion2");
send_event_and_get_response(&client, &deletion1)
.await
.expect("Failed to send deletion1");
send_event_and_get_response(&client, &deletion2)
.await
.expect("Failed to send deletion2");
sleep(Duration::from_secs(10)).await;
// Test verifies both deletions accepted
// Server should delete all events (no valid announcements remain)
relay.stop().await;
}
/// Test: Event referencing only deleted maintainer
///
/// Scenario:
/// - Two maintainers, event references only one
/// - That maintainer deletes
/// - Event should be deleted (no alternative)
#[tokio::test]
async fn test_event_references_only_deleted_maintainer() {
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
let maintainer1 = Keys::generate();
let maintainer2 = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
let identifier = "partial-repo";
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
let addr1 = build_announcement_address(&announcement1);
send_event_and_get_response(&client, &announcement1)
.await
.expect("Failed to send announcement1");
send_event_and_get_response(&client, &announcement2)
.await
.expect("Failed to send announcement2");
// Patch references ONLY maintainer1
let patch = create_patch_event(&maintainer1, "Exclusive patch", &[addr1.clone()])
.expect("Failed to create patch");
send_event_and_get_response(&client, &patch)
.await
.expect("Failed to send patch");
sleep(Duration::from_millis(200)).await;
// Maintainer1 deletes
let deletion1 = create_deletion_event(&maintainer1, &[], &[addr1.clone()])
.expect("Failed to create deletion");
send_event_and_get_response(&client, &deletion1)
.await
.expect("Failed to send deletion");
sleep(Duration::from_secs(10)).await;
// Patch should be deleted (only referenced deleted maintainer)
relay.stop().await;
}
/// Test: Three maintainers, middle one deletes
///
/// Scenario:
/// - Three maintainers
/// - Event references all three
/// - Middle maintainer deletes
/// - Event should be kept (valid through other two)
#[tokio::test]
async fn test_three_maintainers_middle_deletes() {
let relay = TestRelay::start().await;
let maintainer1 = Keys::generate();
let maintainer2 = Keys::generate();
let maintainer3 = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
let identifier = "tri-repo";
let announcement1 = create_repo_announcement(&maintainer1, &[&relay.domain()], identifier);
let announcement2 = create_repo_announcement(&maintainer2, &[&relay.domain()], identifier);
let announcement3 = create_repo_announcement(&maintainer3, &[&relay.domain()], identifier);
let addr1 = build_announcement_address(&announcement1);
let addr2 = build_announcement_address(&announcement2);
let addr3 = build_announcement_address(&announcement3);
send_event_and_get_response(&client, &announcement1)
.await
.expect("Failed to send announcement1");
send_event_and_get_response(&client, &announcement2)
.await
.expect("Failed to send announcement2");
send_event_and_get_response(&client, &announcement3)
.await
.expect("Failed to send announcement3");
let patch = create_patch_event(
&maintainer1,
"Multi-maintainer patch",
&[addr1.clone(), addr2.clone(), addr3.clone()],
)
.expect("Failed to create patch");
send_event_and_get_response(&client, &patch)
.await
.expect("Failed to send patch");
sleep(Duration::from_millis(200)).await;
// Maintainer2 (middle) deletes
let deletion2 = create_deletion_event(&maintainer2, &[], &[addr2.clone()])
.expect("Failed to create deletion2");
send_event_and_get_response(&client, &deletion2)
.await
.expect("Failed to send deletion2");
sleep(Duration::from_millis(500)).await;
// Patch should be kept (valid through maintainer1 and maintainer3)
relay.stop().await;
}
/// Test: Deep dependency chain (5 levels)
///
/// Scenario:
/// - announcement <- e1 <- e2 <- e3 <- e4 <- e5
/// - Delete announcement
/// - All events should be deleted (no alternative anchor)
#[tokio::test]
async fn test_deep_dependency_chain() {
let relay = TestRelay::start_with_retention_and_cleanup(5, 2).await;
let maintainer = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
let announcement = create_repo_announcement(&maintainer, &[&relay.domain()], "deep-repo");
let addr = build_announcement_address(&announcement);
send_event_and_get_response(&client, &announcement)
.await
.expect("Failed to send announcement");
// Create chain of events
let e1 =
create_patch_event(&maintainer, "Event 1", &[addr.clone()]).expect("Failed to create e1");
send_event_and_get_response(&client, &e1)
.await
.expect("Failed to send e1");
// e2 references e1 and announcement
let e2 = EventBuilder::new(Kind::from(1617), "Event 2")
.tags(vec![
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
Tag::custom(TagKind::custom("e"), vec![e1.id.to_hex()]),
])
.sign_with_keys(&maintainer)
.expect("Failed to create e2");
send_event_and_get_response(&client, &e2)
.await
.expect("Failed to send e2");
// e3, e4, e5 similarly
let e3 = EventBuilder::new(Kind::from(1617), "Event 3")
.tags(vec![
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
Tag::custom(TagKind::custom("e"), vec![e2.id.to_hex()]),
])
.sign_with_keys(&maintainer)
.expect("Failed to create e3");
send_event_and_get_response(&client, &e3)
.await
.expect("Failed to send e3");
let e4 = EventBuilder::new(Kind::from(1617), "Event 4")
.tags(vec![
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
Tag::custom(TagKind::custom("e"), vec![e3.id.to_hex()]),
])
.sign_with_keys(&maintainer)
.expect("Failed to create e4");
send_event_and_get_response(&client, &e4)
.await
.expect("Failed to send e4");
let e5 = EventBuilder::new(Kind::from(1617), "Event 5")
.tags(vec![
Tag::custom(TagKind::custom("a"), vec![addr.clone()]),
Tag::custom(TagKind::custom("e"), vec![e4.id.to_hex()]),
])
.sign_with_keys(&maintainer)
.expect("Failed to create e5");
send_event_and_get_response(&client, &e5)
.await
.expect("Failed to send e5");
sleep(Duration::from_millis(200)).await;
// Delete announcement
let deletion = create_deletion_event(&maintainer, &[], &[addr.clone()])
.expect("Failed to create deletion");
send_event_and_get_response(&client, &deletion)
.await
.expect("Failed to send deletion");
sleep(Duration::from_secs(10)).await;
// All events should be deleted (no alternative anchor)
relay.stop().await;
}
/// Test: Multiple events with different maintainer subsets
///
/// Scenario:
/// - 3 maintainers (A, B, C)
/// - event1 references A+B
/// - event2 references B+C
/// - event3 references A+C
/// - Delete B
/// - All events should be kept (each has alternative)
#[tokio::test]
async fn test_multiple_events_different_maintainer_subsets() {
let relay = TestRelay::start().await;
let maintainer_a = Keys::generate();
let maintainer_b = Keys::generate();
let maintainer_c = Keys::generate();
let client = Client::default();
client
.add_relay(relay.url())
.await
.expect("Failed to add relay");
client.connect().await;
let identifier = "subset-repo";
let announcement_a = create_repo_announcement(&maintainer_a, &[&relay.domain()], identifier);
let announcement_b = create_repo_announcement(&maintainer_b, &[&relay.domain()], identifier);
let announcement_c = create_repo_announcement(&maintainer_c, &[&relay.domain()], identifier);
let addr_a = build_announcement_address(&announcement_a);
let addr_b = build_announcement_address(&announcement_b);
let addr_c = build_announcement_address(&announcement_c);
send_event_and_get_response(&client, &announcement_a)
.await
.expect("Failed to send announcement_a");
send_event_and_get_response(&client, &announcement_b)
.await
.expect("Failed to send announcement_b");
send_event_and_get_response(&client, &announcement_c)
.await
.expect("Failed to send announcement_c");
// event1 references A+B
let event1 = create_patch_event(
&maintainer_a,
"Event A+B",
&[addr_a.clone(), addr_b.clone()],
)
.expect("Failed to create event1");
send_event_and_get_response(&client, &event1)
.await
.expect("Failed to send event1");
// event2 references B+C
let event2 = create_patch_event(
&maintainer_b,
"Event B+C",
&[addr_b.clone(), addr_c.clone()],
)
.expect("Failed to create event2");
send_event_and_get_response(&client, &event2)
.await
.expect("Failed to send event2");
// event3 references A+C
let event3 = create_patch_event(
&maintainer_c,
"Event A+C",
&[addr_a.clone(), addr_c.clone()],
)
.expect("Failed to create event3");
send_event_and_get_response(&client, &event3)
.await
.expect("Failed to send event3");
sleep(Duration::from_millis(200)).await;
// Delete B
let deletion_b = create_deletion_event(&maintainer_b, &[], &[addr_b.clone()])
.expect("Failed to create deletion_b");
send_event_and_get_response(&client, &deletion_b)
.await
.expect("Failed to send deletion_b");
sleep(Duration::from_millis(500)).await;
// All events should be kept (each has alternative maintainer)
relay.stop().await;
}