//! Integration tests over the full HTTP surface. //! //! These tests are `#[ignore]`d by default because they require a //! running Postgres reachable via `DATABASE_URL`. To run them: //! //! docker compose up -d postgres //! DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \ //! cargo test -p picloud --test api -- --include-ignored //! //! Each `#[sqlx::test]` test runs against a freshly created database //! with `manager-core`'s migrations applied; tests are isolated and //! can run in parallel. #![allow(clippy::needless_pass_by_value)] use axum_test::TestServer; use serde_json::{json, Value}; use sqlx::PgPool; fn server(pool: PgPool) -> TestServer { TestServer::new(picloud::build_app(pool)).expect("TestServer should build") } // ============================================================================ // Health // ============================================================================ #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn healthz_responds_ok(pool: PgPool) { let r = server(pool).get("/healthz").await; r.assert_status_ok(); assert_eq!(r.text(), "ok"); } // ============================================================================ // Script CRUD // ============================================================================ #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn create_script_returns_201_with_full_record(pool: PgPool) { let s = server(pool); let r = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "echo", "description": "test", "source": "#{ statusCode: 200, body: 42 }", })) .await; r.assert_status(axum::http::StatusCode::CREATED); let body: Value = r.json(); assert_eq!(body["name"], "echo"); assert_eq!(body["version"], 1); assert_eq!(body["timeout_seconds"], 30); assert!(body["id"].as_str().is_some()); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn create_with_invalid_syntax_returns_422(pool: PgPool) { let r = server(pool) .post("/api/v1/admin/scripts") .json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" })) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); let body: Value = r.json(); assert!(body["error"].as_str().unwrap().contains("invalid script")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn duplicate_name_returns_409(pool: PgPool) { let s = server(pool); s.post("/api/v1/admin/scripts") .json(&json!({ "name": "dup", "source": "42" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "dup", "source": "43" })) .await; r.assert_status(axum::http::StatusCode::CONFLICT); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn list_returns_all_scripts(pool: PgPool) { let s = server(pool); for name in ["alpha", "bravo", "charlie"] { s.post("/api/v1/admin/scripts") .json(&json!({ "name": name, "source": "1" })) .await .assert_status(axum::http::StatusCode::CREATED); } let r = s.get("/api/v1/admin/scripts").await; r.assert_status_ok(); let body: Vec = r.json(); assert_eq!(body.len(), 3); let names: Vec<&str> = body.iter().map(|s| s["name"].as_str().unwrap()).collect(); assert_eq!(names, vec!["alpha", "bravo", "charlie"]); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn update_bumps_version_and_persists_changes(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "u", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "source": "#{ statusCode: 200, body: \"v2\" }", "timeout_seconds": 60 })) .await; r.assert_status_ok(); let updated: Value = r.json(); assert_eq!(updated["version"], 2); assert_eq!(updated["timeout_seconds"], 60); assert!(updated["source"].as_str().unwrap().contains("v2")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn update_with_invalid_source_returns_422(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "u", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "source": "@@@ broken @@@" })) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn delete_then_get_returns_404(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "d", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); s.delete(&format!("/api/v1/admin/scripts/{id}")) .await .assert_status(axum::http::StatusCode::NO_CONTENT); s.get(&format!("/api/v1/admin/scripts/{id}")) .await .assert_status_not_found(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn get_nonexistent_returns_404(pool: PgPool) { let r = server(pool) .get("/api/v1/admin/scripts/00000000-0000-0000-0000-000000000000") .await; r.assert_status_not_found(); } // ============================================================================ // Execution + audit logs // ============================================================================ #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execute_echoes_body_back(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "echo", "source": "#{ statusCode: 200, body: ctx.request.body }", })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s .post(&format!("/api/v1/execute/{id}")) .json(&json!({ "n": 42 })) .await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body, json!({ "n": 42 })); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execute_passes_through_status_and_headers(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "header-test", "source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }", })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s .post(&format!("/api/v1/execute/{id}")) .json(&json!({})) .await; r.assert_status(axum::http::StatusCode::CREATED); assert_eq!(r.header("x-tag"), "on"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execute_nonexistent_returns_404(pool: PgPool) { let r = server(pool) .post("/api/v1/execute/00000000-0000-0000-0000-000000000000") .json(&json!({})) .await; r.assert_status_not_found(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execution_logs_capture_invocations(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "logger", "source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }", })) .await .json(); let id = created["id"].as_str().unwrap(); // No logs yet. let r = s.get(&format!("/api/v1/admin/scripts/{id}/logs")).await; r.assert_status_ok(); let logs: Vec = r.json(); assert!(logs.is_empty()); // Two invocations. s.post(&format!("/api/v1/execute/{id}")) .json(&json!({ "first": true })) .await .assert_status_ok(); s.post(&format!("/api/v1/execute/{id}")) .json(&json!({ "second": true })) .await .assert_status_ok(); let logs: Vec = s .get(&format!("/api/v1/admin/scripts/{id}/logs")) .await .json(); assert_eq!(logs.len(), 2); // Most-recent-first ordering. assert_eq!(logs[0]["request_body"], json!({ "second": true })); assert_eq!(logs[1]["request_body"], json!({ "first": true })); // Status + response shape captured. assert_eq!(logs[0]["status"], "success"); assert_eq!(logs[0]["response_code"], 200); assert_eq!(logs[0]["response_body"], json!("done")); // Script-side log entries captured. let entries = logs[0]["script_logs"].as_array().unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0]["level"], "info"); assert_eq!(entries[0]["message"], "called"); assert_eq!(entries[0]["data"], json!({ "marker": 7 })); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execution_errors_are_still_logged(pool: PgPool) { let s = server(pool); let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "boom", "source": "1 / 0", })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s .post(&format!("/api/v1/execute/{id}")) .json(&json!({})) .await; r.assert_status(axum::http::StatusCode::BAD_GATEWAY); let logs: Vec = s .get(&format!("/api/v1/admin/scripts/{id}/logs")) .await .json(); assert_eq!(logs.len(), 1); assert_eq!(logs[0]["status"], "error"); assert!(logs[0]["response_body"]["error"].is_string()); }