mirror of
https://relay.ngit.dev/npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/ngit-grasp.git
synced 2026-10-05 15:08:24 +00:00
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.
805 lines
26 KiB
Rust
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;
|
|
}
|