fabro/lib/apps/fabro-server/tests/it/api/run_files.rs
Bryan Helmkamp 80bc51c40e
Open sandbox provider identity to plugin kinds
SandboxProviderKind is now a validated string newtype instead of a
closed enum. The bundled kinds (local, docker, daytona) keep their
constants and a BundledProvider enum for the code paths that still
dispatch on them; any other well-formed sandbox-driver kind name is
accepted and names a plugin executable. EnvironmentProvider is gone:
environment settings carry SandboxProviderKind directly, and
is_clone_based is replaced by a workspace policy where local runs in a
designated directory and every other provider clones.

Server sandbox policy is keyed by kind. [server.sandbox.providers.<kind>]
accepts the bundled kinds with `enabled` and any plugin kind with its
launch settings (path, sha256, dev, args, env, inherit_env); bundled
kinds reject the plugin keys and a kind with no entry is disabled. The
OpenAPI schema, generated Rust and TypeScript clients, web settings
pages, and docs follow. The environments table drops its provider CHECK
enumeration in favour of the kind name rules so a plugin environment
can be stored.

Bundled-only code paths (run start, preflight, reconnect, terminal,
details) now fail with an explicit message for a plugin kind until the
driver construction function lands in the next step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 15:17:32 -06:00

517 lines
18 KiB
Rust

