Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions crates/gitlawb-node/src/api/arweave.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,61 @@ pub async fn list_anchors(
Query(q): Query<ListAnchorsQuery>,
) -> Result<Json<serde_json::Value>> {
let limit = q.limit.min(200);
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let anchors = state
.db
.list_arweave_anchors(q.repo.as_deref(), limit)
.await
.map_err(crate::error::AppError::Internal)?;
.await?;

Ok(Json(serde_json::json!({
"anchors": anchors,
"count": anchors.len(),
})))
}

#[cfg(test)]
mod closed_pool_tests {
use super::*;
use axum::http::{Request, StatusCode};
use axum::Router;
use serde_json::Value;
use sqlx::PgPool;
use tower::ServiceExt;

/// #251: a closed pool on /api/v1/arweave/anchors must be 503 db_unavailable.
#[sqlx::test]
async fn list_anchors_closed_pool_returns_503_db_unavailable(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
pool.close().await;

let resp = Router::new()
.route("/api/v1/arweave/anchors", axum::routing::get(list_anchors))
.with_state(state)
.oneshot(
Request::builder()
.uri("/api/v1/arweave/anchors")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed-pool outage must be retryable 503, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
serde_json::json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})
);
}
}
102 changes: 95 additions & 7 deletions crates/gitlawb-node/src/api/ipfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,9 @@ pub async fn get_by_cid(
.await
{
Ok(Ok(repos)) => repos,
Ok(Err(e)) => return Err(AppError::Internal(e)),
// Bare conversion (not `AppError::Internal`) so connection-class sqlx
// failures downcast to `AppError::Db` → 503 `db_unavailable` (#251).
Ok(Err(e)) => return Err(e.into()),
Err(_elapsed) => {
tracing::warn!(
budget_secs = state.config.ipfs_request_budget_secs,
Expand All @@ -228,7 +230,8 @@ pub async fn get_by_cid(
.await
{
Ok(Ok(rules)) => rules,
Ok(Err(e)) => return Err(AppError::Internal(e)),
// Same #251 downcast path as list_all_repos above.
Ok(Err(e)) => return Err(e.into()),
// FAIL CLOSED (security-critical): a timeout on the access-control query must
// DENY. Returning here — before the loop — means the scan can never fall
// through and apply an empty rule map, which would serve an unfiltered listing
Expand Down Expand Up @@ -681,18 +684,103 @@ pub async fn get_by_cid(
/// objects received via push. Each entry includes the git SHA-256 hex, the
/// CIDv1 string, and the timestamp when it was pinned.
pub async fn list_pins(State(state): State<AppState>) -> Result<Json<serde_json::Value>> {
let pins = state
.db
.list_pinned_cids()
.await
.map_err(AppError::Internal)?;
// Bare `?` so connection-class sqlx failures downcast to `AppError::Db` and
// map to 503 `db_unavailable` (not 500 via `.map_err(AppError::Internal)`) (#251).
let pins = state.db.list_pinned_cids().await?;

Ok(Json(serde_json::json!({
"pins": pins,
"count": pins.len(),
})))
}

#[cfg(test)]
mod closed_pool_tests {
use super::*;
use axum::http::{Request, StatusCode};
use axum::Router;
use serde_json::Value;
use sqlx::PgPool;
use tower::ServiceExt;

/// #251: a closed pool on /api/v1/ipfs/pins must be 503 db_unavailable,
/// not 500 internal_error from `.map_err(AppError::Internal)`.
#[sqlx::test]
async fn list_pins_closed_pool_returns_503_db_unavailable(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
pool.close().await;

let resp = Router::new()
.route("/api/v1/ipfs/pins", axum::routing::get(list_pins))
.with_state(state)
.oneshot(
Request::builder()
.uri("/api/v1/ipfs/pins")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed-pool outage must be retryable 503, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
serde_json::json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})
);
}

/// #251 / CodeRabbit nit: cover `get_by_cid`'s `list_all_repos` conversion
/// path — a valid CID must still yield 503 on a closed pool.
#[sqlx::test]
async fn get_by_cid_closed_pool_returns_503_db_unavailable(pool: PgPool) {
let state = crate::test_support::test_state(pool.clone()).await;
pool.close().await;

// Any sha2-256 CIDv1 passes the codec gate and then hits the DB.
let cid = gitlawb_core::cid::Cid::from_git_object_bytes(b"closed-pool-probe").to_string();

let resp = Router::new()
.route("/ipfs/{cid}", axum::routing::get(get_by_cid))
.with_state(state)
.oneshot(
Request::builder()
.uri(format!("/ipfs/{cid}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();

assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"closed-pool outage on get_by_cid must be retryable 503, not 500"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.expect("read body");
let v: Value = serde_json::from_slice(&bytes).expect("json body");
assert_eq!(
v,
serde_json::json!({
"error": crate::error::DB_UNAVAILABLE_CODE,
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
})
);
}
}

#[cfg(test)]
mod tests {
//! #174 P1-3 (U3): the public `GET /ipfs/{cid}` walk carries bounded CONCURRENCY
Expand Down
9 changes: 9 additions & 0 deletions crates/gitlawb-node/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,13 @@ mod tests {
})
);
}

/// #251: bare `?` on anyhow-wrapped sqlx relies on this downcast so a
/// closed pool becomes 503 `db_unavailable`, not 500 `internal_error`.
#[test]
fn pool_closed_via_anyhow_from_is_503_db_unavailable() {
let err: AppError = anyhow::Error::from(sqlx::Error::PoolClosed).into();
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}
Loading