Filesystem-backed blob storage as the fifth concrete trigger kind.
- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
(blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
checksum-verified reads → Corrupted) + `FilesServiceImpl` in
manager-core. Metadata in Postgres (0018), bytes on disk under
PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
no bytes), emit_files fan-out, dispatcher arm, admin endpoint
POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
(seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.
Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
524 lines
17 KiB
Rust
524 lines
17 KiB
Rust
//! `docs::` SDK bridge integration tests — runs a real Rhai engine
|
|
//! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`:
|
|
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
|
//! reachable runtime.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use chrono::Utc;
|
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
|
use picloud_shared::{
|
|
AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService,
|
|
NoopEventEmitter, NoopHttpService, NoopKvService, NoopModuleSource, RequestId, ScriptId,
|
|
ScriptSandbox, SdkCallCx, Services,
|
|
};
|
|
use serde_json::{json, Value};
|
|
use tokio::sync::Mutex;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryDocs {
|
|
data: Mutex<HashMap<(AppId, String, DocId), DocRow>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl DocsService for InMemoryDocs {
|
|
async fn create(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
data: Value,
|
|
) -> Result<DocId, DocsError> {
|
|
if !data.is_object() {
|
|
return Err(DocsError::InvalidData);
|
|
}
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
let row = DocRow {
|
|
id,
|
|
data,
|
|
created_at: now,
|
|
updated_at: now,
|
|
};
|
|
self.data
|
|
.lock()
|
|
.await
|
|
.insert((cx.app_id, collection.to_string(), id), row);
|
|
Ok(id)
|
|
}
|
|
|
|
async fn get(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: DocId,
|
|
) -> Result<Option<DocRow>, DocsError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(cx.app_id, collection.to_string(), id))
|
|
.cloned())
|
|
}
|
|
|
|
async fn find(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
filter: Value,
|
|
) -> Result<Vec<DocRow>, DocsError> {
|
|
// Tiny eval: extract top-level equalities + $in arrays + $gt
|
|
// (text lex) so the bridge tests can run end-to-end against a
|
|
// fake. This fake mirrors the real service's reject-unsupported
|
|
// contract so the v1.2-pointer-error test goes through the
|
|
// bridge's error-propagation path.
|
|
let map = self.data.lock().await;
|
|
let obj = filter
|
|
.as_object()
|
|
.ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?;
|
|
reject_unsupported_operators(obj)?;
|
|
let mut out: Vec<DocRow> = map
|
|
.iter()
|
|
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
|
.map(|(_, v)| v.clone())
|
|
.filter(|row| matches_simple(&row.data, obj))
|
|
.collect();
|
|
if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) {
|
|
out.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn find_one(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
filter: Value,
|
|
) -> Result<Option<DocRow>, DocsError> {
|
|
Ok(self.find(cx, collection, filter).await?.into_iter().next())
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
id: DocId,
|
|
data: Value,
|
|
) -> Result<(), DocsError> {
|
|
if !data.is_object() {
|
|
return Err(DocsError::InvalidData);
|
|
}
|
|
let mut map = self.data.lock().await;
|
|
let key = (cx.app_id, collection.to_string(), id);
|
|
let Some(row) = map.get_mut(&key) else {
|
|
return Err(DocsError::NotFound);
|
|
};
|
|
row.data = data;
|
|
row.updated_at = Utc::now();
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.remove(&(cx.app_id, collection.to_string(), id))
|
|
.is_some())
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
_cursor: Option<&str>,
|
|
_limit: u32,
|
|
) -> Result<DocsListPage, DocsError> {
|
|
let mut docs: Vec<DocRow> = self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.iter()
|
|
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
|
.map(|(_, v)| v.clone())
|
|
.collect();
|
|
docs.sort_by_key(|d| d.id);
|
|
Ok(DocsListPage {
|
|
docs,
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Scan an operator object for any `$xxx` key not in the v1.1.2
|
|
/// allowlist and return the same shape of error the real parser
|
|
/// emits. Top-level `$limit` is the only allowed modifier the fake
|
|
/// engages with; the unsupported test passes `$regex`.
|
|
fn reject_unsupported_operators(obj: &serde_json::Map<String, Value>) -> Result<(), DocsError> {
|
|
const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"];
|
|
const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"];
|
|
for (key, value) in obj {
|
|
if let Some(stripped) = key.strip_prefix('$') {
|
|
if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) {
|
|
return Err(DocsError::UnsupportedOperator(format!(
|
|
"docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
|
)));
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(inner) = value.as_object() {
|
|
for op_key in inner.keys() {
|
|
if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) {
|
|
return Err(DocsError::UnsupportedOperator(format!(
|
|
"docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn matches_simple(data: &Value, filter: &serde_json::Map<String, Value>) -> bool {
|
|
for (key, want) in filter {
|
|
if key.starts_with('$') {
|
|
// $limit handled in the find body.
|
|
continue;
|
|
}
|
|
let actual = data.get(key);
|
|
if let Some(obj) = want.as_object() {
|
|
// operator object — handle $in and $gt only (enough for
|
|
// the bridge tests to exercise the round-trip).
|
|
if let Some(arr) = obj.get("$in").and_then(Value::as_array) {
|
|
let Some(actual) = actual else {
|
|
return false;
|
|
};
|
|
if !arr.iter().any(|v| v == actual) {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
if let Some(gt) = obj.get("$gt") {
|
|
let Some(actual) = actual else {
|
|
return false;
|
|
};
|
|
let a = actual.as_str().unwrap_or("");
|
|
let b = gt.as_str().unwrap_or("");
|
|
if a <= b {
|
|
return false;
|
|
}
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
if Some(want) != actual {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn make_engine() -> Arc<Engine> {
|
|
let services = Services::new(
|
|
Arc::new(NoopKvService),
|
|
Arc::new(InMemoryDocs::default()),
|
|
Arc::new(NoopDeadLetterService),
|
|
Arc::new(NoopEventEmitter),
|
|
Arc::new(NoopModuleSource),
|
|
Arc::new(NoopHttpService),
|
|
Arc::new(picloud_shared::NoopFilesService),
|
|
);
|
|
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: "docs-test".into(),
|
|
invocation_type: InvocationType::Http,
|
|
path: "/docs-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 docs_create_then_get_round_trip() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let users = docs::collection("users");
|
|
let id = users.create(#{ name: "Alice", tier: "gold" });
|
|
let doc = users.get(id);
|
|
#{ id_matches: doc.id == id, data_name: doc.data.name }
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
let obj = body.as_object().unwrap();
|
|
assert_eq!(obj["id_matches"], json!(true));
|
|
assert_eq!(obj["data_name"], json!("Alice"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_get_missing_returns_unit() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
let v = c.get("00000000-0000-0000-0000-000000000000");
|
|
v == ()
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!(true));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_get_with_invalid_uuid_throws() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"docs::collection("users").get("not-a-uuid")"#;
|
|
let req = baseline_request(app);
|
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
|
.await
|
|
.unwrap()
|
|
.expect_err("invalid uuid should throw");
|
|
assert!(format!("{err:?}").contains("invalid id"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_find_equality_returns_matches() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.create(#{ tier: "gold" });
|
|
c.create(#{ tier: "silver" });
|
|
c.create(#{ tier: "gold" });
|
|
let golds = c.find(#{ tier: "gold" });
|
|
golds.len()
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!(2));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_find_with_in_operator() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.create(#{ tier: "gold" });
|
|
c.create(#{ tier: "silver" });
|
|
c.create(#{ tier: "platinum" });
|
|
let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } });
|
|
hits.len()
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!(2));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_find_with_gt_comparison() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("events");
|
|
c.create(#{ when: "2026-01-15" });
|
|
c.create(#{ when: "2026-03-15" });
|
|
c.create(#{ when: "2026-05-15" });
|
|
let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } });
|
|
recent.len()
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!(2));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_find_one_returns_envelope_or_unit() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.create(#{ tier: "gold" });
|
|
let hit = c.find_one(#{ tier: "gold" });
|
|
let miss = c.find_one(#{ tier: "platinum" });
|
|
#{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () }
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
let obj = body.as_object().unwrap();
|
|
assert_eq!(obj["hit_has_data"], json!(true));
|
|
assert_eq!(obj["miss_is_unit"], json!(true));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_update_then_get_reflects_change() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
let id = c.create(#{ name: "Alice", tier: "gold" });
|
|
c.update(id, #{ name: "Alice", tier: "platinum" });
|
|
c.get(id).data.tier
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!("platinum"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_update_missing_throws() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 })
|
|
"#;
|
|
let req = baseline_request(app);
|
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
|
.await
|
|
.unwrap()
|
|
.expect_err("update missing should throw");
|
|
assert!(format!("{err:?}").contains("not found"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_delete_returns_was_present() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
let nope = c.delete("00000000-0000-0000-0000-000000000000");
|
|
let id = c.create(#{ x: 1 });
|
|
let yep = c.delete(id);
|
|
#{ nope: nope, yep: yep }
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!({ "nope": false, "yep": true }));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_unsupported_operator_throws_with_v1_2_pointer() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.find(#{ name: #{ "$regex": "^A" } })
|
|
"#;
|
|
let req = baseline_request(app);
|
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
|
.await
|
|
.unwrap()
|
|
.expect_err("unsupported operator should throw");
|
|
let msg = format!("{err:?}");
|
|
assert!(msg.contains("$regex"), "msg: {msg}");
|
|
assert!(msg.contains("v1.2"), "msg: {msg}");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_empty_collection_name_throws() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"docs::collection("")"#;
|
|
let req = baseline_request(app);
|
|
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
|
.await
|
|
.unwrap()
|
|
.expect_err("empty collection should throw");
|
|
assert!(format!("{err:?}").contains("docs::collection"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_list_returns_docs_array() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
c.create(#{ a: 1 });
|
|
c.create(#{ a: 2 });
|
|
let page = c.list();
|
|
page.docs.len()
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
assert_eq!(body, json!(2));
|
|
}
|
|
|
|
/// Cross-app isolation through the bridge — script with `app_id = A`
|
|
/// must NOT see documents written from `app_id = B` even when the
|
|
/// (collection, id) tuple is shared. The bridge captures `cx.app_id`
|
|
/// via `Arc<SdkCallCx>` and the service derives storage `app_id` from
|
|
/// it (never from a script arg).
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_bridge_preserves_cross_app_isolation() {
|
|
let engine = make_engine();
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
|
|
let writer = r#"
|
|
let c = docs::collection("shared");
|
|
let id = c.create(#{ from: "a" });
|
|
id
|
|
"#;
|
|
let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await;
|
|
let id_a_str = id_a.as_str().unwrap().to_string();
|
|
|
|
// App B looks up the same id under the same collection — should
|
|
// see nothing because the service keyed it by app_id = A.
|
|
let reader_src = format!(
|
|
r#"
|
|
let c = docs::collection("shared");
|
|
let v = c.get("{id_a_str}");
|
|
v == ()
|
|
"#
|
|
);
|
|
let body = run_script(engine, &reader_src, baseline_request(app_b)).await;
|
|
assert_eq!(body, json!(true));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn docs_envelope_has_id_data_created_at_updated_at() {
|
|
let engine = make_engine();
|
|
let app = AppId::new();
|
|
let src = r#"
|
|
let c = docs::collection("users");
|
|
let id = c.create(#{ name: "Alice" });
|
|
let doc = c.get(id);
|
|
// Probe each envelope field is present + correctly typed.
|
|
#{
|
|
has_id: type_of(doc.id) == "string",
|
|
has_data: type_of(doc.data) == "map",
|
|
has_created_at: type_of(doc.created_at) == "string",
|
|
has_updated_at: type_of(doc.updated_at) == "string",
|
|
user_field: doc.data.name
|
|
}
|
|
"#;
|
|
let body = run_script(engine, src, baseline_request(app)).await;
|
|
let obj = body.as_object().unwrap();
|
|
assert_eq!(obj["has_id"], json!(true));
|
|
assert_eq!(obj["has_data"], json!(true));
|
|
assert_eq!(obj["has_created_at"], json!(true));
|
|
assert_eq!(obj["has_updated_at"], json!(true));
|
|
assert_eq!(obj["user_field"], json!("Alice"));
|
|
}
|