Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.
Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
existing scripts/routes backfill into it. Fresh installs additionally
get the Hello World seed via seed_hello_world_if_fresh after
migrations run (idempotent — only fires when the default app has no
scripts).
Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.
Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.
Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.
Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.
Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1161 lines
40 KiB
Rust
1161 lines
40 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;
|
|
|
|
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::<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");
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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);
|
|
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<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());
|
|
}
|