//! `invoke()` SDK bridge integration tests — runs a real Rhai engine //! against an in-memory `InvokeService` that fakes script resolution. //! Verifies sync re-entry via `Engine::set_self_weak`, the cx-threading //! that gives the callee `trigger_depth + 1`, cross-app rejection, depth //! limit, FnPtr-in-args rejection, and `invoke_async` payload shape. use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use chrono::Utc; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, InvokeError, InvokeService, InvokeTarget, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, NoopHttpService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService, RequestId, ResolvedScript, ScriptId, ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; #[derive(Default)] struct FakeInvokeService { scripts: Mutex>, // (id, app, name, source) async_payloads: Mutex>, } impl FakeInvokeService { fn register(&self, app: AppId, name: &str, source: &str) -> ScriptId { let id = ScriptId::new(); self.scripts .lock() .unwrap() .push((id, app, name.to_string(), source.to_string())); id } } #[async_trait] impl InvokeService for FakeInvokeService { async fn resolve( &self, cx: &SdkCallCx, target: InvokeTarget, ) -> Result { let entries = self.scripts.lock().unwrap().clone(); let hit = entries.iter().find(|(id, _app, name, _)| match &target { InvokeTarget::Id(t) => t == id, // Test stashes route paths in the `name` column, so Path // and Name match the same field. InvokeTarget::Name(t) | InvokeTarget::Path(t) => t == name, }); let (id, app, name, source): (ScriptId, AppId, String, String) = hit .ok_or_else(|| InvokeError::NotFound(target.describe()))? .clone(); if app != cx.app_id { return Err(InvokeError::CrossApp); } Ok(ResolvedScript { script_id: id, app_id: app, owner: None, source, updated_at: Utc::now(), name, }) } async fn enqueue_async( &self, _cx: &SdkCallCx, _target: InvokeTarget, args: Value, ) -> Result { self.async_payloads.lock().unwrap().push(args); Ok(ExecutionId::new()) } } fn build_engine(svc: Arc) -> Arc { let services = Services::new( Arc::new(NoopKvService), Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), Arc::new(NoopModuleSource), Arc::new(NoopHttpService), Arc::new(NoopFilesService), Arc::new(NoopPubsubService), Arc::new(NoopSecretsService), Arc::new(NoopEmailService), Arc::new(NoopUsersService), Arc::new(NoopQueueService), svc, Arc::new(picloud_shared::NoopVarsService), ); let engine = Arc::new(Engine::new(Limits::default(), services)); engine.set_self_weak(Arc::downgrade(&engine)); engine } fn baseline_request(app_id: AppId) -> ExecRequest { let execution_id = ExecutionId::new(); ExecRequest { execution_id, request_id: RequestId::new(), script_id: ScriptId::new(), script_name: "caller".into(), invocation_type: InvocationType::Http, path: "/caller".into(), method: String::new(), headers: BTreeMap::new(), body: 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", worker_threads = 2)] async fn invoke_returns_callee_value() { let app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); svc.register(app, "worker", "ctx.request.body.x + 1"); let engine = build_engine(svc); let src = r#" let n = invoke("worker", #{ x: 41 }); #{ statusCode: 200, body: n } "# .to_string(); let req = baseline_request(app); let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap() .unwrap(); assert_eq!(resp.body, json!(42)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn invoke_cross_app_rejects() { let caller_app = AppId::new(); let other_app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); svc.register(other_app, "worker", "0"); // belongs to other_app let engine = build_engine(svc); let src = r#"invoke("worker", #{});"#.to_string(); let req = baseline_request(caller_app); let res = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap(); let err = res.unwrap_err().to_string(); assert!( err.contains("different app") || err.contains("CrossApp"), "expected cross-app rejection, got: {err}" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn invoke_depth_limit_exceeded() { let app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); // recursive: calls itself again — would loop without the bound. svc.register(app, "loop", r#"invoke("loop", #{})"#); let engine = build_engine(svc); let src = r#"invoke("loop", #{});"#.to_string(); let req = baseline_request(app); let res = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap(); let err = res.unwrap_err().to_string(); assert!( err.contains("depth limit"), "expected depth limit error, got: {err}" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn invoke_callee_sees_incremented_depth() { let app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); // The callee returns its own trigger_depth so the caller can assert // it bumped from 0 → 1. svc.register(app, "depth_probe", "ctx.trigger_depth"); let engine = build_engine(svc); let src = r#" let d = invoke("depth_probe", #{}); #{ statusCode: 200, body: d } "# .to_string(); // The caller is direct ingress (depth 0); the callee, re-entered via // `invoke`, must see depth 1. Previously this test asserted NOTHING (`let _ = // resp`) because `ctx.trigger_depth` wasn't exposed — so it would have passed // even if `invoke` forwarded the depth unchanged (never incrementing the // chain-depth counter that bounds runaway trigger loops). `ctx.trigger_depth` // is now surfaced in build_ctx_map, so we can pin the increment. let req = baseline_request(app); assert_eq!(req.trigger_depth, 0, "the caller is direct ingress"); let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap() .unwrap(); assert_eq!( resp.body, json!(1), "the invoke callee must see trigger_depth = caller + 1" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn invoke_rejects_fnptr_in_args() { let app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); svc.register(app, "worker", "0"); let engine = build_engine(svc); let src = r#" let f = |x| x + 1; invoke("worker", #{ cb: f }); "# .to_string(); let req = baseline_request(app); let res = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap(); let err = res.unwrap_err().to_string(); assert!( err.contains("FnPtr") || err.contains("closure"), "expected FnPtr rejection, got: {err}" ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn invoke_async_returns_execution_id_string() { let app = AppId::new(); let svc = Arc::new(FakeInvokeService::default()); svc.register(app, "worker", "0"); let engine = build_engine(svc.clone()); let src = r#" let id = invoke_async("worker", #{ x: 1 }); #{ statusCode: 200, body: id } "# .to_string(); let req = baseline_request(app); let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .unwrap() .unwrap(); // Body is a string — a UUID — surfaced as Json::String. let id = resp.body.as_str().expect("body should be a string"); assert!( uuid::Uuid::parse_str(id).is_ok(), "expected UUID, got: {id}" ); let payloads = svc.async_payloads.lock().unwrap().clone(); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0], json!({ "x": 1 })); }