Files
ngit-grasp/tests/lifecycle/nip09_announcement_cascade.rs
T

439 lines
15 KiB
Rust

//! NIP-09 announcement cascade-deletion integration test.
//!
//! This is the end-to-end characterisation of ngit-grasp's *custom* cascade
//! deletion: deleting a kind-30617 repository announcement by its `a`
//! coordinate must hard-delete the announcement AND every dependent event that
//! is only reachable through that announcement (issues, patches, comments,
//! status events, etc.).
//!
//! This is deliberately NOT in `nip09_validation.rs`, which is reserved for
//! stock NIP-09 behaviour. Cascade deletion is an ngit-grasp extension.
//!
//! The announcement is promoted out of purgatory via the shared
//! [`publish_served_repo`] helper (which performs the real git clone + commit +
//! push dance), so the announcement and its dependents are genuinely in the
//! main database before deletion. We assert that pre-condition, submit the
//! kind-5 `a`-tag deletion, and then assert the announcement AND all dependents
//! are hard-deleted from the main DB (no "moved to holding" hand-waving — real
//! `is_empty()` / `!served` checks).
//!
//! # Running
//!
//! ```bash
//! cargo test --test nip09_announcement_cascade
//! cargo test --test nip09_announcement_cascade -- --nocapture
//! ```
#[path = "../common/mod.rs"]
mod common;
use common::{announcement_coordinate, build_deletion, publish_served_repo, TestRelay};
use grasp_audit::{AuditClient, AuditConfig};
use nostr_sdk::prelude::*;
use std::time::Duration;
/// Deleting a repository announcement by its `a` coordinate cascades to all
/// dependent events that are only reachable through that announcement.
///
/// Setup (all genuinely stored in the main DB before deletion):
/// - announcement (kind 30617), promoted out of purgatory via git push
/// - issue (kind 1621) referencing the announcement via an `a` tag
/// - patch (kind 1617) referencing the announcement via an `a` tag
/// - issue status (kind 1630) referencing the issue via an `e` tag
/// - comment (kind 1111) referencing the issue via an `e` tag
///
/// After deleting the announcement: announcement + ALL dependents must be gone.
#[tokio::test]
async fn test_announcement_cascade_deletes_dependents() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let keys = client.keys().clone();
// Stage 1: promote a repo announcement out of purgatory (real git push) so
// it (and everything referencing it) is genuinely served from the main DB.
let (announcement, repo_id) = publish_served_repo(&client, "cascade-deletes").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
// Stage 2: build dependent events.
//
// issue / patch reference the announcement via an `a` tag (the graph's
// address edge). issue_status / comment reference the issue via an `e` tag
// (the graph's event edge), so they are only transitively reachable through
// the announcement.
let issue = client
.create_issue(&announcement, "Cascade issue", "issue body", vec![])
.expect("build issue");
let patch = client
.event_builder(Kind::from(1617), "patch content")
.tag(Tag::custom("a", vec![coordinate.clone()]))
.build(&keys)
.expect("build patch");
let issue_status = client
.event_builder(Kind::from(1630), "status: open")
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
.build(&keys)
.expect("build issue status");
let comment = client
.event_builder(Kind::from(1111), "a comment on the issue")
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
.build(&keys)
.expect("build comment");
for event in [&issue, &patch, &issue_status, &comment] {
client
.send_event(event.clone())
.await
.expect("relay should accept dependent event");
}
tokio::time::sleep(Duration::from_millis(300)).await;
// Stage 3: STRICT pre-condition — every event is genuinely served from the
// main DB before the deletion. If any of these is missing the cascade test
// would be vacuous, so assert them all.
let dependents = [
("announcement", announcement.id),
("issue", issue.id),
("patch", patch.id),
("issue_status", issue_status.id),
("comment", comment.id),
];
for (label, id) in dependents {
assert!(
client
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served from the main DB before deletion"
);
}
// Stage 4: delete the announcement by its `a` coordinate. This is the only
// trigger for the graph-based cascade path.
let deletion = build_deletion(&client, &[], std::slice::from_ref(&coordinate));
client
.send_event(deletion)
.await
.expect("relay should accept the announcement deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
// Stage 5: STRICT post-condition — the announcement AND every dependent are
// hard-deleted from the main DB. These are real removal checks, not
// "moved to holding" placeholders.
let mut survivors = Vec::new();
for (label, id) in dependents {
let served = client
.is_event_on_relay(id)
.await
.expect("query event after deletion");
if served {
survivors.push(format!("{label} ({id})"));
}
}
// Also confirm the announcement is gone for a real NIP-01 addressable query
// (author + kind + d identifier), not just a by-id lookup.
let announcement_by_coord = client
.query(
Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(announcement.pubkey)
.identifier(&repo_id),
)
.await
.expect("query announcement by coordinate")
.iter()
.any(|e| e.id == announcement.id);
relay.stop().await;
assert!(
survivors.is_empty(),
"deleting the announcement must cascade-delete all dependents; \
these events survived: {survivors:?}"
);
assert!(
!announcement_by_coord,
"announcement {} must not be served for an author+kind+identifier query after deletion",
announcement.id
);
}
/// Deleting a patch by `e` tag must stay on the simple deletion path:
/// only the patch is removed, while the announcement and unrelated siblings
/// survive.
#[tokio::test]
async fn test_deleting_patch_by_event_id_does_not_delete_announcement_or_siblings() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let keys = client.keys().clone();
let (announcement, repo_id) = publish_served_repo(&client, "simple-delete-patch").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
let issue = client
.create_issue(&announcement, "Sibling issue", "must survive", vec![])
.expect("build issue");
let patch = client
.event_builder(Kind::from(1617), "patch to delete")
.tag(Tag::custom("a", vec![coordinate.clone()]))
.build(&keys)
.expect("build patch");
for event in [&issue, &patch] {
client
.send_event(event.clone())
.await
.expect("relay should accept dependent event");
}
tokio::time::sleep(Duration::from_millis(300)).await;
let precondition = [
("announcement", announcement.id),
("issue", issue.id),
("patch", patch.id),
];
for (label, id) in precondition {
assert!(
client
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before patch deletion"
);
}
let deletion = build_deletion(&client, &[patch.id], &[]);
client
.send_event(deletion)
.await
.expect("relay should accept patch deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_survived = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after deletion");
let issue_survived = client
.is_event_on_relay(issue.id)
.await
.expect("query issue after deletion");
let patch_survived = client
.is_event_on_relay(patch.id)
.await
.expect("query patch after deletion");
relay.stop().await;
assert!(
announcement_survived,
"announcement must survive patch e-tag deletion"
);
assert!(
issue_survived,
"unrelated sibling issue must survive patch e-tag deletion"
);
assert!(!patch_survived, "patch must be deleted by its e-tag");
}
/// A stale NIP-09 coordinate deletion must not cascade through the current
/// announcement. The deletion's created_at predates the announcement, so no
/// kind-30617 version is actually deletable under NIP-09 replaceable semantics.
#[tokio::test]
async fn test_stale_announcement_coordinate_deletion_does_not_cascade() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (announcement, repo_id) = publish_served_repo(&client, "stale-coordinate-delete").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
let issue = client
.create_issue(&announcement, "Stale delete issue", "must survive", vec![])
.expect("build issue");
client
.send_event(issue.clone())
.await
.expect("relay should accept dependent event");
tokio::time::sleep(Duration::from_millis(300)).await;
let stale_timestamp = Timestamp::from_secs(announcement.created_at.as_secs().saturating_sub(1));
let stale_deletion = client
.event_builder(Kind::EventDeletion, "stale delete")
.custom_time(stale_timestamp)
.tag(Tag::custom("a", vec![coordinate.clone()]))
.build(client.keys())
.expect("build stale deletion event");
client
.send_event(stale_deletion)
.await
.expect("relay should accept stale deletion request");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_survived = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after stale deletion");
let issue_survived = client
.is_event_on_relay(issue.id)
.await
.expect("query issue after stale deletion");
relay.stop().await;
assert!(
announcement_survived,
"newer announcement must survive stale coordinate deletion"
);
assert!(
issue_survived,
"dependent event must not be cascade-deleted when the announcement survives"
);
}
/// Kind 10317 user grasp lists are independently accepted by the write policy,
/// so cascade retention must treat them as independent anchors even if they are
/// connected to a deleted repository component.
#[tokio::test]
async fn test_user_grasp_list_survives_connected_announcement_cascade() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (announcement, repo_id) = publish_served_repo(&client, "user-grasp-list-anchor").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
let grasp_list = client
.event_builder(Kind::GitUserGraspList, "")
.tag(Tag::custom("a", vec![coordinate.clone()]))
.build(client.keys())
.expect("build 10317 user grasp list");
client
.send_event(grasp_list.clone())
.await
.expect("relay should accept 10317 user grasp list");
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(
client
.is_event_on_relay(grasp_list.id)
.await
.expect("query 10317 before deletion"),
"10317 precondition: event must be served before cascade"
);
let deletion = build_deletion(&client, &[], std::slice::from_ref(&coordinate));
client
.send_event(deletion)
.await
.expect("relay should accept the announcement deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_survived = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after deletion");
let grasp_list_survived = client
.is_event_on_relay(grasp_list.id)
.await
.expect("query 10317 after deletion");
relay.stop().await;
assert!(!announcement_survived, "announcement must still be deleted");
assert!(
grasp_list_survived,
"independently accepted 10317 must survive connected announcement cascade"
);
}
/// Deleting a comment by `e` tag must not trigger repository graph cascade:
/// the parent issue and announcement must survive.
#[tokio::test]
async fn test_deleting_comment_by_event_id_does_not_cascade_to_issue_or_announcement() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let keys = client.keys().clone();
let (announcement, _repo_id) = publish_served_repo(&client, "simple-delete-comment").await;
let issue = client
.create_issue(&announcement, "Issue anchor", "must survive", vec![])
.expect("build issue");
let comment = client
.event_builder(Kind::from(1111), "comment to delete")
.tag(Tag::custom("e", vec![issue.id.to_hex()]))
.build(&keys)
.expect("build comment");
for event in [&issue, &comment] {
client
.send_event(event.clone())
.await
.expect("relay should accept event");
}
tokio::time::sleep(Duration::from_millis(300)).await;
for (label, id) in [
("announcement", announcement.id),
("issue", issue.id),
("comment", comment.id),
] {
assert!(
client
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before comment deletion"
);
}
let deletion = build_deletion(&client, &[comment.id], &[]);
client
.send_event(deletion)
.await
.expect("relay should accept comment deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_survived = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after deletion");
let issue_survived = client
.is_event_on_relay(issue.id)
.await
.expect("query issue after deletion");
let comment_survived = client
.is_event_on_relay(comment.id)
.await
.expect("query comment after deletion");
relay.stop().await;
assert!(
announcement_survived,
"announcement must survive comment e-tag deletion"
);
assert!(issue_survived, "issue must survive comment e-tag deletion");
assert!(!comment_survived, "comment must be deleted by its e-tag");
}