mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
983 lines
30 KiB
Rust
983 lines
30 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode, header};
|
|
use fabro_config::Storage;
|
|
use fabro_server::server::build_router;
|
|
use fabro_server::test_support::{
|
|
TestAppStateBuilder, TestAutomationRunMaterializer, build_test_router, test_auth_mode,
|
|
};
|
|
use fabro_static::EnvVars;
|
|
use fabro_types::GitRunTarget;
|
|
use serde_json::{Value, json};
|
|
use sqlx::Row as _;
|
|
use tower::ServiceExt;
|
|
|
|
use crate::helpers::{
|
|
MINIMAL_DOT, api, checked_response, minimal_manifest_json, response_json, response_status,
|
|
run_json,
|
|
};
|
|
|
|
fn automation_body(id: &str, name: &str) -> Value {
|
|
json!({
|
|
"id": id,
|
|
"name": name,
|
|
"description": "Runs on a schedule.",
|
|
"target": {
|
|
"kind": "git",
|
|
"repo": "fabro-sh/fabro",
|
|
"branch": "main"
|
|
},
|
|
"workflow": "release",
|
|
"triggers": [
|
|
{
|
|
"type": "api",
|
|
"id": "manual",
|
|
"enabled": true
|
|
},
|
|
{
|
|
"type": "schedule",
|
|
"id": "nightly",
|
|
"enabled": true,
|
|
"expression": "0 3 * * *"
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn replacement_body(name: &str) -> Value {
|
|
json!({
|
|
"name": name,
|
|
"description": null,
|
|
"target": {
|
|
"kind": "git",
|
|
"repo": "fabro-sh/fabro",
|
|
"branch": "main"
|
|
},
|
|
"workflow": "release",
|
|
"triggers": [
|
|
{
|
|
"type": "api",
|
|
"id": "manual",
|
|
"enabled": false
|
|
}
|
|
]
|
|
})
|
|
}
|
|
|
|
fn automation_app() -> (axum::Router, tempfile::TempDir, PathBuf) {
|
|
let temp_dir = tempfile::tempdir().expect("automation 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 sqlite_path = Storage::new(temp_dir.path()).sqlite_path();
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path)
|
|
.vault_path(vault_path)
|
|
.build();
|
|
(build_test_router(state), temp_dir, sqlite_path)
|
|
}
|
|
|
|
fn automation_app_with_fake_materializer() -> (axum::Router, tempfile::TempDir, PathBuf) {
|
|
let temp_dir = tempfile::tempdir().expect("automation 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 sqlite_path = Storage::new(temp_dir.path()).sqlite_path();
|
|
let materialized_manifest: fabro_api::types::RunManifest =
|
|
serde_json::from_value(minimal_manifest_json(MINIMAL_DOT))
|
|
.expect("minimal run manifest fixture should deserialize");
|
|
let submitted_manifest_bytes =
|
|
serde_json::to_vec(&materialized_manifest).expect("minimal run manifest should serialize");
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path)
|
|
.vault_path(vault_path)
|
|
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
|
.automation_materializer(TestAutomationRunMaterializer::succeed(
|
|
materialized_manifest,
|
|
submitted_manifest_bytes,
|
|
GitRunTarget {
|
|
repo: "fabro-sh/fabro".to_string(),
|
|
branch: "main".to_string(),
|
|
tag: None,
|
|
sha: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
|
|
},
|
|
))
|
|
.build();
|
|
(build_test_router(state), temp_dir, sqlite_path)
|
|
}
|
|
|
|
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("automation fixture should serialize"),
|
|
))
|
|
.expect("automation JSON request should build")
|
|
}
|
|
|
|
fn empty_request(method: Method, path: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method(method)
|
|
.uri(api(path))
|
|
.body(Body::empty())
|
|
.expect("automation 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("automation fixture should serialize"))
|
|
}
|
|
None => Body::empty(),
|
|
};
|
|
builder
|
|
.body(body)
|
|
.expect("automation If-Match request should build")
|
|
}
|
|
|
|
async fn create_automation(app: &axum::Router, id: &str, name: &str) -> Value {
|
|
create_automation_with_body(app, &automation_body(id, name)).await
|
|
}
|
|
|
|
async fn create_automation_with_body(app: &axum::Router, body: &Value) -> Value {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(json_request(Method::POST, "/automations", body))
|
|
.await
|
|
.expect("create automation should respond");
|
|
response_json(response, StatusCode::CREATED, "POST /api/v1/automations").await
|
|
}
|
|
|
|
async fn create_automation_run(
|
|
app: &axum::Router,
|
|
automation_id: &str,
|
|
expected: StatusCode,
|
|
) -> Value {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(empty_request(
|
|
Method::POST,
|
|
&format!("/automations/{automation_id}/runs"),
|
|
))
|
|
.await
|
|
.expect("create automation run should respond");
|
|
response_json(
|
|
response,
|
|
expected,
|
|
format!("POST /api/v1/automations/{automation_id}/runs"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn list_automation_runs(app: &axum::Router, path: &str) -> Value {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(empty_request(Method::GET, path))
|
|
.await
|
|
.expect("list automation runs should respond");
|
|
response_json(response, StatusCode::OK, format!("GET /api/v1{path}")).await
|
|
}
|
|
|
|
fn revision_from(body: &Value) -> &str {
|
|
body["revision"]
|
|
.as_str()
|
|
.expect("automation response should include a revision")
|
|
}
|
|
|
|
fn assert_schedule_trigger(body: &Value, expression: &str, enabled: bool) {
|
|
let trigger = body["triggers"]
|
|
.as_array()
|
|
.expect("automation response should include triggers")
|
|
.iter()
|
|
.find(|trigger| trigger["id"] == "nightly")
|
|
.expect("automation response should include nightly schedule trigger");
|
|
|
|
assert_eq!(
|
|
trigger,
|
|
&json!({
|
|
"type": "schedule",
|
|
"id": "nightly",
|
|
"enabled": enabled,
|
|
"expression": expression
|
|
})
|
|
);
|
|
}
|
|
|
|
async fn persisted_automation(sqlite_path: &Path, id: &str) -> Option<Value> {
|
|
let database = fabro_db::Database::connect(sqlite_path)
|
|
.await
|
|
.expect("automation test database should open");
|
|
let parent = sqlx::query("SELECT api_enabled FROM automations WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(database.pool())
|
|
.await
|
|
.expect("persisted automation should query")?;
|
|
let triggers = sqlx::query(
|
|
"SELECT id, enabled, expression FROM automation_triggers \
|
|
WHERE automation_id = ? ORDER BY id",
|
|
)
|
|
.bind(id)
|
|
.fetch_all(database.pool())
|
|
.await
|
|
.expect("persisted automation triggers should query")
|
|
.into_iter()
|
|
.map(|row| {
|
|
json!({
|
|
"id": row.get::<String, _>("id"),
|
|
"enabled": row.get::<bool, _>("enabled"),
|
|
"expression": row.get::<String, _>("expression")
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
Some(json!({
|
|
"api_enabled": parent.get::<bool, _>("api_enabled"),
|
|
"triggers": triggers
|
|
}))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_automation_list_returns_total_zero() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations"))
|
|
.await
|
|
.expect("list automations should respond");
|
|
let body = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;
|
|
|
|
assert_eq!(
|
|
body,
|
|
json!({
|
|
"data": [],
|
|
"meta": {
|
|
"total": 0
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_automation_persists_sql_aggregate() {
|
|
let (app, _temp_dir, sqlite_path) = automation_app();
|
|
|
|
let body = create_automation(&app, "nightly", "Nightly").await;
|
|
|
|
assert_eq!(body["id"], "nightly");
|
|
assert_eq!(body["name"], "Nightly");
|
|
assert_eq!(
|
|
persisted_automation(&sqlite_path, "nightly").await,
|
|
Some(json!({
|
|
"api_enabled": true,
|
|
"triggers": [{
|
|
"id": "nightly",
|
|
"enabled": true,
|
|
"expression": "0 3 * * *"
|
|
}]
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn automation_persists_across_app_rebuild() {
|
|
let temp_dir = tempfile::tempdir().expect("automation 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 state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path.clone())
|
|
.vault_path(vault_path.clone())
|
|
.build();
|
|
let app = build_test_router(state);
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
drop(app);
|
|
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(active_config_path)
|
|
.vault_path(vault_path)
|
|
.build();
|
|
let response = build_test_router(state)
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get persisted automation should respond");
|
|
let retrieved = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
"GET /api/v1/automations/nightly after app rebuild",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(retrieved, created);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn schedule_trigger_round_trips_through_create_list_get_and_sql() {
|
|
let (app, _temp_dir, sqlite_path) = automation_app();
|
|
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
assert_schedule_trigger(&created, "0 3 * * *", true);
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get automation should respond");
|
|
let retrieved =
|
|
response_json(response, StatusCode::OK, "GET /api/v1/automations/nightly").await;
|
|
assert_schedule_trigger(&retrieved, "0 3 * * *", true);
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations"))
|
|
.await
|
|
.expect("list automations should respond");
|
|
let list = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;
|
|
assert_schedule_trigger(&list["data"][0], "0 3 * * *", true);
|
|
|
|
assert_eq!(
|
|
persisted_automation(&sqlite_path, "nightly")
|
|
.await
|
|
.expect("automation should be persisted")["triggers"][0],
|
|
json!({
|
|
"id": "nightly",
|
|
"enabled": true,
|
|
"expression": "0 3 * * *"
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_stored_automation_returns_internal_server_error() {
|
|
let (app, _temp_dir, sqlite_path) = automation_app();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
let database = fabro_db::Database::connect(sqlite_path)
|
|
.await
|
|
.expect("automation test database should open");
|
|
sqlx::query(
|
|
"UPDATE automation_triggers SET expression = 'not cron' WHERE automation_id = 'nightly'",
|
|
)
|
|
.execute(database.pool())
|
|
.await
|
|
.expect("stored schedule should be corrupted for the test");
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get invalid stored automation should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"GET /api/v1/automations/nightly with invalid stored schedule",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_automations_returns_items_sorted_by_id() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
create_automation(&app, "zulu", "Zulu").await;
|
|
create_automation(&app, "alpha", "Alpha").await;
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations"))
|
|
.await
|
|
.expect("list automations should respond");
|
|
let body = response_json(response, StatusCode::OK, "GET /api/v1/automations").await;
|
|
|
|
assert_eq!(body["meta"]["total"], 2);
|
|
assert_eq!(body["data"][0]["id"], "alpha");
|
|
assert_eq!(body["data"][1]["id"], "zulu");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_automation_create_returns_conflict() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
|
|
let response = app
|
|
.oneshot(json_request(
|
|
Method::POST,
|
|
"/automations",
|
|
&automation_body("nightly", "Duplicate"),
|
|
))
|
|
.await
|
|
.expect("duplicate create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
"POST /api/v1/automations duplicate",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_automation_returns_current_etag() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get automation should respond");
|
|
let response =
|
|
checked_response(response, StatusCode::OK, "GET /api/v1/automations/nightly").await;
|
|
|
|
assert_eq!(
|
|
response
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.expect("GET automation 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_automation_accepts_unquoted_if_match_and_returns_new_etag() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/automations/nightly",
|
|
revision,
|
|
Some(replacement_body("Updated")),
|
|
))
|
|
.await
|
|
.expect("replace automation should respond");
|
|
let response =
|
|
checked_response(response, StatusCode::OK, "PUT /api/v1/automations/nightly").await;
|
|
let etag = response
|
|
.headers()
|
|
.get(header::ETAG)
|
|
.expect("PUT automation 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["name"], "Updated");
|
|
assert_ne!(body["revision"], revision);
|
|
assert_eq!(etag, format!("\"{}\"", revision_from(&body)));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_automation_round_trips_schedule_trigger() {
|
|
let (app, _temp_dir, sqlite_path) = automation_app();
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
let revision = revision_from(&created);
|
|
let mut replacement = replacement_body("Rescheduled");
|
|
replacement["triggers"] = json!([
|
|
{
|
|
"type": "api",
|
|
"id": "manual",
|
|
"enabled": true
|
|
},
|
|
{
|
|
"type": "schedule",
|
|
"id": "nightly",
|
|
"enabled": false,
|
|
"expression": "30 4 * * *"
|
|
}
|
|
]);
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/automations/nightly",
|
|
revision,
|
|
Some(replacement),
|
|
))
|
|
.await
|
|
.expect("replace automation should respond");
|
|
let body = response_json(response, StatusCode::OK, "PUT /api/v1/automations/nightly").await;
|
|
|
|
assert_schedule_trigger(&body, "30 4 * * *", false);
|
|
assert_eq!(
|
|
persisted_automation(&sqlite_path, "nightly")
|
|
.await
|
|
.expect("automation should be persisted")["triggers"][0],
|
|
json!({
|
|
"id": "nightly",
|
|
"enabled": false,
|
|
"expression": "30 4 * * *"
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_automation_replace_returns_conflict() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
let stale_revision = revision_from(&created).to_string();
|
|
|
|
let replaced = app
|
|
.clone()
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/automations/nightly",
|
|
&stale_revision,
|
|
Some(replacement_body("Updated")),
|
|
))
|
|
.await
|
|
.expect("first replace should respond");
|
|
response_status(
|
|
replaced,
|
|
StatusCode::OK,
|
|
"PUT /api/v1/automations/nightly first replace",
|
|
)
|
|
.await;
|
|
|
|
let response = app
|
|
.oneshot(request_with_if_match(
|
|
Method::PUT,
|
|
"/automations/nightly",
|
|
&stale_revision,
|
|
Some(replacement_body("Stale")),
|
|
))
|
|
.await
|
|
.expect("stale replace should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::CONFLICT,
|
|
"PUT /api/v1/automations/nightly stale",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn replace_and_delete_automation_require_if_match() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
|
|
let replace_response = app
|
|
.clone()
|
|
.oneshot(json_request(
|
|
Method::PUT,
|
|
"/automations/nightly",
|
|
&replacement_body("Updated"),
|
|
))
|
|
.await
|
|
.expect("replace without If-Match should respond");
|
|
response_status(
|
|
replace_response,
|
|
StatusCode::PRECONDITION_REQUIRED,
|
|
"PUT /api/v1/automations/nightly without If-Match",
|
|
)
|
|
.await;
|
|
|
|
let delete_response = app
|
|
.oneshot(empty_request(Method::DELETE, "/automations/nightly"))
|
|
.await
|
|
.expect("delete without If-Match should respond");
|
|
response_status(
|
|
delete_response,
|
|
StatusCode::PRECONDITION_REQUIRED,
|
|
"DELETE /api/v1/automations/nightly without If-Match",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_automation_removes_sql_aggregate() {
|
|
let (app, _temp_dir, sqlite_path) = automation_app();
|
|
let created = create_automation(&app, "nightly", "Nightly").await;
|
|
let revision = revision_from(&created);
|
|
|
|
let response = app
|
|
.clone()
|
|
.oneshot(request_with_if_match(
|
|
Method::DELETE,
|
|
"/automations/nightly",
|
|
&format!("\"{revision}\""),
|
|
None,
|
|
))
|
|
.await
|
|
.expect("delete automation should respond");
|
|
response_status(
|
|
response,
|
|
StatusCode::NO_CONTENT,
|
|
"DELETE /api/v1/automations/nightly",
|
|
)
|
|
.await;
|
|
|
|
assert!(
|
|
persisted_automation(&sqlite_path, "nightly")
|
|
.await
|
|
.is_none()
|
|
);
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get deleted automation should respond");
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"GET /api/v1/automations/nightly after delete",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_trigger_type_is_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][1] = json!({
|
|
"type": "event",
|
|
"id": "event_trigger",
|
|
"enabled": true
|
|
});
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("unknown trigger type create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations unknown trigger type",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_trigger_ids_are_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][0]["id"] = json!("Bad!");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("invalid trigger id create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations invalid trigger id",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_automation_name_is_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["name"] = json!(" ");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("empty automation name create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations empty name",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn duplicate_trigger_ids_are_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][1]["id"] = json!("manual");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("duplicate trigger create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations duplicate trigger ids",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn second_api_trigger_is_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][1] = json!({
|
|
"type": "api",
|
|
"id": "manual2",
|
|
"enabled": true
|
|
});
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("second API trigger create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations second API trigger",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn invalid_schedule_expression_is_unprocessable() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][1]["expression"] = json!("60 3 * * *");
|
|
|
|
let response = app
|
|
.oneshot(json_request(Method::POST, "/automations", &body))
|
|
.await
|
|
.expect("invalid schedule create should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNPROCESSABLE_ENTITY,
|
|
"POST /api/v1/automations invalid schedule",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn automation_store_malformed_persisted_toml_fails_startup() {
|
|
let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
|
|
let automation_dir = temp_dir.path().join("automations");
|
|
tokio::fs::create_dir_all(&automation_dir)
|
|
.await
|
|
.expect("automation dir should be created");
|
|
tokio::fs::write(automation_dir.join("broken.toml"), "not valid toml =")
|
|
.await
|
|
.expect("broken automation fixture should be written");
|
|
|
|
let result = TestAppStateBuilder::new()
|
|
.active_config_path(temp_dir.path().join("settings.toml"))
|
|
.try_build();
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_automation_is_imported_and_directory_is_backed_up() {
|
|
let temp_dir = tempfile::tempdir().expect("automation test tempdir should be created");
|
|
let automation_dir = temp_dir.path().join("automations");
|
|
tokio::fs::create_dir_all(&automation_dir)
|
|
.await
|
|
.expect("automation dir should be created");
|
|
tokio::fs::write(
|
|
automation_dir.join("nightly.toml"),
|
|
r#"name = "Legacy nightly"
|
|
|
|
[target]
|
|
repository = "fabro-sh/fabro"
|
|
ref = "main"
|
|
workflow = "release"
|
|
|
|
[[triggers]]
|
|
type = "api"
|
|
id = "manual"
|
|
enabled = true
|
|
"#,
|
|
)
|
|
.await
|
|
.expect("legacy automation fixture should be written");
|
|
let state = TestAppStateBuilder::new()
|
|
.active_config_path(temp_dir.path().join("settings.toml"))
|
|
.vault_path(temp_dir.path().join("secrets.json"))
|
|
.build();
|
|
|
|
let response = build_test_router(state)
|
|
.oneshot(empty_request(Method::GET, "/automations/nightly"))
|
|
.await
|
|
.expect("get imported automation should respond");
|
|
let body = response_json(
|
|
response,
|
|
StatusCode::OK,
|
|
"GET /api/v1/automations/nightly after legacy import",
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(body["name"], "Legacy nightly");
|
|
assert!(!automation_dir.exists());
|
|
let mut entries = tokio::fs::read_dir(temp_dir.path())
|
|
.await
|
|
.expect("storage directory should be readable");
|
|
let mut backup_exists = false;
|
|
while let Some(entry) = entries
|
|
.next_entry()
|
|
.await
|
|
.expect("storage directory entry should be readable")
|
|
{
|
|
if entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("automations.imported-")
|
|
{
|
|
backup_exists = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(backup_exists);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn automations_routes_require_authenticated_user() {
|
|
let temp_dir = tempfile::tempdir().expect("automation 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, "/automations"))
|
|
.await
|
|
.expect("unauthenticated automation list should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::UNAUTHORIZED,
|
|
"GET /api/v1/automations without auth",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_automation_run_endpoint_returns_not_found() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
|
|
create_automation_run(&app, "missing", StatusCode::NOT_FOUND).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_api_trigger_run_endpoint_returns_conflict_code() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"][0]["enabled"] = json!(false);
|
|
create_automation_with_body(&app, &body).await;
|
|
|
|
let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
|
|
|
|
assert_eq!(
|
|
error["errors"][0]["code"],
|
|
"automation_api_trigger_disabled"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_api_trigger_run_endpoint_returns_conflict_code() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
let mut body = automation_body("nightly", "Nightly");
|
|
body["triggers"] = json!([
|
|
{
|
|
"type": "schedule",
|
|
"id": "nightly",
|
|
"enabled": true,
|
|
"expression": "0 3 * * *"
|
|
}
|
|
]);
|
|
create_automation_with_body(&app, &body).await;
|
|
|
|
let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
|
|
|
|
assert_eq!(
|
|
error["errors"][0]["code"],
|
|
"automation_api_trigger_disabled"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn successful_api_triggered_automation_run_persists_automation_metadata() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
|
|
let created = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
|
|
|
assert_eq!(created["automation"]["id"], "nightly");
|
|
assert_eq!(created["automation"]["name"], "Nightly");
|
|
assert_eq!(created["automation"]["trigger_id"], "manual");
|
|
|
|
let run_id = created["id"]
|
|
.as_str()
|
|
.expect("created automation run should include id");
|
|
let retrieved = run_json(&app, run_id).await;
|
|
assert_eq!(retrieved["id"], run_id);
|
|
assert_eq!(retrieved["automation"], created["automation"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn automation_run_listing_includes_only_runs_for_that_automation() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
create_automation(&app, "weekly", "Weekly").await;
|
|
let nightly = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
|
let weekly = create_automation_run(&app, "weekly", StatusCode::CREATED).await;
|
|
|
|
let body = list_automation_runs(&app, "/automations/nightly/runs").await;
|
|
|
|
assert_eq!(body["meta"]["total"], 1);
|
|
assert_eq!(body["meta"]["has_more"], false);
|
|
assert_eq!(
|
|
body["data"].as_array().expect("data should be array").len(),
|
|
1
|
|
);
|
|
assert_eq!(body["data"][0]["id"], nightly["id"]);
|
|
assert_ne!(body["data"][0]["id"], weekly["id"]);
|
|
assert_eq!(body["data"][0]["automation"]["id"], "nightly");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn missing_automation_run_listing_returns_not_found() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
|
|
let response = app
|
|
.oneshot(empty_request(Method::GET, "/automations/missing/runs"))
|
|
.await
|
|
.expect("missing automation run listing should respond");
|
|
|
|
response_status(
|
|
response,
|
|
StatusCode::NOT_FOUND,
|
|
"GET /api/v1/automations/missing/runs",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn automation_run_listing_is_newest_first_and_paginates() {
|
|
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
|
|
create_automation(&app, "nightly", "Nightly").await;
|
|
let oldest = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
|
let middle = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
|
let newest = create_automation_run(&app, "nightly", StatusCode::CREATED).await;
|
|
|
|
let first_page = list_automation_runs(&app, "/automations/nightly/runs?page[limit]=2").await;
|
|
assert_eq!(first_page["meta"]["total"], 3);
|
|
assert_eq!(first_page["meta"]["has_more"], true);
|
|
assert_eq!(first_page["data"][0]["id"], newest["id"]);
|
|
assert_eq!(first_page["data"][1]["id"], middle["id"]);
|
|
|
|
let second_page = list_automation_runs(
|
|
&app,
|
|
"/automations/nightly/runs?page[limit]=2&page[offset]=2",
|
|
)
|
|
.await;
|
|
assert_eq!(second_page["meta"]["total"], 3);
|
|
assert_eq!(second_page["meta"]["has_more"], false);
|
|
assert_eq!(second_page["data"][0]["id"], oldest["id"]);
|
|
}
|