Files
ngit-grasp/tests/grasp06_pr_hosting.rs
DanConwayDev c1255eac48 fix(grasp06): recover placeholder scope from a surviving ref
A crash can leave a /prs/ ref installed without its purgatory checkpoint.
Recover the scope from the signed event's service-local clone URL, signer,
identifier, and exact event-id ref and commit. This lets git-first PR events
complete after restart without relaxing URL or commit validation.

Assume the signed local URL names the intended contributor repository.
Standard endpoint recovery and object staging are separate changes.

Validation: nix develop -c cargo test --test grasp06_pr_hosting
pr_event_after_crash_recovers_prs_placeholder_scope passed.

Assisted-by: GPT-6
2026-09-29 09:16:41 +00:00

423 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GRASP-06 contributor PR hosting integration tests
//!
//! Spec: <https://gitworkshop.dev/danconwaydev.com/grasp/tree/master/06.md>
//!
//! Each test is driven by one of two macros depending on whether the relay
//! under test has GRASP-06 enabled:
//!
//! - [`isolated_test_no_grasp_06!`] — relay started with the default config
//! (GRASP-06 disabled). Tests assert the "off" contract: NIP-11 must not
//! advertise GRASP-06, and `/prs/*` must 404.
//! - [`isolated_test_with_grasp_06!`] — relay started with
//! `NGIT_GRASP06_ENABLE=true`. Tests assert the "on" contract.
//!
//! ## TDD
//!
//! These tests are written before the implementation. They are expected to
//! pass against the current (no-GRASP-06) relay because the discovery-gate
//! contract is satisfied trivially when neither NIP-11 advertisement nor
//! `/prs/` routing exists. They become regression guards once the feature
//! lands — if a future change enables `/prs/` without updating NIP-11
//! (or vice versa), these tests will catch it.
//!
//! ## Running
//!
//! ```bash
//! cargo test --test grasp06_pr_hosting
//! cargo test --test grasp06_pr_hosting -- --nocapture
//! ```
mod common;
use common::TestRelay;
use grasp_audit::*;
/// Generate an integration test that runs against a fresh relay started with
/// the default config (GRASP-06 disabled).
macro_rules! isolated_test_no_grasp_06 {
($test_name:ident, $spec_suite:ident :: $test_fn:ident) => {
#[tokio::test]
async fn $test_name() {
let relay = TestRelay::start().await;
let config = AuditConfig::isolated();
let client = AuditClient::new(relay.url(), config)
.await
.expect("Failed to create audit client");
let result = specs::$spec_suite::$test_fn(&client).await;
relay.stop().await;
assert!(
result.passed,
"{} failed: {}",
stringify!($test_name),
result.error.as_deref().unwrap_or("unknown error")
);
}
};
}
/// Generate an integration test that runs against a fresh relay started with
/// `NGIT_GRASP06_ENABLE=true`.
macro_rules! isolated_test_with_grasp_06 {
($test_name:ident, $spec_suite:ident :: $test_fn:ident) => {
#[tokio::test]
async fn $test_name() {
let relay = TestRelay::start_with_grasp_06_enabled().await;
let config = AuditConfig::isolated();
let client = AuditClient::new(relay.url(), config)
.await
.expect("Failed to create audit client");
let result = specs::$spec_suite::$test_fn(&client).await;
relay.stop().await;
assert!(
result.passed,
"{} failed: {}",
stringify!($test_name),
result.error.as_deref().unwrap_or("unknown error")
);
}
};
}
// =============================================================================
// Test 1: Discovery gate — /prs/ MUST 404 when GRASP-06 is not advertised
// =============================================================================
//
// Spec: GRASP-06 06.md (audit-derived from NIP-11 capability semantics)
//
// Contract: if NIP-11 does NOT list "GRASP-06" in supported_grasps, the
// /prs/<npub>/<id>.git namespace MUST return 404. This lets clients rely on
// NIP-11 alone for capability discovery.
//
// Wired only as `no_grasp_06`: the test asserts the "off" contract. The
// matching "on" contract (NIP-11 advertises when feature is enabled) is a
// distinct invariant and lives in its own test below.
isolated_test_no_grasp_06!(
test_prs_namespace_404_when_grasp06_not_advertised_no_grasp_06,
PrsEndpointTests::test_prs_namespace_404_when_grasp06_not_advertised
);
// =============================================================================
// Test 2: NIP-11 MUST advertise GRASP-06 when the feature is enabled
// =============================================================================
//
// Spec: implementation plan Phase 9 (positive companion to test 1)
//
// Contract: a relay started with NGIT_GRASP06_ENABLE=true MUST include
// "GRASP-06" in its NIP-11 supported_grasps array. Without this, the
// capability is invisible to clients and /prs/ is effectively unreachable.
//
// Wired only as `with_grasp_06`. Pre-implementation this test FAILS (TDD
// red) — the relay binary ignores the env var and NIP-11 doesn't advertise.
// Once Phase 9 lands the test turns green and becomes the regression guard.
isolated_test_with_grasp_06!(
test_nip11_advertises_grasp_06_when_enabled,
Nip11Tests::test_nip11_advertises_grasp_06_when_enabled
);
// =============================================================================
// Test 3: Empty-bare-repo fetch for any well-formed /prs/<npub>/<id>.git
// =============================================================================
//
// Spec: GRASP-06 06.md line 11
//
// Contract: when GRASP-06 is enabled on the relay, a fetch against any
// well-formed /prs/<npub>/<id>.git path that has no accepted
// refs/nostr/<event-id> MUST respond as if serving an empty bare repository.
// In practice: `git clone` must succeed and the resulting working copy must
// have zero refs.
//
// Wired only as `with_grasp_06`. The test asserts the invariant
// unconditionally — gating on NIP-11 advertisement is a caller concern, not
// part of the spec assertion. Pre-implementation this FAILS (TDD red): the
// relay has no `/prs/` route, so the clone gets 404. Once the feature lands
// and the empty-repo synthesis is implemented, this turns green.
isolated_test_with_grasp_06!(
test_prs_fetch_unknown_path_serves_empty_repo_with_grasp_06,
PrsEndpointTests::test_prs_fetch_unknown_path_serves_empty_repo
);
// =============================================================================
// Test 4: /prs/ MUST accept pushes to refs/nostr/<event-id>
// =============================================================================
//
// Spec: GRASP-06 06.md line 13
//
// Contract: a `git push` of refs/nostr/<event-id> to /prs/<npub>/<id>.git
// MUST succeed, even when there is no prior state on the path and no matching
// PR/PR-Update event in the relay's DB. (Whether the ref later gets GC'd by
// the spec's 20-minute SHOULD is out of scope here — only the push acceptance
// contract is asserted.)
//
// Wired only as `with_grasp_06`. Pre-implementation this FAILS — the relay
// has no /prs/ receive-pack handler, so the push gets 404. Once Phase 3+4
// land (routing + receive handler) it goes green.
isolated_test_with_grasp_06!(
test_prs_push_refs_nostr_event_id_accepted_with_grasp_06,
PrsEndpointTests::test_prs_push_refs_nostr_event_id_accepted
);
// =============================================================================
// Test 5: /prs/ MUST reject pushes to anything else
// =============================================================================
//
// Spec: GRASP-06 06.md line 13
//
// Contract: pushes to ref names that don't match refs/nostr/<64-hex-event-id>
// MUST be rejected. Two sub-assertions inside one test:
// - refs/heads/main (wrong top-level namespace)
// - refs/nostr/<not-a-valid-id> (right prefix, wrong event-id shape)
//
// Wired only as `with_grasp_06`. Pre-implementation this also FAILS as a 404,
// but the test stays meaningful once /prs/ exists: it catches a regression
// where the receive handler exists but the ref-name validator is missing or
// over-permissive.
isolated_test_with_grasp_06!(
test_prs_push_other_refs_rejected_with_grasp_06,
PrsEndpointTests::test_prs_push_other_refs_rejected
);
// =============================================================================
// Test 6: PR for un-announced coord MUST be accepted when clone tag names /prs/
// =============================================================================
//
// Spec: GRASP-06 06.md lines 19–22
//
// Contract: a PR event (kind 1618) for a coord this relay has NO accepted
// announcement for MUST be accepted (purgatory or served) when the event
// carries:
// - an `a` tag of the form `30617:<pubkey>:<identifier>`, AND
// - a `clone` tag naming this relay's /prs/<signer-npub>/<identifier>.git
// endpoint.
//
// Wired only as `with_grasp_06`. Pre-implementation this FAILS (TDD red) —
// the relay's GRASP-01 PR policy rejects PR events without a matching
// accepted announcement, and the relaxation branch (Phase 6) is not yet
// implemented. Once that branch lands and the relaxation correctly fires
// for matching `clone` tags, this turns green.
isolated_test_with_grasp_06!(
test_pr_event_accepted_when_clone_tag_names_prs_endpoint_with_grasp_06,
EventAcceptanceTests::test_pr_event_accepted_when_clone_tag_names_prs_endpoint
);
// =============================================================================
// Test 6b: Relaxation-accepted PR MUST stay in purgatory until git data arrives
// =============================================================================
//
// Spec: GRASP-06 06.md lines 19–22, in combination with GRASP-01 line 20
// (the purgatory rule).
//
// Contract: a PR event accepted under the GRASP-06 relaxation (un-announced
// coord, clone tag names this relay's /prs/ endpoint) MUST be held in
// purgatory and MUST NOT be broadcast until the matching `refs/nostr/<id>`
// push arrives. The acceptance message must be `OK true "purgatory: ..."`,
// not `OK true` for an immediately served event.
//
// Why a separate test from 6: test 6 only asserts the relay returned OK,
// discarding the served-vs-purgatory bit. A future regression that
// short-circuited the relaxation to `WritePolicyResult::Accept` would still
// pass test 6 but quietly leak orphan PR events without their git data.
// This test guards that boundary.
//
// Wired only as `with_grasp_06`. Once the relaxation is in place this is
// green and stays green as long as the relaxation routes through purgatory.
isolated_test_with_grasp_06!(
test_pr_event_accepted_via_relaxation_is_held_in_purgatory_with_grasp_06,
EventAcceptanceTests::test_pr_event_accepted_via_relaxation_is_held_in_purgatory
);
// =============================================================================
// Test 7: Relaxation MUST NOT apply when clone tag does not name this relay
// =============================================================================
//
// Spec: GRASP-06 06.md line 22
//
// Contract: the same un-announced-coord PR event, but with a `clone` tag
// pointing at a foreign host, MUST remain rejected. The relaxation is gated
// on the `clone` tag naming this specific relay's /prs/ endpoint — without
// that check, every GRASP-06 relay would accept every matching PR event,
// fanning out abuse across the network.
//
// Wired only as `with_grasp_06`. Today this test PASSES trivially —
// GRASP-01's PR policy already rejects PR events without a matching
// accepted announcement, regardless of clone-tag content. Once Phase 6's
// relaxation branch lands the test stays green: the relaxation must check
// the clone tag's host and decline to fire when it doesn't match. If a
// future change accidentally widens the relaxation, this test will catch
// it.
//
// In the audit CLI report this test is reported as Skipped when GRASP-06
// isn't advertised: a rejection there can only mean GRASP-01 rejected, not
// that the GRASP-06 host-check is wired correctly. Only meaningful with
// the feature enabled.
isolated_test_with_grasp_06!(
test_pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint_with_grasp_06,
EventAcceptanceTests::test_pr_event_rejected_when_clone_tag_does_not_name_prs_endpoint
);
// =============================================================================
// Test 8: /prs/ push MUST be mirrored into the announced repo
// =============================================================================
//
// Spec: design-doc "Cross-service mirror" — refs accepted via /prs/<...>/<id>.git
// MUST be mirrored into any accepted-announcement repos on this relay whose
// coord appears in the PR event's `a` tag. The mirror is what makes a
// contributor PR submitted via /prs/ visible at the maintainer's URL.
//
// Wired only as `with_grasp_06`. Pre-implementation this FAILS — the /prs/
// push gets 404 so there's nothing to mirror. Once Phases 4 + 7 land
// (receive-pack + mirror hook), the test goes green.
isolated_test_with_grasp_06!(
test_prs_push_mirrors_to_announced_repo_with_grasp_06,
MirroringTests::test_prs_push_mirrors_to_announced_repo
);
// =============================================================================
// Test 8b: git-first ordering — PR event arriving after a matching /prs/ push
// MUST be promoted from purgatory and mirrored
// =============================================================================
//
// Spec: design doc `docs/explanation/grasp-06-contributor-pr-submission.md`
// "Git-first" flow (lines 204–218).
//
// Contract: in the reverse ordering of test 8 (push first, event second),
// the mirror contract still holds. When the PR event arrives matching a
// scoped placeholder created by an earlier `/prs/` push, the relay MUST
// promote the event out of purgatory (save to DB, broadcast) AND fire the
// cross-service mirror into matching announced repos. Test 8 alone does
// not cover this: the event-first path triggers promotion via
// `process_newly_available_git_data` on push, while git-first relies on the
// PR-event policy doing the same work on event arrival.
//
// Wired only as `with_grasp_06`. Today this fails: the policy in
// `src/nostr/policy/pr_event.rs::git_data_check` matches the scoped
// placeholder but then falls through to `find_relevant_repo_paths` which
// returns empty for un-announced coords; the function returns `Ok(false)`
// and the builder re-purgatorises the event without mirroring. Once that
// gap is closed, the test goes green and stays green as the regression
// guard for git-first promotion.
isolated_test_with_grasp_06!(
test_prs_push_then_pr_event_promotes_and_mirrors_with_grasp_06,
MirroringTests::test_prs_push_then_pr_event_promotes_and_mirrors
);
// =============================================================================
// Test 9: Standard endpoint push MUST NOT mirror into /prs/
// =============================================================================
//
// Spec: design-doc "Cross-service mirror", reverse direction. /prs/ is a
// contributor-submission side-channel; it must never carry refs that the
// maintainer pushed to their own standard `<npub>/<id>.git` endpoint.
//
// Reuses the existing PREvent2Served fixture — that fixture already drives
// the full standard-endpoint PR cycle (event published, refs/nostr/<id>
// pushed via `<owner>/<repo-id>.git`, event served). All this test does on
// top of that is `git ls-remote` the contributor's /prs/ URL with a
// refname filter and assert empty.
//
// Wired only as `with_grasp_06`. Pre-implementation this can't make its
// assertion (the /prs/ endpoint 404s) and reports a setup failure with a
// pointer to the 06.md line 11 endpoint-reachability requirement. Once
// /prs/ becomes reachable, the test goes green and stays green as long as
// no one adds a reverse-mirror branch.
isolated_test_with_grasp_06!(
test_standard_push_does_not_mirror_to_prs_with_grasp_06,
MirroringTests::test_standard_push_does_not_mirror_to_prs
);
// =============================================================================
// Test 10: commit mismatch MUST delete the ref and MUST NOT promote the event
// =============================================================================
//
// Spec: design-doc push semantics table, line 96:
// "commit ≠ event's c tag → delete ref"
//
// Contract: when a push arrives at /prs/<npub>/<id>.git with a commit that
// does NOT match the `c` tag of the matching PR event, the relay MUST:
// 1. Delete the ref (refs/nostr/<event-id> must be absent afterwards).
// 2. NOT promote the event out of purgatory.
//
// This is the correctness invariant that makes refs/nostr/<event-id>
// self-verifying: the ref name is the event-id, the event's `c` tag pins
// the commit, and the relay enforces the binding. Without this check an
// attacker could substitute arbitrary commits under a foreign event-id.
//
// Wired only as `with_grasp_06`. Pre-implementation this fails in one of
// two ways: if the /prs/ endpoint doesn't exist the push fails and the
// ls-remote check trivially passes (but the test surfaces a setup error);
// if the endpoint exists but the mismatch check is missing, the ref
// survives and the test fails. Once the mismatch branch is implemented
// correctly, the test goes green and stays green as the regression guard.
isolated_test_with_grasp_06!(
test_commit_mismatch_deletes_ref_and_blocks_promotion_with_grasp_06,
PushValidationTests::test_commit_mismatch_deletes_ref_and_blocks_promotion
);
/// A crash can lose the purgatory checkpoint after a `/prs/` push installed
/// its ref. The arriving PR event must still be matched to that push, using
/// only its signed, service-local clone URL and the exact event-id ref.
#[tokio::test]
async fn pr_event_after_crash_recovers_prs_placeholder_scope() {
use nostr_sdk::prelude::*;
let persistent = tempfile::tempdir().unwrap();
let relay = TestRelay::start_with_grasp_06_paths(
persistent.path().join("git"),
persistent.path().join("relay"),
)
.await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.unwrap();
let keys = client.keys().clone();
let npub = keys.public_key().to_bech32().unwrap();
let identifier = "crash-recovered-pr";
let local = tempfile::tempdir().unwrap();
let commit =
common::create_test_repo_with_commit(local.path(), common::CommitVariant::PrTest).unwrap();
let clone_url = format!("http://{}/prs/{npub}/{identifier}.git", relay.domain());
let event = common::create_pr_event_with_clone(
&keys,
&format!("30617:{}:{identifier}", keys.public_key().to_hex()),
&commit,
"Crash-recovered PR",
&[&clone_url],
)
.unwrap();
common::push_ref_to_relay(
local.path(),
&relay.domain(),
&format!("prs/{npub}"),
identifier,
&commit,
&format!("refs/nostr/{}", event.id),
)
.unwrap();
// Killing the relay loses any placeholder not yet checkpointed.
let relay = relay.restart().await;
let client = AuditClient::new(relay.url(), AuditConfig::isolated())
.await
.unwrap();
client.send_event(event.clone()).await.unwrap();
common::wait_for_event_served(relay.url(), &event.id, std::time::Duration::from_secs(10))
.await
.unwrap();
relay.stop().await;
}