@@ -14732,6 +14732,192 @@ mod tests {
1473214732 assert!(resp.status().is_success());
1473314733 }
1473414734
14735+ // ── #341: the id-keyed bounty mutations must gate on repo read before the
14736+ // status and participant checks, so a caller holding an opaque id cannot
14737+ // distinguish an existing private-repo bounty from an absent one. The deny
14738+ // shape is 404 with a "not found" body, identical to the absent-id case.
14739+
14740+ fn seed_bounty(
14741+ id: &str,
14742+ owner: &str,
14743+ repo: &str,
14744+ status: &str,
14745+ claimant: Option<&str>,
14746+ ) -> crate::db::BountyRecord {
14747+ crate::db::BountyRecord {
14748+ id: id.into(),
14749+ repo_owner: owner.into(),
14750+ repo_name: repo.into(),
14751+ issue_id: None,
14752+ title: "Bounty".into(),
14753+ amount: 100,
14754+ creator_did: owner.into(),
14755+ claimant_did: claimant.map(str::to_string),
14756+ claimant_wallet: None,
14757+ pr_id: None,
14758+ status: status.into(),
14759+ created_at: "2026-01-01T00:00:00Z".into(),
14760+ claimed_at: claimant.map(|_| "2026-01-02T00:00:00Z".into()),
14761+ submitted_at: None,
14762+ completed_at: None,
14763+ deadline_secs: 86400,
14764+ tx_hash: None,
14765+ }
14766+ }
14767+
14768+ fn signed_bounty_post(kp: &Keypair, uri: &str, body: &[u8]) -> Request<Body> {
14769+ let signed = gitlawb_core::http_sig::sign_request(kp, "POST", uri, body);
14770+ Request::builder()
14771+ .method(Method::POST)
14772+ .uri(uri)
14773+ .header("content-type", "application/json")
14774+ .header("content-digest", signed.content_digest)
14775+ .header("signature-input", signed.signature_input)
14776+ .header("signature", signed.signature)
14777+ .body(Body::from(body.to_vec()))
14778+ .unwrap()
14779+ }
14780+
14781+ /// Drives one bounty mutation route as a stranger against a private repo and
14782+ /// asserts the denial is indistinguishable from the absent-id case: same
14783+ /// 404 status, same "not found" body shape, no status or participant detail.
14784+ /// `bounty_status` is whichever lifecycle state reaches the route's
14785+ /// participant check today, so the assertion exercises the deepest leak.
14786+ async fn assert_bounty_mutation_denies_stranger_on_private(
14787+ pool: PgPool,
14788+ route: &str,
14789+ bounty_status: &str,
14790+ body: &[u8],
14791+ ) {
14792+ let state = test_state(pool).await;
14793+ let owner = "did:key:zB341PRIVOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
14794+ let claimant = "did:key:zB341PRIVCLAIMANTAAAAAAAAAAAAAAAAAAAAA";
14795+ state
14796+ .db
14797+ .create_repo(&seed_private_repo(owner, "secret-bounty-repo"))
14798+ .await
14799+ .unwrap();
14800+ state
14801+ .db
14802+ .create_bounty(&seed_bounty(
14803+ "b341-held-id",
14804+ owner,
14805+ "secret-bounty-repo",
14806+ bounty_status,
14807+ Some(claimant),
14808+ ))
14809+ .await
14810+ .unwrap();
14811+
14812+ let router = crate::server::build_router(state);
14813+ let stranger = Keypair::generate();
14814+
14815+ // Held id: the stranger knows the bounty id but cannot read the repo.
14816+ let held_uri = format!("/api/v1/bounties/b341-held-id/{route}");
14817+ let resp = router
14818+ .clone()
14819+ .oneshot(signed_bounty_post(&stranger, &held_uri, body))
14820+ .await
14821+ .unwrap();
14822+ assert_eq!(
14823+ resp.status(),
14824+ StatusCode::NOT_FOUND,
14825+ "{route} on a held private-repo bounty id must deny as 404, not a \
14826+ distinguishable status/participant error"
14827+ );
14828+ let msg = json_body(resp).await;
14829+ assert!(
14830+ msg["message"].as_str().unwrap_or("").contains("not found"),
14831+ "{route} denial body must be the not-found shape, got {msg}"
14832+ );
14833+
14834+ // Absent id: the control case, already 404 today.
14835+ let absent_uri = format!("/api/v1/bounties/b341-no-such-id/{route}");
14836+ let resp = router
14837+ .oneshot(signed_bounty_post(&stranger, &absent_uri, body))
14838+ .await
14839+ .unwrap();
14840+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
14841+ }
14842+
14843+ #[sqlx::test]
14844+ async fn submit_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14845+ assert_bounty_mutation_denies_stranger_on_private(
14846+ pool,
14847+ "submit",
14848+ "claimed",
14849+ br#"{"pr_id":"owner/repo#1"}"#,
14850+ )
14851+ .await;
14852+ }
14853+
14854+ #[sqlx::test]
14855+ async fn approve_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14856+ assert_bounty_mutation_denies_stranger_on_private(pool, "approve", "submitted", b"{}")
14857+ .await;
14858+ }
14859+
14860+ #[sqlx::test]
14861+ async fn cancel_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14862+ assert_bounty_mutation_denies_stranger_on_private(pool, "cancel", "open", b"").await;
14863+ }
14864+
14865+ #[sqlx::test]
14866+ async fn dispute_bounty_gate_denies_stranger_on_private(pool: PgPool) {
14867+ assert_bounty_mutation_denies_stranger_on_private(pool, "dispute", "claimed", b"").await;
14868+ }
14869+
14870+ /// The read gate must admit readable repos: on a PUBLIC repo a stranger
14871+ /// still reaches the participant checks, so the four routes answer 403
14872+ /// rather than the gated 404.
14873+ #[sqlx::test]
14874+ async fn bounty_mutation_gates_still_reach_participant_check_on_public(pool: PgPool) {
14875+ let state = test_state(pool).await;
14876+ let owner = "did:key:zB341PUBOWNERAAAAAAAAAAAAAAAAAAAAAAAAA";
14877+ let claimant = "did:key:zB341PUBCLAIMANTAAAAAAAAAAAAAAAAAAAAAA";
14878+ let mut repo = seed_private_repo(owner, "public-bounty-repo");
14879+ repo.is_public = true;
14880+ state.db.create_repo(&repo).await.unwrap();
14881+
14882+ let cases: [(&str, &str, &[u8]); 4] = [
14883+ ("submit", "claimed", br#"{"pr_id":"owner/repo#1"}"#),
14884+ ("approve", "submitted", b"{}"),
14885+ ("cancel", "open", b""),
14886+ ("dispute", "claimed", b""),
14887+ ];
14888+ for (i, (_, status, _)) in cases.iter().enumerate() {
14889+ let id = format!("b341-pub-{i}");
14890+ state
14891+ .db
14892+ .create_bounty(&seed_bounty(
14893+ &id,
14894+ owner,
14895+ "public-bounty-repo",
14896+ status,
14897+ Some(claimant),
14898+ ))
14899+ .await
14900+ .unwrap();
14901+ }
14902+
14903+ let router = crate::server::build_router(state);
14904+ let stranger = Keypair::generate();
14905+ for (i, (route, _, body)) in cases.iter().enumerate() {
14906+ let id = format!("b341-pub-{i}");
14907+ let uri = format!("/api/v1/bounties/{id}/{route}");
14908+ let resp = router
14909+ .clone()
14910+ .oneshot(signed_bounty_post(&stranger, &uri, body))
14911+ .await
14912+ .unwrap();
14913+ assert_eq!(
14914+ resp.status(),
14915+ StatusCode::FORBIDDEN,
14916+ "{route} on a public repo must still reach the participant check"
14917+ );
14918+ }
14919+ }
14920+
1473514921 #[sqlx::test]
1473614922 async fn list_all_bounties_filters_private_repos_for_anon(pool: PgPool) {
1473714923 let state = test_state(pool).await;
0 commit comments