fabro/lib/apps/fabro-server/tests/it/api/environments.rs
Scott Werner e87130ae23 Require a server-managed environment for automations
Automations now store an environment_id that must reference an enabled
Docker or Daytona environment. Each trigger fire resolves the current
environment definition and snapshots its settings into the run, and
deleting an environment still referenced by an automation is rejected
with a conflict.

Existing automations are backfilled conservatively: a compatible
environment named default is selected when present, otherwise the sole
compatible environment. Anything ambiguous is left incomplete and cannot
run until an operator selects an environment in the web UI.

Scheduler failures are recorded on the automation as last_error and
cleared after the next successful scheduled run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 09:58:13 -04:00

789 lines
24 KiB
Rust

use std::path::PathBuf;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_config::{RunEnvironmentLayer, RunLayer};
use fabro_server::server::build_router;
use fabro_server::test_support::{
TestAppStateBuilder, build_test_router, default_test_server_settings, test_auth_mode,
test_environment_from_storage_dir,
};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::helpers::{api, checked_response, response_json, response_status};
fn environment_settings(provider: &str) -> Value {
json!({
"provider": provider,
"image": {
"docker": if provider == "docker" { json!("alpine:3.20") } else { Value::Null },
"dockerfile": null
},
"resources": {
"cpu": null,
"memory": null,
"disk": null
},
"network": {
"mode": "allow_all",
"allow": []
},
"lifecycle": {
"preserve": false,
"stop_on_terminal": true,
"auto_stop": null
},
"labels": {},
"env": {}
})
}
fn environment_body(id: &str, provider: &str) -> Value {
let mut body = environment_settings(provider);
body["id"] = json!(id);
body
}
fn environment_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let environment_dir = temp_dir.path().join("environments");
let vault_path = temp_dir.path().join("secrets.json");
let state = TestAppStateBuilder::new()
.active_config_path(active_config_path)
.vault_path(vault_path)
.build();
(build_test_router(state), temp_dir, environment_dir)
}
fn environment_app_with_default_environment(
environment_id: &str,
) -> (axum::Router, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let active_config_path = temp_dir.path().join("settings.toml");
let vault_path = temp_dir.path().join("secrets.json");
let manifest_run_defaults = RunLayer {
environment: Some(RunEnvironmentLayer {
id: Some(environment_id.to_string()),
..RunEnvironmentLayer::default()
}),
..RunLayer::default()
};
let state = TestAppStateBuilder::new()
.runtime_settings(default_test_server_settings(), manifest_run_defaults)
.active_config_path(active_config_path)
.vault_path(vault_path)
.build();
(build_test_router(state), temp_dir)
}
fn json_request(method: Method, path: &str, body: &Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(body).expect("environment fixture should serialize"),
))
.expect("environment JSON request should build")
}
fn empty_request(method: Method, path: &str) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.body(Body::empty())
.expect("environment request should build")
}
fn request_with_if_match(
method: Method,
path: &str,
revision: &str,
body: Option<Value>,
) -> Request<Body> {
let mut builder = Request::builder()
.method(method)
.uri(api(path))
.header(header::IF_MATCH, revision);
let body = match body {
Some(value) => {
builder = builder.header(header::CONTENT_TYPE, "application/json");
Body::from(serde_json::to_vec(&value).expect("environment fixture should serialize"))
}
None => Body::empty(),
};
builder
.body(body)
.expect("environment If-Match request should build")
}
async fn create_environment(app: &axum::Router, id: &str, provider: &str) -> Value {
create_environment_with_body(app, &environment_body(id, provider)).await
}
async fn create_environment_with_body(app: &axum::Router, body: &Value) -> Value {
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", body))
.await
.expect("create environment should respond");
response_json(response, StatusCode::CREATED, "POST /api/v1/environments").await
}
fn revision_from(body: &Value) -> &str {
body["revision"]
.as_str()
.expect("environment response should include a revision")
}
async fn persisted_environment(
temp_dir: &tempfile::TempDir,
id: &str,
) -> Option<fabro_environment::Environment> {
test_environment_from_storage_dir(temp_dir.path(), id)
.await
.expect("environment store should load from test storage")
}
async fn system_info(app: &axum::Router) -> Value {
let response = app
.clone()
.oneshot(empty_request(Method::GET, "/system/info"))
.await
.expect("system info should respond");
response_json(response, StatusCode::OK, "GET /api/v1/system/info").await
}
#[tokio::test]
async fn list_environments_returns_seeded_catalog_sorted_by_id() {
let (app, _temp_dir, _environment_dir) = environment_app();
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let body = response_json(response, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(body["meta"]["total"], 2);
assert_eq!(
body["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.map(|environment| environment["id"]
.as_str()
.expect("environment should have id"))
.collect::<Vec<_>>(),
vec!["default", "local"]
);
}
#[tokio::test]
async fn create_environment_persists_to_sqlite_and_is_visible() {
let (app, temp_dir, environment_dir) = environment_app();
let mut body = environment_body("custom-env", "docker");
body["cwd"] = json!("/workspace/custom");
let created = create_environment_with_body(&app, &body).await;
assert_eq!(created["id"], "custom-env");
assert_eq!(created["provider"], "docker");
assert_eq!(created["cwd"], "/workspace/custom");
assert!(!environment_dir.join("custom-env.toml").exists());
let retrieved = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/custom-env"))
.await
.expect("get environment should respond");
let retrieved = response_json(
retrieved,
StatusCode::OK,
"GET /api/v1/environments/custom-env",
)
.await;
assert_eq!(retrieved["id"], "custom-env");
assert_eq!(retrieved["cwd"], "/workspace/custom");
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert_eq!(list["meta"]["total"], 3);
assert!(
list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "custom-env")
);
let persisted = persisted_environment(&temp_dir, "custom-env")
.await
.expect("custom environment should persist to SQLite");
assert_eq!(persisted.settings.provider.to_string(), "docker");
assert_eq!(persisted.settings.cwd.as_deref(), Some("/workspace/custom"));
}
#[tokio::test]
async fn get_environment_returns_current_etag() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "etag-env", "local").await;
let revision = revision_from(&created);
let response = app
.oneshot(empty_request(Method::GET, "/environments/etag-env"))
.await
.expect("get environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"GET /api/v1/environments/etag-env",
)
.await;
assert_eq!(
response
.headers()
.get(header::ETAG)
.expect("GET environment should include ETag"),
&format!("\"{revision}\"")
);
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["revision"], revision);
}
#[tokio::test]
async fn replace_environment_updates_sqlite_and_returns_new_etag() {
let (app, temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "replace-env", "docker").await;
let revision = revision_from(&created);
let mut replacement = environment_settings("local");
replacement["labels"] = json!({ "tier": "dev" });
replacement["cwd"] = json!("/srv/fabro/local");
let response = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/replace-env",
revision,
Some(replacement),
))
.await
.expect("replace environment should respond");
let response = checked_response(
response,
StatusCode::OK,
"PUT /api/v1/environments/replace-env",
)
.await;
let etag = response
.headers()
.get(header::ETAG)
.expect("PUT environment should include ETag")
.to_str()
.expect("ETag should be ASCII")
.to_string();
let body = crate::helpers::body_json(response.into_body()).await;
assert_eq!(body["provider"], "local");
assert_eq!(body["labels"]["tier"], "dev");
assert_eq!(body["cwd"], "/srv/fabro/local");
assert_ne!(body["revision"], revision);
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
assert!(!environment_dir.join("replace-env.toml").exists());
let persisted = persisted_environment(&temp_dir, "replace-env")
.await
.expect("replacement should persist to SQLite");
assert_eq!(
persisted.settings.labels.get("tier").map(String::as_str),
Some("dev")
);
assert_eq!(persisted.settings.cwd.as_deref(), Some("/srv/fabro/local"));
}
#[tokio::test]
async fn replace_and_delete_environment_require_if_match() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "match-env", "local").await;
let replace_response = app
.clone()
.oneshot(json_request(
Method::PUT,
"/environments/match-env",
&environment_settings("docker"),
))
.await
.expect("replace without If-Match should respond");
response_status(
replace_response,
StatusCode::PRECONDITION_REQUIRED,
"PUT /api/v1/environments/match-env without If-Match",
)
.await;
let delete_response = app
.oneshot(empty_request(Method::DELETE, "/environments/match-env"))
.await
.expect("delete without If-Match should respond");
response_status(
delete_response,
StatusCode::PRECONDITION_REQUIRED,
"DELETE /api/v1/environments/match-env without If-Match",
)
.await;
}
#[tokio::test]
async fn stale_environment_replace_and_delete_return_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
let created = create_environment(&app, "stale-env", "docker").await;
let stale_revision = revision_from(&created).to_string();
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("local")),
))
.await
.expect("first replace should respond");
response_status(
replaced,
StatusCode::OK,
"PUT /api/v1/environments/stale-env first replace",
)
.await;
let stale_replace = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/stale-env",
&stale_revision,
Some(environment_settings("docker")),
))
.await
.expect("stale replace should respond");
response_status(
stale_replace,
StatusCode::CONFLICT,
"PUT /api/v1/environments/stale-env stale",
)
.await;
let stale_delete = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/stale-env",
&stale_revision,
None,
))
.await
.expect("stale delete should respond");
response_status(
stale_delete,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/stale-env stale",
)
.await;
}
#[tokio::test]
async fn duplicate_environment_create_returns_conflict() {
let (app, _temp_dir, _environment_dir) = environment_app();
create_environment(&app, "duplicate-env", "local").await;
let response = app
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("duplicate-env", "docker"),
))
.await
.expect("duplicate create should respond");
response_status(
response,
StatusCode::CONFLICT,
"POST /api/v1/environments duplicate",
)
.await;
}
#[tokio::test]
async fn reserved_local_environment_cannot_be_created_or_modified() {
let (app, _temp_dir, _environment_dir) = environment_app();
// `local` is reserved: creating it is rejected with a conflict.
let created = app
.clone()
.oneshot(json_request(
Method::POST,
"/environments",
&environment_body("local", "local"),
))
.await
.expect("reserved create should respond");
response_status(
created,
StatusCode::CONFLICT,
"POST /api/v1/environments local",
)
.await;
// It is synthesized in memory (local provider enabled) and readable.
let local = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/local"))
.await
.expect("get local should respond");
let local = response_json(local, StatusCode::OK, "GET /api/v1/environments/local").await;
let revision = revision_from(&local);
// Replace and delete are rejected even with a valid If-Match.
let replaced = app
.clone()
.oneshot(request_with_if_match(
Method::PUT,
"/environments/local",
&format!("\"{revision}\""),
Some(environment_settings("local")),
))
.await
.expect("reserved replace should respond");
response_status(
replaced,
StatusCode::CONFLICT,
"PUT /api/v1/environments/local",
)
.await;
let deleted = app
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/local",
&format!("\"{revision}\""),
None,
))
.await
.expect("reserved delete should respond");
response_status(
deleted,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/local",
)
.await;
}
#[tokio::test]
async fn invalid_environment_id_and_if_match_return_bad_request() {
let (app, _temp_dir, _environment_dir) = environment_app();
let invalid_id = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/Bad!"))
.await
.expect("invalid id request should respond");
response_status(
invalid_id,
StatusCode::BAD_REQUEST,
"GET /api/v1/environments/Bad!",
)
.await;
create_environment(&app, "header-env", "local").await;
let invalid_header = app
.oneshot(request_with_if_match(
Method::PUT,
"/environments/header-env",
"not-a-revision",
Some(environment_settings("docker")),
))
.await
.expect("invalid If-Match request should respond");
response_status(
invalid_header,
StatusCode::BAD_REQUEST,
"PUT /api/v1/environments/header-env invalid If-Match",
)
.await;
}
#[tokio::test]
async fn invalid_environment_settings_return_unprocessable_entity() {
let (app, _temp_dir, _environment_dir) = environment_app();
let mut body = environment_body("invalid-env", "local");
body["network"]["mode"] = json!("block");
let response = app
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("invalid environment create should respond");
response_status(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments invalid settings",
)
.await;
}
#[tokio::test]
async fn relative_environment_cwd_over_rest_returns_unprocessable_entity() {
let (app, temp_dir, environment_dir) = environment_app();
let mut body = environment_body("relative-cwd", "local");
body["cwd"] = json!("relative/workspace");
let response = app
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("invalid cwd create should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments relative cwd",
)
.await;
assert!(!environment_dir.join("relative-cwd.toml").exists());
assert!(
persisted_environment(&temp_dir, "relative-cwd")
.await
.is_none()
);
let message = serde_json::to_string(&error).expect("error should serialize");
assert!(
message.contains("environment.cwd") && message.contains("absolute path"),
"unexpected error: {message}"
);
}
#[tokio::test]
async fn dockerfile_path_over_rest_is_rejected_without_persisting_or_exposing_contents() {
let (app, temp_dir, environment_dir) = environment_app();
tokio::fs::write(
temp_dir.path().join("Dockerfile"),
"FROM private.example/secret\n",
)
.await
.expect("secret Dockerfile fixture should be written");
let mut body = environment_body("path-env", "docker");
body["image"]["docker"] = Value::Null;
body["image"]["dockerfile"] = json!({
"type": "path",
"path": "Dockerfile"
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/environments", &body))
.await
.expect("path Dockerfile create should respond");
let error = response_json(
response,
StatusCode::UNPROCESSABLE_ENTITY,
"POST /api/v1/environments Dockerfile path",
)
.await;
assert!(!environment_dir.join("path-env.toml").exists());
assert!(persisted_environment(&temp_dir, "path-env").await.is_none());
assert!(
!serde_json::to_string(&error)
.expect("error body should serialize")
.contains("private.example/secret")
);
let list = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("list environments should respond");
let list = response_json(list, StatusCode::OK, "GET /api/v1/environments").await;
assert!(
!list["data"]
.as_array()
.expect("environment list data should be an array")
.iter()
.any(|environment| environment["id"] == "path-env")
);
}
#[tokio::test]
async fn delete_environment_removes_non_default_and_default_is_deletable() {
let (app, temp_dir, environment_dir) = environment_app();
let created = create_environment(&app, "delete-env", "local").await;
let revision = revision_from(&created);
let response = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/delete-env",
&format!("\"{revision}\""),
None,
))
.await
.expect("delete environment should respond");
response_status(
response,
StatusCode::NO_CONTENT,
"DELETE /api/v1/environments/delete-env",
)
.await;
assert!(!environment_dir.join("delete-env.toml").exists());
assert!(
persisted_environment(&temp_dir, "delete-env")
.await
.is_none()
);
let missing = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/delete-env"))
.await
.expect("get deleted environment should respond");
response_status(
missing,
StatusCode::NOT_FOUND,
"GET /api/v1/environments/delete-env after delete",
)
.await;
// `default` is an ordinary environment: it can be deleted, which removes the
// run fallback. The server no longer protects it.
let default = app
.clone()
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get default environment should respond");
let default = response_json(default, StatusCode::OK, "GET /api/v1/environments/default").await;
let deleted = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/default",
revision_from(&default),
None,
))
.await
.expect("delete default environment should respond");
response_status(
deleted,
StatusCode::NO_CONTENT,
"DELETE /api/v1/environments/default",
)
.await;
assert!(!environment_dir.join("default.toml").exists());
assert!(persisted_environment(&temp_dir, "default").await.is_none());
let missing_default = app
.oneshot(empty_request(Method::GET, "/environments/default"))
.await
.expect("get deleted default environment should respond");
response_status(
missing_default,
StatusCode::NOT_FOUND,
"GET /api/v1/environments/default after delete",
)
.await;
}
#[tokio::test]
async fn delete_environment_rejects_an_environment_used_by_an_automation() {
let (app, _temp_dir, _environment_dir) = environment_app();
let environment = create_environment(&app, "automation-env", "docker").await;
let revision = revision_from(&environment);
let automation = json!({
"id": "nightly",
"name": "Nightly",
"description": null,
"environment_id": "automation-env",
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "main"
},
"workflow": "release",
"triggers": [{ "type": "api", "id": "manual", "enabled": true }]
});
let response = app
.clone()
.oneshot(json_request(Method::POST, "/automations", &automation))
.await
.expect("create automation should respond");
response_status(
response,
StatusCode::CREATED,
"POST /api/v1/automations using environment",
)
.await;
let response = app
.clone()
.oneshot(request_with_if_match(
Method::DELETE,
"/environments/automation-env",
revision,
None,
))
.await
.expect("delete used environment should respond");
let error = response_json(
response,
StatusCode::CONFLICT,
"DELETE /api/v1/environments/automation-env in use",
)
.await;
assert_eq!(error["errors"][0]["code"], "environment_in_use");
let response = app
.oneshot(empty_request(Method::GET, "/environments/automation-env"))
.await
.expect("used environment should still exist");
response_status(
response,
StatusCode::OK,
"GET /api/v1/environments/automation-env after rejected delete",
)
.await;
}
#[tokio::test]
async fn environment_routes_require_authenticated_user() {
let temp_dir = tempfile::tempdir().expect("environment test tempdir should be created");
let state = TestAppStateBuilder::new()
.active_config_path(temp_dir.path().join("settings.toml"))
.build();
let app = build_router(state, test_auth_mode());
let response = app
.oneshot(empty_request(Method::GET, "/environments"))
.await
.expect("unauthenticated environment list should respond");
response_status(
response,
StatusCode::UNAUTHORIZED,
"GET /api/v1/environments without auth",
)
.await;
}
#[tokio::test]
async fn create_environment_refreshes_cached_manifest_run_settings() {
let (app, _temp_dir) = environment_app_with_default_environment("api-default");
let before = system_info(&app).await;
assert_eq!(before["sandbox_provider"], "local");
create_environment(&app, "api-default", "daytona").await;
let after = system_info(&app).await;
assert_eq!(after["sandbox_provider"], "daytona");
}