//! 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; async fn server(pool: PgPool) -> TestServer { let app = picloud::build_app(pool).await.expect("build_app"); TestServer::new(app).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).await.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).await; 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) .await .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).await; 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).await; 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).await; 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).await; 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).await; 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) .await .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).await; 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).await; 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) .await .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).await; 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 })); } // ============================================================================ // Sandbox overrides // ============================================================================ #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn create_without_sandbox_returns_empty_object(pool: PgPool) { let s = server(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "no-sandbox", "source": "1" })) .await .json(); assert_eq!(created["sandbox"], json!({})); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) { let s = server(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "tight", "source": "1", "sandbox": { "max_operations": 500, "max_string_size": 1024 } })) .await .json(); assert_eq!( created["sandbox"], json!({ "max_operations": 500, "max_string_size": 1024 }) ); let id = created["id"].as_str().unwrap(); let fetched: Value = s.get(&format!("/api/v1/admin/scripts/{id}")).await.json(); assert_eq!( fetched["sandbox"], json!({ "max_operations": 500, "max_string_size": 1024 }) ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) { // Default conservative ceiling caps max_operations at 10_000_000. let s = server(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "too-loose", "source": "1", "sandbox": { "max_operations": 100_000_000 } })) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); let body: Value = r.json(); assert!(body["error"].as_str().unwrap().contains("max_operations")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn sandbox_unknown_field_returns_422(pool: PgPool) { let s = server(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "typo", "source": "1", "sandbox": { "max_operashuns": 500 } })) .await; // serde's deny_unknown_fields causes axum to reject with 422 or // 400 depending on extractor; the routing is irrelevant here, just // that it doesn't get stored silently. assert!( r.status_code() == axum::http::StatusCode::UNPROCESSABLE_ENTITY || r.status_code() == axum::http::StatusCode::BAD_REQUEST ); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) { let s = server(pool).await; // Tight max_operations on a loop the default would happily run. let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "tight-exec", "source": "let n = 0; for i in 0..10000 { n += 1; } n", "sandbox": { "max_operations": 500 } })) .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::INSUFFICIENT_STORAGE); let body: Value = r.json(); assert!(body["error"].as_str().unwrap().contains("operation budget")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn update_replaces_sandbox_wholesale(pool: PgPool) { let s = server(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": "patch-target", "source": "1", "sandbox": { "max_operations": 500, "max_string_size": 1024 } })) .await .json(); let id = created["id"].as_str().unwrap(); // Replace with a single override; the other field disappears. let updated: Value = s .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "sandbox": { "max_array_size": 5000 } })) .await .json(); assert_eq!(updated["sandbox"], json!({ "max_array_size": 5000 })); // Send empty object to clear all overrides. let cleared: Value = s .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "sandbox": {} })) .await .json(); assert_eq!(cleared["sandbox"], json!({})); } // ============================================================================ // Custom routing // ============================================================================ async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String { let v: Value = s .post("/api/v1/admin/scripts") .json(&json!({ "name": name, "source": source })) .await .json(); v["id"].as_str().unwrap().to_string() } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_exact_dispatches_to_script(pool: PgPool) { let s = server(pool).await; let id = create_basic_script( &s, "greet", "#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }", ) .await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/greet" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s.get("/greet").await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body["msg"], "hi"); assert_eq!(body["path"], "/greet"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_param_captures_path_vars(pool: PgPool) { let s = server(pool).await; let id = create_basic_script( &s, "greet-name", "#{ statusCode: 200, body: #{ name: ctx.request.params.name } }", ) .await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/greet/:name" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s.get("/greet/alice").await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body["name"], "alice"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_prefix_captures_rest(pool: PgPool) { let s = server(pool).await; let id = create_basic_script( &s, "echo-prefix", "#{ statusCode: 200, body: #{ rest: ctx.request.rest } }", ) .await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "prefix", "path": "/echo/*" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s.get("/echo/foo/bar").await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body["rest"], "foo/bar"); s.get("/echo").await.assert_status_not_found(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_query_string_exposed_to_script(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/qs" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s.get("/qs?a=1&b=two").await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body, json!({ "a": "1", "b": "two" })); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_invalid_pattern_returns_422(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "x", "1").await; let r = s .post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/greet/my:name" })) .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 route_conflict_returns_409(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "x", "1").await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/users/:id" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s .post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/users/:userId" })) .await; r.assert_status(axum::http::StatusCode::CONFLICT); let body: Value = r.json(); assert!(body["conflicting_route"].is_object()); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_reserved_path_returns_422(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "x", "1").await; let r = s .post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/admin/foo" })) .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 route_match_preview_endpoint(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "g", "1").await; s.post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/greet/:name" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s .post("/api/v1/admin/routes:match") .json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" })) .await; r.assert_status_ok(); let body: Value = r.json(); assert!(body["matched"].is_object()); assert_eq!(body["matched"]["params"]["name"], "alice"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_delete_removes_dispatch(pool: PgPool) { let s = server(pool).await; let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await; let created: Value = s .post(&format!("/api/v1/admin/scripts/{id}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/g" })) .await .json(); let route_id = created["id"].as_str().unwrap(); s.get("/g").await.assert_status_ok(); s.delete(&format!("/api/v1/admin/routes/{route_id}")) .await .assert_status(axum::http::StatusCode::NO_CONTENT); s.get("/g").await.assert_status_not_found(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn route_specificity_param_beats_prefix(pool: PgPool) { let s = server(pool).await; let id_p = create_basic_script( &s, "by-param", "#{ statusCode: 200, body: #{ tag: \"param\" } }", ) .await; let id_pr = create_basic_script( &s, "by-prefix", "#{ statusCode: 200, body: #{ tag: \"prefix\" } }", ) .await; s.post(&format!("/api/v1/admin/scripts/{id_p}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "param", "path": "/foo/:bar" })) .await .assert_status(axum::http::StatusCode::CREATED); s.post(&format!("/api/v1/admin/scripts/{id_pr}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "prefix", "path": "/foo/*" })) .await .assert_status(axum::http::StatusCode::CREATED); // Single segment under /foo/ — both match; param wins by spec. let r = s.get("/foo/x").await; let body: Value = r.json(); assert_eq!(body["tag"], "param"); // Two segments — only prefix matches. let r2 = s.get("/foo/x/y").await; let body2: Value = r2.json(); assert_eq!(body2["tag"], "prefix"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn root_returns_404_when_no_route(pool: PgPool) { let s = server(pool).await; let r = s.get("/").await; r.assert_status_not_found(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn version_includes_public_base_url(pool: PgPool) { let s = server(pool).await; let r = s.get("/version").await; r.assert_status_ok(); let v: Value = r.json(); assert!(v["public_base_url"].is_string()); assert_eq!(v["api"], 1); assert_eq!(v["schema"], 3); assert_eq!(v["sdk"], "1.1"); } // ============================================================================ #[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).await; 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()); }