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 { 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 { 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, ) -> Request { 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 { 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!["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"); }