Compare commits

..
Author SHA1 Message Date
beardthelion ef597a1936 fix(node): bound the invalid Hash echoed in the add error 2026-11-05 21:18:07 -06:00
beardthelion 99a9c661c7 Merge remote-tracking branch 'origin/main' into rehome-453 2026-11-05 19:35:39 -06:00
beardthelion dc3ed80360 test(node): return real CIDs from the add-response test mocks
The pin_git_object CID validation rejects non-CID Hash strings, and a
dozen test_support fixtures answered 200 with placeholder values like
"bafyprovider" or "x". Replace them with a valid CID so the fixtures
model a healthy backend; no test asserts on the literal value.
2026-09-14 16:47:01 -05:00
beardthelion 8dc6c26190 fix(node): reject a non-CID Hash in the add response
Address PR review feedback (#453): a string-valued Hash that does not
parse as a CID would be recorded into encrypted_blobs.cid and fail on
every later cat, the same failure class as a missing Hash. Validate the
returned Hash before accepting it and cover the malformed case with a
regression test.
2026-09-14 16:29:47 -05:00
beardthelion 63e6e3f34b fix(node): fail pin_git_object when the add response carries no Hash
A 200 from /api/v0/add without a parseable Hash fell back to the
caller-computed raw CID, recording an address the backend never
confirmed stored. For payloads above the backend's chunk threshold
(262144 bytes on Kubo) that raw CID is not even a stored block, so
every later /api/v0/cat on it fails permanently: get_encrypted_blob
errors on every fetch and mirrors retry the envelope on every sync.

A Hash-less 200 is now an add error carrying a truncated body excerpt.
The delaying_endpoint test fixture, which relied on the fallback by
returning an empty 200 body, now answers with a conformant NDJSON line.
2026-09-14 16:01:46 -05:00
5 changed files with 66 additions and 1117 deletions

No files matched your search

+9 -59
View File
@@ -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,
+9 -24
View File
@@ -14,13 +14,14 @@ pub struct LabelRequest {
pub label: String,
}
/// Canonicalize and validate a label string. `add_label` and `remove_label`
/// must agree on the form stored in the DB: `add_label` already trims and
/// lowercases before storing, so `remove_label` has to do the same to find
/// the row. Validation is shared so neither handler can drift on charset or
/// length (#344).
fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
let label = raw.trim().to_lowercase();
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = req.label.trim().to_lowercase();
if label.is_empty() || label.len() > 50 {
return Err(AppError::BadRequest("label must be 1–50 characters".into()));
}
@@ -32,17 +33,6 @@ fn canonicalize_label(raw: &str) -> std::result::Result<String, AppError> {
"label must contain only alphanumeric characters, hyphens, and colons".into(),
));
}
Ok(label)
}
/// POST /api/v1/repos/:owner/:repo/labels
pub async fn add_label(
State(state): State<AppState>,
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name)): Path<(String, String)>,
Json(req): Json<LabelRequest>,
) -> Result<(StatusCode, Json<serde_json::Value>)> {
let label = canonicalize_label(&req.label)?;
let record = state
.db
@@ -69,8 +59,6 @@ pub async fn remove_label(
Extension(auth): Extension<AuthenticatedDid>,
Path((owner, name, label)): Path<(String, String, String)>,
) -> Result<Json<serde_json::Value>> {
let label = canonicalize_label(&label)?;
let record = state
.db
.get_repo(&owner, &name)
@@ -78,10 +66,7 @@ pub async fn remove_label(
.ok_or_else(|| AppError::RepoNotFound(format!("{owner}/{name}")))?;
crate::api::require_repo_owner(&record, &auth.0)?;
let removed = state.db.remove_label(&record.id, &label).await?;
if !removed {
return Err(AppError::NotFound(format!("label '{label}'")));
}
state.db.remove_label(&record.id, &label).await?;
Ok(Json(serde_json::json!({ "label": label, "removed": true })))
}
+19 -108
View File
@@ -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;
@@ -2261,13 +2251,13 @@ impl Db {
Ok(result.rows_affected() > 0)
}
pub async fn remove_label(&self, repo_id: &str, label: &str) -> Result<bool> {
let result = sqlx::query("DELETE FROM repo_labels WHERE repo_id = $1 AND label = $2")
pub async fn remove_label(&self, repo_id: &str, label: &str) -> Result<()> {
sqlx::query("DELETE FROM repo_labels WHERE repo_id = $1 AND label = $2")
.bind(repo_id)
.bind(label)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() > 0)
Ok(())
}
pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<String>> {
@@ -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| {
+29 -76
View File
@@ -141,10 +141,19 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
return Ok(None);
}
// List all refs that start with the given string; the glob also matches the exact
// ref. The id handed back is always git's own refname, never the request input.
// Try exact match first — fast path for callers passing the full UUID.
let exact_ref = format!("refs/gitlawb/issues/{id_or_prefix}");
let prefix_glob = format!("{exact_ref}*");
let check = Command::new("git")
.args(["cat-file", "-e", &exact_ref])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
if check.status.success() {
return Ok(Some(id_or_prefix.to_string()));
}
// Prefix search: list all refs that start with the given string.
let prefix_glob = format!("refs/gitlawb/issues/{id_or_prefix}*");
let list = Command::new("git")
.args(["for-each-ref", "--format=%(refname)", &prefix_glob])
.current_dir(repo_path)
@@ -156,44 +165,25 @@ pub fn resolve_issue_id(repo_path: &Path, id_or_prefix: &str) -> Result<Option<S
}
let output = String::from_utf8_lossy(&list.stdout);
let matches: Vec<&str> = output
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let matches: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
// An exact id wins over longer ids that share it as a prefix, but only if its
// object exists: for-each-ref also lists a ref whose blob is missing, so a
// dangling exact ref falls through to the prefix rules below.
let exact = match matches.iter().find(|m| **m == exact_ref) {
Some(m) => {
let check = Command::new("git")
.args(["cat-file", "-e", m])
.current_dir(repo_path)
.output()
.context("failed to run git cat-file -e")?;
check.status.success().then_some(*m)
match matches.len() {
0 => Ok(None),
1 => {
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = matches[0]
.trim()
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(matches[0].trim())
.to_string();
Ok(Some(full_id))
}
None => None,
};
let found = match exact {
Some(m) => m,
None => match matches.as_slice() {
[] => return Ok(None),
[m] => *m,
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
},
};
// Strip the "refs/gitlawb/issues/" prefix to get the bare ID.
let full_id = found
.strip_prefix("refs/gitlawb/issues/")
.unwrap_or(found)
.to_string();
Ok(Some(full_id))
_ => anyhow::bail!(
"ambiguous issue prefix '{}': matches {} issues",
id_or_prefix,
matches.len()
),
}
}
/// Close an issue by updating its status to "closed" in the git ref.
@@ -277,43 +267,6 @@ mod tests {
assert_eq!(resolved, Some(full_id.to_string()));
}
#[test]
fn test_resolve_exact_id_wins_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open"}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open"}"#).unwrap();
let resolved = resolve_issue_id(dir.path(), "abc12345").unwrap();
assert_eq!(resolved, Some("abc12345".to_string()));
}
#[test]
fn test_resolve_dangling_exact_id_does_not_win_over_longer_id() {
let dir = TempDir::new().unwrap();
init_repo(&dir);
create_issue(dir.path(), "abc12345", r#"{"status":"open","n":1}"#).unwrap();
create_issue(dir.path(), "abc12345-0000", r#"{"status":"open","n":2}"#).unwrap();
// Dangle the exact ref: the ref stays listed by for-each-ref, but its blob is gone.
let out = Command::new("git")
.args(["rev-parse", "refs/gitlawb/issues/abc12345"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8(out.stdout).unwrap();
let hash = hash.trim();
std::fs::remove_file(
dir.path()
.join(".git/objects")
.join(&hash[..2])
.join(&hash[2..]),
)
.unwrap();
let result = resolve_issue_id(dir.path(), "abc12345");
assert!(result.unwrap_err().to_string().contains("ambiguous"));
assert!(close_issue(dir.path(), "abc12345").is_err());
}
#[test]
fn test_resolve_prefix_matches_unique() {
let dir = TempDir::new().unwrap();
-850
View File
@@ -17153,236 +17153,6 @@ mod tests {
assert!(resp.status().is_success());
}
/// #344: `remove_label` must canonicalize the path segment the same way
/// `add_label` does, so a label stored as `bug` is matched by `DELETE
/// .../labels/Bug`. A delete that matched nothing must be reported as 404
/// rather than as `removed:true` — the no-op-rendered-as-success shape the
/// rest of the API already rejects on the client side.
#[sqlx::test]
async fn remove_label_canonicalizes_and_404s_on_no_match(pool: PgPool) {
let state = test_state(pool).await;
let owner = "did:key:zLBLREMOVEO0000000000000000000000000000000000";
let owner_short = "zLBLREMOVEO0000000000000000000000000000000000";
let repo_name = "lbl-remove";
state
.db
.create_repo(&seed_repo(owner, repo_name))
.await
.expect("seed repo");
state
.db
.add_label(
&state
.db
.get_repo(owner_short, repo_name)
.await
.unwrap()
.unwrap()
.id,
"bug",
)
.await
.expect("seed label");
let router = || {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/labels/{label}",
axum::routing::delete(crate::api::labels::remove_label),
)
.with_state(state.clone())
};
// Mixed-case path segment matches the lowercased stored label.
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/Bug"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"canonicalized delete succeeds"
);
let body = json_body(resp).await;
assert_eq!(body["label"], "bug");
assert_eq!(body["removed"], true);
// Second delete with the same form → no row, must 404 not 200/removed:true.
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/Bug"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"no-op delete must report 404, not removed:true"
);
// Path with surrounding whitespace still canonicalizes to a valid label.
state
.db
.add_label(
&state
.db
.get_repo(owner_short, repo_name)
.await
.unwrap()
.unwrap()
.id,
"feature",
)
.await
.expect("seed feature label");
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/%20feature%20"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"trimmed path segment matches the stored label"
);
// Path segment that canonicalizes to an invalid (empty) label → 400.
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/%20%20"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a label that canonicalizes to empty is a client error"
);
// Disallowed charset → 400 (validation lives in the shared path now).
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/bad%20label"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"spaces are rejected by canonicalize_label"
);
// Length arm: 51 chars → 400, 50 chars decouples the no-match 404 from
// the double-delete above so the helper's len>50 branch is pinned
// even if the earlier 404 assertion is ever reworked.
let too_long = "a".repeat(51);
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/{too_long}"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"51-char label is rejected by canonicalize_label"
);
let exactly_50 = "a".repeat(50);
let resp = router()
.oneshot(signed_request_as(
owner,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/{exactly_50}"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"50-char label passes validation but still 404s when absent"
);
}
/// #344: `remove_label` stays owner-gated. A non-owner with a real signed
/// request against a label that does exist gets 403, never 404 — the
/// owner-gated write keeps the existence-leak shape separate from the
/// not-found shape (matches `add_label` and the AGENTS.md gate rules).
#[sqlx::test]
async fn remove_label_denies_non_owner(pool: PgPool) {
let state = test_state(pool).await;
let owner = "did:key:zLBLRMOwnr0000000000000000000000000000000000";
let owner_short = "zLBLRMOwnr0000000000000000000000000000000000";
let stranger = "did:key:zLBLRMStrn0000000000000000000000000000000000";
let repo_name = "lbl-rm-priv";
state
.db
.create_repo(&seed_repo(owner, repo_name))
.await
.expect("seed repo");
let repo_id = state
.db
.get_repo(owner_short, repo_name)
.await
.unwrap()
.unwrap()
.id;
state
.db
.add_label(&repo_id, "bug")
.await
.expect("seed label");
let router = || {
Router::new()
.route(
"/api/v1/repos/{owner}/{repo}/labels/{label}",
axum::routing::delete(crate::api::labels::remove_label),
)
.with_state(state.clone())
};
let resp = router()
.oneshot(signed_request_as(
stranger,
Method::DELETE,
&format!("/api/v1/repos/{owner_short}/{repo_name}/labels/bug"),
Body::empty(),
))
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"non-owner gets 403, the owner-gated mutation shape"
);
// Label is still there.
let labels = state.db.list_labels(&repo_id).await.unwrap();
assert!(labels.contains(&"bug".to_string()));
}
#[sqlx::test]
async fn list_repo_bounties_gate_denies_anon_on_private(pool: PgPool) {
let state = test_state(pool).await;
@@ -17877,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 {