mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-05 08:10:39 +00:00
401 lines
13 KiB
Rust
401 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_manifest_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 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 manifest = minimal_manifest_json(MINIMAL_DOT);
|
|
manifest["configs"] = serde_json::json!([{
|
|
"type": "project",
|
|
"path": ".fabro/project.toml",
|
|
"source": r#"
|
|
_version = 1
|
|
|
|
[run]
|
|
goal = "secret: {{ vars.RUNTIME_TOKEN }}"
|
|
|
|
[run.environment]
|
|
id = "local"
|
|
"#
|
|
}]);
|
|
|
|
let create_run = app
|
|
.clone()
|
|
.oneshot(json_request(Method::POST, "/runs", &manifest))
|
|
.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() {
|
|
// 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 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 create_run = app
|
|
.clone()
|
|
.oneshot(json_request(
|
|
Method::POST,
|
|
"/runs",
|
|
&minimal_manifest_json(dot),
|
|
))
|
|
.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 persisted `run.created` event carries the fully-rendered graph.
|
|
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(|event| event["event"] == "run.created")
|
|
.expect("expected a run.created event");
|
|
assert_eq!(
|
|
created["properties"]["graph"]["nodes"]["work"]["attrs"]["prompt"]["String"],
|
|
"Service: billing",
|
|
"node prompt should interpolate the run variable; event: {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",
|
|
&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:?}"
|
|
);
|
|
}
|