mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
517 lines
18 KiB
Rust
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(Database::new(
|
|
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());
|
|
}
|