fix: rewrite deletion integration tests to avoid shared-state side effects

The previous tests deleted purgatory announcements (kind 30617) and checked
for bare-repo absence via git ls-remote, which would corrupt shared-mode
test state by destroying repos other tests depend on.

New approach tests deletion of purgatory state events (kind 30618) instead:

- e-tag test: promotes a repo, creates a unique commit locally, submits a
  state event pointing to it (enters purgatory), deletes the state event by
  event ID, then verifies git push of that commit is rejected.

- a-tag coordinate test: promotes a repo, generates a fresh maintainer
  keypair, sends a replacement announcement adding that maintainer, submits
  a state event signed by the new maintainer (enters purgatory), deletes by
  coordinate 30618:<new_maintainer_pubkey>:<identifier>, then verifies git
  push is rejected.

Also extends DeletionPolicy to handle kind 30618 state events in purgatory
for both e-tag (event ID) and a-tag (coordinate) deletion paths.
This commit is contained in:
DanConwayDev
2026-02-23 13:42:57 +00:00
parent 65ac6ef832
commit 0c71e19196
3 changed files with 306 additions and 163 deletions
+215 -112
View File
@@ -27,9 +27,11 @@
//! - `test_pr_event_in_purgatory_git_push_accepted` - Git push to refs/nostr/<event-id> succeeds
//! - `test_pr_event_served_after_git_push` - Event becomes queryable after git data
use crate::fixtures::{clone_repo, create_commit, try_push};
use crate::specs::grasp01::SpecRef;
use crate::{AuditClient, AuditResult, FixtureKind, TestContext, TestResult};
use nostr_sdk::prelude::*;
use std::fs;
use std::time::Duration;
/// Test suite for GRASP-01 purgatory behavior
@@ -47,9 +49,9 @@ impl PurgatoryTests {
results.add(Self::test_state_event_accepted_for_purgatory_announcement(client).await);
// Deletion event tests (NIP-09)
results.add(Self::test_deletion_by_event_id_removes_purgatory_announcement(client).await);
results.add(Self::test_deletion_by_event_id_removes_purgatory_state_event(client).await);
results.add(
Self::test_deletion_by_coordinate_removes_purgatory_announcement(client).await,
Self::test_deletion_by_coordinate_removes_purgatory_state_event(client).await,
);
// State event purgatory tests (already implemented)
@@ -656,192 +658,293 @@ impl PurgatoryTests {
// Deletion Event Tests (NIP-09)
// ============================================================
/// Test: Kind 5 deletion event by event ID removes purgatory announcement
/// Test: Kind 5 deletion event by event ID removes a purgatory state event
///
/// Spec: NIP-09
/// "A special event with kind 5... having a list of one or more `e` or `a` tags,
/// each referencing an event the author is requesting to be deleted."
///
/// This test verifies:
/// 1. Send a valid repository announcement (enters purgatory)
/// 2. Send a kind 5 deletion event referencing the announcement by event ID
/// 3. The announcement is no longer in purgatory (git push would fail)
/// 4. The deletion event itself is accepted by the relay
pub async fn test_deletion_by_event_id_removes_purgatory_announcement(
/// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
/// 2. Clone the repo and create a unique commit (not yet pushed)
/// 3. Submit a state event pointing to that unique commit (enters purgatory)
/// 4. Send a kind 5 deletion event referencing the state event by event ID
/// 5. Attempt to push the unique commit — MUST be rejected (no authorized state event)
pub async fn test_deletion_by_event_id_removes_purgatory_state_event(
client: &AuditClient,
) -> TestResult {
TestResult::new(
"deletion_by_event_id_removes_purgatory_announcement",
"deletion_by_event_id_removes_purgatory_state_event",
SpecRef::PurgatoryAcceptUntilGitData,
"Kind 5 deletion by event ID SHOULD remove a purgatory announcement",
"Kind 5 deletion by event ID SHOULD remove a purgatory state event, causing push rejection",
)
.run(|| async {
let ctx = TestContext::new(client);
// Send announcement to purgatory
let repo = ctx
.get_fixture(FixtureKind::ValidRepoSent)
// Stage 1: get a promoted repo with git data already on the relay
let existing_state = ctx
.get_fixture(FixtureKind::OwnerStateDataPushed)
.await
.map_err(|e| format!("Failed to create repo announcement: {}", e))?;
.map_err(|e| format!("Failed to get promoted repo: {}", e))?;
let repo_id = repo
let repo_id = existing_state
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or("Missing d tag in repo announcement")?
.ok_or("Missing d tag in state event")?
.to_string();
// Verify it's in purgatory (not served)
tokio::time::sleep(Duration::from_millis(300)).await;
if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
return Err(
"Announcement was served immediately - purgatory not working".to_string(),
);
}
let relay_domain = client
.relay_url()
.await
.map_err(|e| e.to_string())?
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
// Build and send kind 5 deletion event referencing the announcement by event ID
let deletion = client
.event_builder(Kind::EventDeletion, "")
.tag(Tag::event(repo.id))
let npub = client
.public_key()
.to_bech32()
.map_err(|e| e.to_string())?;
// Stage 2: clone the repo and create a unique commit (not pushed yet)
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| format!("Failed to clone repo: {}", e))?;
let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
let unique_commit = match create_commit(&clone_path, "deletion test unique commit") {
Ok(h) => h,
Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
};
// Stage 3: submit a state event pointing to the unique commit (enters purgatory)
let state_event = client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("k"),
vec!["30617"],
TagKind::custom("refs/heads/main"),
vec![unique_commit.clone()],
))
.tag(Tag::custom(
TagKind::custom("HEAD"),
vec!["ref: refs/heads/main".to_string()],
))
.build(client.keys())
.map_err(|e| format!("Failed to build deletion event: {}", e))?;
.map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
let (_, in_purgatory) = client
.send_event_and_note_purgatory(state_event.clone())
.await
.map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
if !in_purgatory {
cleanup();
return Err(format!(
"State event was served immediately (not in purgatory). \
Commit {} may already exist on relay.",
unique_commit
));
}
// Stage 4: send kind 5 deletion event referencing the state event by event ID
let deletion = client
.event_builder(Kind::EventDeletion, "")
.tag(Tag::event(state_event.id))
.tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
.build(client.keys())
.map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
client
.send_event(deletion)
.await
.map_err(|e| format!("Relay rejected deletion event: {}", e))?;
.map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
tokio::time::sleep(Duration::from_millis(300)).await;
// Verify the announcement can no longer be promoted by attempting a git push.
// We check this indirectly: if the purgatory entry was removed, a subsequent
// git push to the repo path should fail (no bare repo).
// For the integration test we verify the announcement is still not served
// (it was never promoted) and that the deletion event was accepted.
// The bare-repo deletion is verified by attempting a git clone.
let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let clone_url = format!(
"{}/{}/{}.git",
http_url,
client.public_key().to_bech32().map_err(|e| e.to_string())?,
repo_id
);
// Stage 5: attempt to push the unique commit — must be rejected
let push_result = try_push(&clone_path);
cleanup();
// git ls-remote should fail (bare repo deleted)
let output = std::process::Command::new("git")
.args(["ls-remote", &clone_url])
.output()
.map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
if output.status.success() {
return Err(format!(
"Bare repo still exists after deletion event. \
Expected git ls-remote to fail for {}",
clone_url
));
match push_result {
Ok(false) => Ok(()), // push rejected as expected
Ok(true) => Err(format!(
"Push was accepted but should have been rejected. \
The state event (id={}) was deleted, so commit {} \
should not be authorized.",
state_event.id, unique_commit
)),
Err(e) => Err(format!("Git push error: {}", e)),
}
Ok(())
})
.await
}
/// Test: Kind 5 deletion event by `a` tag coordinate removes purgatory announcement
/// Test: Kind 5 deletion event by `a` tag coordinate removes a purgatory state event
///
/// Spec: NIP-09
/// "When an `a` tag is used, relays SHOULD delete all versions of the replaceable
/// event up to the `created_at` timestamp of the deletion request event."
///
/// This test verifies:
/// 1. Send a valid repository announcement (enters purgatory)
/// 2. Send a kind 5 deletion event referencing the announcement by coordinate
/// (`30617:<pubkey>:<identifier>`)
/// 3. The announcement is no longer in purgatory
pub async fn test_deletion_by_coordinate_removes_purgatory_announcement(
/// 1. Get a promoted repo (OwnerStateDataPushed) so git pushes are possible
/// 2. Generate a fresh keypair for a new maintainer
/// 3. Send a replacement owner announcement adding the new maintainer (goes to DB)
/// 4. Send a state event signed by the new maintainer pointing to a unique commit
/// (enters purgatory — maintainer is authorized but commit doesn't exist yet)
/// 5. Delete by coordinate `30618:<new_maintainer_pubkey>:<identifier>`
/// 6. Clone repo, create that unique commit, attempt to push — MUST be rejected
/// (the state event was deleted, so the commit is no longer authorized)
pub async fn test_deletion_by_coordinate_removes_purgatory_state_event(
client: &AuditClient,
) -> TestResult {
TestResult::new(
"deletion_by_coordinate_removes_purgatory_announcement",
"deletion_by_coordinate_removes_purgatory_state_event",
SpecRef::PurgatoryAcceptUntilGitData,
"Kind 5 deletion by `a` coordinate SHOULD remove a purgatory announcement",
"Kind 5 deletion by `a` coordinate SHOULD remove a purgatory state event, causing push rejection",
)
.run(|| async {
let ctx = TestContext::new(client);
// Send announcement to purgatory
let repo = ctx
.get_fixture(FixtureKind::ValidRepoSent)
// Stage 1: get a promoted repo with git data already on the relay
let existing_state = ctx
.get_fixture(FixtureKind::OwnerStateDataPushed)
.await
.map_err(|e| format!("Failed to create repo announcement: {}", e))?;
.map_err(|e| format!("Failed to get promoted repo: {}", e))?;
let repo_id = repo
let repo_id = existing_state
.tags
.iter()
.find(|t| t.kind() == TagKind::d())
.and_then(|t| t.content())
.ok_or("Missing d tag in repo announcement")?
.ok_or("Missing d tag in state event")?
.to_string();
// Verify it's in purgatory (not served)
tokio::time::sleep(Duration::from_millis(300)).await;
if client.is_event_on_relay(repo.id).await.map_err(|e| e.to_string())? {
return Err(
"Announcement was served immediately - purgatory not working".to_string(),
);
// Stage 2: generate a fresh keypair for a new maintainer
let new_maintainer_keys = Keys::generate();
let new_maintainer_hex = new_maintainer_keys.public_key().to_hex();
// Stage 3: send a replacement owner announcement that adds the new maintainer.
// This is a replacement (same pubkey + identifier already in DB) so it goes
// straight to the database without entering purgatory.
let relay_url = client
.relay_url()
.await
.map_err(|e| e.to_string())?;
let http_url = relay_url
.replace("ws://", "http://")
.replace("wss://", "https://");
let npub = client
.public_key()
.to_bech32()
.map_err(|e| e.to_string())?;
let replacement_announcement = client
.event_builder(Kind::GitRepoAnnouncement, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("clone"),
vec![format!("{}/{}/{}.git", http_url, npub, repo_id)],
))
.tag(Tag::custom(
TagKind::custom("relays"),
vec![relay_url.clone()],
))
.tag(Tag::custom(
TagKind::custom("maintainers"),
vec![new_maintainer_hex.clone()],
))
.build(client.keys())
.map_err(|e| format!("Failed to build replacement announcement: {}", e))?;
client
.send_event(replacement_announcement)
.await
.map_err(|e| format!("Relay rejected replacement announcement: {}", e))?;
tokio::time::sleep(Duration::from_millis(200)).await;
// Stage 4: clone the repo and create a unique commit (not pushed yet)
let relay_domain = relay_url
.trim_start_matches("ws://")
.trim_start_matches("wss://")
.to_string();
let clone_path = clone_repo(&relay_domain, &npub, &repo_id)
.map_err(|e| format!("Failed to clone repo: {}", e))?;
let cleanup = || { let _ = fs::remove_dir_all(&clone_path); };
let unique_commit = match create_commit(&clone_path, "deletion coordinate test unique commit") {
Ok(h) => h,
Err(e) => { cleanup(); return Err(format!("Failed to create commit: {}", e)); }
};
// Stage 5: submit a state event signed by the new maintainer pointing to the
// unique commit. The new maintainer is now authorized (listed in the replacement
// announcement), so the state event should enter purgatory (commit doesn't exist).
let state_event = client
.event_builder(Kind::RepoState, "")
.tag(Tag::identifier(&repo_id))
.tag(Tag::custom(
TagKind::custom("refs/heads/main"),
vec![unique_commit.clone()],
))
.tag(Tag::custom(
TagKind::custom("HEAD"),
vec!["ref: refs/heads/main".to_string()],
))
.build(&new_maintainer_keys)
.map_err(|e| { cleanup(); format!("Failed to build state event: {}", e) })?;
let (_, in_purgatory) = client
.send_event_and_note_purgatory(state_event.clone())
.await
.map_err(|e| { cleanup(); format!("Failed to send state event: {}", e) })?;
if !in_purgatory {
cleanup();
return Err(format!(
"State event was served immediately (not in purgatory). \
Commit {} may already exist on relay.",
unique_commit
));
}
// Build coordinate: `30617:<pubkey_hex>:<identifier>`
let coord = format!(
"30617:{}:{}",
client.public_key().to_hex(),
repo_id
);
// Stage 6: send kind 5 deletion event signed by the new maintainer,
// referencing their state event by coordinate `30618:<pubkey>:<identifier>`
let coord = format!("30618:{}:{}", new_maintainer_hex, repo_id);
// Build and send kind 5 deletion event referencing by coordinate
let deletion = client
.event_builder(Kind::EventDeletion, "")
.tag(Tag::custom(TagKind::custom("a"), vec![coord]))
.tag(Tag::custom(TagKind::custom("k"), vec!["30617"]))
.build(client.keys())
.map_err(|e| format!("Failed to build deletion event: {}", e))?;
.tag(Tag::custom(TagKind::custom("k"), vec!["30618"]))
.build(&new_maintainer_keys)
.map_err(|e| { cleanup(); format!("Failed to build deletion event: {}", e) })?;
client
.send_event(deletion)
.await
.map_err(|e| format!("Relay rejected deletion event: {}", e))?;
.map_err(|e| { cleanup(); format!("Relay rejected deletion event: {}", e) })?;
tokio::time::sleep(Duration::from_millis(300)).await;
// Verify bare repo was deleted
let http_url = AuditClient::ws_to_http_url(&client.relay_url().await.map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let clone_url = format!(
"{}/{}/{}.git",
http_url,
client.public_key().to_bech32().map_err(|e| e.to_string())?,
repo_id
);
// Stage 7: attempt to push the unique commit — must be rejected because
// the new maintainer's state event was deleted from purgatory
let push_result = try_push(&clone_path);
cleanup();
let output = std::process::Command::new("git")
.args(["ls-remote", &clone_url])
.output()
.map_err(|e| format!("Failed to run git ls-remote: {}", e))?;
if output.status.success() {
return Err(format!(
"Bare repo still exists after deletion event. \
Expected git ls-remote to fail for {}",
clone_url
));
match push_result {
Ok(false) => Ok(()), // push rejected as expected
Ok(true) => Err(format!(
"Push was accepted but should have been rejected. \
The new maintainer's state event (id={}) was deleted by coordinate, \
so commit {} should not be authorized.",
state_event.id, unique_commit
)),
Err(e) => Err(format!("Git push error: {}", e)),
}
Ok(())
})
.await
}
+89 -49
View File
@@ -1,7 +1,7 @@
/// Deletion Policy - NIP-09 event deletion request handling
///
/// Handles kind 5 (EventDeletion) events that request removal of repository
/// announcements (kind 30617) from purgatory.
/// Handles kind 5 (EventDeletion) events that request removal of purgatory entries
/// for repository announcements (kind 30617) and state events (kind 30618).
///
/// ## NIP-09 Rules Enforced
///
@@ -13,9 +13,9 @@
///
/// ## Purgatory Interaction
///
/// When a valid deletion request targets a kind 30617 announcement that is currently
/// in purgatory (not yet promoted to the database), the purgatory entry is removed
/// and the bare repository is deleted from disk.
/// - Kind 30617 (announcement) in purgatory: entry removed, bare repo deleted from disk
/// - Kind 30618 (state event) in purgatory: matching state event(s) removed by event ID
/// or by (author, identifier) coordinate
use nostr_relay_builder::prelude::{Event, WritePolicyResult};
use super::PolicyContext;
@@ -48,13 +48,13 @@ impl DeletionPolicy {
WritePolicyResult::Accept
}
/// Remove any purgatory announcements targeted by this deletion event.
/// Remove any purgatory entries targeted by this deletion event.
///
/// Handles both reference styles from NIP-09:
/// - `e` tags: event ID references — match against purgatory entry event IDs
/// - `a` tags: addressable coordinate references — `30617:<pubkey>:<identifier>`
/// - `e` tags: event ID references — match against announcement or state event IDs
/// - `a` tags: addressable coordinate references — `30617:…` or `30618:…`
///
/// Only removes entries where the purgatory entry's owner matches the deletion
/// Only removes entries where the purgatory entry's author matches the deletion
/// event's pubkey (enforces author-only deletion).
fn remove_purgatory_targets(&self, event: &Event) {
let author = &event.pubkey;
@@ -81,17 +81,19 @@ impl DeletionPolicy {
}
}
/// Remove a purgatory announcement matched by event ID.
/// Remove a purgatory entry (announcement or state event) matched by event ID.
///
/// Scans all purgatory announcements owned by `author` and removes the one
/// whose event ID hex matches `target_id_hex`.
fn remove_by_event_id(&self, author: &nostr_relay_builder::prelude::PublicKey, target_id_hex: &str, _deletion_created_at: u64) {
// Scan announcements owned by this author for a matching event ID
// We use get_announcements_by_identifier would require knowing the identifier,
// so instead we iterate via find_announcement after collecting all entries.
/// Checks announcements first (kind 30617), then state events (kind 30618).
/// Only removes entries whose author matches `author`.
fn remove_by_event_id(
&self,
author: &nostr_relay_builder::prelude::PublicKey,
target_id_hex: &str,
_deletion_created_at: u64,
) {
// --- Check announcements (kind 30617) ---
// The DashMap doesn't expose a direct "find by event ID" method, so we use
// the announcements_for_sync snapshot to get all (repo_id, _) pairs and then
// look up each one.
// the announcements_for_sync snapshot to enumerate all (repo_id, _) pairs.
let all = self.ctx.purgatory.announcements_for_sync();
for (repo_id, _) in all {
// repo_id format: "30617:{pubkey_hex}:{identifier}"
@@ -102,7 +104,6 @@ impl DeletionPolicy {
let entry_pubkey_hex = parts[1];
let identifier = parts[2];
// Only check entries owned by the deletion event author
if entry_pubkey_hex != author.to_hex() {
continue;
}
@@ -116,18 +117,37 @@ impl DeletionPolicy {
"Deletion request: removing purgatory announcement by event ID"
);
self.evict_purgatory_entry(author, identifier);
return; // event IDs are unique, no need to continue
return; // event IDs are unique
}
}
}
// --- Check state events (kind 30618) ---
// State events are keyed by identifier; scan all identifiers for a match.
let state_identifiers = self.ctx.purgatory.get_all_identifiers();
for identifier in state_identifiers {
let entries = self.ctx.purgatory.find_state(&identifier);
for entry in entries {
if entry.author == *author && entry.event.id.to_hex() == target_id_hex {
tracing::info!(
event_id = %target_id_hex,
identifier = %identifier,
author = %author.to_hex(),
"Deletion request: removing purgatory state event by event ID"
);
self.ctx.purgatory.remove_state_event(&identifier, &entry.event.id);
return; // event IDs are unique
}
}
}
}
/// Remove a purgatory announcement matched by addressable coordinate.
/// Remove a purgatory entry matched by addressable coordinate.
///
/// The coordinate format is `<kind>:<pubkey>:<d-identifier>`. Only kind 30617
/// coordinates are relevant here. Per NIP-09, all versions up to `deletion_created_at`
/// are considered deleted — since purgatory entries are always a single event per
/// (owner, identifier), we delete if the entry's `created_at` ≤ `deletion_created_at`.
/// The coordinate format is `<kind>:<pubkey>:<d-identifier>`.
/// Handles kind 30617 (announcements) and kind 30618 (state events).
///
/// Per NIP-09, all versions up to `deletion_created_at` are considered deleted.
fn remove_by_coordinate(
&self,
author: &nostr_relay_builder::prelude::PublicKey,
@@ -144,11 +164,6 @@ impl DeletionPolicy {
let coord_pubkey_hex = parts[1];
let identifier = parts[2];
// Only handle kind 30617 (GitRepoAnnouncement)
if kind_str != "30617" {
return;
}
// The coordinate pubkey must match the deletion event author
if coord_pubkey_hex != author.to_hex() {
tracing::debug!(
@@ -159,25 +174,50 @@ impl DeletionPolicy {
return;
}
if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
// Per NIP-09: delete all versions up to deletion_created_at
if entry.event.created_at.as_secs() <= deletion_created_at {
tracing::info!(
identifier = %identifier,
author = %author.to_hex(),
entry_created_at = entry.event.created_at.as_secs(),
deletion_created_at = %deletion_created_at,
"Deletion request: removing purgatory announcement by coordinate"
);
self.evict_purgatory_entry(author, identifier);
} else {
tracing::debug!(
identifier = %identifier,
author = %author.to_hex(),
entry_created_at = entry.event.created_at.as_secs(),
deletion_created_at = %deletion_created_at,
"Ignoring deletion: purgatory entry is newer than deletion request"
);
match kind_str {
"30617" => {
// Announcement purgatory entry
if let Some(entry) = self.ctx.purgatory.find_announcement(author, identifier) {
if entry.event.created_at.as_secs() <= deletion_created_at {
tracing::info!(
identifier = %identifier,
author = %author.to_hex(),
"Deletion request: removing purgatory announcement by coordinate"
);
self.evict_purgatory_entry(author, identifier);
} else {
tracing::debug!(
identifier = %identifier,
author = %author.to_hex(),
"Ignoring deletion: purgatory announcement is newer than deletion request"
);
}
}
}
"30618" => {
// State event purgatory entries for this (author, identifier).
// Remove all entries authored by `author` with created_at ≤ deletion_created_at.
let entries = self.ctx.purgatory.find_state(identifier);
let mut removed = 0usize;
for entry in entries {
if entry.author == *author
&& entry.event.created_at.as_secs() <= deletion_created_at
{
self.ctx.purgatory.remove_state_event(identifier, &entry.event.id);
removed += 1;
}
}
if removed > 0 {
tracing::info!(
identifier = %identifier,
author = %author.to_hex(),
removed = %removed,
"Deletion request: removed purgatory state event(s) by coordinate"
);
}
}
_ => {
// Other kinds not handled
}
}
}
+2 -2
View File
@@ -70,8 +70,8 @@ isolated_purgatory_test!(test_state_event_accepted_for_purgatory_announcement);
// Deletion Event Tests (NIP-09)
// ============================================================
isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_announcement);
isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_announcement);
isolated_purgatory_test!(test_deletion_by_event_id_removes_purgatory_state_event);
isolated_purgatory_test!(test_deletion_by_coordinate_removes_purgatory_state_event);
// ============================================================
// State Event Purgatory Tests (already implemented)