From 83a21e5d37338ce6d1abfc8bfaebc8f3201218ec Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 17:40:14 -0400 Subject: [PATCH] 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) --- lib/crates/fabro-server/tests/it/api/mod.rs | 1 + .../fabro-server/tests/it/api/run_files.rs | 191 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 lib/crates/fabro-server/tests/it/api/run_files.rs diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index f0ec3e972..40d4fb95c 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -2,6 +2,7 @@ mod docs; mod install; mod install_openai_compatible; mod routing; +mod run_files; mod runs; mod settings; mod system; diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs new file mode 100644 index 000000000..65b86098c --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -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()); + } +}