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:
Bryan Helmkamp 2026-04-19 17:42:50 -04:00
parent 05743050ec
commit 3ae46b37c1
No known key found for this signature in database
3 changed files with 383 additions and 0 deletions

View file

@ -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();

View 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:?}"
);
}

View file

@ -1,3 +1,4 @@
mod archive;
mod dry_run;
mod lifecycle;
mod run_completion;