fabro/lib/apps/fabro-server/tests/it/scenario/archive.rs
Bryan Helmkamp eca2812602
Fix what the gates found after the removal sweep
The CLI artifact scenario seeded its run through the deleted upload
route; it now runs a real Petri workflow whose hooks collect the
artifacts, and the fabro artifact list and cp assertions read those.
A real command retry is not producible from a command node (a plain
failure or a timeout routes onward), so the retry dimension of the old
fixture goes; the stage, node, and retry filters, the tree copies, the
cross-stage ambiguity, and the filename collision stay covered. The
archive guard test drops its upload row (the blob write row covers an
octet-stream mutation). A dangling doc comment and two absolute paths
clippy flagged are fixed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:15:15 -04:00

219 lines
7.1 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_intent, minimal_intent_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 workspace = tempfile::tempdir().unwrap();
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_intent(
&app,
minimal_intent_json_with_dry_run(&app, MINIMAL_DOT, workspace.path()).await,
)
.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}"
);
}
// 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",
),
(
"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 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 workspace = tempfile::tempdir().unwrap();
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_intent(
&app,
minimal_intent_json_with_dry_run(&app, MINIMAL_DOT, workspace.path()).await,
)
.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:?}"
);
}