Adds optional per-script overrides for the six Rhai sandbox knobs
(max_operations, max_string_size, max_array_size, max_map_size,
max_call_levels, max_expr_depth). The executor merges its defaults
with each script's overrides on every call; the manager validates
overrides against an admin-set ceiling at write time, so the
executor trusts whatever is stored.
Storage chose JSONB on the existing scripts table over six new
columns: lets future knobs land as code-only changes, keeps the
sparse common case (most scripts override nothing) cheap to store
and serialize, and matches how the manager + executor pass the
config across the wire.
* 0002_sandbox.sql — ALTER TABLE scripts ADD COLUMN sandbox
JSONB NOT NULL DEFAULT '{}'
* shared::ScriptSandbox — six Option<u64> fields with
deny_unknown_fields so typos surface as 422
* Script.sandbox + ExecRequest.sandbox_overrides — typed end
to end; cluster mode just serializes the same struct
* executor-core::Limits::with_overrides — field-by-field
replacement; tests cover the override actually tightening
the live engine
* manager-core::SandboxCeiling — built-in conservative
defaults (10M ops, 1 MiB strings, 100k array/map, 128
call/expr depth); env vars override per knob, invalid
values warn-and-skip rather than blocking boot
* manager-core admin API — POST/PUT accept `sandbox`; values
above the ceiling return 422 with the specific field +
requested + ceiling; absent or `{}` keeps platform defaults
* picloud all-in-one — wires SandboxCeiling::from_env() into
AdminState
* memory_limit_mb stays in the schema, marked v1.3+ advisory
(no enforcement until OS-level isolation lands with
cluster-mode executors)
Verified live through Caddy:
* /version reports schema 2, product 0.3.0
* Script with max_operations: 500 → 507 on a 10k-iteration loop
* Same script after PUT raising to 1M → succeeds, returns 10000
* POST with max_operations: 1_000_000_000 → 422 (exceeds ceiling)
Tests:
* 13 executor-core unit tests (added 2 for override semantics)
* 20 integration tests (added 6 for sandbox CRUD + ceiling +
unknown-field rejection + executor honoring overrides)
* default cargo test --workspace stays green (integration tests
remain #[ignore]'d until DATABASE_URL is set)
Bumps:
* schema 1 → 2
* product 0.2.0 → 0.3.0
* SDK unchanged (scripts see nothing new)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
452 lines
15 KiB
Rust
452 lines
15 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;
|
|
|
|
fn server(pool: PgPool) -> TestServer {
|
|
TestServer::new(picloud::build_app(pool)).expect("TestServer should build")
|
|
}
|
|
|
|
// ============================================================================
|
|
// Health
|
|
// ============================================================================
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn healthz_responds_ok(pool: PgPool) {
|
|
let r = server(pool).get("/healthz").await;
|
|
r.assert_status_ok();
|
|
assert_eq!(r.text(), "ok");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Script CRUD
|
|
// ============================================================================
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn create_script_returns_201_with_full_record(pool: PgPool) {
|
|
let s = server(pool);
|
|
let r = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({
|
|
"name": "echo",
|
|
"description": "test",
|
|
"source": "#{ statusCode: 200, body: 42 }",
|
|
}))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::CREATED);
|
|
let body: Value = r.json();
|
|
assert_eq!(body["name"], "echo");
|
|
assert_eq!(body["version"], 1);
|
|
assert_eq!(body["timeout_seconds"], 30);
|
|
assert!(body["id"].as_str().is_some());
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
|
|
let r = server(pool)
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
|
let body: Value = r.json();
|
|
assert!(body["error"].as_str().unwrap().contains("invalid script"));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn duplicate_name_returns_409(pool: PgPool) {
|
|
let s = server(pool);
|
|
s.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "dup", "source": "42" }))
|
|
.await
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
|
let r = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "dup", "source": "43" }))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn list_returns_all_scripts(pool: PgPool) {
|
|
let s = server(pool);
|
|
for name in ["alpha", "bravo", "charlie"] {
|
|
s.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": name, "source": "1" }))
|
|
.await
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
|
}
|
|
let r = s.get("/api/v1/admin/scripts").await;
|
|
r.assert_status_ok();
|
|
let body: Vec<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 = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "u", "source": "1" }))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
let r = s
|
|
.put(&format!("/api/v1/admin/scripts/{id}"))
|
|
.json(&json!({ "source": "#{ statusCode: 200, body: \"v2\" }", "timeout_seconds": 60 }))
|
|
.await;
|
|
r.assert_status_ok();
|
|
let updated: Value = r.json();
|
|
assert_eq!(updated["version"], 2);
|
|
assert_eq!(updated["timeout_seconds"], 60);
|
|
assert!(updated["source"].as_str().unwrap().contains("v2"));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn update_with_invalid_source_returns_422(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "u", "source": "1" }))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
let r = s
|
|
.put(&format!("/api/v1/admin/scripts/{id}"))
|
|
.json(&json!({ "source": "@@@ broken @@@" }))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn delete_then_get_returns_404(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({ "name": "d", "source": "1" }))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
s.delete(&format!("/api/v1/admin/scripts/{id}"))
|
|
.await
|
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
|
|
|
s.get(&format!("/api/v1/admin/scripts/{id}"))
|
|
.await
|
|
.assert_status_not_found();
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn get_nonexistent_returns_404(pool: PgPool) {
|
|
let r = server(pool)
|
|
.get("/api/v1/admin/scripts/00000000-0000-0000-0000-000000000000")
|
|
.await;
|
|
r.assert_status_not_found();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Execution + audit logs
|
|
// ============================================================================
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn execute_echoes_body_back(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({
|
|
"name": "echo",
|
|
"source": "#{ statusCode: 200, body: ctx.request.body }",
|
|
}))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
let r = s
|
|
.post(&format!("/api/v1/execute/{id}"))
|
|
.json(&json!({ "n": 42 }))
|
|
.await;
|
|
r.assert_status_ok();
|
|
let body: Value = r.json();
|
|
assert_eq!(body, json!({ "n": 42 }));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn execute_passes_through_status_and_headers(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({
|
|
"name": "header-test",
|
|
"source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }",
|
|
}))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
let r = s
|
|
.post(&format!("/api/v1/execute/{id}"))
|
|
.json(&json!({}))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::CREATED);
|
|
assert_eq!(r.header("x-tag"), "on");
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn execute_nonexistent_returns_404(pool: PgPool) {
|
|
let r = server(pool)
|
|
.post("/api/v1/execute/00000000-0000-0000-0000-000000000000")
|
|
.json(&json!({}))
|
|
.await;
|
|
r.assert_status_not_found();
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn execution_logs_capture_invocations(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({
|
|
"name": "logger",
|
|
"source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }",
|
|
}))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
// No logs yet.
|
|
let r = s.get(&format!("/api/v1/admin/scripts/{id}/logs")).await;
|
|
r.assert_status_ok();
|
|
let logs: Vec<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 = server(pool);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
// 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);
|
|
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!({}));
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
|
async fn execution_errors_are_still_logged(pool: PgPool) {
|
|
let s = server(pool);
|
|
let created: Value = s
|
|
.post("/api/v1/admin/scripts")
|
|
.json(&json!({
|
|
"name": "boom",
|
|
"source": "1 / 0",
|
|
}))
|
|
.await
|
|
.json();
|
|
let id = created["id"].as_str().unwrap();
|
|
|
|
let r = s
|
|
.post(&format!("/api/v1/execute/{id}"))
|
|
.json(&json!({}))
|
|
.await;
|
|
r.assert_status(axum::http::StatusCode::BAD_GATEWAY);
|
|
|
|
let logs: Vec<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());
|
|
}
|