//! 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 std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::test_support::test_app_state_with_store;
use fabro_store::{ArtifactStore, Database};
use fabro_types::{Graph, RunId, SandboxProviderKind, WorkflowSettings, test_support};
use fabro_workflow::event as workflow_event;
use fabro_workflow::run_status::SuccessReason;
use object_store::memory::InMemory as MemoryObjectStore;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state,
test_settings,
};
fn files_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/files"))
}
fn commits_url(run_id: &str) -> String {
api(&format!("/runs/{run_id}/commits"))
}
fn files_url_with_scope(run_id: &str, scope: &str) -> String {
format!("{}?scope={scope}", files_url(run_id))
}
fn store_bundle() -> (Arc<Database>, ArtifactStore) {
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(MemoryObjectStore::new());
let store = Arc::new(fabro_store::test_support::test_database(
Arc::clone(&object_store),
"",
Duration::from_millis(1),
None,
));
let artifact_store = ArtifactStore::new(object_store, "artifacts");
(store, artifact_store)
}
async fn append_completed_run_with_final_patch(
store: &Database,
run_id: &RunId,
final_patch: &str,
) {
let run_store = store.create_run(run_id).await.expect("create run store");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunCreated {
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default())
.expect("workflow settings should serialize"),
graph: serde_json::to_value(Graph::new("test"))
.expect("graph should serialize"),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.expect("append RunCreated");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.expect("append RunRunnable");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunStarting)
.await
.expect("append RunStarting");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::WorkflowRunStarted {
name: "test".to_string(),
run_id: *run_id,
base_branch: Some("main".to_string()),
base_sha: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
run_branch: Some("fabro/run/test".to_string()),
worktree_dir: None,
goal: Some("Test degraded files".to_string()),
},
)
.await
.expect("append WorkflowRunStarted");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunning)
.await
.expect("append RunRunning");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::WorkflowRunCompleted {
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
final_patch: Some(final_patch.to_string()),
diff_summary: None,
billing: None,
},
)
.await
.expect("append WorkflowRunCompleted");
}
async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) {
let run_store = store.open_run(run_id).await.expect("open run store");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::SandboxInitialized {
working_directory: std::env::current_dir()
.expect("test should run inside a source checkout")
.display()
.to_string(),
provider: SandboxProviderKind::LOCAL,
id: "local:test-sandbox".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
)
.await
.expect("append SandboxInitialized");
}
#[tokio::test]
async fn invalid_run_id_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(files_url("not-a-ulid"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
"GET /api/v1/runs/not-a-ulid/files",
)
.await;
}
#[tokio::test]
async fn unknown_run_returns_404() {
let app = fabro_server::test_support::build_test_router(test_app_state());
// 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();
response_status(
resp,
StatusCode::NOT_FOUND,
format!("GET /api/v1/runs/{fake}/files"),
)
.await;
}
#[tokio::test]
async fn malformed_from_sha_query_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
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();
let body = crate::helpers::response_json(
resp,
StatusCode::BAD_REQUEST,
format!("{}:{}", file!(), line!()),
)
.await;
assert!(
body["errors"][0]["detail"]
.as_str()
.unwrap_or_default()
.contains("from_sha")
);
}
#[tokio::test]
async fn one_sided_from_sha_returns_400_even_when_hex() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
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();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?from_sha=<one-sided>"),
)
.await;
}
#[tokio::test]
async fn malformed_to_sha_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
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();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?to_sha=xyz"),
)
.await;
}
#[tokio::test]
async fn invalid_scope_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let fake = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let req = Request::builder()
.method("GET")
.uri(files_url_with_scope(fake, "dirty"))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
response_status(
resp,
StatusCode::BAD_REQUEST,
format!("GET /api/v1/runs/{fake}/files?scope=dirty"),
)
.await;
}
#[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
// run sandbox, so the handler returns an empty envelope. The UI
// maps that to R4(a).
let app = fabro_server::test_support::build_test_router(test_app_state());
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();
let create_body = response_json(create_resp, StatusCode::CREATED, "POST /api/v1/runs").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();
let body = response_json(
resp,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/files"),
)
.await;
assert!(
body["data"].as_array().is_some_and(Vec::is_empty),
"expected empty data: {body}"
);
assert_eq!(body["meta"]["total_changed"], 0);
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
// 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 degraded_run_returns_file_diff_shape_without_meta_patch() {
let settings = test_settings();
let (store, artifact_store) = store_bundle();
let state = test_app_state_with_store(
settings.server_settings,
settings.manifest_run_defaults,
5,
Arc::clone(&store),
artifact_store,
);
let app = fabro_server::test_support::build_test_router(state);
let run_id = RunId::new();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,2 @@
old
+new
diff --git a/.env.production b/.env.production
--- a/.env.production
+++ b/.env.production
@@ -1 +1 @@
-SECRET=old
+SECRET=new
";
append_completed_run_with_final_patch(&store, &run_id, patch).await;
append_local_sandbox_initialized(&store, &run_id).await;
let req = Request::builder()
.method("GET")
.uri(files_url(&run_id.to_string()))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(
resp,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/files"),
)
.await;
assert_eq!(body["meta"]["degraded"].as_bool(), Some(true));
assert!(body["meta"]["degraded_reason"].is_string());
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
assert!(body["meta"].get("patch").is_none());
assert_eq!(body["meta"]["total_changed"], 2);
assert_eq!(body["meta"]["truncated"].as_bool(), Some(false));
let data = body["data"].as_array().expect("data should be an array");
assert_eq!(data.len(), 2);
assert_eq!(data[0]["old_file"]["contents"], serde_json::Value::Null);
assert_eq!(data[0]["new_file"]["contents"], serde_json::Value::Null);
assert!(data[0]["unified_patch"].is_string());
assert_eq!(data[1]["sensitive"].as_bool(), Some(true));
assert_eq!(data[1]["old_file"]["contents"], serde_json::Value::Null);
assert_eq!(data[1]["new_file"]["contents"], serde_json::Value::Null);
assert!(data[1].get("unified_patch").is_none());
}
#[tokio::test]
async fn planned_sandbox_rejects_files_for_every_scope() {
let settings = test_settings();
let (store, artifact_store) = store_bundle();
let state = test_app_state_with_store(
settings.server_settings,
settings.manifest_run_defaults,
5,
Arc::clone(&store),
artifact_store,
);
let app = fabro_server::test_support::build_test_router(state);
let run_id = RunId::new();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,2 @@
old
+new
";
append_completed_run_with_final_patch(&store, &run_id, patch).await;
for scope in ["committed", "uncommitted", "all"] {
let req = Request::builder()
.method("GET")
.uri(files_url_with_scope(&run_id.to_string(), scope))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let body = response_json(
resp,
StatusCode::NOT_FOUND,
format!("GET /api/v1/runs/{run_id}/files?scope={scope}"),
)
.await;
assert_eq!(
body["errors"][0]["detail"].as_str(),
Some("Run sandbox was not created.")
);
}
}
#[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 = fabro_server::test_support::build_test_router(test_app_state());
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();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
// Demo fixture ships three entries (modified + added + renamed).
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
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 = fabro_server::test_support::build_test_router(test_app_state());
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();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert!(body.get("source").is_none());
assert!(body["meta"]["source"].is_string());
assert!(body["meta"]["scope"].is_string());
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());
}
}
#[tokio::test]
async fn commit_response_envelope_matches_openapi_paginated_run_commit_list_shape() {
// Sanity check that the commits route is wired and returns the envelope
// shape generated into the TypeScript client. Demo mode keeps this
// route-level test deterministic without requiring a live sandbox.
let app = fabro_server::test_support::build_test_router(test_app_state());
let req = Request::builder()
.method("GET")
.uri(commits_url("whatever"))
.header("x-fabro-demo", "1")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/commits").await;
assert!(body["data"].is_array());
assert!(body["meta"].is_object());
assert_eq!(body["meta"]["source"].as_str(), Some("sandbox"));
assert!(body["meta"]["base_sha"].is_string());
assert!(body["meta"]["head_sha"].is_string());
assert!(body["meta"]["limit"].is_number());
assert!(body["meta"]["total_returned"].is_number());
assert!(body["meta"]["truncated"].is_boolean());
let data = body["data"].as_array().expect("data array");
assert_eq!(data.len(), 1, "demo commits fixture should have one commit");
let commit = &data[0];
assert!(commit["sha"].is_string());
assert!(commit["short_sha"].is_string());
assert!(commit["parents"].is_array());
assert!(commit["author"].is_object());
assert!(commit["committer"].is_object());
assert!(commit["subject"].is_string());
assert!(commit["message"].is_string());
assert!(commit["trailers"].is_object());
assert!(commit["tree_sha"].is_string());
}