test(node): drive the bounty mutation gates and pin fixture object format (#195)

- submit/approve/cancel_bounty become MultiPrincipalGate registry rows
  with status-appropriate bounty fixtures (claimed/submitted/open): their
  status check fires before the auth gate, so each row seeds its own
  bounty through the real API and the arm twins consume the status
- pin --object-format=sha1 in both fixture worktrees so a sha256 default
  object format cannot break the OID equality with the served sha1 repos
This commit is contained in:
t committed 2026-07-15 20:23:55 -05:00
1 parent e649d2bd23
commit 07abcc647b
3 files changed
+199 -26

No files matched your search

+6 -3
View File
@@ -404,7 +404,8 @@ fn build_branch_push_body(server_main_tip: &str, branch: &str, files: &[(&str, &
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run(&["init", "-q", "-b", "main"]);
// Pin sha1 to match the served fixture repo; init.defaultObjectFormat=sha256 breaks the ref update.
run(&["init", "-q", "-b", "main", "--object-format=sha1"]);
run(&["config", "user.email", "t@t"]);
run(&["config", "user.name", "t"]);
std::fs::write(work.join("base.txt"), "prdiff base").unwrap();
@@ -829,11 +830,13 @@ async fn deny_bearing_registry_denies_hostile_and_admits_authorized(pool: sqlx::
}
}
// One anon hostile per row, plus one signed-stranger hostile per read-gate row.
// One hostile per row (a signed stranger for the 403 gates, including the
// #195 N2 status-gated bounty rows; anon for the signature and read-gate
// rows), plus the EXTRA signed-stranger 404 each read-gate row drives.
assert_eq!(
hostile_driven,
rows.len() + readgate_rows,
"expected one anon hostile per row ({} rows) plus one signed-stranger hostile per read-gate row ({readgate_rows}), drove {hostile_driven}",
"expected one hostile per row ({} rows) plus one signed-stranger hostile per read-gate row ({readgate_rows}), drove {hostile_driven}",
rows.len()
);
// #195 (F1): every read-gate row must be probed by a signed non-reader — a
+120 -15
View File
@@ -35,6 +35,18 @@ pub struct Fixture {
/// The id (UUID) of the seeded disputable bounty, filled into the
/// `dispute_bounty` row's `{id}` placeholder (IdSource::BountyId).
pub bounty_id: String,
/// #195 (N2): one bounty per status-gated bounty mutation row. submit/approve/
/// cancel check the bounty's STATUS before their 403 auth gate, so the stranger
/// hostile only exercises the gate against a bounty in the gate-reaching
/// status, and each row's authorized twin CONSUMES that status; the rows
/// cannot share a bounty. This one stays "claimed" (create + claim) for the
/// submit_bounty row (IdSource::ClaimedBountyId).
pub claimed_bounty_id: String,
/// Driven to "submitted" (create + claim + submit) for the approve_bounty row
/// (IdSource::SubmittedBountyId).
pub submitted_bounty_id: String,
/// Left "open" (create only) for the cancel_bounty row (IdSource::OpenBountyId).
pub open_bounty_id: String,
/// The id (UUID) of the seeded issue (authored by `author`), filled into the
/// `close_issue` row's `{id}` placeholder (IdSource::IssueId).
pub issue_id: String,
@@ -123,13 +135,50 @@ impl Fixture {
// public repo (both signers can read it). Seeded over HTTP through the real
// handlers — create (creator) then claim (claimant) — because the DB seam is
// private to TestNode and a test-only seed method would be a src/ change.
let bounty_id = seed_disputable_bounty(
let bounty_id = seed_bounty_at_stage(
node,
&owner_did,
pub_repo,
&creator,
&creator_did,
&claimant,
"prober dispute bounty",
BountyStage::Claimed,
)
.await;
// #195 (N2): submit/approve/cancel check the bounty's STATUS before their
// 403 auth gate, so each row gets its OWN bounty in the gate-reaching
// status (claimed / submitted / open); the authorized twins consume that
// status, so the rows cannot share a bounty with each other or with the
// disputable one above.
let claimed_bounty_id = seed_bounty_at_stage(
node,
&owner_did,
pub_repo,
&creator,
&claimant,
"prober submit bounty",
BountyStage::Claimed,
)
.await;
let submitted_bounty_id = seed_bounty_at_stage(
node,
&owner_did,
pub_repo,
&creator,
&claimant,
"prober approve bounty",
BountyStage::Submitted,
)
.await;
let open_bounty_id = seed_bounty_at_stage(
node,
&owner_did,
pub_repo,
&creator,
&claimant,
"prober cancel bounty",
BountyStage::Open,
)
.await;
@@ -200,6 +249,9 @@ impl Fixture {
creator_did,
claimant_did,
bounty_id,
claimed_bounty_id,
submitted_bounty_id,
open_bounty_id,
issue_id,
priv_issue_id,
priv_bounty_id,
@@ -256,22 +308,38 @@ async fn seed_authored_issue(
.to_string()
}
/// Seed a bounty in the `claimed` state (creator + claimant set) so the
/// dispute_bounty creator/claimant arms reach their auth check, and return its id.
/// How far along its lifecycle a seeded bounty is driven, expressed as the real
/// API transitions that get it there. Each status-gated bounty row needs the
/// status its handler's pre-auth check demands (#195, N2): cancel needs "open",
/// submit (and dispute) need "claimed", approve needs "submitted".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BountyStage {
/// `create_bounty` only: status "open".
Open,
/// create + claim: status "claimed" (creator + claimant set).
Claimed,
/// create + claim + submit: status "submitted".
Submitted,
}
/// Seed a bounty on the (public) repo, drive it to `stage`, and return its id.
///
/// Driven through the real HTTP handlers rather than the DB: `create_bounty` mints
/// a UUID id we read back from the response, and `claim_bounty` sets the claimant.
/// Both read-gate the (public) repo, which the creator/claimant can read. The
/// bounty lands `status = "claimed"`; because its deadline has not been exceeded,
/// a creator/claimant dispute returns 400 (not 403) after passing auth — which is
/// still a Not403 twin — while a stranger is denied 403 at the auth check first.
async fn seed_disputable_bounty(
/// Driven through the real HTTP handlers rather than the DB: `create_bounty`
/// (creator) mints a UUID id we read back from the response, `claim_bounty`
/// (claimant) sets the claimant, `submit_bounty` (claimant) records a PR id.
/// All read-gate the (public) repo, which the creator/claimant can read, and the
/// claim/submit transitions pass their own auth gates as the claimant. A Claimed
/// bounty's deadline has not been exceeded, so a creator/claimant dispute returns
/// 400 (not 403) after passing auth, which is still a Not403 twin, while a
/// stranger is denied 403 at the auth check first.
async fn seed_bounty_at_stage(
node: &TestNode,
owner_did: &str,
repo: &str,
creator: &Keypair,
creator_did: &str,
claimant: &Keypair,
title: &str,
stage: BountyStage,
) -> String {
use super::signing::signed_request;
@@ -279,7 +347,7 @@ async fn seed_disputable_bounty(
// create_bounty (creator) -> 201 with the minted BountyRecord (carries the id).
let create_path = format!("/api/v1/repos/{owner_did}/{repo}/bounties");
let body = br#"{"title":"prober dispute bounty","amount":1}"#.to_vec();
let body = format!(r#"{{"title":"{title}","amount":1}}"#).into_bytes();
let resp = signed_request(
&client,
reqwest::Method::POST,
@@ -302,11 +370,15 @@ async fn seed_disputable_bounty(
.as_str()
.expect("created bounty carries an id")
.to_string();
let creator_did = creator.did().to_string();
assert_eq!(
created["creator_did"].as_str(),
Some(creator_did),
Some(creator_did.as_str()),
"seeded bounty creator must be the fixture creator"
);
if stage == BountyStage::Open {
return bounty_id;
}
// claim_bounty (claimant) -> 200, status becomes "claimed", claimant recorded.
let claim_path = format!("/api/v1/bounties/{bounty_id}/claim");
@@ -327,6 +399,30 @@ async fn seed_disputable_bounty(
200,
"seeding: bounty claim must return 200 (claimant can read the public repo)"
);
if stage == BountyStage::Claimed {
return bounty_id;
}
// submit_bounty (claimant) -> 200, status becomes "submitted". pr_id is a
// free-text column; the seed value only needs to clear serde.
let submit_path = format!("/api/v1/bounties/{bounty_id}/submit");
let resp = signed_request(
&client,
reqwest::Method::POST,
&node.base_url,
&submit_path,
br#"{"pr_id":"1"}"#.to_vec(),
claimant,
)
.header("content-type", "application/json")
.send()
.await
.expect("submit bounty sends");
assert_eq!(
resp.status().as_u16(),
200,
"seeding: bounty submit must return 200 (the claimant passes the submit gate)"
);
bounty_id
}
@@ -503,7 +599,8 @@ async fn seed_private_push_and_cert(
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
run(&["init", "-q", "-b", "main"], &work);
// Pin sha1 to match the served fixture repo; init.defaultObjectFormat=sha256 skews the OIDs.
run(&["init", "-q", "-b", "main", "--object-format=sha1"], &work);
run(&["config", "user.email", "t@t"], &work);
run(&["config", "user.name", "t"], &work);
// Keep `public/a.txt` (the seeded blob) on the new main so the get_blob /
@@ -681,6 +778,11 @@ fn fill(path: &str, fixture: &Fixture, repo: &str, id_source: IdSource) -> Strin
IdSource::PrivIssueId => fixture.priv_issue_id.as_str(),
IdSource::PrivBountyId => fixture.priv_bounty_id.as_str(),
IdSource::CertId => fixture.cert_id.as_str(),
// #195 (N2): the status-gated bounty mutations, each keyed to the bounty
// seeded in its gate-reaching status.
IdSource::ClaimedBountyId => fixture.claimed_bounty_id.as_str(),
IdSource::SubmittedBountyId => fixture.submitted_bounty_id.as_str(),
IdSource::OpenBountyId => fixture.open_bounty_id.as_str(),
};
path.replace("{owner}", &fixture.owner_did)
.replace("{repo}", repo)
@@ -876,6 +978,9 @@ pub mod tests_support {
creator,
claimant,
bounty_id: "bounty-uuid-1234".to_string(),
claimed_bounty_id: "claimed-bounty-uuid-1234".to_string(),
submitted_bounty_id: "submitted-bounty-uuid-1234".to_string(),
open_bounty_id: "open-bounty-uuid-1234".to_string(),
issue_id: "issue-uuid-1234".to_string(),
priv_issue_id: "priv-issue-uuid-1234".to_string(),
priv_bounty_id: "priv-bounty-uuid-1234".to_string(),
+73 -8
View File
@@ -22,12 +22,14 @@ pub enum GateClass {
/// `require_repo_owner` / `require_owner` / inline `did_matches(caller, owner)`
/// -> `AppError::Forbidden` == 403. Probed with a validly-signed non-owner.
OwnerGate,
/// A 403 gate with MORE THAN ONE authorizing principal (owner-OR-author,
/// creator-OR-claimant, …). Probed with a validly-signed stranger (403) plus
/// one Not403 twin per declared arm (`Row.principals`), so reverting ANY
/// single arm turns that arm's twin RED. A single-arm `OwnerGate` cannot
/// express this: it tests one identity, so the untested arm is invisible
/// (#195, F1).
/// A 403 gate with ONE OR MORE NAMED authorizing principals (owner-OR-author,
/// creator-OR-claimant, claimant-only, …). Probed with a validly-signed
/// stranger (403) plus one Not403 twin per declared arm (`Row.principals`),
/// so reverting ANY single arm turns that arm's twin RED (#195, F1).
/// `OwnerGate` is the owner-arm special case; a single-arm gate whose
/// principal is NOT the repo owner (submit_bounty's claimant-only check,
/// approve/cancel's creator-only check) belongs here, because the point is
/// that the arms are DECLARED and each one gets its own twin (#195, N2).
MultiPrincipalGate,
/// `authorize_repo_read` / `visibility_check` -> `RepoNotFound` == 404
/// (existence-hiding). Probed with a non-reader against a private/withheld
@@ -109,6 +111,22 @@ pub enum IdSource {
/// then fetches the cert by id, so the owner-2xx twin needs a real cert whose
/// repo_id is the private repo's (#195, U3).
CertId,
/// `{id}` -> the fixture's bounty seeded in status "claimed" (#195, N2).
/// submit_bounty checks status BEFORE auth (bounties.rs:294), so the stranger
/// only reaches the claimant 403 gate on a bounty that is already "claimed";
/// the claimant twin then consumes that status (claimed -> submitted), so the
/// row needs its own bounty.
ClaimedBountyId,
/// `{id}` -> the fixture's bounty driven to status "submitted" (#195, N2).
/// approve_bounty's status check fires before its creator gate
/// (bounties.rs:334), so only a "submitted" bounty reaches the 403; the
/// creator twin consumes it (submitted -> completed).
SubmittedBountyId,
/// `{id}` -> the fixture's bounty left in status "open" (#195, N2).
/// cancel_bounty's status check fires before its creator gate
/// (bounties.rs:381), so only an "open" bounty reaches the 403; the creator
/// twin consumes it (open -> cancelled).
OpenBountyId,
}
/// One deny-bearing route. `path` is a template with `{owner}`/`{repo}`/`{id}`
@@ -197,6 +215,52 @@ pub fn deny_bearing_routes() -> &'static [Row] {
principals: &[Principal::Creator, Principal::Claimant],
id_source: IdSource::BountyId,
},
// #195 (N2): submit/approve/cancel each 403 a non-claimant/non-creator, but
// their STATUS check fires before the auth check, so each row gets its own
// bounty seeded in the gate-reaching status (claimed / submitted / open).
// The sweep drives probes in emission order (hostile first, probes_for),
// so the stranger 403 fires while the bounty still holds that status; the
// arm twin then consumes it (submit: claimed -> submitted, approve:
// submitted -> completed, cancel: open -> cancelled), which is why the
// rows cannot share a bounty with each other or with dispute_bounty.
Row {
method: "POST",
path: "/api/v1/bounties/{id}/submit",
gate: GateClass::MultiPrincipalGate,
handler: "bounties::submit_bounty",
// pr_id is a free-text column (no FK); any string clears serde and
// the UPDATE. The gate fires before the value is used.
body: Some(r#"{"pr_id":"1"}"#),
needs: &["claimed_bounty_id"],
reach: Reach::None,
principals: &[Principal::Claimant],
id_source: IdSource::ClaimedBountyId,
},
Row {
method: "POST",
path: "/api/v1/bounties/{id}/approve",
gate: GateClass::MultiPrincipalGate,
handler: "bounties::approve_bounty",
// ApproveBountyRequest's only field (tx_hash) is Option, so an empty
// JSON object clears the Json extractor; a missing body would 4xx at
// the extractor, before the gate under test.
body: Some(r#"{}"#),
needs: &["submitted_bounty_id"],
reach: Reach::None,
principals: &[Principal::Creator],
id_source: IdSource::SubmittedBountyId,
},
Row {
method: "POST",
path: "/api/v1/bounties/{id}/cancel",
gate: GateClass::MultiPrincipalGate,
handler: "bounties::cancel_bounty",
body: None,
needs: &["open_bounty_id"],
reach: Reach::None,
principals: &[Principal::Creator],
id_source: IdSource::OpenBountyId,
},
Row {
method: "POST",
path: "/api/v1/repos/{owner}/{repo}/hooks",
@@ -804,8 +868,9 @@ mod tests {
);
}
assert!(
checked >= 3,
"expected at least the three known multi-principal rows, checked {checked}"
checked >= 6,
"expected at least the six known multi-principal rows (close_pr, \
close_issue, dispute/submit/approve/cancel bounty), checked {checked}"
);
}
}