mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
288 lines
9.6 KiB
Rust
288 lines
9.6 KiB
Rust
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{
|
|
MINIMAL_DOT, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run,
|
|
response_json, response_status, test_app_state_with_options, test_app_with_scheduler,
|
|
test_settings, wait_for_run_status,
|
|
};
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn archived_runs_reject_mutations_with_actionable_body() {
|
|
let state = test_app_state_with_options(test_settings(), 5);
|
|
let app = test_app_with_scheduler(state);
|
|
|
|
let run_id =
|
|
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
|
.await;
|
|
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
|
assert_eq!(status, "succeeded");
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/archive")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
format!("POST /api/v1/runs/{run_id}/archive"),
|
|
)
|
|
.await;
|
|
assert_eq!(body["lifecycle"]["status"]["kind"], "succeeded");
|
|
assert_eq!(body["lifecycle"]["archived"], true);
|
|
|
|
for path in &["/cancel", "/pause", "/unpause", "/start"] {
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}{path}")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
format!("POST /api/v1/runs/{run_id}{path}"),
|
|
)
|
|
.await;
|
|
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
|
|
assert!(
|
|
detail.contains("is archived") && detail.contains("fabro unarchive"),
|
|
"expected archived-rejection body on {path}, got: {body}"
|
|
);
|
|
}
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
"ts": "2026-04-19T12:00:00.000Z",
|
|
"run_id": run_id,
|
|
"event": "agent.message",
|
|
"properties": {}
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
format!("POST /api/v1/runs/{run_id}/events"),
|
|
)
|
|
.await;
|
|
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
|
|
assert!(
|
|
detail.contains("is archived") && detail.contains("fabro unarchive"),
|
|
"expected archived-rejection body on /events, got: {body}"
|
|
);
|
|
|
|
// The archive guard runs before each endpoint's state-specific lookups, so
|
|
// synthetic stage/question/filename values are enough to drive these
|
|
// write surfaces into the guard.
|
|
for (method, path, body, content_type) in [
|
|
(
|
|
"POST",
|
|
format!("/runs/{run_id}/questions/q-fake/answer"),
|
|
r#"{"kind":"text","text":"x"}"#,
|
|
"application/json",
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/runs/{run_id}/stages/fake@1/artifacts?filename=smoke.txt&retry=1"),
|
|
"payload",
|
|
"application/octet-stream",
|
|
),
|
|
(
|
|
"PUT",
|
|
format!("/runs/{run_id}/sandbox/file?path=smoke.txt"),
|
|
"payload",
|
|
"application/octet-stream",
|
|
),
|
|
(
|
|
"POST",
|
|
format!("/runs/{run_id}/blobs"),
|
|
"payload",
|
|
"application/octet-stream",
|
|
),
|
|
] {
|
|
let req = Request::builder()
|
|
.method(method)
|
|
.uri(api(&path))
|
|
.header("content-type", content_type)
|
|
.body(Body::from(body))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
format!("{method} /api/v1{path}"),
|
|
)
|
|
.await;
|
|
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
|
|
assert!(
|
|
detail.contains("is archived") && detail.contains("fabro unarchive"),
|
|
"expected archived-rejection body on {method} {path}, got: {body}"
|
|
);
|
|
}
|
|
|
|
// Unarchive restores the prior terminal status.
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/unarchive")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
format!("POST /api/v1/runs/{run_id}/unarchive"),
|
|
)
|
|
.await;
|
|
assert_eq!(body["lifecycle"]["status"]["kind"], "succeeded");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn appending_run_archived_event_directly_is_rejected() {
|
|
// Regression: archive/unarchive events must not be injectable via
|
|
// `append_run_event` — clients must use the operation endpoints.
|
|
let state = test_app_state_with_options(test_settings(), 5);
|
|
let app = test_app_with_scheduler(state);
|
|
|
|
let run_id =
|
|
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
|
.await;
|
|
wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/events")))
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(
|
|
serde_json::to_string(&serde_json::json!({
|
|
"id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
"ts": "2026-04-19T12:00:00.000Z",
|
|
"run_id": run_id,
|
|
"event": "run.archived",
|
|
"properties": {}
|
|
}))
|
|
.unwrap(),
|
|
))
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = crate::helpers::response_json(
|
|
response,
|
|
StatusCode::BAD_REQUEST,
|
|
format!("{}:{}", file!(), line!()),
|
|
)
|
|
.await;
|
|
let detail = body["errors"][0]["detail"].as_str().unwrap_or_default();
|
|
assert!(
|
|
detail.contains("lifecycle event"),
|
|
"expected lifecycle rejection, got: {body}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn archive_returns_404_for_unknown_run() {
|
|
let state = test_app_state_with_options(test_settings(), 5);
|
|
let app = test_app_with_scheduler(state);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/archive"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/archive",
|
|
)
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn list_runs_respects_include_archived_flag() {
|
|
let state = test_app_state_with_options(test_settings(), 5);
|
|
let app = test_app_with_scheduler(state);
|
|
|
|
let run_id =
|
|
create_and_start_run_from_manifest(&app, minimal_manifest_json_with_dry_run(MINIMAL_DOT))
|
|
.await;
|
|
wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
|
|
|
// Archive it.
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri(api(&format!("/runs/{run_id}/archive")))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
response_status(
|
|
app.clone().oneshot(req).await.unwrap(),
|
|
StatusCode::OK,
|
|
format!("POST /api/v1/runs/{run_id}/archive"),
|
|
)
|
|
.await;
|
|
|
|
// Default listing hides archived.
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(response, StatusCode::OK, "GET /api/v1/runs").await;
|
|
let ids_visible: Vec<String> = body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|item| item["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
assert!(
|
|
!ids_visible.contains(&run_id),
|
|
"archived run should be hidden, got {ids_visible:?}"
|
|
);
|
|
|
|
// `include_archived=true` surfaces it.
|
|
let req = Request::builder()
|
|
.method("GET")
|
|
.uri(api("/runs?include_archived=true"))
|
|
.body(Body::empty())
|
|
.unwrap();
|
|
let response = app.clone().oneshot(req).await.unwrap();
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
"GET /api/v1/runs?include_archived=true",
|
|
)
|
|
.await;
|
|
let ids_all: Vec<String> = body["data"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|item| item["id"].as_str().unwrap().to_string())
|
|
.collect();
|
|
assert!(
|
|
ids_all.contains(&run_id),
|
|
"include_archived=true should surface the run, got {ids_all:?}"
|
|
);
|
|
}
|