fabro/lib/apps/fabro-server/tests/it/api/variables.rs
Bryan Helmkamp a36bea15d2
Remove the engine flag: every run is a Petri run
Delete `Engine`, `RunEngine`, `[workflow] engine`, `[server.execution]
engine`, `FABRO_SERVER_ENGINE` and `fabro server start --engine`. The run
spec records what Petri admitted as `admission: PetriAdmission`; the
create handler always admits through `Runtime::check`; `execute_run`
always launches the Petri worker (or executes in process under the test
override); the CLI runner takes only the Petri worker path, and its
legacy control arm, artifact uploader, signal pause handlers and
credential helpers go with it. The CLI's `attach` and `events` read the
run stream only.

Two gaps this surfaced are closed here: the check adapter binds the
server's run variables as Petri compile variables (`{{ vars.* }}` in a
prompt no longer fails admission), and deleting a run removes its Petri
records, lease, platform records, projection and stream.

Tests: the config engine tests are replaced (an engine key is unknown),
the API round-trip test covers `PetriAdmission`, the server and CLI
Petri scenarios drop their engine settings, and the API tests that read
legacy event names now read the run stream or the session events. The
remaining red tests are fixtures and scenarios of the legacy executor
and the legacy event store (`fabro-store` `slate` and `run_state`,
`fabro-types` legacy `run.created` JSON, the server's handler-registry
scenarios, the CLI dry-run snapshots), which the next steps of the F4.3
series delete or port.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 09:58:21 -04:00

394 lines
13 KiB
Rust

