M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).
Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
ext point on the importer's chain first; on a hit it resolves against
`App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
query returns Some only when the NEAREST declaration of the name is an ext
point (a peer/nearer concrete module shadows it). A group origin can't reach
app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).
Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
(name-based, default change = Update), reconcile (upsert after scripts so the
default resolves) + prune, `validate_bundle` (module-name shape; default must
be a local module), `check_imports_resolve` (a declared/on-chain ext point
satisfies an import), and the state_token fold. Writes gate on the
script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.
CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.
Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
983 lines
32 KiB
Rust
983 lines
32 KiB
Rust
//! v1.1.3 — `PicloudModuleResolver` integration tests.
|
|
#![allow(clippy::needless_raw_string_hashes)] // r#""# is more uniform when many tests embed Rhai sources
|
|
//!
|
|
//! Each test wires an `Engine` with a `CountingModuleSource` (an
|
|
//! in-memory fake), a `Services` bundle, and an `ExecRequest` whose
|
|
//! `app_id` controls the cross-app boundary. The resolver is
|
|
//! exercised end-to-end through `Engine::execute`, so these tests
|
|
//! verify the same code path the `picloud` binary runs at request
|
|
//! time.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::{DateTime, Utc};
|
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
|
use picloud_shared::{
|
|
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
|
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
|
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
|
};
|
|
use tokio::sync::Mutex;
|
|
|
|
/// In-memory `ModuleSource` backed by a `HashMap<(AppId, name)>`.
|
|
/// Tracks total lookup count so tests can assert cache hit/miss.
|
|
#[derive(Default)]
|
|
struct CountingModuleSource {
|
|
table: Mutex<HashMap<(AppId, String), ModuleScript>>,
|
|
lookups: AtomicUsize,
|
|
/// When `Some`, every lookup returns this error instead of the
|
|
/// table — used by the backend-error test.
|
|
fail_with: Mutex<Option<String>>,
|
|
}
|
|
|
|
impl CountingModuleSource {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self::default())
|
|
}
|
|
|
|
async fn put(self: &Arc<Self>, app_id: AppId, name: &str, source: &str) -> ScriptId {
|
|
self.put_with_updated_at(app_id, name, source, Utc::now())
|
|
.await
|
|
}
|
|
|
|
async fn put_with_updated_at(
|
|
self: &Arc<Self>,
|
|
app_id: AppId,
|
|
name: &str,
|
|
source: &str,
|
|
updated_at: DateTime<Utc>,
|
|
) -> ScriptId {
|
|
let script_id = ScriptId::new();
|
|
self.table.lock().await.insert(
|
|
(app_id, name.to_string()),
|
|
ModuleScript {
|
|
script_id,
|
|
app_id: Some(app_id),
|
|
group_id: None,
|
|
name: name.to_string(),
|
|
source: source.to_string(),
|
|
updated_at,
|
|
},
|
|
);
|
|
script_id
|
|
}
|
|
|
|
fn lookup_count(&self) -> usize {
|
|
self.lookups.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModuleSource for CountingModuleSource {
|
|
async fn resolve(
|
|
&self,
|
|
origin: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
|
self.lookups.fetch_add(1, Ordering::SeqCst);
|
|
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
|
return Err(ModuleSourceError::Backend(err.clone()));
|
|
}
|
|
// This fake is flat/app-scoped — the inheritance + lexical
|
|
// resolution semantics are covered by the CLI journey tests
|
|
// against real Postgres. A group origin has no entries here.
|
|
let app_id = match origin {
|
|
ScriptOwner::App(a) => a,
|
|
ScriptOwner::Group(_) => return Ok(None),
|
|
};
|
|
Ok(self
|
|
.table
|
|
.lock()
|
|
.await
|
|
.get(&(app_id, name.to_string()))
|
|
.cloned())
|
|
}
|
|
|
|
async fn resolve_extension_point(
|
|
&self,
|
|
_origin: ScriptOwner,
|
|
_name: &str,
|
|
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
|
// This flat fake has no extension points; the inversion is covered by
|
|
// `LexicalModuleSource` below and the Postgres journey tests.
|
|
Ok(None)
|
|
}
|
|
|
|
async fn resolve_by_id(
|
|
&self,
|
|
_origin: ScriptOwner,
|
|
script_id: ScriptId,
|
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
|
Ok(self
|
|
.table
|
|
.lock()
|
|
.await
|
|
.values()
|
|
.find(|m| m.script_id == script_id)
|
|
.cloned())
|
|
}
|
|
}
|
|
|
|
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
|
Services::new(
|
|
Arc::new(NoopKvService),
|
|
Arc::new(NoopDocsService),
|
|
Arc::new(NoopDeadLetterService),
|
|
Arc::new(NoopEventEmitter),
|
|
modules,
|
|
Arc::new(NoopHttpService),
|
|
Arc::new(picloud_shared::NoopFilesService),
|
|
Arc::new(picloud_shared::NoopPubsubService),
|
|
Arc::new(picloud_shared::NoopSecretsService),
|
|
Arc::new(picloud_shared::NoopEmailService),
|
|
Arc::new(picloud_shared::NoopUsersService),
|
|
Arc::new(picloud_shared::NoopQueueService),
|
|
Arc::new(picloud_shared::NoopInvokeService),
|
|
Arc::new(picloud_shared::NoopVarsService),
|
|
)
|
|
}
|
|
|
|
fn engine_with(modules: Arc<dyn ModuleSource>) -> Engine {
|
|
Engine::new(Limits::default(), services_with(modules))
|
|
}
|
|
|
|
fn req(app_id: AppId) -> ExecRequest {
|
|
let execution_id = ExecutionId::new();
|
|
ExecRequest {
|
|
execution_id,
|
|
request_id: RequestId::new(),
|
|
script_id: ScriptId::new(),
|
|
script_name: "test".into(),
|
|
invocation_type: InvocationType::Http,
|
|
path: "/test".into(),
|
|
method: String::new(),
|
|
headers: BTreeMap::new(),
|
|
body: serde_json::Value::Null,
|
|
params: BTreeMap::new(),
|
|
query: BTreeMap::new(),
|
|
rest: String::new(),
|
|
sandbox_overrides: ScriptSandbox::default(),
|
|
app_id,
|
|
script_owner: None,
|
|
principal: None,
|
|
trigger_depth: 0,
|
|
root_execution_id: execution_id,
|
|
is_dead_letter_handler: false,
|
|
event: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_loads_simple_module() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
source.put(app_id, "math", "fn add(a, b) { a + b }").await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
let resp = engine
|
|
.execute(r#"import "math" as m; m::add(2, 3)"#, req(app_id))
|
|
.expect("should execute");
|
|
assert_eq!(resp.status_code, 200);
|
|
assert_eq!(resp.body, serde_json::json!(5));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_cross_app_blocked() {
|
|
let source = CountingModuleSource::new();
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
source
|
|
.put(app_a, "secrets", "fn token() { \"A-token\" }")
|
|
.await;
|
|
source
|
|
.put(app_b, "secrets", "fn token() { \"B-token\" }")
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
|
|
// App A sees A's module.
|
|
let resp = engine
|
|
.execute(r#"import "secrets" as s; s::token()"#, req(app_a))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!("A-token"));
|
|
|
|
// App B sees B's module — same name, completely separate value.
|
|
let resp = engine
|
|
.execute(r#"import "secrets" as s; s::token()"#, req(app_b))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!("B-token"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_cross_app_module_not_found() {
|
|
let source = CountingModuleSource::new();
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
// Only app A has the module.
|
|
source.put(app_a, "lonely", "fn ping() { \"pong\" }").await;
|
|
|
|
// App B's lookup should return None → resolver surfaces
|
|
// ErrorModuleNotFound.
|
|
let engine = engine_with(source.clone());
|
|
let err = engine
|
|
.execute(r#"import "lonely" as l; l::ping()"#, req(app_b))
|
|
.expect_err("cross-app import should fail");
|
|
let msg = format!("{err:?}");
|
|
assert!(
|
|
msg.to_lowercase().contains("module")
|
|
|| msg.to_lowercase().contains("not found")
|
|
|| msg.to_lowercase().contains("lonely"),
|
|
"expected module-not-found-flavoured error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_module_not_found() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
let engine = engine_with(source);
|
|
|
|
let err = engine
|
|
.execute(r#"import "doesnotexist" as x; 1"#, req(app_id))
|
|
.expect_err("unknown module should fail");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("doesnotexist") || msg.contains("not found"),
|
|
"expected ErrorModuleNotFound-flavoured error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_self_import_detected() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
// a imports itself
|
|
source
|
|
.put(app_id, "a", r#"import "a" as a; fn nope() { 0 }"#)
|
|
.await;
|
|
let engine = engine_with(source);
|
|
|
|
let err = engine
|
|
.execute(r#"import "a" as a; a::nope()"#, req(app_id))
|
|
.expect_err("self-import should detect cycle");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("circular") || msg.contains("cycle"),
|
|
"expected circular-import error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_circular_detected() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
// a imports b; b imports a; both then declare a fn.
|
|
source
|
|
.put(app_id, "a", r#"import "b" as b; fn x() { 0 }"#)
|
|
.await;
|
|
source
|
|
.put(app_id, "b", r#"import "a" as a; fn y() { 0 }"#)
|
|
.await;
|
|
let engine = engine_with(source);
|
|
|
|
let err = engine
|
|
.execute(r#"import "a" as a; a::x()"#, req(app_id))
|
|
.expect_err("circular import should fail");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("circular") || msg.contains("cycle"),
|
|
"expected circular-import error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_depth_limit_enforced() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
// Chain `m0 -> m1 -> ... -> m9` (10 levels). Default depth limit is 8.
|
|
for i in 0..9 {
|
|
let next = format!("m{}", i + 1);
|
|
source
|
|
.put(
|
|
app_id,
|
|
&format!("m{i}"),
|
|
&format!(r#"import "{next}" as nxt; fn x() {{ 0 }}"#),
|
|
)
|
|
.await;
|
|
}
|
|
source.put(app_id, "m9", "fn x() { 0 }").await;
|
|
|
|
let engine = engine_with(source);
|
|
let err = engine
|
|
.execute(r#"import "m0" as m0; m0::x()"#, req(app_id))
|
|
.expect_err("chain exceeding depth limit should fail");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("depth"),
|
|
"expected depth-exceeded error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_depth_limit_just_under_succeeds() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
// Chain depth 7 (under default 8). m0 -> m1 -> ... -> m6 (terminal).
|
|
for i in 0..6 {
|
|
let next = format!("m{}", i + 1);
|
|
source
|
|
.put(
|
|
app_id,
|
|
&format!("m{i}"),
|
|
&format!(r#"import "{next}" as nxt; fn x() {{ nxt::x() }}"#),
|
|
)
|
|
.await;
|
|
}
|
|
source.put(app_id, "m6", "fn x() { 42 }").await;
|
|
|
|
let engine = engine_with(source);
|
|
let resp = engine
|
|
.execute(r#"import "m0" as m0; m0::x()"#, req(app_id))
|
|
.expect("chain under depth limit should succeed");
|
|
assert_eq!(resp.body, serde_json::json!(42));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_runtime_validation_rejects_top_level_expr() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
// Module has a top-level expression — bypassed the admin gate,
|
|
// but the resolver re-validates and rejects.
|
|
source.put(app_id, "bad", r#"42; fn x() { 1 }"#).await;
|
|
let engine = engine_with(source);
|
|
|
|
let err = engine
|
|
.execute(r#"import "bad" as b; b::x()"#, req(app_id))
|
|
.expect_err("top-level expr in module should be rejected at resolve");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("top-level") || msg.contains("module"),
|
|
"expected module-shape error, got {msg}"
|
|
);
|
|
}
|
|
|
|
/// v1.1.4 §10a regression: the backend error must be REDACTED before
|
|
/// it reaches a script. The verbatim message (which can leak internal
|
|
/// infrastructure shape, e.g. "connection refused") must not appear;
|
|
/// the script sees only a stable generic.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn resolver_backend_error_is_redacted_from_script() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
*source.fail_with.lock().await = Some("connection refused to 10.1.2.3:5432".into());
|
|
let engine = engine_with(source);
|
|
|
|
let err = engine
|
|
.execute(r#"import "x" as x; 1"#, req(app_id))
|
|
.expect_err("backend error should propagate");
|
|
let msg = format!("{err:?}");
|
|
assert!(
|
|
msg.contains("module backend unavailable"),
|
|
"expected redacted generic message, got {msg}"
|
|
);
|
|
assert!(
|
|
!msg.contains("connection refused") && !msg.contains("10.1.2.3"),
|
|
"redacted message must not leak the backend error, got {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_cache_hit_reuses_compiled_module() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
source.put(app_id, "u", "fn ping() { 1 }").await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
|
|
// First execution compiles and caches.
|
|
engine
|
|
.execute(r#"import "u" as u; u::ping()"#, req(app_id))
|
|
.unwrap();
|
|
let lookups_after_first = source.lookup_count();
|
|
assert_eq!(
|
|
lookups_after_first, 1,
|
|
"first invocation should look up once"
|
|
);
|
|
|
|
// Second execution should re-lookup (to compare updated_at) but
|
|
// serve from cache without recompiling. We can't directly observe
|
|
// compile-vs-cache here, but we can assert lookup count grew by
|
|
// one (no spurious extra calls).
|
|
engine
|
|
.execute(r#"import "u" as u; u::ping()"#, req(app_id))
|
|
.unwrap();
|
|
assert_eq!(source.lookup_count(), 2);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_cache_stale_invalidated_on_updated_at_change() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
let t0 = Utc::now() - chrono::Duration::seconds(10);
|
|
source
|
|
.put_with_updated_at(app_id, "u", r#"fn v() { 1 }"#, t0)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
|
|
let resp = engine
|
|
.execute(r#"import "u" as u; u::v()"#, req(app_id))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(1));
|
|
|
|
// Replace with newer updated_at — cache should refresh.
|
|
let t1 = Utc::now();
|
|
source
|
|
.put_with_updated_at(app_id, "u", r#"fn v() { 99 }"#, t1)
|
|
.await;
|
|
|
|
let resp = engine
|
|
.execute(r#"import "u" as u; u::v()"#, req(app_id))
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.body,
|
|
serde_json::json!(99),
|
|
"edited module should be visible on next invocation"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_cache_keyed_by_app() {
|
|
let source = CountingModuleSource::new();
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
source.put(app_a, "u", "fn id() { 1 }").await;
|
|
source.put(app_b, "u", "fn id() { 2 }").await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
|
|
// Both apps should compile + cache independently; neither sees
|
|
// the other's compiled module.
|
|
let resp = engine
|
|
.execute(r#"import "u" as u; u::id()"#, req(app_a))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(1));
|
|
let resp = engine
|
|
.execute(r#"import "u" as u; u::id()"#, req(app_b))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(2));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_cache_lru_evicts_when_capacity_exceeded() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
source.put(app_id, "a", "fn v() { 1 }").await;
|
|
source.put(app_id, "b", "fn v() { 2 }").await;
|
|
source.put(app_id, "c", "fn v() { 3 }").await;
|
|
|
|
// Capacity 1 — only the most recently used entry stays cached.
|
|
let engine =
|
|
Engine::with_module_cache_capacity(Limits::default(), services_with(source.clone()), 1);
|
|
|
|
engine
|
|
.execute(r#"import "a" as m; m::v()"#, req(app_id))
|
|
.unwrap();
|
|
engine
|
|
.execute(r#"import "b" as m; m::v()"#, req(app_id))
|
|
.unwrap();
|
|
engine
|
|
.execute(r#"import "c" as m; m::v()"#, req(app_id))
|
|
.unwrap();
|
|
|
|
// Cache should hold at most one entry.
|
|
let cache = engine.module_cache().lock().unwrap();
|
|
assert!(
|
|
cache.len() <= 1,
|
|
"cache size {} exceeded capacity 1",
|
|
cache.len()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn endpoint_can_import_module() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
source
|
|
.put(app_id, "helpers", r#"fn greet(name) { `hello, ${name}` }"#)
|
|
.await;
|
|
|
|
let engine = engine_with(source);
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "helpers" as h; #{ statusCode: 200, body: h::greet("world") }"#,
|
|
req(app_id),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(resp.status_code, 200);
|
|
assert_eq!(resp.body, serde_json::json!("hello, world"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn module_can_import_module() {
|
|
let source = CountingModuleSource::new();
|
|
let app_id = AppId::new();
|
|
source.put(app_id, "inner", "fn three() { 3 }").await;
|
|
source
|
|
.put(
|
|
app_id,
|
|
"outer",
|
|
r#"import "inner" as i; fn nine() { i::three() * 3 }"#,
|
|
)
|
|
.await;
|
|
let engine = engine_with(source);
|
|
|
|
let resp = engine
|
|
.execute(r#"import "outer" as o; o::nine()"#, req(app_id))
|
|
.unwrap();
|
|
assert_eq!(resp.body, serde_json::json!(9));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_module_accepts_fn_const_import_only() {
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let valid = r#"
|
|
const PI = 3.14;
|
|
import "other" as o;
|
|
fn area(r) { PI * r * r }
|
|
"#;
|
|
let v = engine.validate_module(valid).expect("valid module body");
|
|
assert_eq!(v.imports, vec!["other".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_module_rejects_top_level_let() {
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let bad = "let x = 1; fn f() { x }";
|
|
let err = engine
|
|
.validate_module(bad)
|
|
.expect_err("top-level let should be rejected");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(msg.contains("top-level") || msg.contains("module"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_module_rejects_top_level_expr() {
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let bad = "42";
|
|
let err = engine
|
|
.validate_module(bad)
|
|
.expect_err("top-level expr should be rejected");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(msg.contains("top-level") || msg.contains("module"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_module_rejects_top_level_while() {
|
|
// Avoid `if true { ... }` — Rhai folds constant-condition `if`s
|
|
// at optimize time, leaving an empty statement list that passes
|
|
// module-shape validation vacuously. A `while` with a variable
|
|
// condition isn't folded.
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let bad = r#"let i = 0; while i < 1 { i += 1; }"#;
|
|
let err = engine
|
|
.validate_module(bad)
|
|
.expect_err("top-level loop should be rejected");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(msg.contains("top-level") || msg.contains("module"));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_endpoint_extracts_literal_imports() {
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let src = r#"
|
|
import "a" as a;
|
|
import "b" as b;
|
|
a::run() + b::run()
|
|
"#;
|
|
let v = engine
|
|
.validate(src)
|
|
.expect("endpoint with imports should parse");
|
|
assert_eq!(v.imports, vec!["a".to_string(), "b".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn validate_endpoint_top_level_expr_still_allowed() {
|
|
// Endpoints can have arbitrary top-level statements — only
|
|
// modules are restricted. Confirm v1.1.3 didn't tighten endpoints.
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let src = r#"let x = 1; #{ statusCode: 200, body: x }"#;
|
|
engine
|
|
.validate(src)
|
|
.expect("endpoints may have top-level statements");
|
|
}
|
|
|
|
#[test]
|
|
fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
|
// `import some_var as y;` parses but is not a literal-path
|
|
// import — the dep graph cannot track it. The imports list
|
|
// should be empty for such a script.
|
|
let engine = Engine::new(Limits::default(), Services::default());
|
|
let src = r#"
|
|
let name = "x";
|
|
import name as y;
|
|
y::run()
|
|
"#;
|
|
let v = engine.validate(src).expect("dynamic import should parse");
|
|
assert!(
|
|
v.imports.is_empty(),
|
|
"dynamic imports should not appear in the dep-graph imports list, got {:?}",
|
|
v.imports
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 4b — lexical (origin-aware) resolution.
|
|
//
|
|
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
|
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
|
// `default_origin` for the entry AST, and each module's own owner (decoded
|
|
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
|
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct LexicalModuleSource {
|
|
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
|
/// Extension-point declarations, keyed by exact declaring owner →
|
|
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
|
/// shadowing tie-break is covered by the Postgres journey tests.
|
|
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
|
}
|
|
|
|
impl LexicalModuleSource {
|
|
fn new() -> Arc<Self> {
|
|
Arc::new(Self::default())
|
|
}
|
|
|
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
|
|
let (app_id, group_id) = match owner {
|
|
ScriptOwner::App(a) => (Some(a), None),
|
|
ScriptOwner::Group(g) => (None, Some(g)),
|
|
};
|
|
let script_id = ScriptId::new();
|
|
self.table.lock().await.insert(
|
|
(owner, name.to_string()),
|
|
ModuleScript {
|
|
script_id,
|
|
app_id,
|
|
group_id,
|
|
name: name.to_string(),
|
|
source: source.to_string(),
|
|
updated_at: Utc::now(),
|
|
},
|
|
);
|
|
script_id
|
|
}
|
|
|
|
/// Declare `name` an extension point at `owner`, with an optional default
|
|
/// module body id.
|
|
async fn put_ext_point(
|
|
self: &Arc<Self>,
|
|
owner: ScriptOwner,
|
|
name: &str,
|
|
default_script_id: Option<ScriptId>,
|
|
) {
|
|
self.ext_points
|
|
.lock()
|
|
.await
|
|
.insert((owner, name.to_string()), default_script_id);
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ModuleSource for LexicalModuleSource {
|
|
async fn resolve(
|
|
&self,
|
|
origin: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
|
Ok(self
|
|
.table
|
|
.lock()
|
|
.await
|
|
.get(&(origin, name.to_string()))
|
|
.cloned())
|
|
}
|
|
|
|
async fn resolve_extension_point(
|
|
&self,
|
|
origin: ScriptOwner,
|
|
name: &str,
|
|
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
|
Ok(self
|
|
.ext_points
|
|
.lock()
|
|
.await
|
|
.get(&(origin, name.to_string()))
|
|
.map(|default_script_id| picloud_shared::ExtPointResolution {
|
|
default_script_id: *default_script_id,
|
|
}))
|
|
}
|
|
|
|
async fn resolve_by_id(
|
|
&self,
|
|
_origin: ScriptOwner,
|
|
script_id: ScriptId,
|
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
|
Ok(self
|
|
.table
|
|
.lock()
|
|
.await
|
|
.values()
|
|
.find(|m| m.script_id == script_id)
|
|
.cloned())
|
|
}
|
|
}
|
|
|
|
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
|
let mut r = req(app_id);
|
|
r.script_owner = Some(owner);
|
|
r
|
|
}
|
|
|
|
/// A group module's `import` resolves from the GROUP (its own defining
|
|
/// node), not the inheriting app — even when an app-owned module of the
|
|
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
|
/// a module an inherited group script depends on.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn lexical_nested_import_seals_to_module_owner() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
|
|
// Two "inner" modules: the group's (real) and the app's (a trap).
|
|
source
|
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
|
.await;
|
|
source
|
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
|
.await;
|
|
// The group's "outer" imports "inner" — must bind the group's inner.
|
|
source
|
|
.put(
|
|
ScriptOwner::Group(group),
|
|
"outer",
|
|
r#"import "inner" as i; fn val() { i::v() }"#,
|
|
)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
// Entry script runs as the inherited GROUP script (default_origin = group).
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "outer" as o; o::val()"#,
|
|
req_with_owner(app, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("should execute");
|
|
assert_eq!(
|
|
resp.body,
|
|
serde_json::json!(42),
|
|
"group module's import must seal to the group's `inner`, not the app's trap"
|
|
);
|
|
}
|
|
|
|
/// The same registry, entered as an APP-owned script, reaches the app's
|
|
/// module — proving the fake distinguishes origins and the entry uses
|
|
/// `default_origin`.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn lexical_entry_origin_selects_app_module() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
source
|
|
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
|
.await;
|
|
source
|
|
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "inner" as i; i::v()"#,
|
|
req_with_owner(app, ScriptOwner::App(app)),
|
|
)
|
|
.expect("should execute");
|
|
assert_eq!(resp.body, serde_json::json!(999));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
|
// a module name an extension point; an importer on the group's chain resolves
|
|
// it against the INHERITING APP, not the sealed lexical chain.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A group script importing a declared extension point resolves it against the
|
|
/// executing app's own module — each tenant supplies its own body.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ext_point_resolves_to_app_module() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
// The group opens "theme" as an extension point (no default).
|
|
source
|
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
|
.await;
|
|
// The app provides its own "theme".
|
|
source
|
|
.put(
|
|
ScriptOwner::App(app),
|
|
"theme",
|
|
r#"fn name() { "app-theme" }"#,
|
|
)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
// Entry runs as the inherited GROUP script (default_origin = group), but
|
|
// the executing app is `app` (cx.app_id).
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "theme" as t; t::name()"#,
|
|
req_with_owner(app, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("should execute");
|
|
assert_eq!(
|
|
resp.body,
|
|
serde_json::json!("app-theme"),
|
|
"extension point must resolve to the inheriting app's module"
|
|
);
|
|
}
|
|
|
|
/// When the app provides no module for the extension point, the declared
|
|
/// default body is used.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ext_point_falls_back_to_default() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
// The group's default "theme" body, registered as a group module.
|
|
let default_id = source
|
|
.put(
|
|
ScriptOwner::Group(group),
|
|
"default-theme",
|
|
r#"fn name() { "default-theme" }"#,
|
|
)
|
|
.await;
|
|
source
|
|
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
|
.await;
|
|
// The app provides NO "theme".
|
|
|
|
let engine = engine_with(source.clone());
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "theme" as t; t::name()"#,
|
|
req_with_owner(app, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("should execute");
|
|
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
|
}
|
|
|
|
/// An extension point with neither an app provider nor a default is a clean
|
|
/// module-not-found at import time.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ext_point_no_provider_no_default_errors() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
source
|
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
let err = engine
|
|
.execute(
|
|
r#"import "theme" as t; t::name()"#,
|
|
req_with_owner(app, ScriptOwner::Group(group)),
|
|
)
|
|
.expect_err("ext point with no provider/default should fail");
|
|
let msg = format!("{err:?}").to_lowercase();
|
|
assert!(
|
|
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
|
"expected module-not-found-flavoured error, got {msg}"
|
|
);
|
|
}
|
|
|
|
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
|
/// module of the same name — the inversion only fires for declared extension
|
|
/// points, so the trust boundary holds.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
|
let source = LexicalModuleSource::new();
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
// "auth" is a concrete group module — NOT an extension point.
|
|
source
|
|
.put(
|
|
ScriptOwner::Group(group),
|
|
"auth",
|
|
r#"fn who() { "group-auth" }"#,
|
|
)
|
|
.await;
|
|
// The app has a trap "auth" that must NOT be selected.
|
|
source
|
|
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
let resp = engine
|
|
.execute(
|
|
r#"import "auth" as a; a::who()"#,
|
|
req_with_owner(app, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("should execute");
|
|
assert_eq!(
|
|
resp.body,
|
|
serde_json::json!("group-auth"),
|
|
"a non-ext-point import must seal to the group, never the leaf's trap"
|
|
);
|
|
}
|
|
|
|
/// Two apps inheriting the same extension point get their OWN bodies — the
|
|
/// id-keyed module cache does not bleed one tenant's body into another.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn ext_point_two_apps_get_distinct_bodies() {
|
|
let source = LexicalModuleSource::new();
|
|
let app1 = AppId::new();
|
|
let app2 = AppId::new();
|
|
let group = GroupId::new();
|
|
source
|
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
|
.await;
|
|
source
|
|
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
|
.await;
|
|
source
|
|
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
|
.await;
|
|
|
|
let engine = engine_with(source.clone());
|
|
let r1 = engine
|
|
.execute(
|
|
r#"import "theme" as t; t::name()"#,
|
|
req_with_owner(app1, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("app1 executes");
|
|
let r2 = engine
|
|
.execute(
|
|
r#"import "theme" as t; t::name()"#,
|
|
req_with_owner(app2, ScriptOwner::Group(group)),
|
|
)
|
|
.expect("app2 executes");
|
|
assert_eq!(r1.body, serde_json::json!("one"));
|
|
assert_eq!(
|
|
r2.body,
|
|
serde_json::json!("two"),
|
|
"no cross-tenant cache bleed"
|
|
);
|
|
}
|