Files
ngit-grasp/tests/lifecycle/nip09_state_cascade.rs
T
DanConwayDev 7be865bac8 test: route integration-test git subprocesses through hermetic helper
Adopt grasp_audit::git_command() at every git call site in tests/ so
the integration suite sees only configuration it supplies itself,
matching the hermeticity grasp-audit's fixtures now enforce. Ambient
settings such as a failing hook from core.hooksPath/init.templateDir
or an init.defaultBranch override previously reached these
subprocesses directly; the nip09 helpers even depended on
init.defaultBranch=main to make their swallowed branch-setup errors
harmless.

This commit is the mechanical conversion only: arguments and error
handling are unchanged (tokio call sites wrap the helper with
tokio::process::Command::from). Load-bearing swallowed exit statuses
are fixed in a follow-up so each change stays independently
reviewable.

Validated: cargo clippy --workspace --all-targets -D warnings;
cargo fmt --all -- --check; full suite runs in the final validation
pass.
2026-08-01 16:16:26 +00:00

805 lines
26 KiB
Rust

//! NIP-09 repository-state cascade-deletion integration tests.
//!
//! These tests extend the announcement-cascade coverage to kind-30618
//! repository state events in the **single-maintainer** case.
#[path = "../common/mod.rs"]
mod common;
use common::{
announcement_coordinate, announcement_served_by_coordinate, build_deletion,
create_test_repo_with_commit, publish_served_announcement_with_state_for_identifier,
publish_served_repo, publish_served_repo_with_state_event,
publish_served_repo_with_state_event_and_maintainers, push_to_relay, CommitVariant, TestRelay,
};
use grasp_audit::git_command;
use grasp_audit::{AuditClient, AuditConfig, DETERMINISTIC_COMMIT_HASH};
use ngit_grasp::nostr::lifecycle::HoldingStore;
use nostr_sdk::prelude::*;
use std::collections::HashSet;
use std::time::Duration;
fn build_state_with_branches(
client: &AuditClient,
repo_id: &str,
created_at: Timestamp,
branches: &[&str],
) -> Event {
let mut tags = vec![Tag::identifier(repo_id)];
for branch in branches {
tags.push(Tag::custom(
format!("refs/heads/{branch}"),
vec![DETERMINISTIC_COMMIT_HASH.to_string()],
));
}
tags.push(Tag::custom(
"HEAD",
vec!["ref: refs/heads/main".to_string()],
));
EventBuilder::new(Kind::RepoState, "")
.tags(tags)
.custom_created_at(created_at)
.finalize(client.keys())
.expect("build state event")
}
fn repo_path(relay: &TestRelay, client: &AuditClient, repo_id: &str) -> std::path::PathBuf {
relay
.git_data_path()
.join(client.public_key().to_bech32().expect("npub"))
.join(format!("{repo_id}.git"))
}
fn list_repo_refs(repo_path: &std::path::Path) -> HashSet<String> {
let output = git_command()
.args([
"--git-dir",
repo_path.to_str().expect("repo path"),
"show-ref",
])
.output()
.expect("run git show-ref");
if !output.status.success() {
return HashSet::new();
}
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_whitespace().nth(1))
.map(|s| s.to_string())
.collect()
}
fn assert_valid_empty_bare_repo(repo_path: &std::path::Path) {
assert!(
repo_path.is_dir(),
"purgatory replacement repo path must exist: {}",
repo_path.display()
);
let bare = git_command()
.args([
"--git-dir",
repo_path.to_str().expect("repo path"),
"rev-parse",
"--is-bare-repository",
])
.output()
.expect("run git rev-parse --is-bare-repository");
assert!(
bare.status.success(),
"replacement repo must be a valid bare git repo: {}",
String::from_utf8_lossy(&bare.stderr)
);
assert_eq!(
String::from_utf8_lossy(&bare.stdout).trim(),
"true",
"replacement repo must report itself as bare"
);
let refs = git_command()
.args([
"--git-dir",
repo_path.to_str().expect("repo path"),
"show-ref",
])
.output()
.expect("run git show-ref on replacement repo");
assert!(
!refs.status.success(),
"empty replacement repo must not have refs: {}",
String::from_utf8_lossy(&refs.stdout)
);
}
async fn open_holding(relay: &TestRelay) -> HoldingStore {
HoldingStore::open_lmdb(relay.relay_data_path(), relay.git_data_path())
.await
.expect("open holding db")
}
async fn raw_announcement_served_by_coordinate(
relay_url: &str,
announcement: &Event,
repo_id: &str,
) -> bool {
let raw_client = Client::builder()
.authenticator(SignerAuthenticator::new(Keys::generate()))
.build();
raw_client.add_relay(relay_url).await.expect("add relay");
raw_client.connect().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let filter = Filter::new()
.kind(Kind::GitRepoAnnouncement)
.author(announcement.pubkey)
.identifier(repo_id);
raw_client
.fetch_events(filter)
.timeout(Duration::from_secs(2))
.await
.expect("raw query announcement by coordinate")
.iter()
.any(|e| e.id == announcement.id)
}
fn metadata_has_tag(event: &Event, key: &str, value: Option<&str>) -> bool {
event.tags.iter().any(|tag| {
let v = tag.as_slice();
v.len() >= 2 && v[0] == key && value.map(|expected| v[1] == expected).unwrap_or(true)
})
}
fn metadata_archive_relative_path(metadata: &[Event]) -> Option<String> {
metadata.iter().find_map(|event| {
event.tags.iter().find_map(|tag| {
let v = tag.as_slice();
if v.len() >= 2 && v[0] == "holding-archive-path" {
Some(v[1].clone())
} else {
None
}
})
})
}
/// Deleting an announcement by coordinate must hard-delete the repository state
/// event (kind 30618) for the same repository from the main DB.
#[tokio::test]
async fn test_state_event_deleted_with_announcement() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (announcement, repo_id, state_event) =
publish_served_repo_with_state_event(&client, "state-cascade-single").await;
let coordinate = announcement_coordinate(&announcement, &repo_id);
// STRICT pre-condition: both announcement and state are genuinely served.
for (label, id) in [("announcement", announcement.id), ("state", state_event.id)] {
assert!(
client
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion = build_deletion(&client, &[], std::slice::from_ref(&coordinate));
client
.send_event(deletion)
.await
.expect("relay should accept 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 state_survived = client
.is_event_on_relay(state_event.id)
.await
.expect("query state event after deletion");
let announcement_by_coordinate =
announcement_served_by_coordinate(&client, &announcement, &repo_id).await;
relay.stop().await;
assert!(
!announcement_survived,
"announcement {} must be hard-deleted",
announcement.id
);
assert!(
!state_survived,
"state event {} must be hard-deleted with its announcement",
state_event.id
);
assert!(
!announcement_by_coordinate,
"announcement must not be served by author+kind+identifier after deletion"
);
}
/// Two repositories by the same maintainer are independent: deleting one
/// announcement must delete only that repository's state event.
#[tokio::test]
async fn test_state_events_are_independent_per_repository() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (announcement_a, repo_a, state_a) =
publish_served_repo_with_state_event(&client, "state-cascade-a").await;
let (announcement_b, repo_b, state_b) =
publish_served_repo_with_state_event(&client, "state-cascade-b").await;
let coordinate_a = announcement_coordinate(&announcement_a, &repo_a);
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
.is_event_on_relay(id)
.await
.expect("query event before deletion"),
"{label} ({id}) must be served before deletion"
);
}
let deletion = build_deletion(&client, &[], std::slice::from_ref(&coordinate_a));
client
.send_event(deletion)
.await
.expect("relay should accept announcement deletion");
tokio::time::sleep(Duration::from_millis(600)).await;
let announcement_a_survived = client
.is_event_on_relay(announcement_a.id)
.await
.expect("query announcement a after deletion");
let state_a_survived = client
.is_event_on_relay(state_a.id)
.await
.expect("query state a after deletion");
let announcement_b_survived = client
.is_event_on_relay(announcement_b.id)
.await
.expect("query announcement b after deletion");
let state_b_survived = client
.is_event_on_relay(state_b.id)
.await
.expect("query state b after deletion");
let announcement_a_by_coordinate =
announcement_served_by_coordinate(&client, &announcement_a, &repo_a).await;
let announcement_b_by_coordinate =
announcement_served_by_coordinate(&client, &announcement_b, &repo_b).await;
relay.stop().await;
assert!(
!announcement_a_survived && !state_a_survived,
"deleted repository must lose both announcement and state event"
);
assert!(
announcement_b_survived && state_b_survived,
"unrelated repository announcement and state must survive"
);
assert!(
!announcement_a_by_coordinate,
"deleted repository coordinate must not be served"
);
assert!(
announcement_b_by_coordinate,
"unrelated repository coordinate must remain served"
);
}
#[tokio::test]
async fn test_e_delete_active_state_rolls_back_and_realigns_refs() {
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, "state-rollback-e").await;
let base = Timestamp::from_secs(Timestamp::now().as_secs() + 20);
let v1 = build_state_with_branches(&client, &repo_id, base, &["main"]);
let v2 = build_state_with_branches(
&client,
&repo_id,
Timestamp::from_secs(base.as_secs() + 1),
&["main", "feature"],
);
client.send_event(v1.clone()).await.expect("send v1 state");
client.send_event(v2.clone()).await.expect("send v2 state");
tokio::time::sleep(Duration::from_millis(500)).await;
let owner_repo = repo_path(&relay, &client, &repo_id);
let refs_before = list_repo_refs(&owner_repo);
assert!(
refs_before.contains("refs/heads/feature"),
"feature ref must exist before rollback deletion"
);
client
.send_event(build_deletion(&client, &[v2.id], &[]))
.await
.expect("send e-tag deletion for active state");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
client
.is_event_on_relay(v1.id)
.await
.expect("query v1 after rollback"),
"previous state must be restored"
);
assert!(
!client
.is_event_on_relay(v2.id)
.await
.expect("query v2 after rollback"),
"deleted active state must stay deleted"
);
let refs_after = list_repo_refs(&owner_repo);
assert!(refs_after.contains("refs/heads/main"));
assert!(
!refs_after.contains("refs/heads/feature"),
"feature ref must be removed after rollback realignment"
);
relay.stop().await;
}
#[tokio::test]
async fn test_e_delete_active_announcement_rolls_back_previous_version() {
let relay = TestRelay::start().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (v1, repo_id) = publish_served_repo(&client, "announcement-rollback-e").await;
let base = Timestamp::from_secs(Timestamp::now().as_secs() + 60);
let v2 = EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(v1.tags.clone())
.custom_created_at(base)
.finalize(client.keys())
.expect("build replacement announcement");
client
.send_event(v2.clone())
.await
.expect("send replacement announcement");
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(
client
.is_event_on_relay(v2.id)
.await
.expect("query replacement announcement before deletion"),
"replacement announcement must be active before deletion"
);
client
.send_event(build_deletion(&client, &[v2.id], &[]))
.await
.expect("send e-tag deletion for active announcement");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
client
.is_event_on_relay(v1.id)
.await
.expect("query v1 after announcement rollback"),
"previous announcement must be restored"
);
assert!(
!client
.is_event_on_relay(v2.id)
.await
.expect("query v2 after announcement rollback"),
"deleted active announcement must stay deleted"
);
assert!(
announcement_served_by_coordinate(&client, &v1, &repo_id).await,
"announcement coordinate must remain served after rollback"
);
relay.stop().await;
}
#[tokio::test]
async fn test_a_delete_coordinate_old_cutoff_keeps_newer_active_state() {
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, "state-rollback-a").await;
let base = Timestamp::from_secs(Timestamp::now().as_secs() + 40);
let v1 = build_state_with_branches(&client, &repo_id, base, &["main"]);
let v2 = build_state_with_branches(
&client,
&repo_id,
Timestamp::from_secs(base.as_secs() + 1),
&["main", "feature"],
);
client.send_event(v1.clone()).await.expect("send v1 state");
client.send_event(v2.clone()).await.expect("send v2 state");
tokio::time::sleep(Duration::from_millis(500)).await;
let owner_repo = repo_path(&relay, &client, &repo_id);
let refs_before = list_repo_refs(&owner_repo);
assert!(refs_before.contains("refs/heads/feature"));
let coordinate = format!("30618:{}:{}", client.public_key().to_hex(), repo_id);
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tags(vec![Tag::custom("a", vec![coordinate])])
.custom_created_at(v1.created_at)
.finalize(client.keys())
.expect("build coordinate deletion");
client
.send_event(deletion)
.await
.expect("send a-tag deletion with cutoff");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
!client
.is_event_on_relay(v1.id)
.await
.expect("query v1 after coordinate deletion"),
"state at or before cutoff must be deleted"
);
assert!(
client
.is_event_on_relay(v2.id)
.await
.expect("query v2 after coordinate deletion"),
"newer active state must remain served"
);
let refs_after = list_repo_refs(&owner_repo);
assert!(refs_after.contains("refs/heads/main"));
assert!(
refs_after.contains("refs/heads/feature"),
"realignment must keep refs from surviving active state"
);
relay.stop().await;
}
#[tokio::test]
async fn test_a_delete_coordinate_cutoff_deletes_active_without_history_resurrection() {
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, "state-rollback-a-active").await;
let base = Timestamp::from_secs(Timestamp::now().as_secs() + 50);
let v1 = build_state_with_branches(&client, &repo_id, base, &["main"]);
let v2 = build_state_with_branches(
&client,
&repo_id,
Timestamp::from_secs(base.as_secs() + 1),
&["main", "feature"],
);
client.send_event(v1.clone()).await.expect("send v1 state");
client.send_event(v2.clone()).await.expect("send v2 state");
tokio::time::sleep(Duration::from_millis(500)).await;
let owner_repo = repo_path(&relay, &client, &repo_id);
let coordinate = format!("30618:{}:{}", client.public_key().to_hex(), repo_id);
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tags(vec![Tag::custom("a", vec![coordinate])])
.custom_created_at(v2.created_at)
.finalize(client.keys())
.expect("build coordinate deletion");
client
.send_event(deletion)
.await
.expect("send a-tag deletion with active cutoff");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
!client
.is_event_on_relay(v1.id)
.await
.expect("query v1 after active cutoff deletion"),
"deleted coordinate versions must not be restored from history"
);
assert!(
!client
.is_event_on_relay(v2.id)
.await
.expect("query v2 after active cutoff deletion"),
"active state at cutoff must be deleted"
);
let refs_after = list_repo_refs(&owner_repo);
assert!(
!refs_after.iter().any(|r| r.starts_with("refs/heads/")),
"managed branch refs must be cleared when no state survives"
);
assert!(
!refs_after.iter().any(|r| r.starts_with("refs/tags/")),
"managed tag refs must be cleared when no state survives"
);
relay.stop().await;
}
#[tokio::test]
async fn test_delete_only_active_state_with_no_history_moves_scope_to_purgatory_until_new_state_and_git(
) {
let relay = TestRelay::start_with_lmdb().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.expect("create audit client");
let (announcement, repo_id, original_state) =
publish_served_repo_with_state_event(&client, "state-rollback-none").await;
let dependent_issue = client
.create_issue(
&announcement,
"state delete dependent issue",
"must move with announcement scope",
vec![],
)
.expect("build dependent issue");
client
.send_event(dependent_issue.clone())
.await
.expect("send dependent issue");
let owner_repo = repo_path(&relay, &client, &repo_id);
let holding = open_holding(&relay).await;
let relay_url = relay.url().to_string();
assert!(
list_repo_refs(&owner_repo).contains("refs/heads/main"),
"main ref must exist before deleting the only active state"
);
assert!(
client
.is_event_on_relay(dependent_issue.id)
.await
.expect("query dependent issue before deletion"),
"dependent issue must be served before deleting the only active state"
);
client
.send_event(build_deletion(&client, &[original_state.id], &[]))
.await
.expect("send deletion for only active state");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
!client
.is_event_on_relay(original_state.id)
.await
.expect("query deleted state"),
"deleted only-active state must not be served"
);
assert!(
!client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after no-active-state transition"),
"announcement must not remain served by id when no active state exists"
);
assert!(
!announcement_served_by_coordinate(&client, &announcement, &repo_id).await,
"announcement must not remain served by coordinate when no active state exists"
);
assert!(
!client
.is_event_on_relay(dependent_issue.id)
.await
.expect("query dependent issue after no-active-state transition"),
"dependent issue must not remain served when its announcement scope moves to purgatory"
);
assert_valid_empty_bare_repo(&owner_repo);
let refs_after_delete = list_repo_refs(&owner_repo);
assert!(
!refs_after_delete
.iter()
.any(|r| r.starts_with("refs/heads/")),
"repo must not serve old live branch refs after no-active-state transition"
);
assert!(
!refs_after_delete
.iter()
.any(|r| r.starts_with("refs/tags/")),
"repo must not serve old live tag refs after no-active-state transition"
);
assert!(
holding.has_event(&announcement.id).await,
"announcement must be moved to holding during no-active-state transition"
);
assert!(
holding.has_event(&dependent_issue.id).await,
"dependent issue must be moved to holding during no-active-state transition"
);
assert!(
holding.has_event(&original_state.id).await,
"deleted state must be moved to holding"
);
let announcement_meta = holding.metadata_for_event(&announcement.id).await;
assert!(
announcement_meta.iter().any(|event| metadata_has_tag(
event,
"holding-source",
Some("nip09")
)),
"announcement holding metadata must record nip09 source"
);
let archive_rel = metadata_archive_relative_path(&announcement_meta)
.expect("announcement metadata must include git archive path");
let archive_abs = relay.git_data_path().join(".archive").join(archive_rel);
assert!(
archive_abs.exists(),
"git archive artifact must exist for no-active-state transition"
);
let temp_dir = tempfile::tempdir().expect("create recovery repo");
let recovery_commit =
create_test_repo_with_commit(temp_dir.path(), CommitVariant::SecondCommit)
.expect("create recovery commit");
let recovery_state = client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
"refs/heads/main",
vec![recovery_commit.clone()],
))
.tag(Tag::custom(
"HEAD",
vec!["ref: refs/heads/main".to_string()],
))
.build(client.keys())
.expect("build recovery state");
client
.send_event_and_note_purgatory(recovery_state.clone())
.await
.expect("send recovery state to purgatory");
assert!(
!client
.is_event_on_relay(recovery_state.id)
.await
.expect("query recovery state before git push"),
"new state must wait in purgatory until matching git data arrives"
);
let npub = client.public_key().to_bech32().expect("npub");
push_to_relay(temp_dir.path(), &relay.domain(), &npub, &repo_id)
.expect("push recovery git data");
tokio::time::sleep(Duration::from_millis(900)).await;
assert!(
client
.is_event_on_relay(announcement.id)
.await
.expect("query announcement after recovery promotion"),
"announcement should be promoted from purgatory after new matching state+git data arrive"
);
assert!(
raw_announcement_served_by_coordinate(&relay_url, &announcement, &repo_id).await,
"announcement coordinate should be served again after recovery promotion"
);
assert!(
client
.is_event_on_relay(dependent_issue.id)
.await
.expect("query dependent issue after recovery promotion"),
"dependent issue should be restored after new matching state+git data arrive"
);
assert!(
client
.is_event_on_relay(recovery_state.id)
.await
.expect("query recovery state after promotion"),
"new matching state should be promoted after git data arrives"
);
assert!(
!client
.is_event_on_relay(original_state.id)
.await
.expect("query deleted original state after recovery"),
"tombstoned original state must not be restored during recovery"
);
let refs_after_recovery = list_repo_refs(&owner_repo);
assert!(
refs_after_recovery.contains("refs/heads/main"),
"main ref should be restored from the new valid state"
);
relay.stop().await;
}
#[tokio::test]
async fn test_multi_maintainer_state_deletion_rolls_back_without_affecting_other_maintainer() {
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-rollback-multi",
&[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 base = Timestamp::from_secs(Timestamp::now().as_secs() + 60);
let a_v1 = build_state_with_branches(&client_a, &repo_id, base, &["main"]);
let a_v2 = build_state_with_branches(
&client_a,
&repo_id,
Timestamp::from_secs(base.as_secs() + 1),
&["main", "feature"],
);
client_a
.send_event(a_v1.clone())
.await
.expect("send maintainer A v1");
client_a
.send_event(a_v2.clone())
.await
.expect("send maintainer A v2");
tokio::time::sleep(Duration::from_millis(700)).await;
client_a
.send_event(build_deletion(&client_a, &[a_v2.id], &[]))
.await
.expect("delete maintainer A active state");
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(
client_a
.is_event_on_relay(a_v1.id)
.await
.expect("query restored maintainer A state"),
"maintainer A should rollback to previous state"
);
assert!(
client_b
.is_event_on_relay(state_b.id)
.await
.expect("query maintainer B state"),
"maintainer B state must remain served"
);
relay.stop().await;
}