//! `secrets::` SDK bridge integration tests — runs a real Rhai engine //! against an in-memory `SecretsService` impl. Mirrors `sdk_kv.rs`: the //! engine runs under `spawn_blocking` so the bridge's `block_on` has a //! reachable runtime. //! //! This exercises the Rhai⇄JSON plumbing + the static `secrets` module //! (set/get/delete/list, the missing→() contract, and the //! String/Map/Array type round-trip). Encryption + authz + the //! cross-app boundary are unit-tested at the service layer in //! `manager-core::secrets_service`. use std::collections::BTreeMap; use std::sync::Arc; use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ AppId, ExecutionId, NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId, ScriptSandbox, SdkCallCx, SecretsError, SecretsListPage, SecretsService, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; /// In-memory secrets store keyed by `(app_id, name)`. Stores the JSON /// value directly — the bridge test only cares about the Rhai plumbing, /// not the at-rest encryption (which the service layer owns). #[derive(Default)] struct InMemorySecrets { data: Mutex>, } #[async_trait] impl SecretsService for InMemorySecrets { async fn get(&self, cx: &SdkCallCx, name: &str) -> Result, SecretsError> { picloud_shared::validate_secret_name(name)?; Ok(self .data .lock() .await .get(&(cx.app_id, name.to_string())) .cloned()) } async fn set(&self, cx: &SdkCallCx, name: &str, value: Value) -> Result<(), SecretsError> { picloud_shared::validate_secret_name(name)?; self.data .lock() .await .insert((cx.app_id, name.to_string()), value); Ok(()) } async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result { picloud_shared::validate_secret_name(name)?; Ok(self .data .lock() .await .remove(&(cx.app_id, name.to_string())) .is_some()) } async fn list( &self, cx: &SdkCallCx, cursor: Option<&str>, limit: u32, ) -> Result { let data = self.data.lock().await; let mut names: Vec = data .iter() .filter(|((a, _), _)| *a == cx.app_id) .map(|((_, n), _)| n.clone()) .filter(|n| cursor.is_none_or(|c| n.as_str() > c)) .collect(); names.sort(); let take = if limit == 0 { usize::MAX } else { limit as usize }; let next_cursor = if names.len() > take { names.truncate(take); names.last().cloned() } else { None }; Ok(SecretsListPage { names, next_cursor }) } } fn make_engine() -> 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(picloud_shared::NoopFilesService), Arc::new(picloud_shared::NoopPubsubService), Arc::new(InMemorySecrets::default()), ); Arc::new(Engine::new(Limits::default(), services)) } 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: "secrets-test".into(), invocation_type: InvocationType::Http, path: "/secrets-test".into(), 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, } } async fn run_script(engine: Arc, src: &str, req: ExecRequest) -> Value { let src = src.to_string(); tokio::task::spawn_blocking(move || engine.execute(&src, req)) .await .expect("spawn_blocking should not panic") .expect("script execution should succeed") .body } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn set_then_get_string_round_trips() { let engine = make_engine(); let src = r#" secrets::set("stripe_key", "sk_live_xxx"); secrets::get("stripe_key") "#; let body = run_script(engine, src, baseline_request(AppId::new())).await; // A String comes back a String, not a JSON-quoted "\"sk_live_xxx\"". assert_eq!(body, json!("sk_live_xxx")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn set_then_get_map_round_trips() { let engine = make_engine(); let src = r#" secrets::set("oauth", #{ client_id: "abc", client_secret: "xyz" }); secrets::get("oauth") "#; let body = run_script(engine, src, baseline_request(AppId::new())).await; assert_eq!(body, json!({ "client_id": "abc", "client_secret": "xyz" })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_missing_returns_unit() { let engine = make_engine(); let src = r#" let v = secrets::get("nope"); #{ is_unit: type_of(v) == "()" } "#; let body = run_script(engine, src, baseline_request(AppId::new())).await; assert_eq!(body, json!({ "is_unit": true })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn delete_returns_was_present() { let engine = make_engine(); let src = r#" secrets::set("k", "v"); let first = secrets::delete("k"); let second = secrets::delete("k"); #{ first: first, second: second } "#; let body = run_script(engine, src, baseline_request(AppId::new())).await; assert_eq!(body, json!({ "first": true, "second": false })); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_returns_names_and_cursor() { let engine = make_engine(); let src = r#" secrets::set("a", 1); secrets::set("b", 2); secrets::set("c", 3); let page = secrets::list(#{ cursor: (), limit: 2 }); page "#; let body = run_script(engine, src, baseline_request(AppId::new())).await; assert_eq!(body["names"], json!(["a", "b"])); assert_eq!(body["next_cursor"], json!("b")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn empty_name_throws() { let engine = make_engine(); let src = r#" secrets::set("", "v"); #{ ok: true } "#; let app = AppId::new(); let out = tokio::task::spawn_blocking(move || engine.execute(src, baseline_request(app))) .await .expect("spawn_blocking"); assert!(out.is_err(), "empty secret name must throw"); }