//! 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, HashSet}; 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>, lookups: AtomicUsize, /// When `Some`, every lookup returns this error instead of the /// table — used by the backend-error test. fail_with: Mutex>, } impl CountingModuleSource { fn new() -> Arc { Arc::new(Self::default()) } async fn put(self: &Arc, 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, app_id: AppId, name: &str, source: &str, updated_at: DateTime, ) -> 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, 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()) } } fn services_with(modules: Arc) -> 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) -> 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>, } impl LexicalModuleSource { fn new() -> Arc { Arc::new(Self::default()) } async fn put(self: &Arc, owner: ScriptOwner, name: &str, source: &str) { let (app_id, group_id) = match owner { ScriptOwner::App(a) => (Some(a), None), ScriptOwner::Group(g) => (None, Some(g)), }; self.table.lock().await.insert( (owner, name.to_string()), ModuleScript { script_id: ScriptId::new(), app_id, group_id, name: name.to_string(), source: source.to_string(), updated_at: Utc::now(), }, ); } } #[async_trait] impl ModuleSource for LexicalModuleSource { async fn resolve( &self, origin: ScriptOwner, name: &str, ) -> Result, ModuleSourceError> { Ok(self .table .lock() .await .get(&(origin, name.to_string())) .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)); } // --------------------------------------------------------------------------- // §5.5 extension points — resolver handling of the policy outcomes. // // `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP // name resolves dynamically against the inheriting app; a non-EP name resolves // lexically from the importing origin. This verifies the resolver maps // Module/NoProvider/NotFound correctly. The full chain semantics (default body // up-chain, nearest-declaration tie) are covered by the CLI journey tests. // --------------------------------------------------------------------------- #[derive(Default)] struct PolicyModuleSource { modules: Mutex>, eps: Mutex>, } impl PolicyModuleSource { fn new() -> Arc { Arc::new(Self::default()) } async fn put(self: &Arc, owner: ScriptOwner, name: &str, source: &str) { let (app_id, group_id) = match owner { ScriptOwner::App(a) => (Some(a), None), ScriptOwner::Group(g) => (None, Some(g)), }; self.modules.lock().await.insert( (owner, name.to_string()), ModuleScript { script_id: ScriptId::new(), app_id, group_id, name: name.to_string(), source: source.to_string(), updated_at: Utc::now(), }, ); } async fn mark_ep(self: &Arc, name: &str) { self.eps.lock().await.insert(name.to_string()); } } #[async_trait] impl ModuleSource for PolicyModuleSource { async fn resolve( &self, origin: ScriptOwner, name: &str, ) -> Result, ModuleSourceError> { Ok(self .modules .lock() .await .get(&(origin, name.to_string())) .cloned()) } async fn resolve_policy( &self, origin: ScriptOwner, inheriting_app: AppId, name: &str, ) -> Result { use picloud_shared::ModuleResolution; if self.eps.lock().await.contains(name) { // Extension point → dynamic, resolved against the inheriting app. return Ok( match self.resolve(ScriptOwner::App(inheriting_app), name).await? { Some(m) => ModuleResolution::Module(m), None => ModuleResolution::NoProvider, }, ); } Ok(match self.resolve(origin, name).await? { Some(m) => ModuleResolution::Module(m), None => ModuleResolution::NotFound, }) } } /// An extension-point import binds the inheriting APP's module even though the /// importing script's defining node is the group (dynamic override — the /// inverse of the Phase 4b sealed/lexical import). #[tokio::test(flavor = "multi_thread")] async fn extension_point_resolves_app_override() { let source = PolicyModuleSource::new(); let app = AppId::new(); let group = GroupId::new(); source.mark_ep("theme").await; // App provides its own `theme`; group has none. source .put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#) .await; let engine = engine_with(source.clone()); // Entry runs as the inherited GROUP endpoint importing the EP `theme`. let resp = engine .execute( r#"import "theme" as t; t::color()"#, req_with_owner(app, ScriptOwner::Group(group)), ) .expect("should execute"); assert_eq!(resp.body, serde_json::json!("red")); } /// An extension point with no provider for the app is a hard error. #[tokio::test(flavor = "multi_thread")] async fn extension_point_without_provider_errors() { let source = PolicyModuleSource::new(); let app = AppId::new(); let group = GroupId::new(); source.mark_ep("theme").await; // declared, but no module anywhere. let engine = engine_with(source.clone()); let err = engine .execute( r#"import "theme" as t; t::color()"#, req_with_owner(app, ScriptOwner::Group(group)), ) .expect_err("missing provider must error"); let msg = format!("{err:?}").to_lowercase(); assert!( msg.contains("no provider") || msg.contains("extension point"), "expected a no-provider error, got {msg}" ); } /// A non-extension-point name still resolves lexically from the origin. #[tokio::test(flavor = "multi_thread")] async fn non_extension_point_stays_lexical() { let source = PolicyModuleSource::new(); let app = AppId::new(); let group = GroupId::new(); source .put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#) .await; let engine = engine_with(source.clone()); let resp = engine .execute( r#"import "util" as u; u::v()"#, req_with_owner(app, ScriptOwner::Group(group)), ) .expect("should execute"); assert_eq!(resp.body, serde_json::json!(7)); }