Outbound email reachable from scripts as email::send(#{...}) (plain
text) and email::send_html(#{...}) (multipart text + HTML). Backed by a
lettre SMTP relay configured from PICLOUD_SMTP_HOST/PORT/USER/PASSWORD/
TLS/TIMEOUT_SECS; if HOST/USER/PASSWORD aren't all set the service runs
in disabled mode (every send throws NotConfigured, warned at startup).
- EmailService trait + OutboundEmail DTO (picloud-shared);
EmailServiceImpl + EmailTransport seam + lettre transport
(manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppEmailSend (→ script:write); seven-scope commitment held.
- Required-field + RFC5322-ish address validation; 25 MB per-message cap
(PICLOUD_EMAIL_MAX_MESSAGE_BYTES). reply_to defaults to from.
- Per-call connection (pooling deferred to v1.2); no per-app from
validation (operator's SMTP/SPF/DKIM concern).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
7.1 KiB
Rust
214 lines
7.1 KiB
Rust
//! `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<BTreeMap<(AppId, String), Value>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl SecretsService for InMemorySecrets {
|
|
async fn get(&self, cx: &SdkCallCx, name: &str) -> Result<Option<Value>, 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<bool, SecretsError> {
|
|
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<SecretsListPage, SecretsError> {
|
|
let data = self.data.lock().await;
|
|
let mut names: Vec<String> = 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<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(picloud_shared::NoopFilesService),
|
|
Arc::new(picloud_shared::NoopPubsubService),
|
|
Arc::new(InMemorySecrets::default()),
|
|
Arc::new(picloud_shared::NoopEmailService),
|
|
);
|
|
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<Engine>, 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");
|
|
}
|