use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, minimal_intent_json, response_json, response_status,
test_app_state, test_app_state_with_options, test_settings,
};
fn json_request(method: Method, path: &str, body: &serde_json::Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(body).expect("request body should serialize"),
))
.expect("request should build")
}
fn empty_request(method: Method, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.body(Body::empty())
.expect("request should build")
}
#[tokio::test]
async fn variables_crud_exposes_values() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let list_empty = app
.clone()
.oneshot(empty_request(Method::GET, "/variables"))
.await
.expect("GET /variables should route");
let body = response_json(list_empty, StatusCode::OK, "GET /api/v1/variables").await;
assert_eq!(body, serde_json::json!({ "data": [] }));
let create = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "DEPLOY_ENV",
"value": "staging",
"description": "Deployment target"
}),
))
.await
.expect("POST /variables should route");
let body = response_json(create, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["name"], "DEPLOY_ENV");
assert_eq!(body["value"], "staging");
assert_eq!(body["description"], "Deployment target");
assert!(body.get("created_at").is_some());
assert!(body.get("updated_at").is_some());
let post_upsert = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "DEPLOY_ENV",
"value": "qa"
}),
))
.await
.expect("POST /variables upsert should route");
let body = response_json(post_upsert, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["value"], "qa");
assert_eq!(body["description"], "Deployment target");
let get = app
.clone()
.oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV"))
.await
.expect("GET /variables/DEPLOY_ENV should route");
let body = response_json(get, StatusCode::OK, "GET /api/v1/variables/DEPLOY_ENV").await;
assert_eq!(body["value"], "qa");
let update = app
.clone()
.oneshot(json_request(
Method::PUT,
"/variables/DEPLOY_ENV",
&serde_json::json!({ "value": "production" }),
))
.await
.expect("PUT /variables/DEPLOY_ENV should route");
let body = response_json(update, StatusCode::OK, "PUT /api/v1/variables/DEPLOY_ENV").await;
assert_eq!(body["value"], "production");
assert_eq!(body["description"], "Deployment target");
let list = app
.clone()
.oneshot(empty_request(Method::GET, "/variables"))
.await
.expect("GET /variables should route");
let body = response_json(list, StatusCode::OK, "GET /api/v1/variables").await;
assert_eq!(body["data"][0]["name"], "DEPLOY_ENV");
assert_eq!(body["data"][0]["value"], "production");
let delete = app
.clone()
.oneshot(empty_request(Method::DELETE, "/variables/DEPLOY_ENV"))
.await
.expect("DELETE /variables/DEPLOY_ENV should route");
response_status(
delete,
StatusCode::NO_CONTENT,
"DELETE /api/v1/variables/DEPLOY_ENV",
)
.await;
let missing = app
.oneshot(empty_request(Method::GET, "/variables/DEPLOY_ENV"))
.await
.expect("GET /variables/DEPLOY_ENV should route");
response_status(
missing,
StatusCode::NOT_FOUND,
"GET /api/v1/variables/DEPLOY_ENV",
)
.await;
}
#[tokio::test]
async fn variables_validate_names_and_allow_empty_values() {
let app = fabro_server::test_support::build_test_router(test_app_state());
let invalid = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "1BAD",
"value": "nope"
}),
))
.await
.expect("POST /variables should route");
response_status(invalid, StatusCode::BAD_REQUEST, "POST /api/v1/variables").await;
let invalid_get = app
.clone()
.oneshot(empty_request(Method::GET, "/variables/1BAD"))
.await
.expect("GET /variables/1BAD should route");
response_status(
invalid_get,
StatusCode::BAD_REQUEST,
"GET /api/v1/variables/1BAD",
)
.await;
let empty = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "EMPTY_ALLOWED",
"value": ""
}),
))
.await
.expect("POST /variables should route");
let body = response_json(empty, StatusCode::OK, "POST /api/v1/variables").await;
assert_eq!(body["value"], "");
let missing_delete = app
.oneshot(empty_request(Method::DELETE, "/variables/MISSING"))
.await
.expect("DELETE /variables/MISSING should route");
response_status(
missing_delete,
StatusCode::NOT_FOUND,
"DELETE /api/v1/variables/MISSING",
)
.await;
}
#[tokio::test]
async fn variables_persist_across_rebuilt_app_state() {
let vault_path = fabro_server::test_support::test_secret_store_path();
let state = fabro_server::test_support::TestAppStateBuilder::new()
.vault_path(vault_path.clone())
.build();
let app = fabro_server::test_support::build_test_router(state);
let create = app
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "PERSISTED",
"value": "from-sqlite"
}),
))
.await
.expect("POST /variables should route");
response_status(create, StatusCode::OK, "POST /api/v1/variables").await;
let rebuilt_state = fabro_server::test_support::TestAppStateBuilder::new()
.vault_path(vault_path)
.build();
let rebuilt_app = fabro_server::test_support::build_test_router(rebuilt_state);
let get = rebuilt_app
.oneshot(empty_request(Method::GET, "/variables/PERSISTED"))
.await
.expect("GET /variables/PERSISTED should route");
let body = response_json(get, StatusCode::OK, "GET /api/v1/variables/PERSISTED").await;
assert_eq!(body["value"], "from-sqlite");
}
#[tokio::test]
async fn run_config_substitutes_variables_before_persisting_settings() {
let workspace = tempfile::tempdir().unwrap();
let app = fabro_server::test_support::build_test_router(test_app_state());
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({
"name": "RUNTIME_TOKEN",
"value": "token-from-variable"
}),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let mut intent = minimal_intent_json(&app, MINIMAL_DOT, workspace.path()).await;
intent["goal"] = serde_json::json!("secret: {{ vars.RUNTIME_TOKEN }}");
let create_run = app
.clone()
.oneshot(json_request(Method::POST, "/runs", &intent))
.await
.expect("POST /runs should route");
let create_status = create_run.status();
let create_body = body_json(create_run.into_body()).await;
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let run_id = create_body["id"]
.as_str()
.expect("create run response should include id");
let settings = app
.oneshot(empty_request(
Method::GET,
&format!("/runs/{run_id}/settings"),
))
.await
.expect("GET run settings should route");
let body = response_json(
settings,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/settings"),
)
.await;
assert_eq!(body["run"]["goal"]["value"], "secret: token-from-variable");
}
#[tokio::test]
async fn run_create_interpolates_variables_into_node_prompts() {
let workspace = tempfile::tempdir().unwrap();
// End-to-end through the real run-create path: a server variable resolves
// inside a node `prompt` (a DOT graph attribute the settings substitution
// pass never touches), proving the variable store is snapshotted into the
// template render context at create time.
let state = test_app_state_with_options(test_settings(), 5);
let app = fabro_server::test_support::build_test_router(std::sync::Arc::clone(&state));
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({ "name": "SERVICE", "value": "billing" }),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond]
work [shape=box, prompt="Service: {{ vars.SERVICE }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let create_run = app
.clone()
.oneshot(json_request(
Method::POST,
"/runs",
&minimal_intent_json(&app, dot, workspace.path()).await,
))
.await
.expect("POST /runs should route");
let create_status = create_run.status();
let create_body = body_json(create_run.into_body()).await;
assert_eq!(create_status, StatusCode::CREATED, "{create_body}");
let run_id = create_body["id"]
.as_str()
.expect("create run response should include id");
// The run's stream holds its `run.created` record, whose spec carries
// the fully-rendered graph. The view trails the record, so wait for it.
state
.test_petri_projector()
.settle(run_id.parse().expect("run id"))
.await;
let events = app
.oneshot(empty_request(
Method::GET,
&format!("/runs/{run_id}/events"),
))
.await
.expect("GET run events should route");
let body = response_json(
events,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events"),
)
.await;
let created = body["data"]
.as_array()
.expect("events response should include data")
.iter()
.find(|item| item["item"]["record"]["kind"] == "run.created")
.expect("expected a run.created record");
assert_eq!(
created["item"]["record"]["spec"]["graph"]["nodes"]["work"]["attrs"]["prompt"]["String"],
"Service: billing",
"node prompt should interpolate the run variable; record: {created}"
);
}
#[tokio::test]
async fn run_validate_resolves_variables_in_node_prompts() {
let app = fabro_server::test_support::build_test_router(test_app_state_with_options(
test_settings(),
5,
));
let create_variable = app
.clone()
.oneshot(json_request(
Method::POST,
"/variables",
&serde_json::json!({ "name": "SERVICE", "value": "billing" }),
))
.await
.expect("POST /variables should route");
response_status(create_variable, StatusCode::OK, "POST /api/v1/variables").await;
let dot = r#"digraph Test {
graph [goal="Ship it"]
start [shape=Mdiamond]
work [shape=box, prompt="Service: {{ vars.SERVICE }}"]
exit [shape=Msquare]
start -> work -> exit
}"#;
let validate = app
.oneshot(json_request(
Method::POST,
"/validate",
&crate::helpers::minimal_manifest_json(dot),
))
.await
.expect("POST /validate should route");
let body = response_json(validate, StatusCode::OK, "POST /api/v1/validate").await;
assert_eq!(body["ok"], true, "{body}");
let diagnostics = body["workflow"]["diagnostics"]
.as_array()
.expect("validate response should include diagnostics");
assert!(
!diagnostics
.iter()
.any(|diagnostic| diagnostic["rule"] == "template_undefined_variable"),
"vars.SERVICE should resolve during validation; diagnostics: {diagnostics:?}"
);
}