Scripts can now read group-inherited config via vars::get(key) / vars::all(). - shared: VarsService trait (read-only get/all) + NoopVarsService; added as the 14th field on the Services bundle. - manager-core: VarsServiceImpl resolves the calling app's config via config_resolver (derives app_id from cx.app_id; AppVarsRead-gated for authed principals, script-as-gate for anon). - executor-core: vars:: Rhai bridge (get/all), registered in the SDK. - authz: AppVarsRead/Write, GroupVarsRead/Write, GroupSecretsRead/Write capabilities (group caps resolve via the Phase-2 ancestor walk; the GroupSecretsRead human-read gate is group_admin-only, distinct from an app's runtime injection). - wired VarsServiceImpl into the picloud binary; updated the executor-core test Services::new call sites. 386 manager-core lib tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
255 lines
8.6 KiB
Rust
255 lines
8.6 KiB
Rust
//! `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<Vec<(ScriptId, AppId, String, String)>>, // (id, app, name, source)
|
|
async_payloads: Mutex<Vec<Value>>,
|
|
}
|
|
|
|
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<ResolvedScript, InvokeError> {
|
|
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,
|
|
source,
|
|
updated_at: Utc::now(),
|
|
name,
|
|
})
|
|
}
|
|
|
|
async fn enqueue_async(
|
|
&self,
|
|
_cx: &SdkCallCx,
|
|
_target: InvokeTarget,
|
|
args: Value,
|
|
) -> Result<ExecutionId, InvokeError> {
|
|
self.async_payloads.lock().unwrap().push(args);
|
|
Ok(ExecutionId::new())
|
|
}
|
|
}
|
|
|
|
fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
|
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,
|
|
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();
|
|
let req = baseline_request(app);
|
|
let resp = tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
// ctx exposes trigger_depth? — not yet (it's not in build_ctx_map).
|
|
// Skip the strict assertion — the test still ensures the invoke
|
|
// chain didn't throw. (See HANDBACK §11 for cx.trigger_depth surface
|
|
// exposure as a v1.2 follow-up.)
|
|
let _ = resp;
|
|
}
|
|
|
|
#[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 }));
|
|
}
|