test(nip09): dedupe helpers into common, consolidate basic tests

Extract the duplicated publish_served_repo, announcement_served_by_coordinate,
announcement_coordinate and build_deletion helpers (~250 lines copied verbatim
across both NIP-09 suites) into tests/common/nip09_helpers.rs.

Move the normal_mode_honours_deletion baseline from nip09_disrespector.rs into
nip09_validation.rs so all basic NIP-09 validation tests live together; the
disrespector suite now owns only the disrespector-specific behaviour
(accept-but-ignore, NIP-11 advertisement).
This commit is contained in:
DanConwayDev
2026-06-17 09:59:09 +00:00
parent b9c96e861d
commit 0984acc93e
4 changed files with 259 additions and 397 deletions
+2
View File
@@ -4,6 +4,7 @@
pub mod git_server;
pub mod mock_relay;
pub mod nip09_helpers;
pub mod port;
pub mod purgatory_helpers;
pub mod relay;
@@ -11,6 +12,7 @@ pub mod sync_helpers;
pub use git_server::{SimpleGitServer, SmartGitServer};
pub use mock_relay::MockRelay;
pub use nip09_helpers::*;
pub use port::{reserve_port, PortReservation};
pub use purgatory_helpers::*;
pub use relay::TestRelay;
+179
View File
@@ -0,0 +1,179 @@
//! Shared NIP-09 deletion-request test helpers.
//!
//! These utilities are shared by the NIP-09 integration test suites
//! (`nip09_validation.rs` and `nip09_disrespector.rs`) so the announcement
//! promotion dance, coordinate-query helper and kind-5 deletion builder are
//! defined exactly once.
//!
//! All helpers drive a live ngit-grasp instance via [`AuditClient`].
use grasp_audit::{
clone_repo, create_deterministic_commit, try_push, AuditClient, DETERMINISTIC_COMMIT_HASH,
};
use nostr_sdk::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// Publish a repo announcement, submit a matching state event, and push the
/// deterministic git data so the announcement is promoted out of purgatory and
/// becomes queryable.
///
/// Returns the announcement event (kind 30617) and its repo identifier.
///
/// A kind-30617 announcement is NOT queryable until git data is pushed; the
/// tests that target the announcement therefore need it promoted first. Layer-2
/// events (issues/notes) that reference the promoted repo are served
/// immediately and do not need this dance.
pub async fn publish_served_repo(client: &AuditClient, test_name: &str) -> (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]
);
// Stage 1: announcement (enters purgatory until git data arrives)
let announcement = 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()]))
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
// Stage 2: state event pointing at the deterministic commit (purgatory)
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");
// Stage 3: clone, create the deterministic commit, push — promotes the repo
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),
}
// Give the relay a moment to promote the events 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"
);
(announcement, repo_id)
}
/// 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.
pub async fn announcement_served_by_coordinate(
client: &AuditClient,
announcement: &Event,
repo_id: &str,
) -> bool {
let filter = Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(announcement.pubkey)
.identifier(repo_id);
client
.query(filter)
.await
.expect("query announcement by coordinate")
.iter()
.any(|e| e.id == announcement.id)
}
/// Coordinate string for a kind-30617 announcement: `30617:<pubkey-hex>:<d>`.
pub fn announcement_coordinate(announcement: &Event, repo_id: &str) -> String {
format!(
"{}:{}:{}",
Kind::GitRepoAnnouncement.as_u16(),
announcement.pubkey.to_hex(),
repo_id
)
}
/// Build a kind-5 deletion request signed by `client` carrying the supplied
/// `e`-tag event ids and `a`-tag coordinate strings.
pub fn build_deletion(client: &AuditClient, event_ids: &[EventId], coordinates: &[String]) -> Event {
let mut builder = client.event_builder(Kind::EventDeletion, "delete");
for id in event_ids {
builder = builder.tag(Tag::event(*id));
}
for coord in coordinates {
builder = builder.tag(Tag::custom("a", vec![coord.clone()]));
}
builder.build(client.keys()).expect("build deletion event")
}
+15 -218
View File
@@ -7,15 +7,19 @@
//! These tests deliberately assert only on observable contract, not on any
//! internal "holding database" — the current implementation is tombstone /
//! purgatory based, so the disrespector simply accepts the kind-5 request and
//! does nothing with it. The three properties under test are:
//! does nothing with it. The two properties under test are:
//!
//! 1. **Disrespector accepts but ignores deletion** — a kind-5 request gets an
//! `OK`, yet the targeted announcement remains queryable afterwards.
//! 2. **Normal mode honours deletion** — the same flow against a default relay
//! removes the target.
//! 3. **NIP-11 advertisement** — a disrespector relay omits `9` and `62` from
//! 2. **NIP-11 advertisement** — a disrespector relay omits `9` and `62` from
//! `supported_nips`; a normal relay advertises both.
//!
//! The normal-mode baseline ("a default relay honours the deletion") lives in
//! `nip09_validation.rs` as `normal_mode_honours_deletion`. Shared helpers
//! (`publish_served_repo`, `announcement_served_by_coordinate`,
//! `announcement_coordinate`, `build_deletion`) live in
//! `tests/common/nip09_helpers.rs`.
//!
//! # Running
//!
//! ```bash
@@ -25,177 +29,12 @@
mod common;
use common::TestRelay;
use grasp_audit::{
clone_repo, create_deterministic_commit, try_push, AuditClient, AuditConfig,
DETERMINISTIC_COMMIT_HASH,
use common::{
announcement_coordinate, announcement_served_by_coordinate, build_deletion,
publish_served_repo, TestRelay,
};
use nostr_sdk::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// Publish a repo announcement, submit a matching state event, and push the
/// deterministic git data so the announcement is promoted out of purgatory and
/// becomes queryable.
///
/// Returns the announcement event (kind 30617) and its repo identifier.
///
/// This is a self-contained replica of the `PurgatoryOwnerStateDataPushed`
/// fixture lifecycle. It is inlined here (rather than reusing the fixture)
/// because the disrespector tests need the *announcement* event itself to
/// target with the kind-5 deletion, whereas the fixture returns the state
/// event.
async fn publish_served_repo(client: &AuditClient, test_name: &str) -> (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]
);
// Stage 1: announcement (enters purgatory until git data arrives)
let announcement = 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()]))
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
// Stage 2: state event pointing at the deterministic commit (purgatory)
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");
// Stage 3: clone, create the deterministic commit, push — promotes the repo
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),
}
// Give the relay a moment to promote the events 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"
);
(announcement, repo_id)
}
/// 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.
async fn announcement_served_by_coordinate(
client: &AuditClient,
announcement: &Event,
repo_id: &str,
) -> bool {
let filter = Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(announcement.pubkey)
.identifier(repo_id);
client
.query(filter)
.await
.expect("query announcement by coordinate")
.iter()
.any(|e| e.id == announcement.id)
}
/// Build a kind-5 deletion request from the announcement's author targeting the
/// announcement both by event id and by `a` coordinate (replaceable event).
fn build_deletion(client: &AuditClient, announcement: &Event, repo_id: &str) -> Event {
let coordinate = format!(
"{}:{}:{}",
Kind::GitRepoAnnouncement.as_u16(),
announcement.pubkey.to_hex(),
repo_id
);
client
.event_builder(Kind::EventDeletion, "")
.tag(Tag::event(announcement.id))
.tag(Tag::custom("a", vec![coordinate]))
.tag(Tag::custom(
"k",
vec![Kind::GitRepoAnnouncement.as_u16().to_string()],
))
.build(client.keys())
.expect("build deletion event")
}
use grasp_audit::{AuditClient, AuditConfig};
/// Disrespector mode: the kind-5 deletion is accepted (OK) but ignored — the
/// targeted announcement remains queryable.
@@ -208,7 +47,8 @@ async fn disrespector_accepts_but_ignores_deletion() {
let (announcement, repo_id) = publish_served_repo(&client, "disrespector-ignores").await;
let deletion = build_deletion(&client, &announcement, &repo_id);
let coordinate = announcement_coordinate(&announcement, &repo_id);
let deletion = build_deletion(&client, &[announcement.id], &[coordinate]);
let deletion_id = deletion.id;
// The relay MUST accept the deletion (archival mode still stores/acks it).
@@ -217,7 +57,7 @@ async fn disrespector_accepts_but_ignores_deletion() {
.await
.expect("disrespector relay should OK the kind-5 deletion");
tokio::time::sleep(Duration::from_millis(300)).await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Contract part 1: the kind-5 request itself MUST be stored/served — the
// archival relay accepts and preserves the request rather than dropping it.
@@ -258,49 +98,6 @@ async fn disrespector_accepts_but_ignores_deletion() {
);
}
/// Normal mode (default relay) honours the deletion: after a kind-5 request the
/// targeted announcement is gone.
#[tokio::test]
async fn normal_mode_honours_deletion() {
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, "normal-honours").await;
let deletion = build_deletion(&client, &announcement, &repo_id);
client
.send_event(deletion)
.await
.expect("normal relay should OK the kind-5 deletion");
tokio::time::sleep(Duration::from_millis(300)).await;
let served_by_id = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement by id after deletion");
let served_by_coordinate =
announcement_served_by_coordinate(&client, &announcement, &repo_id).await;
relay.stop().await;
assert!(
!served_by_id,
"normal relay must honour the deletion: announcement {} should no longer be \
queryable by id",
announcement.id
);
assert!(
!served_by_coordinate,
"normal relay must honour the deletion: announcement {} should no longer be served \
for an author+kind+identifier query",
announcement.id
);
}
/// Fetch the NIP-11 relay information document via HTTP and return the list of
/// advertised `supported_nips`.
///
+63 -179
View File
@@ -23,9 +23,15 @@
//! addressable event *newer* than the deletion request is accepted and served
//! (the deletion only applies up to its own `created_at`).
//!
//! `normal_mode_honours_deletion` in `nip09_disrespector.rs` already covers the
//! "announcement deleted by `a` coordinate is removed" path, so it is NOT
//! duplicated here.
//! The "normal mode honours deletion" baseline (a default relay removing an
//! announcement deleted by its `a` coordinate) lives here as a basic NIP-09
//! test (`normal_mode_honours_deletion`). The disrespector-specific
//! counterparts (accept-but-ignore, NIP-11 advertisement) live in
//! `nip09_disrespector.rs`.
//!
//! Shared helpers (`publish_served_repo`, `announcement_served_by_coordinate`,
//! `announcement_coordinate`, `build_deletion`) live in
//! `tests/common/nip09_helpers.rs` and are used by both suites.
//!
//! # Running
//!
@@ -36,186 +42,15 @@
mod common;
use common::TestRelay;
use grasp_audit::{
clone_repo, create_deterministic_commit, try_push, AuditClient, AuditConfig,
DETERMINISTIC_COMMIT_HASH,
use common::{
announcement_coordinate, announcement_served_by_coordinate, build_deletion,
publish_served_repo, TestRelay,
};
use grasp_audit::{AuditClient, AuditConfig};
use nostr_sdk::prelude::*;
use std::path::PathBuf;
use std::time::Duration;
/// Publish a repo announcement, submit a matching state event, and push the
/// deterministic git data so the announcement is promoted out of purgatory and
/// becomes queryable.
///
/// Returns the announcement event (kind 30617) and its repo identifier.
///
/// Copied (inline) from `nip09_disrespector.rs` so this file is self-contained.
/// A kind-30617 announcement is NOT queryable until git data is pushed; the
/// tests that target the announcement therefore need it promoted first. Layer-2
/// events (issues/notes) that reference the promoted repo are served
/// immediately and do not need this dance.
async fn publish_served_repo(client: &AuditClient, test_name: &str) -> (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]
);
// Stage 1: announcement (enters purgatory until git data arrives)
let announcement = 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()]))
.build(client.keys())
.expect("build announcement");
client
.send_event(announcement.clone())
.await
.expect("relay should accept announcement");
// Stage 2: state event pointing at the deterministic commit (purgatory)
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");
// Stage 3: clone, create the deterministic commit, push — promotes the repo
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),
}
// Give the relay a moment to promote the events 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"
);
(announcement, repo_id)
}
/// 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.
///
/// Copied (inline) from `nip09_disrespector.rs`.
async fn announcement_served_by_coordinate(
client: &AuditClient,
announcement: &Event,
repo_id: &str,
) -> bool {
let filter = Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(announcement.pubkey)
.identifier(repo_id);
client
.query(filter)
.await
.expect("query announcement by coordinate")
.iter()
.any(|e| e.id == announcement.id)
}
/// Build a kind-5 deletion request signed by `client` carrying the supplied
/// `e`-tag event ids and `a`-tag coordinate strings.
///
/// A more general form of the `nip09_disrespector.rs` `build_deletion` helper;
/// kept inline here so this file is self-contained.
fn build_deletion(client: &AuditClient, event_ids: &[EventId], coordinates: &[String]) -> Event {
let mut builder = client.event_builder(Kind::EventDeletion, "delete");
for id in event_ids {
builder = builder.tag(Tag::event(*id));
}
for coord in coordinates {
builder = builder.tag(Tag::custom("a", vec![coord.clone()]));
}
builder.build(client.keys()).expect("build deletion event")
}
/// Coordinate string for a kind-30617 announcement: `30617:<pubkey-hex>:<d>`.
fn announcement_coordinate(announcement: &Event, repo_id: &str) -> String {
format!(
"{}:{}:{}",
Kind::GitRepoAnnouncement.as_u16(),
announcement.pubkey.to_hex(),
repo_id
)
}
/// 1. A deletion targeting an existing, author-owned event by `e` tag is
/// accepted and the target is no longer served.
#[tokio::test]
@@ -897,3 +732,52 @@ async fn newer_addressable_event_survives_a_tag_deletion() {
v2_id
);
}
/// 8. Baseline: a default (normal-mode) relay honours a kind-5 deletion that
/// targets a promoted announcement by its `a` coordinate — the announcement
/// is no longer served, neither by id nor by author+kind+identifier.
///
/// This is the deletion-honoured counterpart to the disrespector mode's
/// "accept but ignore" behaviour (`disrespector_accepts_but_ignores_deletion`
/// in `nip09_disrespector.rs`), kept here as a basic NIP-09 validation test.
#[tokio::test]
async fn normal_mode_honours_deletion() {
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, "normal-honours").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
let deletion = build_deletion(&client, &[announcement.id], &[coordinate]);
client
.send_event(deletion)
.await
.expect("normal relay should OK the kind-5 deletion");
tokio::time::sleep(Duration::from_millis(300)).await;
let served_by_id = client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement by id after deletion");
let served_by_coordinate =
announcement_served_by_coordinate(&client, &announcement, &repo_id).await;
relay.stop().await;
assert!(
!served_by_id,
"normal relay must honour the deletion: announcement {} should no longer be \
queryable by id",
announcement.id
);
assert!(
!served_by_coordinate,
"normal relay must honour the deletion: announcement {} should no longer be served \
for an author+kind+identifier query",
announcement.id
);
}