Files
PiCloud/crates/picloud/tests/api.rs
MechaCat02 b5ce4cec01 ci: run the DB-backed tests that CI was silently skipping
CI ran `cargo test --workspace` with no `--include-ignored`, so it executed 927
tests and skipped 237 — every DB-backed integration test is
`#[ignore = "needs DATABASE_URL..."]`, which covers ALL of api.rs, ALL of
authz.rs, and the entire CLI journey suite. The isolation and RBAC tests existed
but never ran (AUDIT.md F-Q-014, logged and never remediated). CI provides
Postgres, so it can run them.

Three things had to be right for that to go green:

- **`--all-targets`, not a bare workspace run.** `-- --include-ignored` un-ignores
  not just `#[ignore]` tests but also ` ```ignore ` DOCTESTS, which are
  illustrative pseudocode that does not compile. `--all-targets` runs lib/bins/
  integration tests but excludes doctests (the same reason clippy uses it); a
  separate `--doc` step runs the doctests without the flag. Structural, so a
  future pseudocode doctest can't silently break CI either.

- **The CLI journeys are their own step.** They spawn a real picloud whose
  dispatcher/orchestrator claim loops are global by design; run concurrently with
  the manager-core suites on the shared database they would claim those suites'
  outbox and workflow rows. Sequential steps keep the live server off the DB
  while the other suites use it. The step also rebuilds `-p picloud` first (the
  harness execs the prebuilt binary) and sets the dev-mode env the server needs.

- **A higher `max_connections`.** `#[sqlx::test]` pools are lazy, but mass-parallel
  test startup briefly opens many at once (each test creates its own throwaway
  database); on a many-core box that transient spike exceeded the default 100 and
  Postgres answered "sorry, too many clients already". Steady-state peak is only
  ~26; 500 absorbs the spike with room to spare. Serving the app needs nothing
  like this many. (This is the local compose ceiling; a small CI runner's default
  100 has ample headroom for its lower parallelism.)

Also fixes the test the CI gap had let rot: api.rs asserted `v["schema"] == 66`
with a hand-bump comment, and since nothing ran it, it sat broken from migration
0066 to 0073. It now asserts `/version` surfaces the live constants
(`migrations::latest_version()`, `SDK_VERSION`) — the wiring — while value drift
stays caught by schema_snapshot + check-versioning. A constant hand-synced to
another constant is a chore, not a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:27:21 +02:00

1550 lines
52 KiB
Rust

