use anyhow::{Context, Result};
use nostr_sdk::prelude::*;
use test_harness::{
Harness, KIND_PULL_REQUEST, KIND_REPO_STATE, PublishPrOpts, tag_values_multiple,
};
const KIND_LABEL: Kind = Kind::Custom(1985);
#[tokio::test]
async fn acknowledged_moderator_can_label_and_set_status() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_lead_repo, graph) = harness
.publish_repo_with_role_graph("moderator-member-actions")
.await?;
let moderator_pubkey = graph.moderator_keys.public_key();
let pr = harness
.publish_pr(
&graph.published,
PublishPrOpts {
branch: None,
commits: vec![],
title: "a proposal for the moderator to act on".to_string(),
description: "moderator authorization regression".to_string(),
in_reply_to: vec![],
},
)
.await?;
let moderator_coordinate = format!(
"30617:{}:{}",
moderator_pubkey.to_hex(),
graph.published.identifier,
);
let a_tags = tag_values_multiple(&pr.root_event, "a");
assert!(
a_tags.contains(&moderator_coordinate),
"the PR's a tags should include the moderator's coordinate; got {a_tags:?}",
);
let moderator_clone = harness
.clone_published_repo_as(&graph.published, &graph.moderator_keys)
.await?;
let pr_id = pr.event_id.to_hex();
let label = moderator_clone
.ngit(["pr", "label", &pr_id, "--label", "bug"])
.output()
.await
.context("failed to spawn ngit pr label as moderator")?;
assert!(
label.status.success(),
"ngit pr label as an acknowledged moderator exited non-zero ({:?})\nstdout: {}\nstderr: {}",
label.status,
String::from_utf8_lossy(&label.stdout),
String::from_utf8_lossy(&label.stderr),
);
let label_events = harness
.relay("default")
.events(Filter::new().author(moderator_pubkey).kind(KIND_LABEL))
.await?;
assert!(
label_events
.iter()
.any(|e| tag_values_multiple(e, "e").contains(&pr_id)),
"expected a moderator-signed kind-1985 label event referencing the PR; got {label_events:?}",
);
let close = moderator_clone
.ngit(["pr", "close", &pr_id, "--reason", "closed by moderator"])
.output()
.await
.context("failed to spawn ngit pr close as moderator")?;
assert!(
close.status.success(),
"ngit pr close as an acknowledged moderator exited non-zero ({:?})\nstdout: {}\nstderr: {}",
close.status,
String::from_utf8_lossy(&close.stdout),
String::from_utf8_lossy(&close.stderr),
);
let status_events = harness
.relay("default")
.events(
Filter::new()
.author(moderator_pubkey)
.kind(Kind::GitStatusClosed),
)
.await?;
assert!(
status_events
.iter()
.any(|e| tag_values_multiple(e, "e").contains(&pr_id)),
"expected a moderator-signed closed-status event referencing the PR; got {status_events:?}",
);
Ok(())
}
#[tokio::test]
async fn unacknowledged_moderator_cannot_set_status() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_lead_repo, graph) = harness
.publish_repo_with_role_graph("moderator-unacknowledged")
.await?;
let unack_pubkey = graph.unacknowledged_moderator_keys.public_key();
let pr = harness
.publish_pr(
&graph.published,
PublishPrOpts {
branch: None,
commits: vec![],
title: "a proposal the invited moderator may not close".to_string(),
description: "moderator authorization regression".to_string(),
in_reply_to: vec![],
},
)
.await?;
let unack_clone = harness
.clone_published_repo_as(&graph.published, &graph.unacknowledged_moderator_keys)
.await?;
let pr_id = pr.event_id.to_hex();
let close = unack_clone
.ngit(["pr", "close", &pr_id, "--reason", "not my call"])
.output()
.await
.context("failed to spawn ngit pr close as unacknowledged moderator")?;
assert!(
!close.status.success(),
"an assigned-but-unacknowledged moderator must not be able to change status\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&close.stdout),
String::from_utf8_lossy(&close.stderr),
);
let stderr = String::from_utf8_lossy(&close.stderr).to_lowercase();
assert!(
stderr.contains("repository member"),
"expected the member-authorization guard's error, got: {stderr}",
);
let status_events = harness
.relay("default")
.events(Filter::new().author(unack_pubkey).kinds(vec![
Kind::GitStatusOpen,
Kind::GitStatusApplied,
Kind::GitStatusClosed,
Kind::GitStatusDraft,
]))
.await?;
assert!(
status_events.is_empty(),
"the refused status change must not have published anything: {status_events:?}",
);
Ok(())
}
#[tokio::test]
async fn moderator_cannot_push_state() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_lead_repo, graph) = harness
.publish_repo_with_role_graph("moderator-push-state")
.await?;
let moderator_pubkey = graph.moderator_keys.public_key();
let moderator_clone = harness
.clone_published_repo_as(&graph.published, &graph.moderator_keys)
.await?;
std::fs::write(
moderator_clone.dir().join("moderator.md"),
"a change the moderator may not land directly\n",
)?;
moderator_clone
.git_ok(["add", "moderator.md"], "git add moderator.md")
.await?;
moderator_clone
.git_ok(
["commit", "-m", "moderator commit", "--no-gpg-sign"],
"git commit (moderator)",
)
.await?;
let out = moderator_clone
.nostr_push_expecting_failure(["origin", "main"])
.await?;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
.to_lowercase();
assert!(
combined.contains("not a confirmed maintainer"),
"the rejected push should cite the confirmed-maintainer check, got: {combined}",
);
let state_events = harness
.grasp("repo")
.events(Filter::new().author(moderator_pubkey).kind(KIND_REPO_STATE))
.await?;
assert!(
state_events.is_empty(),
"a moderator must never produce an authoritative state event: {state_events:?}",
);
Ok(())
}
#[tokio::test]
async fn moderator_mixed_push_rejects_branch_but_delivers_proposal() -> Result<()> {
let harness = Harness::builder(
env!("CARGO_BIN_EXE_ngit"),
env!("CARGO_BIN_EXE_git-remote-nostr"),
)
.with_relay("default")
.with_grasp_server("repo")
.build()
.await?;
let (_lead_repo, graph) = harness
.publish_repo_with_role_graph("moderator-mixed-push")
.await?;
let moderator_pubkey = graph.moderator_keys.public_key();
let moderator_clone = harness
.clone_published_repo_as(&graph.published, &graph.moderator_keys)
.await?;
moderator_clone
.git_ok(
["checkout", "-b", "pr/moderator-suggestion"],
"git checkout -b pr/moderator-suggestion",
)
.await?;
std::fs::write(
moderator_clone.dir().join("suggestion.md"),
"a change offered as a proposal\n",
)?;
moderator_clone
.git_ok(["add", "suggestion.md"], "git add suggestion.md")
.await?;
moderator_clone
.git_ok(
["commit", "-m", "moderator suggestion", "--no-gpg-sign"],
"git commit (proposal branch)",
)
.await?;
moderator_clone
.git_ok(["checkout", "main"], "git checkout main")
.await?;
std::fs::write(
moderator_clone.dir().join("moderator.md"),
"a change the moderator may not land directly\n",
)?;
moderator_clone
.git_ok(["add", "moderator.md"], "git add moderator.md")
.await?;
moderator_clone
.git_ok(
["commit", "-m", "moderator commit", "--no-gpg-sign"],
"git commit (main)",
)
.await?;
let out = moderator_clone
.nostr_push_expecting_failure(["origin", "main", "pr/moderator-suggestion"])
.await?;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
)
.to_lowercase();
assert!(
combined.contains("not a confirmed maintainer"),
"the branch refspec should be rejected by the confirmed-maintainer check, got: {combined}",
);
let proposal_events = harness
.relay("default")
.events(
Filter::new()
.author(moderator_pubkey)
.kinds(vec![Kind::GitPatch, KIND_PULL_REQUEST]),
)
.await?;
assert!(
!proposal_events.is_empty(),
"the pr/ refspec pushed alongside the rejected branch must still produce its proposal events",
);
for events in [
harness
.grasp("repo")
.events(Filter::new().author(moderator_pubkey).kind(KIND_REPO_STATE))
.await?,
harness
.relay("default")
.events(Filter::new().author(moderator_pubkey).kind(KIND_REPO_STATE))
.await?,
] {
assert!(
events.is_empty(),
"a moderator must never produce an authoritative state event: {events:?}",
);
}
Ok(())
}