fabro/lib/crates/fabro-server/tests/it/api/variables.rs
Bryan Helmkamp b1bd2f522c
feat(server): add variables API (#430)
## Summary

Adds a workflow-visible variables store and HTTP API for managing
non-sensitive run variables, then wires those variables into run config
interpolation before run creation, validation, and preflight.

## What Changed

- Adds `/api/v1/variables` CRUD endpoints backed by a JSON variable
store and generated Rust/TypeScript API types.
- Supports `{{ vars.NAME }}` interpolation alongside existing `{{
env.NAME }}` handling for run-owned config fields, including
environment, MCP, hook, artifact, checkpoint, SCM, and notification
settings.
- Reuses canonical `fabro-types` variable DTOs in `fabro-api` and adds
OpenAPI name patterns so clients see the same env-style variable
contract enforced by the server.
- Keeps variable updates store-owned with `update_existing`, avoiding
duplicated not-found/update semantics in the HTTP handler.
- Shares env-style name validation between variables, interpolation
parsing, and vault token names to avoid grammar drift.

Variables are intentionally non-sensitive: list/get responses include
values, unlike vault secrets.

## Validation

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo test -p fabro-types`
- `cargo test -p fabro-variable`
- `cargo test -p fabro-api --test variable_round_trip`
- `cargo test -p fabro-server --features test-support --test it
api::variables`
- `cargo +nightly-2026-04-14 clippy -p fabro-types -p fabro-variable -p
fabro-vault --all-targets -- -D warnings`
- `cargo +nightly-2026-04-14 clippy -p fabro-server --features
test-support --all-targets -- -D warnings`

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex/)
2026-05-27 11:46:36 -04:00

254 lines
7.5 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,
};
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 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.environment]
id = "local"
[environments.local]
provider = "local"
[environments.local.env]
RUNTIME_TOKEN = "{{ vars.RUNTIME_TOKEN }}"
"#
}]);
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"]["environment"]["env"]["RUNTIME_TOKEN"],
"token-from-variable"
);
}