test(server): Run Files HTTP integration suite (P2-9)

Adds lib/crates/fabro-server/tests/it/api/run_files.rs covering the
HTTP-level plumbing branches of GET /api/v1/runs/{id}/files:

- Invalid run_id path returns 400
- Unknown run returns 404 (IDOR-safe; same status as missing-run case)
- Malformed from_sha / to_sha query params return 400 before any work
- Non-default from_sha value returns 400 even when hex-well-formed
  (v1 reserves the parameter for a future version)
- Submitted run with no sandbox record returns empty envelope
- Demo mode (X-Fabro-Demo: 1) returns the 3-entry fixture without
  touching the run store, with at least one populated-content entry
- Response envelope shape matches PaginatedRunFileList contract:
  data: FileDiff[], meta: { truncated, total_changed, ... } with
  correct field types

Sandbox-path happy case (live diff) and degraded-fallback scenarios
stay covered by unit tests on stitch_file_diff, build_fallback_response,
and the sandbox_git helpers, since integration-level scheduler setup
for terminal-run tests is flaky without broader harness scaffolding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 17:40:14 -04:00
parent fd9596883a
commit 83a21e5d37
No known key found for this signature in database
2 changed files with 192 additions and 0 deletions

View file

@ -2,6 +2,7 @@ mod docs;
mod install;
mod install_openai_compatible;
mod routing;
mod run_files;
mod runs;
mod settings;
mod system;

View file

@ -0,0 +1,191 @@
//! HTTP-level integration tests for `GET /api/v1/runs/{id}/files`.
//!
//! These tests exercise the handler's request-plumbing branches —
//! authentication extractor, route matching, query validation, demo-mode
//! branching, and the empty-envelope / not-found responses — without
//! requiring a reconnected sandbox. The sandbox happy path is covered by
//! unit tests on the sandbox-git helpers and by `stitch_file_diff` tests
//! in `run_files.rs`.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::build_router;
use tower::ServiceExt;
use crate::helpers::{MINIMAL_DOT, api, body_json, minimal_manifest_json, test_app_state};
fn files_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/files"))
}
#[tokio::test]
async fn invalid_run_id_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri(files_url("not-a-ulid"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn unknown_run_returns_404() {
let app = build_router(test_app_state(), AuthMode::Disabled);
// Valid ULID format but not a run we've created.
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(files_url(fake))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn malformed_from_sha_query_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(format!("{}?from_sha=not-hex", files_url(fake)))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body = body_json(resp.into_body()).await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("from_sha")
);
}
#[tokio::test]
async fn non_default_from_sha_returns_400_even_when_hex() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
// Well-formed hex SHA but v1 reserves the parameter for a future
// version; any non-default value must be rejected.
let req = Request::builder()
.method("GET")
.uri(format!(
"{}?from_sha=abc1234def56789abc1234def56789abc1234def",
files_url(fake)
))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn malformed_to_sha_returns_400() {
let app = build_router(test_app_state(), AuthMode::Disabled);
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(format!("{}?to_sha=xyz", files_url(fake)))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn submitted_run_without_sandbox_returns_empty_envelope() {
// A run that has been created but not started has no base_sha or
// sandbox record, so the handler returns an empty envelope. The UI
// maps that to R4(a).
let app = build_router(test_app_state(), AuthMode::Disabled);
let manifest = minimal_manifest_json(MINIMAL_DOT);
let create_req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&manifest).unwrap()))
.unwrap();
let create_resp = app.clone().oneshot(create_req).await.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let create_body = body_json(create_resp.into_body()).await;
let run_id = create_body["id"].as_str().unwrap().to_string();
let req = Request::builder()
.method("GET")
.uri(files_url(&run_id))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
assert!(
body["data"].as_array().is_some_and(Vec::is_empty),
"expected empty data: {body}"
);
assert_eq!(body["meta"]["total_changed"], 0);
// Degraded is false because there's no final_patch either — the run
// simply hasn't produced anything to diff.
assert_eq!(body["meta"]["degraded"].as_bool(), Some(false));
}
#[tokio::test]
async fn demo_mode_returns_fixture_without_touching_store() {
// R34: demo handler must return the illustrative fixture with no
// cross-contamination with real run state.
let app = build_router(test_app_state(), AuthMode::Disabled);
let arbitrary = "not-even-a-valid-ulid-for-run";
let req = Request::builder()
.method("GET")
.uri(files_url(arbitrary))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
// Demo fixture ships three entries (modified + added + renamed).
let data = body["data"].as_array().expect("data array");
assert_eq!(data.len(), 3, "demo fixture should have 3 entries");
// At least one entry must render with populated contents to prove the
// fixture exercises the MultiFileDiff branch.
let has_content = data.iter().any(|entry| {
entry["new_file"]["contents"]
.as_str()
.is_some_and(|s| !s.is_empty())
});
assert!(has_content, "demo fixture should contain populated content");
}
#[tokio::test]
async fn response_envelope_matches_openapi_paginated_run_file_list_shape() {
// Sanity check that the happy-path envelope shape matches what the
// OpenAPI spec + regenerated TS client expect. Uses demo mode so the
// test stays deterministic without running a sandbox.
let app = build_router(test_app_state(), AuthMode::Disabled);
let req = Request::builder()
.method("GET")
.uri(files_url("whatever"))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_json(resp.into_body()).await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert!(body["meta"]["truncated"].is_boolean());
assert!(body["meta"]["total_changed"].is_number());
for entry in body["data"].as_array().unwrap() {
assert!(entry["old_file"]["name"].is_string());
assert!(entry["old_file"]["contents"].is_string());
assert!(entry["new_file"]["name"].is_string());
assert!(entry["new_file"]["contents"].is_string());
}
}