mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
test(server): verify R14 contract and tighten rewind guard
Scenario coverage for the plan's R14 read-only-on-archived contract over HTTP: - archived_runs_reject_mutations_with_actionable_body drives a run to succeeded, archives it, then asserts 409 on /cancel, /pause, /unpause, /start, and /events with the actionable 'fabro unarchive' body. - appending_run_archived_event_directly_is_rejected covers the widened denylist on append_run_event. - archive_returns_404_for_unknown_run proves the RunNotFound mapping. - list_runs_respects_include_archived_flag exercises Unit 5's listing filter. Also adds inline server.rs tests that pin the spec/router behavior at the unit layer and documents that rewind.rs now requires callers to pass current_status (already threaded through from the CLI and scenario tests).
This commit is contained in:
parent
05743050ec
commit
3ae46b37c1
3 changed files with 383 additions and 0 deletions
|
|
@ -8265,6 +8265,41 @@ slug = "fabro"
|
|||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_run_event_rejects_reserved_archive_event() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id = create_run(&app, MINIMAL_DOT).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/events")))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"id": "evt-run-archived",
|
||||
"ts": "2026-04-19T12:00:00Z",
|
||||
"run_id": run_id,
|
||||
"event": "run.archived",
|
||||
"properties": {
|
||||
"actor": null
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert!(
|
||||
body["errors"][0]["detail"]
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("run.archived is a lifecycle event")),
|
||||
"expected lifecycle rejection, got: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_checkpoint_returns_null_initially() {
|
||||
let state = create_app_state();
|
||||
|
|
@ -8900,6 +8935,145 @@ slug = "fabro"
|
|||
assert!(items[0]["total_usd_micros"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_and_unarchive_updates_listing_visibility() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
let run_id = fixtures::RUN_1;
|
||||
|
||||
create_durable_run_with_events(&state, run_id, &[
|
||||
workflow_event::Event::RunSubmitted {
|
||||
reason: None,
|
||||
definition_blob: None,
|
||||
},
|
||||
workflow_event::Event::RunStarting { reason: None },
|
||||
workflow_event::Event::RunRunning { reason: None },
|
||||
workflow_event::Event::WorkflowRunCompleted {
|
||||
duration_ms: 1000,
|
||||
artifact_count: 0,
|
||||
status: "success".to_string(),
|
||||
reason: None,
|
||||
total_usd_micros: None,
|
||||
final_git_commit_sha: None,
|
||||
final_patch: None,
|
||||
billing: None,
|
||||
},
|
||||
])
|
||||
.await;
|
||||
|
||||
let archive_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(archive_response.status(), StatusCode::OK);
|
||||
let archive_body = body_json(archive_response.into_body()).await;
|
||||
assert_eq!(archive_body["status"].as_str(), Some("archived"));
|
||||
|
||||
let hidden_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hidden_response.status(), StatusCode::OK);
|
||||
let hidden_body = body_json(hidden_response.into_body()).await;
|
||||
assert!(
|
||||
!hidden_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|item| item["run_id"].as_str() == Some(&run_id.to_string())),
|
||||
"archived run should be hidden from default listing"
|
||||
);
|
||||
|
||||
let visible_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs?include_archived=true"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(visible_response.status(), StatusCode::OK);
|
||||
let visible_body = body_json(visible_response.into_body()).await;
|
||||
let archived_item = visible_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
||||
.expect("archived run should appear when include_archived=true");
|
||||
assert_eq!(archived_item["status"].as_str(), Some("archived"));
|
||||
|
||||
let unarchive_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/unarchive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unarchive_response.status(), StatusCode::OK);
|
||||
let unarchive_body = body_json(unarchive_response.into_body()).await;
|
||||
assert_eq!(unarchive_body["status"].as_str(), Some("succeeded"));
|
||||
|
||||
let restored_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(api("/runs"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restored_response.status(), StatusCode::OK);
|
||||
let restored_body = body_json(restored_response.into_body()).await;
|
||||
let restored_item = restored_body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|item| item["run_id"].as_str() == Some(&run_id.to_string()))
|
||||
.expect("unarchived run should reappear in default listing");
|
||||
assert_eq!(restored_item["status"].as_str(), Some("succeeded"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn archive_unknown_run_returns_not_found() {
|
||||
let app = test_app_with();
|
||||
let run_id = fixtures::RUN_64;
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(api(&format!("/runs/{run_id}/archive")))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_run_removes_durable_run() {
|
||||
let state = create_app_state();
|
||||
|
|
|
|||
208
lib/crates/fabro-server/tests/it/scenario/archive.rs
Normal file
208
lib/crates/fabro-server/tests/it/scenario/archive.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
//! End-to-end HTTP coverage for the archived run status: the R14 mutation
|
||||
//! rejection contract, archive/unarchive status codes, and the
|
||||
//! `include_archived` listing filter.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{
|
||||
MINIMAL_DOT, api, body_json, create_and_start_run_from_manifest,
|
||||
minimal_manifest_json_with_dry_run, 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");
|
||||
|
||||
// Archive the run.
|
||||
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();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert_eq!(body["status"], "archived");
|
||||
|
||||
// Every mutation endpoint that guards against archived runs returns 409 with
|
||||
// an actionable "unarchive first" body.
|
||||
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();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"expected 409 for POST /runs/{{id}}{path} on archived run"
|
||||
);
|
||||
let body = body_json(response.into_body()).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}"
|
||||
);
|
||||
}
|
||||
|
||||
// Client-supplied lifecycle events on the archived run are rejected.
|
||||
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();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::CONFLICT,
|
||||
"expected 409 for POST /runs/{{id}}/events on archived run"
|
||||
);
|
||||
|
||||
// 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();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert_eq!(body["status"], "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();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = body_json(response.into_body()).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();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[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();
|
||||
app.clone().oneshot(req).await.unwrap();
|
||||
|
||||
// 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 = body_json(response.into_body()).await;
|
||||
let ids_visible: Vec<String> = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["run_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 = body_json(response.into_body()).await;
|
||||
let ids_all: Vec<String> = body["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["run_id"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
ids_all.contains(&run_id),
|
||||
"include_archived=true should surface the run, got {ids_all:?}"
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
mod archive;
|
||||
mod dry_run;
|
||||
mod lifecycle;
|
||||
mod run_completion;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue