mirror of
https://github.com/Twigpine/node.git
synced 2026-11-12 19:13:02 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef597a1936 | ||
|
|
99a9c661c7 | ||
|
|
a23c81d75a | ||
|
|
dc3ed80360 | ||
|
|
8dc6c26190 | ||
|
|
63e6e3f34b |
No files matched your search
@@ -456,68 +456,21 @@ pub async fn dispute_bounty(
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
/// Resolve the set of `(owner_did, name)` pairs for repos an anonymous caller
|
||||
/// can list. Mirrors the `stats()` pattern in `server.rs` (#104): batch-load
|
||||
/// all deduped repos, batch-load their visibility rules, keep only those that
|
||||
/// pass `listable_at_root`. Pure I/O after the two DB round-trips — no
|
||||
/// per-repo authorization queries.
|
||||
///
|
||||
/// Both bounty stats handlers mount behind `optional_signature` (see
|
||||
/// `bounty_read_routes` in server.rs), so the caller is always `None` for
|
||||
/// anonymous access. Every caller, signed or not, currently receives the
|
||||
/// anonymous-scoped aggregates (matching the #104 pattern); per-caller
|
||||
/// visibility can be threaded through here when that work lands.
|
||||
///
|
||||
/// A database error is returned to the caller, never collapsed to an empty
|
||||
/// set: an empty set would render as HTTP 200 with zero counts, which a client
|
||||
/// reads as confirmed absence of activity.
|
||||
async fn visible_repo_pairs(state: &AppState) -> Result<Vec<(String, String)>> {
|
||||
let rows = state.db.list_all_repos_deduped().await?;
|
||||
let ids: Vec<String> = rows.iter().map(|r| r.id.clone()).collect();
|
||||
let rules_by_repo = state.db.list_visibility_rules_for_repos(&ids).await?;
|
||||
let pairs = rows
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
let rules = rules_by_repo.get(&r.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
// mirror-rows-handled: a mirror with a canonical twin is deduped to the
|
||||
// canonical row and its rules; a mirror-only repo counts as public, the
|
||||
// same answer authorize_repo_read and /stats give. Pinned by
|
||||
// bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone.
|
||||
crate::visibility::listable_at_root(rules, r.is_public, &r.owner_did, None)
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
crate::db::normalize_owner_key(&r.owner_did).to_string(),
|
||||
r.name.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
/// GET /api/v1/bounties/stats
|
||||
///
|
||||
/// Aggregates are restricted to anonymously-listable repos so private-repo
|
||||
/// bounty activity is not exposed (#477). The visible-repo set is resolved
|
||||
/// once per request via `visible_repo_pairs` (two SQL round-trips), then
|
||||
/// passed into the filtered aggregate queries.
|
||||
pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountyStatsResponse>> {
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
|
||||
let open = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("open", &visible)
|
||||
.await?;
|
||||
let open = state.db.count_bounties_by_status("open").await.unwrap_or(0);
|
||||
let claimed = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("claimed", &visible)
|
||||
.await?;
|
||||
.count_bounties_by_status("claimed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let completed = state
|
||||
.db
|
||||
.count_bounties_by_status_visible("completed", &visible)
|
||||
.await?;
|
||||
.count_bounties_by_status("completed")
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let leaders = state.db.bounty_leaderboard_visible(10, &visible).await?;
|
||||
let leaders = state.db.bounty_leaderboard(10).await.unwrap_or_default();
|
||||
let leaderboard = leaders
|
||||
.into_iter()
|
||||
.map(|(did, cnt, total)| AgentBountyEntry {
|
||||
@@ -536,14 +489,11 @@ pub async fn bounty_stats(State(state): State<AppState>) -> Result<Json<BountySt
|
||||
}
|
||||
|
||||
/// GET /api/v1/agents/{did}/bounties
|
||||
///
|
||||
/// Per-agent earnings restricted to anonymously-listable repos (#477).
|
||||
pub async fn agent_bounty_stats(
|
||||
State(state): State<AppState>,
|
||||
Path(did): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>> {
|
||||
let visible = visible_repo_pairs(&state).await?;
|
||||
let (count, total) = state.db.agent_bounty_stats_visible(&did, &visible).await?;
|
||||
let (count, total) = state.db.agent_bounty_stats(&did).await.unwrap_or((0, 0));
|
||||
Ok(Json(serde_json::json!({
|
||||
"did": did,
|
||||
"completed_bounties": count,
|
||||
|
||||
@@ -8289,7 +8289,7 @@ mod tests {
|
||||
let add = server
|
||||
.mock("POST", mockito::Matcher::Any)
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"QmShouldNotHappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
@@ -1184,16 +1184,6 @@ const OWNER_KEY_CASE_SQL: &str = "CASE WHEN owner_did LIKE 'did:key:%' AND posit
|
||||
/// named `did` (like in agent_profiles) instead of `owner_did`.
|
||||
const PROFILE_DID_CASE_SQL: &str = "CASE WHEN did LIKE 'did:key:%' AND position(':' in substr(did, 9)) = 0 THEN substr(did, 9) ELSE did END";
|
||||
|
||||
/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns
|
||||
/// named `repo_owner` (bounties table). The bounties `repo_owner` is stored
|
||||
/// verbatim from the URL segment, so it may be either the full `did:key:` form
|
||||
/// or the bare key.
|
||||
const BOUNTY_OWNER_CASE_SQL: &str = "CASE WHEN repo_owner LIKE 'did:key:%' AND position(':' in substr(repo_owner, 9)) = 0 THEN substr(repo_owner, 9) ELSE repo_owner END";
|
||||
|
||||
/// SQL CASE expression byte-identical to `normalize_owner_key`, but for columns
|
||||
/// named `claimant_did` (bounties table).
|
||||
const BOUNTY_CLAIMANT_CASE_SQL: &str = "CASE WHEN claimant_did LIKE 'did:key:%' AND position(':' in substr(claimant_did, 9)) = 0 THEN substr(claimant_did, 9) ELSE claimant_did END";
|
||||
|
||||
#[cfg(test)]
|
||||
mod normalize_owner_key_tests {
|
||||
use super::normalize_owner_key;
|
||||
@@ -4579,110 +4569,31 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Visibility-filtered bounty aggregates (#477) ─────────────────────
|
||||
|
||||
/// Count bounties by status, restricted to repos whose `(owner_did, name)`
|
||||
/// pairs are in `visible`. Owner keys are normalized on both sides so
|
||||
/// `did:key:` and bare-key forms match. An empty set returns 0.
|
||||
pub async fn count_bounties_by_status_visible(
|
||||
&self,
|
||||
status: &str,
|
||||
visible: &[(String, String)],
|
||||
) -> Result<i64> {
|
||||
if visible.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
|
||||
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) as c FROM bounties b \
|
||||
WHERE b.status = $1 \
|
||||
AND EXISTS ( \
|
||||
SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \
|
||||
WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \
|
||||
)",
|
||||
bounty_owner = BOUNTY_OWNER_CASE_SQL,
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
pub async fn count_bounties_by_status(&self, status: &str) -> Result<i64> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as c FROM bounties WHERE status = $1")
|
||||
.bind(status)
|
||||
.bind(&owners)
|
||||
.bind(&names)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("c"))
|
||||
}
|
||||
|
||||
/// Per-agent bounty stats restricted to visible repos. `b.repo_owner` is
|
||||
/// normalized and `b.claimant_did` is matched against both spellings of the
|
||||
/// agent key, so `did:key:` and bare-key forms match.
|
||||
pub async fn agent_bounty_stats_visible(
|
||||
&self,
|
||||
agent_did: &str,
|
||||
visible: &[(String, String)],
|
||||
) -> Result<(i64, i64)> {
|
||||
if visible.is_empty() {
|
||||
return Ok((0, 0));
|
||||
}
|
||||
// Index-friendly form of `normalize(claimant_did) = N`: a claimant
|
||||
// normalizes to N only when stored as N or as `did:key:N` (the latter
|
||||
// only when N has no ':', else the prefix is not stripped). Matching
|
||||
// the raw column keeps `idx_bounties_claimant` usable.
|
||||
let normalized_agent = normalize_owner_key(agent_did);
|
||||
let mut claimants: Vec<String> = vec![normalized_agent.to_string()];
|
||||
if !normalized_agent.contains(':') {
|
||||
claimants.push(format!("did:key:{normalized_agent}"));
|
||||
}
|
||||
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
|
||||
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total FROM bounties b \
|
||||
WHERE b.claimant_did = ANY($1::text[]) \
|
||||
AND b.status = 'completed' \
|
||||
AND EXISTS ( \
|
||||
SELECT 1 FROM unnest($2::text[], $3::text[]) AS v(o, n) \
|
||||
WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \
|
||||
)",
|
||||
bounty_owner = BOUNTY_OWNER_CASE_SQL,
|
||||
);
|
||||
let row = sqlx::query(&sql)
|
||||
.bind(&claimants)
|
||||
.bind(&owners)
|
||||
.bind(&names)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
pub async fn agent_bounty_stats(&self, agent_did: &str) -> Result<(i64, i64)> {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) as cnt, COALESCE(SUM(amount),0) as total FROM bounties WHERE claimant_did = $1 AND status = 'completed'",
|
||||
)
|
||||
.bind(agent_did)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok((row.get::<i64, _>("cnt"), row.get::<i64, _>("total")))
|
||||
}
|
||||
|
||||
/// Leaderboard restricted to visible repos. Owner and claimant keys are
|
||||
/// normalized so `did:key:` and bare-key forms match and group together.
|
||||
pub async fn bounty_leaderboard_visible(
|
||||
&self,
|
||||
limit: i64,
|
||||
visible: &[(String, String)],
|
||||
) -> Result<Vec<(String, i64, i64)>> {
|
||||
if visible.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let owners: Vec<String> = visible.iter().map(|(o, _)| o.clone()).collect();
|
||||
let names: Vec<String> = visible.iter().map(|(_, n)| n.clone()).collect();
|
||||
let sql = format!(
|
||||
"SELECT MIN(claimant_did) as claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount), 0)::BIGINT as total \
|
||||
FROM bounties b \
|
||||
WHERE b.status = 'completed' AND b.claimant_did IS NOT NULL \
|
||||
AND EXISTS ( \
|
||||
SELECT 1 FROM unnest($1::text[], $2::text[]) AS v(o, n) \
|
||||
WHERE ({bounty_owner}) = v.o AND b.repo_name = v.n \
|
||||
) \
|
||||
GROUP BY ({claimant_case}) ORDER BY total DESC LIMIT $3",
|
||||
claimant_case = BOUNTY_CLAIMANT_CASE_SQL,
|
||||
bounty_owner = BOUNTY_OWNER_CASE_SQL,
|
||||
);
|
||||
let rows = sqlx::query(&sql)
|
||||
.bind(&owners)
|
||||
.bind(&names)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
pub async fn bounty_leaderboard(&self, limit: i64) -> Result<Vec<(String, i64, i64)>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT claimant_did, COUNT(*) as cnt, COALESCE(SUM(amount),0) as total FROM bounties WHERE status='completed' AND claimant_did IS NOT NULL GROUP BY claimant_did ORDER BY total DESC LIMIT $1",
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
|
||||
@@ -1508,9 +1508,6 @@ pub async fn pin_git_object(
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
// Compute the expected CIDv1 from the content bytes
|
||||
let expected_cid = Cid::from_git_object_bytes(data).to_string();
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v0/add?cid-version=1&raw-leaves=true&pin=true",
|
||||
ipfs_api.trim_end_matches('/')
|
||||
@@ -1557,6 +1554,10 @@ pub async fn pin_git_object(
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("IPFS add response body read failed: {e}"))?;
|
||||
// A 200 without a parseable Hash is not a successful pin. Fabricating the
|
||||
// caller-computed CID would record an address the backend never confirmed;
|
||||
// above its chunk threshold that raw CID is not even a stored block, so
|
||||
// every later cat fails permanently.
|
||||
let cid = body
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
@@ -1565,7 +1566,17 @@ pub async fn pin_git_object(
|
||||
v["Hash"].as_str().map(|s| s.to_string())
|
||||
})
|
||||
.next_back()
|
||||
.unwrap_or(expected_cid.clone());
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"IPFS /api/v0/add returned 200 without a usable Hash: {:.200}",
|
||||
body
|
||||
)
|
||||
})?;
|
||||
// The string still has to be a CID. A garbage Hash would be recorded and
|
||||
// later cat-ed the same way a missing one was, so reject it here.
|
||||
Cid::from_str(&cid).map_err(|e| {
|
||||
anyhow::anyhow!("IPFS /api/v0/add returned a Hash that is not a CID ({cid:.200}): {e}")
|
||||
})?;
|
||||
|
||||
tracing::debug!(sha256 = %sha256_hex, %cid, "pinned git object to IPFS");
|
||||
Ok(cid)
|
||||
@@ -2223,16 +2234,16 @@ mod tests {
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// A sleeping-but-live endpoint. Answers `200` with an empty body after
|
||||
/// `delays[i]` for the i-th request it accepts (the last entry repeats), so
|
||||
/// a test can make one add slow and the next fast. Drains the full request,
|
||||
/// headers plus the declared `Content-Length` body, before sleeping: exactly
|
||||
/// as in `rejecting_endpoint`, answering early and closing would surface as
|
||||
/// a write failure on the client and turn a slow-but-healthy add into a
|
||||
/// different failure shape.
|
||||
/// A sleeping-but-live endpoint. Answers `200` with a conformant NDJSON add
|
||||
/// response after `delays[i]` for the i-th request it accepts (the last
|
||||
/// entry repeats), so a test can make one add slow and the next fast.
|
||||
/// Drains the full request, headers plus the declared `Content-Length`
|
||||
/// body, before sleeping: exactly as in `rejecting_endpoint`, answering
|
||||
/// early and closing would surface as a write failure on the client and
|
||||
/// turn a slow-but-healthy add into a different failure shape.
|
||||
///
|
||||
/// An empty body is a successful pin: `pin_git_object` falls back to the CID
|
||||
/// it computed from the bytes when the response carries no `Hash`.
|
||||
/// The Hash is a fixed, real CID: the timing tests assert on pin counts,
|
||||
/// not on the recorded address.
|
||||
async fn delaying_endpoint(delays: Vec<Duration>) -> String {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -2269,9 +2280,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(delay).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
|
||||
.await;
|
||||
let body = br#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4","Size":"12"}
|
||||
"#;
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nContent-Length: {}\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = sock.write_all(resp.as_bytes()).await;
|
||||
let _ = sock.write_all(body).await;
|
||||
let _ = sock.flush().await;
|
||||
});
|
||||
}
|
||||
@@ -2373,6 +2389,71 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A 200 add response with no parseable Hash must surface as an add
|
||||
/// failure. Falling back to the caller-computed CID records an
|
||||
/// unverifiable address: above the backend's chunk threshold that raw
|
||||
/// CID is not a stored block, so every later cat 500s.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_rejects_a_200_without_a_hash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Name":"object","Size":"19"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a 200 without a Hash must be an add failure, not a fabricated CID: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Hash that is a string but not a CID must also fail. It would be
|
||||
/// recorded into `encrypted_blobs.cid` and every later cat would miss.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_rejects_a_malformed_hash() {
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"not-a-cid","Size":"19"}"#)
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a Hash that is not a CID must be an add failure: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Hash text goes into the error and `encrypted_pin` logs that error,
|
||||
/// so a backend answering with a huge invalid Hash must not produce an
|
||||
/// unbounded log entry.
|
||||
#[tokio::test]
|
||||
async fn pin_git_object_bounds_an_oversized_invalid_hash_in_the_error() {
|
||||
let long = "x".repeat(5000);
|
||||
let mut server = mockito::Server::new_async().await;
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(format!(r#"{{"Hash":"{long}","Size":"19"}}"#))
|
||||
.create_async()
|
||||
.await;
|
||||
let result = pin_git_object(&server.url(), "deadbeef", b"some object bytes\n", None).await;
|
||||
let err = result.expect_err("an invalid Hash must be an add failure");
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.len() < 1000,
|
||||
"the error must bound the echoed Hash, got {} bytes",
|
||||
msg.len()
|
||||
);
|
||||
assert!(
|
||||
!msg.contains(&long),
|
||||
"the error must not carry the full invalid Hash"
|
||||
);
|
||||
}
|
||||
|
||||
/// The permit-hold bound. `pin_new_objects` runs under a deferring
|
||||
/// `pin_semaphore`, so without a batch deadline the hold is O(N) with N
|
||||
/// chosen by the pusher. Five objects against an endpoint that takes 2s
|
||||
|
||||
@@ -6374,7 +6374,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -6500,7 +6500,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -6690,7 +6690,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8510,7 +8510,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovtest"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8570,7 +8570,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyproviderhash"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(2)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8829,7 +8829,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -8952,7 +8952,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyshouldnothappen"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -9054,7 +9054,7 @@ mod tests {
|
||||
let m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"x"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -9118,7 +9118,7 @@ mod tests {
|
||||
server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"x"}"#)
|
||||
.with_body(r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#)
|
||||
.expect(0)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -17647,626 +17647,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── #477: bounty stats aggregates ignore repo visibility ──────────────────
|
||||
|
||||
#[sqlx::test]
|
||||
async fn bounty_stats_filters_private_repos_for_anon(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zSTATSBOUNTYOWNERAAAAAAAAAAAAAAAAAAAA";
|
||||
let agent_private = "did:key:zSTATSPRIVATECLAIMANT00000000000";
|
||||
let agent_public = "did:key:zSTATSPUBLICCLAIMANT000000000000";
|
||||
|
||||
// Private repo with 1 open and 1 completed bounty
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_private_repo(owner, "private-repo"))
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-priv-open".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "private-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Private Open".into(),
|
||||
amount: 100,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Drive the private completed bounty through the real claim/approve path
|
||||
// so `claimant_did` and `completed_at` are actually persisted (#477 P1).
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-priv-comp".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "private-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Private Completed".into(),
|
||||
amount: 500,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-02T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty(
|
||||
"bounty-priv-comp",
|
||||
agent_private,
|
||||
None,
|
||||
"2026-01-02T01:00:00Z",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty("bounty-priv-comp", "pr-priv-1", "2026-01-02T02:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty("bounty-priv-comp", "2026-01-03T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Public repo with 1 open and 1 completed bounty
|
||||
let mut public_repo = seed_private_repo(owner, "public-repo");
|
||||
public_repo.is_public = true;
|
||||
state.db.create_repo(&public_repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-pub-open".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "public-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Public Open".into(),
|
||||
amount: 200,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-04T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// Drive the public completed bounty through claim→submit→approve.
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-pub-comp".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "public-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Public Completed".into(),
|
||||
amount: 300,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-05T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty(
|
||||
"bounty-pub-comp",
|
||||
agent_public,
|
||||
None,
|
||||
"2026-01-05T01:00:00Z",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty("bounty-pub-comp", "pr-pub-1", "2026-01-05T02:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty("bounty-pub-comp", "2026-01-06T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(anon_get("/api/v1/bounties/stats"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = json_body(resp).await;
|
||||
|
||||
// Anonymous observer must only see counts from the public repo (#477)
|
||||
assert_eq!(
|
||||
body["open"], 1,
|
||||
"only 1 public open bounty should be counted"
|
||||
);
|
||||
assert_eq!(
|
||||
body["completed"], 1,
|
||||
"only 1 public completed bounty should be counted"
|
||||
);
|
||||
let leaders = body["leaderboard"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
leaders.len(),
|
||||
1,
|
||||
"leaderboard must exclude private earnings"
|
||||
);
|
||||
assert_eq!(leaders[0]["did"], agent_public);
|
||||
assert_eq!(leaders[0]["completed"], 1);
|
||||
assert_eq!(leaders[0]["total_earned"], 300);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn agent_bounty_stats_filters_private_repos_for_anon(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zAGNTSTATSBOWNERAAAAAAAAAAAAAAAAAAAA";
|
||||
let agent = "did:key:zAGNTSTATSAAGENT000000000000000000";
|
||||
|
||||
// Private repo where agent earned a bounty
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_private_repo(owner, "agent-secret-repo"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Drive through the real claim path so claimant_did persists.
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-secret-agent".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "agent-secret-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Secret Task".into(),
|
||||
amount: 750,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty("bounty-secret-agent", agent, None, "2026-01-01T01:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty("bounty-secret-agent", "pr-secret-1", "2026-01-01T02:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty("bounty-secret-agent", "2026-01-02T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Public repo where the SAME agent also earned a bounty: proves the
|
||||
// filter admits public earnings rather than always returning zero.
|
||||
let mut public_repo = seed_private_repo(owner, "agent-public-repo");
|
||||
public_repo.is_public = true;
|
||||
state.db.create_repo(&public_repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-public-agent".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "agent-public-repo".into(),
|
||||
issue_id: None,
|
||||
title: "Public Task".into(),
|
||||
amount: 250,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-01-03T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty("bounty-public-agent", agent, None, "2026-01-03T01:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty("bounty-public-agent", "pr-pub-1", "2026-01-03T02:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty("bounty-public-agent", "2026-01-04T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let uri = format!("/api/v1/agents/{agent}/bounties");
|
||||
let resp = router.oneshot(anon_get(&uri)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = json_body(resp).await;
|
||||
|
||||
// Anonymous query must not leak private earnings, but must still admit
|
||||
// the agent's public earnings (not an always-zero over-denial).
|
||||
assert_eq!(
|
||||
body["completed_bounties"], 1,
|
||||
"only the public completed bounty must be counted"
|
||||
);
|
||||
assert_eq!(
|
||||
body["total_earned"], 250,
|
||||
"only the public bounty's earnings must be counted, not the private 750"
|
||||
);
|
||||
}
|
||||
|
||||
fn open_bounty_on(id: &str, owner: &str, repo: &str, amount: i64) -> crate::db::BountyRecord {
|
||||
crate::db::BountyRecord {
|
||||
id: id.into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: repo.into(),
|
||||
issue_id: None,
|
||||
title: id.into(),
|
||||
amount,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-03-01T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `claimant_did` is stored in whatever form the claimer sent, so the
|
||||
/// per-agent lookup must match both `did:key:<key>` and the bare key for
|
||||
/// either spelling of the request path.
|
||||
#[sqlx::test]
|
||||
async fn agent_bounty_stats_matches_full_and_bare_claimant_forms(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zCLAIMFORMOWNERAAAAAAAAAAAAAAAAAAAA";
|
||||
let bare = "zCLAIMFORMAGENT0000000000000000000";
|
||||
let full = format!("did:key:{bare}");
|
||||
|
||||
let mut repo = seed_private_repo(owner, "claim-form-repo");
|
||||
repo.is_public = true;
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
|
||||
for (id, claimant, amount) in [("cf-full", full.as_str(), 100), ("cf-bare", bare, 30)] {
|
||||
state
|
||||
.db
|
||||
.create_bounty(&open_bounty_on(id, owner, "claim-form-repo", amount))
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty(id, claimant, None, "2026-03-01T01:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty(id, "pr-cf", "2026-03-01T02:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty(id, "2026-03-02T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
for path_did in [full.as_str(), bare] {
|
||||
let uri = format!("/api/v1/agents/{path_did}/bounties");
|
||||
let resp = router.clone().oneshot(anon_get(&uri)).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = json_body(resp).await;
|
||||
assert_eq!(
|
||||
body["completed_bounties"], 2,
|
||||
"both claimant spellings must count for path {path_did}"
|
||||
);
|
||||
assert_eq!(
|
||||
body["total_earned"], 130,
|
||||
"both claimant spellings must be summed for path {path_did}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A database failure must surface as an error status, never as a 200
|
||||
/// carrying zero counts that read as confirmed absence of activity.
|
||||
#[sqlx::test]
|
||||
async fn bounty_stats_db_failure_is_not_reported_as_zero(pool: PgPool) {
|
||||
let state = test_state(pool.clone()).await;
|
||||
let router = crate::server::build_router(state);
|
||||
pool.close().await;
|
||||
|
||||
for uri in [
|
||||
"/api/v1/bounties/stats",
|
||||
"/api/v1/agents/did:key:zDBFAILAGENTAAAAAAAAAAAAAAAAAAAAA/bounties",
|
||||
] {
|
||||
let resp = router.clone().oneshot(anon_get(uri)).await.unwrap();
|
||||
assert_ne!(
|
||||
resp.status(),
|
||||
StatusCode::OK,
|
||||
"{uri} must not report zero counts when the database is down"
|
||||
);
|
||||
assert!(resp.status().is_server_error(), "{uri}: {}", resp.status());
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror rows are written with `is_public = true` and no replicated rules,
|
||||
/// so a mirror alone says nothing about the origin's visibility. The
|
||||
/// aggregates must (a) hide bounties of a repo whose canonical twin is
|
||||
/// private (the dedup keeps the canonical row and its rules), and (b) count
|
||||
/// bounties of a mirror-only repo, which is how `authorize_repo_read` and
|
||||
/// `/stats` already treat it. Both classes are pinned here.
|
||||
#[sqlx::test]
|
||||
async fn bounty_stats_mirror_rows_follow_canonical_twin_or_count_when_alone(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zMIRRORTWINOWNERAAAAAAAAAAAAAAAAAAA";
|
||||
let short = owner.strip_prefix("did:key:").unwrap();
|
||||
let remote = "did:key:zMIRRORONLYOWNERAAAAAAAAAAAAAAAAAAA";
|
||||
let remote_short = remote.strip_prefix("did:key:").unwrap();
|
||||
|
||||
// Canonical private repo plus a mirror row for the same (owner, name).
|
||||
state
|
||||
.db
|
||||
.create_repo(&seed_private_repo(owner, "twinned"))
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(short, "twinned", "/tmp/mirror-twinned", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&open_bounty_on("bounty-twinned", owner, "twinned", 10))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Mirror-only repo: no canonical twin on this node.
|
||||
state
|
||||
.db
|
||||
.upsert_mirror_repo(remote_short, "alone", "/tmp/mirror-alone", None, false)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&open_bounty_on("bounty-alone", remote, "alone", 20))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state);
|
||||
let resp = router
|
||||
.oneshot(anon_get("/api/v1/bounties/stats"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = json_body(resp).await;
|
||||
assert_eq!(
|
||||
body["open"], 1,
|
||||
"only the mirror-only repo's bounty counts; the private canonical twin's must not"
|
||||
);
|
||||
}
|
||||
|
||||
/// Exercises the root-rule branch of `listable_at_root`: a repo with
|
||||
/// `is_public = true` plus a root visibility rule (`path_glob = "/"`)
|
||||
/// whose `reader_dids` does NOT include the anonymous caller. The rule
|
||||
/// takes precedence over `is_public`, so the repo's bounties must be
|
||||
/// excluded from anonymous aggregates.
|
||||
#[sqlx::test]
|
||||
async fn bounty_stats_root_rule_overrides_is_public(pool: PgPool) {
|
||||
let state = test_state(pool).await;
|
||||
let owner = "did:key:zROOTRULEOWNERAAAAAAAAAAAAAAAAAAAAA";
|
||||
let insider = "did:key:zROOTRULEINSIDERAAAAAAAAAAAAAAAAA";
|
||||
let agent = "did:key:zROOTRULEAGENT0AAAAAAAAAAAAAAAAAAA";
|
||||
|
||||
// Public repo with a root rule that restricts to `insider` only.
|
||||
// `is_public = true`, but the root rule makes it anonymous-invisible.
|
||||
let mut repo = seed_private_repo(owner, "restricted-public");
|
||||
repo.is_public = true;
|
||||
state.db.create_repo(&repo).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.set_visibility_rule(
|
||||
&repo.id,
|
||||
"/",
|
||||
crate::db::VisibilityMode::A,
|
||||
&[insider.to_string()],
|
||||
owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Open bounty on the root-rule-restricted repo.
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-rootrule-open".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "restricted-public".into(),
|
||||
issue_id: None,
|
||||
title: "Root Rule Open".into(),
|
||||
amount: 400,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-02-01T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Completed bounty, driven through claim→submit→approve.
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-rootrule-comp".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "restricted-public".into(),
|
||||
issue_id: None,
|
||||
title: "Root Rule Completed".into(),
|
||||
amount: 600,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-02-02T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.claim_bounty("bounty-rootrule-comp", agent, None, "2026-02-02T01:00:00Z")
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.submit_bounty(
|
||||
"bounty-rootrule-comp",
|
||||
"pr-rootrule-1",
|
||||
"2026-02-02T02:00:00Z",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
state
|
||||
.db
|
||||
.approve_bounty("bounty-rootrule-comp", "2026-02-03T00:00:00Z", None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Truly public repo (no root rule) as a control.
|
||||
let mut control = seed_private_repo(owner, "truly-public");
|
||||
control.is_public = true;
|
||||
state.db.create_repo(&control).await.unwrap();
|
||||
state
|
||||
.db
|
||||
.create_bounty(&crate::db::BountyRecord {
|
||||
id: "bounty-control-open".into(),
|
||||
repo_owner: owner.into(),
|
||||
repo_name: "truly-public".into(),
|
||||
issue_id: None,
|
||||
title: "Control Open".into(),
|
||||
amount: 100,
|
||||
creator_did: owner.into(),
|
||||
claimant_did: None,
|
||||
claimant_wallet: None,
|
||||
pr_id: None,
|
||||
status: "open".into(),
|
||||
created_at: "2026-02-04T00:00:00Z".into(),
|
||||
claimed_at: None,
|
||||
submitted_at: None,
|
||||
completed_at: None,
|
||||
deadline_secs: 86400,
|
||||
tx_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let router = crate::server::build_router(state.clone());
|
||||
let resp = router
|
||||
.oneshot(anon_get("/api/v1/bounties/stats"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = json_body(resp).await;
|
||||
|
||||
// Anonymous caller must see only the control repo's bounty, not the
|
||||
// root-rule-restricted repo's bounties — even though is_public = true.
|
||||
assert_eq!(
|
||||
body["open"], 1,
|
||||
"root-rule-restricted repo's open bounty must be excluded"
|
||||
);
|
||||
assert_eq!(
|
||||
body["completed"], 0,
|
||||
"root-rule-restricted repo's completed bounty must be excluded"
|
||||
);
|
||||
let leaders = body["leaderboard"].as_array().unwrap();
|
||||
assert!(
|
||||
leaders.is_empty(),
|
||||
"leaderboard must not show earnings from root-rule-restricted repo"
|
||||
);
|
||||
|
||||
// Also verify agent bounty stats for the root-rule-restricted repo's claimant
|
||||
let router = crate::server::build_router(state);
|
||||
let resp_agent = router
|
||||
.oneshot(anon_get(&format!("/api/v1/agents/{agent}/bounties")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp_agent.status(), StatusCode::OK);
|
||||
let body_agent = json_body(resp_agent).await;
|
||||
assert_eq!(
|
||||
body_agent["completed_bounties"], 0,
|
||||
"root-rule-restricted repo's completed bounty must not count for agent"
|
||||
);
|
||||
assert_eq!(
|
||||
body_agent["total_earned"], 0,
|
||||
"root-rule-restricted repo's earnings must not count for agent"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Ref-update events (issue #144: owner_did wire format) ─────────────────
|
||||
|
||||
fn events_router(state: AppState) -> Router {
|
||||
@@ -19103,7 +18483,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19171,7 +18553,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19239,7 +18623,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19309,7 +18695,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19373,7 +18761,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19435,7 +18825,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19571,7 +18963,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19650,7 +19044,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19711,7 +19107,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19810,7 +19208,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
@@ -19896,7 +19296,9 @@ mod tests {
|
||||
let _m = server
|
||||
.mock("POST", mockito::Matcher::Regex(r"^/api/v0/add".to_string()))
|
||||
.with_status(200)
|
||||
.with_body(r#"{"Hash":"bafyprovider"}"#)
|
||||
.with_body(
|
||||
r#"{"Hash":"bafkreifjjcie6lypi6ny7amxnfftagclbuxndqonfipmb64f2km2devei4"}"#,
|
||||
)
|
||||
.expect_at_least(1)
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
Generated
+6
-6
@@ -1067,9 +1067,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
|
||||
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -1600,9 +1600,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"version": "8.22.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
|
||||
"integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
"arbundles": "^0.10.0",
|
||||
"base64url": "^3.0.1",
|
||||
"bs58": "^4.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"ws": "^8.21.0"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user