//! 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; /// Build the all-in-one app over the test pool, seed a single admin /// directly through the repo (bypassing the env-var bootstrap path so /// tests don't contaminate the process environment), log in, and bake /// the bearer token into the TestServer as a default header so every /// request in the test passes the `require_admin` middleware. async fn server(pool: PgPool) -> TestServer { let (server, _app_id) = server_with_app(pool).await; server } /// Like `server`, but also returns the default app's id — needed by /// any test that creates scripts (every script now requires `app_id`). async fn server_with_app(pool: PgPool) -> (TestServer, String) { use picloud_manager_core::auth::hash_password; let auth = picloud::AuthDeps::from_pool(pool.clone()); let hash = hash_password("test-pw").expect("hash"); auth.users .create("test-admin", &hash) .await .expect("seed admin"); let app = picloud::build_app(pool, auth).await.expect("build_app"); let mut server = TestServer::new(app).expect("TestServer should build"); let resp = server .post("/api/v1/admin/auth/login") .json(&json!({ "username": "test-admin", "password": "test-pw" })) .await; resp.assert_status_ok(); let token = resp.json::()["token"] .as_str() .expect("login should return token") .to_string(); server.add_header("authorization", format!("Bearer {token}")); // Note: user-route dispatch needs an explicit `host: ` header // on each request (the axum_test client doesn't default to a real // host). The default app claims `localhost`; user-route tests below // add the header per request via `.add_header("host", "localhost")` // so per-test overrides for other apps cleanly replace it. // The 0005 migration unconditionally inserts a `default` app; fetch // its id so tests can attach scripts to it without re-running the // Rust-side hello-world seed (which only fires from main.rs). // The get-app handler returns `{ ...App, redirect_to?: ... }` — // the app fields are flattened at the response root. let app: Value = server.get("/api/v1/admin/apps/default").await.json(); let app_id = app["id"] .as_str() .unwrap_or_else(|| panic!("default app id missing from response: {app}")) .to_string(); (server, app_id) } /// Merge `{ "app_id": }` into a create-script body. Saves /// repeating the same field in 25+ tests. fn with_app(app_id: &str, mut body: Value) -> Value { body.as_object_mut() .expect("script body must be a JSON object") .insert("app_id".into(), Value::String(app_id.to_string())); body } // ============================================================================ // 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, app_id) = server_with_app(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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_eq!(body["app_id"], app_id); 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 (s, app_id) = server_with_app(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; s.post("/api/v1/admin/scripts") .json(&with_app(&app_id, json!({ "name": "dup", "source": "42" }))) .await .assert_status(axum::http::StatusCode::CREATED); let r = s .post("/api/v1/admin/scripts") .json(&with_app(&app_id, 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, app_id) = server_with_app(pool).await; for name in ["alpha", "bravo", "charlie"] { s.post("/api/v1/admin/scripts") .json(&with_app(&app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app(&app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app(&app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app(&app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let r = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; // Tight max_operations on a loop the default would happily run. let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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, app_id: &str, name: &str, source: &str) -> String { let v: Value = s .post("/api/v1/admin/scripts") .json(&with_app(app_id, 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, app_id) = server_with_app(pool).await; let id = create_basic_script( &s, &app_id, "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").add_header("host", "localhost").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, app_id) = server_with_app(pool).await; let id = create_basic_script( &s, &app_id, "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").add_header("host", "localhost").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, app_id) = server_with_app(pool).await; let id = create_basic_script( &s, &app_id, "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").add_header("host", "localhost").await; r.assert_status_ok(); let body: Value = r.json(); assert_eq!(body["rest"], "foo/bar"); s.get("/echo") .add_header("host", "localhost") .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, app_id) = server_with_app(pool).await; let id = create_basic_script( &s, &app_id, "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").add_header("host", "localhost").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, app_id) = server_with_app(pool).await; let id = create_basic_script(&s, &app_id, "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, app_id) = server_with_app(pool).await; let id = create_basic_script(&s, &app_id, "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, app_id) = server_with_app(pool).await; let id = create_basic_script(&s, &app_id, "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, app_id) = server_with_app(pool).await; let id = create_basic_script(&s, &app_id, "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!({ "app_id": app_id, "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, app_id) = server_with_app(pool).await; let id = create_basic_script(&s, &app_id, "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") .add_header("host", "localhost") .await .assert_status_ok(); s.delete(&format!("/api/v1/admin/routes/{route_id}")) .await .assert_status(axum::http::StatusCode::NO_CONTENT); s.get("/g") .add_header("host", "localhost") .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, app_id) = server_with_app(pool).await; let id_p = create_basic_script( &s, &app_id, "by-param", "#{ statusCode: 200, body: #{ tag: \"param\" } }", ) .await; let id_pr = create_basic_script( &s, &app_id, "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").add_header("host", "localhost").await; let body: Value = r.json(); assert_eq!(body["tag"], "param"); // Two segments — only prefix matches. let r2 = s.get("/foo/x/y").add_header("host", "localhost").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("/").add_header("host", "localhost").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"], 5); assert_eq!(v["sdk"], "1.1"); } // ============================================================================ // ============================================================================ // App scoping (Phase 3b) // ============================================================================ #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn default_app_is_seeded_by_migration(pool: PgPool) { let s = server(pool).await; let r = s.get("/api/v1/admin/apps").await; r.assert_status_ok(); let apps: Vec = r.json(); let default = apps .iter() .find(|a| a["slug"] == "default") .expect("default app must exist"); assert_eq!(default["name"], "Default"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn cross_app_isolation_at_dispatch(pool: PgPool) { let (s, default_id) = server_with_app(pool).await; // Two apps each create a script with the same name (per-app // uniqueness — would have collided pre-3b). let app_b: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "tenant-b", "name": "Tenant B" })) .await .json(); let b_id = app_b["id"].as_str().unwrap(); s.post(&format!("/api/v1/admin/apps/{b_id}/domains")) .json(&json!({ "pattern": "b.localhost" })) .await .assert_status(axum::http::StatusCode::CREATED); let id_default: String = s .post("/api/v1/admin/scripts") .json(&with_app( &default_id, json!({ "name": "echo", "source": "#{ statusCode: 200, body: #{ from: \"default\" } }" }), )) .await .json::()["id"] .as_str() .unwrap() .to_string(); let id_b: String = s .post("/api/v1/admin/scripts") .json(&with_app( b_id, json!({ "name": "echo", "source": "#{ statusCode: 200, body: #{ from: \"b\" } }" }), )) .await .json::()["id"] .as_str() .unwrap() .to_string(); s.post(&format!("/api/v1/admin/scripts/{id_default}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" })) .await .assert_status(axum::http::StatusCode::CREATED); s.post(&format!("/api/v1/admin/scripts/{id_b}/routes")) .json(&json!({ "host_kind": "any", "path_kind": "exact", "path": "/echo" })) .await .assert_status(axum::http::StatusCode::CREATED); // Same path, different host — routes land in different apps. let from_default: Value = s.get("/echo").add_header("host", "localhost").await.json(); assert_eq!(from_default["from"], "default"); let from_b: Value = s .get("/echo") .add_header("host", "b.localhost") .await .json(); assert_eq!(from_b["from"], "b"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn unknown_host_returns_404(pool: PgPool) { let s = server(pool).await; let r = s.get("/whatever").add_header("host", "nope.invalid").await; r.assert_status_not_found(); let body: Value = r.json(); assert!(body["error"] .as_str() .unwrap() .contains("no app claims host")); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn execute_by_id_works_without_host_claim(pool: PgPool) { // The /api/v1/execute/{id} bypass is the implicit __internal__ // claim of every app — it MUST keep working for an app with zero // public domain claims. let (s, _) = server_with_app(pool).await; let app: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "internal-only", "name": "Internal Only" })) .await .json(); let app_id = app["id"].as_str().unwrap(); let script: Value = s .post("/api/v1/admin/scripts") .json(&with_app( app_id, json!({ "name": "x", "source": "#{ statusCode: 200, body: \"ok\" }" }), )) .await .json(); let id = script["id"].as_str().unwrap(); let r = s .post(&format!("/api/v1/execute/{id}")) .json(&json!({})) .await; r.assert_status_ok(); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn duplicate_slug_creates_a_409(pool: PgPool) { let s = server(pool).await; s.post("/api/v1/admin/apps") .json(&json!({ "slug": "alpha", "name": "First" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "alpha", "name": "Second" })) .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 reserved_slug_rejected(pool: PgPool) { let s = server(pool).await; for bad in ["new", "api", "admin", "login"] { let r = s .post("/api/v1/admin/apps") .json(&json!({ "slug": bad, "name": "x" })) .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 slug_rename_keeps_old_as_redirect(pool: PgPool) { let s = server(pool).await; let app: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "old-slug", "name": "x" })) .await .json(); let id = app["id"].as_str().unwrap(); s.patch(&format!("/api/v1/admin/apps/{id}")) .json(&json!({ "slug": "new-slug" })) .await .assert_status_ok(); let resp: Value = s.get("/api/v1/admin/apps/old-slug").await.json(); // The old slug resolves via history and surfaces `redirect_to`. assert_eq!(resp["redirect_to"], "new-slug"); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn claiming_historical_slug_needs_force_takeover(pool: PgPool) { let s = server(pool).await; // Set up a history row. let first: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "soon-retired", "name": "x" })) .await .json(); s.patch(&format!( "/api/v1/admin/apps/{}", first["id"].as_str().unwrap() )) .json(&json!({ "slug": "kept" })) .await .assert_status_ok(); // Plain create against the retired slug → 409. let r = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "soon-retired", "name": "y" })) .await; r.assert_status(axum::http::StatusCode::CONFLICT); // With force_takeover → 201. let r = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "soon-retired", "name": "y", "force_takeover": true })) .await; r.assert_status(axum::http::StatusCode::CREATED); } #[ignore = "needs DATABASE_URL pointing at a running Postgres"] #[sqlx::test(migrations = "../manager-core/migrations")] async fn shape_key_collision_rejected(pool: PgPool) { let s = server(pool).await; let a: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "a", "name": "A" })) .await .json(); let b: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "b", "name": "B" })) .await .json(); s.post(&format!( "/api/v1/admin/apps/{}/domains", a["id"].as_str().unwrap() )) .json(&json!({ "pattern": "*.example.com" })) .await .assert_status(axum::http::StatusCode::CREATED); // Parameterized form should collide with wildcard form. let r = s .post(&format!( "/api/v1/admin/apps/{}/domains", b["id"].as_str().unwrap() )) .json(&json!({ "pattern": "{tenant}.example.com" })) .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 delete_app_with_scripts_returns_409(pool: PgPool) { let s = server(pool).await; let app: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "with-scripts", "name": "x" })) .await .json(); let id = app["id"].as_str().unwrap(); s.post("/api/v1/admin/scripts") .json(&with_app(id, json!({ "name": "s", "source": "1" }))) .await .assert_status(axum::http::StatusCode::CREATED); let r = s.delete(&format!("/api/v1/admin/apps/{id}")).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_scripts_filtered_by_app(pool: PgPool) { let (s, default_id) = server_with_app(pool).await; let other: Value = s .post("/api/v1/admin/apps") .json(&json!({ "slug": "filter-target", "name": "x" })) .await .json(); let other_id = other["id"].as_str().unwrap(); s.post("/api/v1/admin/scripts") .json(&with_app( &default_id, json!({ "name": "in-default", "source": "1" }), )) .await .assert_status(axum::http::StatusCode::CREATED); s.post("/api/v1/admin/scripts") .json(&with_app( other_id, json!({ "name": "in-other", "source": "1" }), )) .await .assert_status(axum::http::StatusCode::CREATED); // Filter by id. let filtered: Vec = s .get(&format!("/api/v1/admin/scripts?app={other_id}")) .await .json(); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0]["name"], "in-other"); // Filter by slug. let filtered_by_slug: Vec = s .get("/api/v1/admin/scripts?app=filter-target") .await .json(); assert_eq!(filtered_by_slug.len(), 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, app_id) = server_with_app(pool).await; let created: Value = s .post("/api/v1/admin/scripts") .json(&with_app( &app_id, 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()); }