//! 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;
use picloud_shared::InstanceRole;
let auth = picloud::AuthDeps::from_pool(pool.clone());
let hash = hash_password("test-pw").expect("hash");
auth.users
.create("test-admin", &hash, InstanceRole::Owner, None)
.await
.expect("seed admin");
let app = picloud::build_app(
pool,
auth,
picloud_shared::MasterKey::from_bytes([0x42u8; 32]),
)
.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::<Value>()["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: <claim>` 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": <default> }` 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");
}
// ============================================================================
// Auth
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/api/v1/admin/auth/me").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["username"], "test-admin");
assert_eq!(body["instance_role"], "owner");
// Seeded admin has no email — must round-trip as null, not be missing.
assert!(
body.get("email").is_some_and(Value::is_null),
"email should be present and null, got: {body}"
);
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_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
let s = server(pool).await;
// Create with email set.
let created = s
.post("/api/v1/admin/admins")
.json(&json!({
"username": "alice",
"password": "correct-horse-battery",
"instance_role": "member",
"email": "alice@example.com",
}))
.await;
created.assert_status(axum::http::StatusCode::CREATED);
let body: Value = created.json();
let alice_id = body["id"].as_str().expect("id").to_string();
assert_eq!(body["email"], "alice@example.com");
// Patch with email present-and-null clears it.
let cleared = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": null }))
.await;
cleared.assert_status_ok();
assert!(cleared.json::<Value>()["email"].is_null());
// Patch with email omitted is a no-op (doesn't clobber a re-set).
let reset = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "email": "alice2@example.com" }))
.await;
reset.assert_status_ok();
let omit = s
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
.json(&json!({ "username": "alice" })) // no email key
.await;
omit.assert_status_ok();
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
}
// ============================================================================
// 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<Value> = 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<Value> = 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<Value> = 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);
// This asserts the WIRING — that `/version` actually surfaces the live
// constants — not the values themselves. It used to hardcode `66`, with a
// comment to bump it by hand on every migration. Nobody did: CI never ran
// this test (it is `#[ignore]`d, and CI omitted `--include-ignored`), so it
// sat broken from 0066 all the way to 0073 and only surfaced when the CI gap
// was closed. A constant that must be hand-synced with a constant is not a
// test, it is a chore that fails silently.
//
// Drift in the values is already caught where it belongs: the schema by
// `manager-core/tests/schema_snapshot.rs`, the version surfaces by
// `scripts/check-versioning.sh`.
assert_eq!(
v["schema"],
picloud_manager_core::migrations::latest_version()
);
assert_eq!(v["sdk"], picloud_shared::version::SDK_VERSION);
}
// ============================================================================
// ============================================================================
// 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<Value> = 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::<Value>()["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::<Value>()["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<Value> = 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<Value> = 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<Value> = 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());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn runtime_error_detail_is_scrubbed_from_public_response(pool: PgPool) {
// Audit 2026-06-11 (F-SE-H-04) — a runtime error's verbose detail
// (here a thrown marker string, but in the wild SDK/SSRF/internal
// paths) must not reach the public data-plane response body. The
// caller gets a stable message + a correlation id; the full text
// only goes to the server log.
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": "leaky",
"source": r#"throw "SECRET_MARKER_98765""#,
}),
))
.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 body: Value = r.json();
let err = body["error"].as_str().unwrap();
assert!(
!err.contains("SECRET_MARKER_98765"),
"runtime detail leaked to public response: {err}"
);
assert!(
err.contains("ref:"),
"scrubbed response should carry a correlation id, got: {err}"
);
}
// ============================================================================
// v1.1.3 — Modules: scripts.kind, route + trigger rejection, end-to-end import
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_script_default_kind_is_endpoint(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": "default-kind", "source": "1" }),
))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
assert_eq!(body["kind"], "endpoint");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_module_kind_persists(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": "helpers",
"kind": "module",
"source": "fn add(a, b) { a + b }"
}),
))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
assert_eq!(body["kind"], "module");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_module_with_top_level_expr_rejected(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": "badmod",
"kind": "module",
"source": "42; fn ok() { 1 }"
}),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json();
assert!(body["error"].as_str().unwrap().contains("module"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_module_with_reserved_name_rejected(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": "kv",
"kind": "module",
"source": "fn ok() { 1 }"
}),
))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
let body: Value = r.json();
assert!(body["error"].as_str().unwrap().contains("reserved"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_bind_rejects_module(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": "lib",
"kind": "module",
"source": "fn pong() { 42 }"
}),
))
.await;
r.assert_status(axum::http::StatusCode::CREATED);
let body: Value = r.json();
let id = body["id"].as_str().unwrap();
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/lib"
}))
.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 endpoint_imports_module_end_to_end(pool: PgPool) {
let (s, app_id) = server_with_app(pool).await;
// Create a module script.
s.post("/api/v1/admin/scripts")
.json(&with_app(
&app_id,
json!({
"name": "math",
"kind": "module",
"source": "fn add(a, b) { a + b }"
}),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Create an endpoint that imports it.
let id = create_basic_script(
&s,
&app_id,
"calc",
r#"import "math" as m; #{ statusCode: 200, body: m::add(2, 3) }"#,
)
.await;
// Bind a route.
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/calc"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Hit it — the endpoint should consume the module and return 5.
let r = s.get("/calc").add_header("host", "localhost").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body, json!(5));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn module_edit_visible_on_next_invocation(pool: PgPool) {
let (s, app_id) = server_with_app(pool).await;
let lib: Value = s
.post("/api/v1/admin/scripts")
.json(&with_app(
&app_id,
json!({
"name": "greet",
"kind": "module",
"source": r"fn say(n) { `hello, ${n}` }"
}),
))
.await
.json();
let lib_id = lib["id"].as_str().unwrap();
let id = create_basic_script(
&s,
&app_id,
"hello",
r#"import "greet" as g; #{ statusCode: 200, body: g::say("world") }"#,
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/hello"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r1: Value = s.get("/hello").add_header("host", "localhost").await.json();
assert_eq!(r1, json!("hello, world"));
// Edit the module — bump updated_at.
s.put(&format!("/api/v1/admin/scripts/{lib_id}"))
.json(&json!({ "source": r"fn say(n) { `hi, ${n}` }" }))
.await
.assert_status_ok();
// Cache invalidation must surface the new behavior.
let r2: Value = s.get("/hello").add_header("host", "localhost").await.json();
assert_eq!(r2, json!("hi, world"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn cross_app_import_blocked(pool: PgPool) {
// Two apps each have a module named "helpers" with different
// behavior. An endpoint in app A must import A's module, not B's.
// App A is already created by `server_with_app`. Create app B.
let (s, app_a) = server_with_app(pool).await;
let app_b: Value = s
.post("/api/v1/admin/apps")
.json(&json!({ "slug": "appb", "name": "App B" }))
.await
.json();
let app_b_id = app_b["id"].as_str().unwrap();
// App A's module returns "A". App B's returns "B".
s.post("/api/v1/admin/scripts")
.json(&with_app(
&app_a,
json!({
"name": "helpers",
"kind": "module",
"source": r#"fn who() { "A" }"#
}),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post("/api/v1/admin/scripts")
.json(&with_app(
app_b_id,
json!({
"name": "helpers",
"kind": "module",
"source": r#"fn who() { "B" }"#
}),
))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Endpoint in app A imports "helpers" and exposes the result.
let id = create_basic_script(
&s,
&app_a,
"who-am-i",
r#"import "helpers" as h; #{ statusCode: 200, body: h::who() }"#,
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/who-am-i"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r: Value = s
.get("/who-am-i")
.add_header("host", "localhost")
.await
.json();
assert_eq!(r, json!("A"), "must see app A's module, not app B's");
}