feat(v1.1.2-docs): manager-core docs service + repo + query DSL parser
DocsServiceImpl mirrors KvServiceImpl's script-as-gate authz pattern,
the empty-collection rejection, and the best-effort emitter call —
adding "data must be a JSON object" validation, NotFound on update of
a missing doc, and prev_data plumbing via repo.update returning the
prior data.
PostgresDocsRepo handles CRUD against the docs table. The find path
runs through the v1.1.2 query DSL parser (docs_filter::parse_filter)
before building parameterised SQL via sqlx::QueryBuilder:
* Every field-path segment + comparison value is bound as $N.
* jsonb_extract_path_text(data, $N1, $N2, ...) handles variable
depth without segment interpolation.
* Base WHERE is fixed: WHERE app_id = $1 AND collection = $2.
Filter conditions can only narrow, never widen. Load-bearing
test in sql_shape_tests pins this prefix on every emitted query
+ asserts no user string ever lands in the SQL text.
* $ne uses IS DISTINCT FROM (not <>) so missing paths + JSON nulls
are correctly included.
* $in binds the value list as TEXT[] via = ANY($N::text[]).
* $sort always appends a ", id ASC" tiebreaker for stable cursor
pagination semantics; $limit is clamped to MAX_FIND_LIMIT.
docs_filter is the AST + parser for the DSL. Operator allowlist is
explicit; any non-v1.1.2 operator throws UnsupportedOperator with a
v1.2 pointer. Snapshot tests pin the SDK-contract error strings so
changing them is a deliberate act.
Two new Capability variants — AppDocsRead and AppDocsWrite — map to
the existing Scope::ScriptRead and ScriptWrite per the seven-scope
commitment from v1.1.0. role_satisfies grants read at Viewer,
write at Editor (same trust shape as KV).
59 unit tests added across the three new files. All pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
890
crates/manager-core/src/docs_service.rs
Normal file
890
crates/manager-core/src/docs_service.rs
Normal file
@@ -0,0 +1,890 @@
|
||||
//! `DocsServiceImpl` — wires the `DocsRepo` underneath the
|
||||
//! `picloud_shared::DocsService` trait that scripts see via the Rhai
|
||||
//! bridge.
|
||||
//!
|
||||
//! Layers added here (vs the raw repo):
|
||||
//!
|
||||
//! 1. Empty-collection rejection at the SDK boundary
|
||||
//! (`docs/sdk-shape.md`).
|
||||
//! 2. `data` must be a JSON object for create + update. (The repo
|
||||
//! accepts anything serde_json can serialise; the SDK contract
|
||||
//! pins documents to map shape so dotted-path queries make sense.)
|
||||
//! 3. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
||||
//! `authz::require(...)`; when it's `None` (public unauthenticated
|
||||
//! HTTP — the common case for public routes) we skip the check.
|
||||
//! Cross-app isolation isn't affected — every query is keyed by
|
||||
//! `cx.app_id`, never an argument.
|
||||
//! 4. Query DSL parse — `find`/`find_one` parse the opaque filter
|
||||
//! into `DocsFilter` before passing it down. Parse errors map to
|
||||
//! `DocsError::InvalidFilter` / `UnsupportedOperator` with the
|
||||
//! parser's message verbatim (script-visible).
|
||||
//! 5. `ServiceEvent` emission after each mutation (`create` / `update`
|
||||
//! / `delete`). The outbox emitter (when wired) turns these into
|
||||
//! docs-trigger fan-out via `OutboxEventEmitter::emit_docs`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocId, DocRow, DocsError, DocsListPage, DocsService, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::docs_filter::{parse_filter, FilterParseError};
|
||||
use crate::docs_repo::{DocsRepo, DocsRepoError};
|
||||
|
||||
pub struct DocsServiceImpl {
|
||||
repo: Arc<dyn DocsRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl DocsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn DocsRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authz,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppDocsRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| DocsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), DocsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppDocsWrite(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| DocsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_collection(collection: &str) -> Result<(), DocsError> {
|
||||
if collection.is_empty() {
|
||||
return Err(DocsError::InvalidCollection);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_data(data: &serde_json::Value) -> Result<(), DocsError> {
|
||||
if !data.is_object() {
|
||||
return Err(DocsError::InvalidData);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<DocsRepoError> for DocsError {
|
||||
fn from(e: DocsRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FilterParseError> for DocsError {
|
||||
fn from(e: FilterParseError) -> Self {
|
||||
match e {
|
||||
FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s),
|
||||
FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DocsService for DocsServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocId, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
validate_data(&data)?;
|
||||
self.check_write(cx).await?;
|
||||
let row = self
|
||||
.repo
|
||||
.create(cx.app_id, collection, data.clone())
|
||||
.await?;
|
||||
// Best-effort emit — a failed emit logs but does not roll back
|
||||
// the write (mirrors KV's pattern).
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "docs",
|
||||
op: "create",
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(row.id.to_string()),
|
||||
payload: Some(data),
|
||||
old_payload: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "docs", op = "create", "event emit failed");
|
||||
}
|
||||
Ok(row.id)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.get(cx.app_id, collection, id).await?)
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
let parsed = parse_filter(&filter)?;
|
||||
Ok(self.repo.find(cx.app_id, collection, &parsed).await?)
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
let mut parsed = parse_filter(&filter)?;
|
||||
// Inject the implicit `LIMIT 1` for find_one — explicit
|
||||
// caller-supplied `$limit` wins.
|
||||
if parsed.limit.is_none() {
|
||||
parsed.limit = Some(1);
|
||||
}
|
||||
let rows = self.repo.find(cx.app_id, collection, &parsed).await?;
|
||||
Ok(rows.into_iter().next())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), DocsError> {
|
||||
validate_collection(collection)?;
|
||||
validate_data(&data)?;
|
||||
self.check_write(cx).await?;
|
||||
let previous = self
|
||||
.repo
|
||||
.update(cx.app_id, collection, id, data.clone())
|
||||
.await?;
|
||||
match previous {
|
||||
Some(prev) => {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "docs",
|
||||
op: "update",
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(id.to_string()),
|
||||
payload: Some(data),
|
||||
old_payload: Some(prev),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "docs", op = "update", "event emit failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(DocsError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result<bool, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
let previous = self.repo.delete(cx.app_id, collection, id).await?;
|
||||
let was_present = previous.is_some();
|
||||
if let Some(prev) = previous {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "docs",
|
||||
op: "delete",
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(id.to_string()),
|
||||
payload: None,
|
||||
old_payload: Some(prev),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "docs", op = "delete", "event emit failed");
|
||||
}
|
||||
}
|
||||
Ok(was_present)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, DocsError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory DocsRepo so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::docs_filter::DocsFilter;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
|
||||
RequestId, UserId,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// In-memory backing: BTreeMap keyed by `(app_id, collection, id)`
|
||||
/// so iteration is naturally ordered for stable cursor pagination
|
||||
/// (matches the Postgres `ORDER BY id ASC`).
|
||||
#[derive(Default)]
|
||||
struct InMemoryDocsRepo {
|
||||
data: Mutex<BTreeMap<(AppId, String, DocId), DocRow>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DocsRepo for InMemoryDocsRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocRow, DocsRepoError> {
|
||||
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((app_id, collection.to_string(), id), row.clone());
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, DocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, collection.to_string(), id))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||||
let map = self.data.lock().await;
|
||||
let mut out: Vec<DocRow> = map
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|row| in_memory_matches(row, filter))
|
||||
.collect();
|
||||
if let Some(sort) = &filter.sort {
|
||||
let path = sort.path.segments().to_vec();
|
||||
let dir = sort.direction;
|
||||
out.sort_by(|a, b| {
|
||||
let av = extract_path_str(&a.data, &path);
|
||||
let bv = extract_path_str(&b.data, &path);
|
||||
let ord = av.cmp(&bv);
|
||||
match dir {
|
||||
crate::docs_filter::SortDir::Asc => ord,
|
||||
crate::docs_filter::SortDir::Desc => ord.reverse(),
|
||||
}
|
||||
});
|
||||
} else {
|
||||
out.sort_by_key(|d| d.id);
|
||||
}
|
||||
if let Some(limit) = filter.limit {
|
||||
out.truncate(limit as usize);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, DocsRepoError> {
|
||||
let mut map = self.data.lock().await;
|
||||
let key = (app_id, collection.to_string(), id);
|
||||
let Some(existing) = map.get_mut(&key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let prev = std::mem::replace(&mut existing.data, data);
|
||||
existing.updated_at = Utc::now();
|
||||
Ok(Some(prev))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<serde_json::Value>, DocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, collection.to_string(), id))
|
||||
.map(|row| row.data))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, DocsRepoError> {
|
||||
let map = self.data.lock().await;
|
||||
let last_id = cursor
|
||||
.map(|c| Uuid::parse_str(c).map_err(|_| DocsRepoError::InvalidCursor))
|
||||
.transpose()?;
|
||||
let mut docs: Vec<DocRow> = map
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||
.map(|(_, v)| v.clone())
|
||||
.filter(|d| last_id.is_none_or(|lid| d.id > lid))
|
||||
.collect();
|
||||
docs.sort_by_key(|d| d.id);
|
||||
let take = if limit == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let next_cursor = if docs.len() > take {
|
||||
docs.truncate(take);
|
||||
docs.last().map(|d| d.id.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(DocsListPage { docs, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort in-memory filter eval mirroring the Postgres
|
||||
/// semantics: extract each field path as a text-form string, then
|
||||
/// apply the operator. Good enough for the unit tests; production
|
||||
/// always goes through the Postgres impl.
|
||||
fn in_memory_matches(row: &DocRow, filter: &DocsFilter) -> bool {
|
||||
for cond in &filter.conditions {
|
||||
let actual = extract_path_str(&row.data, cond.path.segments());
|
||||
if !cond_matches(actual.as_ref(), cond) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn cond_matches(actual: Option<&String>, cond: &crate::docs_filter::FieldCondition) -> bool {
|
||||
use crate::docs_filter::ComparisonOp::*;
|
||||
let actual: Option<&str> = actual.map(String::as_str);
|
||||
let want = json_text(&cond.value);
|
||||
let want_ref: Option<&str> = want.as_deref();
|
||||
match cond.op {
|
||||
Eq => actual == want_ref,
|
||||
Ne => actual != want_ref,
|
||||
Gt => actual.zip(want_ref).is_some_and(|(a, b)| a > b),
|
||||
Gte => actual.zip(want_ref).is_some_and(|(a, b)| a >= b),
|
||||
Lt => actual.zip(want_ref).is_some_and(|(a, b)| a < b),
|
||||
Lte => actual.zip(want_ref).is_some_and(|(a, b)| a <= b),
|
||||
In => {
|
||||
let Some(arr) = cond.value.as_array() else {
|
||||
return false;
|
||||
};
|
||||
arr.iter()
|
||||
.any(|v| actual == json_text(v).as_deref())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_path_str(value: &serde_json::Value, segments: &[String]) -> Option<String> {
|
||||
let mut cur = value;
|
||||
for seg in segments {
|
||||
cur = cur.as_object()?.get(seg)?;
|
||||
}
|
||||
json_text(cur)
|
||||
}
|
||||
|
||||
fn json_text(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::Null => None,
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Array(_) | serde_json::Value::Object(_) => Some(v.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AllowingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for AllowingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(Some(AppRole::Editor))
|
||||
}
|
||||
}
|
||||
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc() -> DocsServiceImpl {
|
||||
DocsServiceImpl::new(
|
||||
Arc::new(InMemoryDocsRepo::default()),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
Arc::new(NoopEventEmitter),
|
||||
)
|
||||
}
|
||||
|
||||
fn svc_allowing() -> DocsServiceImpl {
|
||||
DocsServiceImpl::new(
|
||||
Arc::new(InMemoryDocsRepo::default()),
|
||||
Arc::new(AllowingAuthzRepo),
|
||||
Arc::new(NoopEventEmitter),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_then_get_round_trips() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s
|
||||
.create(&cx, "users", json!({ "name": "Alice" }))
|
||||
.await
|
||||
.unwrap();
|
||||
let row = s.get(&cx, "users", id).await.unwrap().unwrap();
|
||||
assert_eq!(row.id, id);
|
||||
assert_eq!(row.data, json!({ "name": "Alice" }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_none() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let v = s.get(&cx, "users", Uuid::new_v4()).await.unwrap();
|
||||
assert!(v.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_missing_returns_not_found() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s
|
||||
.update(&cx, "users", Uuid::new_v4(), json!({ "x": 1 }))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DocsError::NotFound));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_missing_returns_false() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let was_present = s.delete(&cx, "users", Uuid::new_v4()).await.unwrap();
|
||||
assert!(!was_present);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_present_returns_true() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
let was_present = s.delete(&cx, "users", id).await.unwrap();
|
||||
assert!(was_present);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_present_succeeds() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
||||
let row = s.get(&cx, "users", id).await.unwrap().unwrap();
|
||||
assert_eq!(row.data, json!({ "x": 2 }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_rejected() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s.create(&cx, "", json!({})).await.unwrap_err();
|
||||
assert!(matches!(err, DocsError::InvalidCollection));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_with_non_object_data_rejected() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s.create(&cx, "users", json!(42)).await.unwrap_err();
|
||||
assert!(matches!(err, DocsError::InvalidData));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_with_non_object_data_rejected() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
let err = s
|
||||
.update(&cx, "users", id, json!("not an object"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DocsError::InvalidData));
|
||||
}
|
||||
|
||||
/// Load-bearing: a script with `cx.app_id = A` must NOT see
|
||||
/// documents created under `cx.app_id = B`. Cross-app isolation
|
||||
/// boundary; tested through both `get` and `find` because each
|
||||
/// path could conceivably leak independently.
|
||||
#[tokio::test]
|
||||
async fn cross_app_isolation_via_cx_app_id() {
|
||||
let s = svc();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let cx_a = anon_cx(app_a);
|
||||
let cx_b = anon_cx(app_b);
|
||||
|
||||
let id_a = s
|
||||
.create(&cx_a, "shared", json!({ "from": "a" }))
|
||||
.await
|
||||
.unwrap();
|
||||
let id_b = s
|
||||
.create(&cx_b, "shared", json!({ "from": "b" }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(id_a, id_b);
|
||||
|
||||
// Each app sees only its own doc via get.
|
||||
assert!(s.get(&cx_a, "shared", id_b).await.unwrap().is_none());
|
||||
assert!(s.get(&cx_b, "shared", id_a).await.unwrap().is_none());
|
||||
|
||||
// And via find.
|
||||
let from_a = s.find(&cx_a, "shared", json!({})).await.unwrap();
|
||||
assert_eq!(from_a.len(), 1);
|
||||
assert_eq!(from_a[0].id, id_a);
|
||||
|
||||
let from_b = s.find(&cx_b, "shared", json!({})).await.unwrap();
|
||||
assert_eq!(from_b.len(), 1);
|
||||
assert_eq!(from_b[0].id, id_b);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn anonymous_cx_skips_authz() {
|
||||
// Denying authz repo + anon cx (no principal) ⇒ writes still
|
||||
// succeed under script-as-gate.
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
let _ = s.delete(&cx, "users", id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authed_cx_with_no_role_is_forbidden_on_write() {
|
||||
let s = svc();
|
||||
let cx = member_no_role_cx(AppId::new());
|
||||
let err = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap_err();
|
||||
assert!(matches!(err, DocsError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authed_cx_with_no_role_is_forbidden_on_read() {
|
||||
let s = svc();
|
||||
let cx = member_no_role_cx(AppId::new());
|
||||
let err = s.get(&cx, "users", Uuid::new_v4()).await.unwrap_err();
|
||||
assert!(matches!(err, DocsError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_principal_can_write() {
|
||||
let s = svc();
|
||||
let cx = owner_cx(AppId::new());
|
||||
let _ = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_member_can_write_via_role() {
|
||||
// AllowingAuthzRepo grants Editor — should be able to write
|
||||
// (AppDocsWrite is in_editor in role_satisfies).
|
||||
let s = svc_allowing();
|
||||
let cx = member_no_role_cx(AppId::new());
|
||||
let _ = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_with_equality_returns_matches() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
s.create(&cx, "users", json!({ "tier": "silver" }))
|
||||
.await
|
||||
.unwrap();
|
||||
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let golds = s
|
||||
.find(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(golds.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_one_returns_first_or_none() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hit = s
|
||||
.find_one(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hit.is_some());
|
||||
|
||||
let miss = s
|
||||
.find_one(&cx, "users", json!({ "tier": "platinum" }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(miss.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_with_unsupported_operator_throws() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s
|
||||
.find(&cx, "users", json!({ "name": { "$regex": "^A" } }))
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
DocsError::UnsupportedOperator(m) => {
|
||||
assert!(m.contains("$regex"));
|
||||
assert!(m.contains("v1.2"));
|
||||
}
|
||||
other => panic!("expected UnsupportedOperator, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_with_invalid_filter_throws() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = s
|
||||
.find(&cx, "users", json!({ "a.b.c.d.e.f": "x" }))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DocsError::InvalidFilter(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_with_dollar_in_returns_subset() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
s.create(&cx, "users", json!({ "tier": "silver" }))
|
||||
.await
|
||||
.unwrap();
|
||||
s.create(&cx, "users", json!({ "tier": "platinum" }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let hits = s
|
||||
.find(
|
||||
&cx,
|
||||
"users",
|
||||
json!({ "tier": { "$in": ["gold", "platinum"] } }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_one_explicit_limit_is_honoured() {
|
||||
// The service injects limit=1 ONLY when caller didn't set
|
||||
// $limit. An explicit `$limit: 5` survives — and find_one
|
||||
// still returns the first.
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
for _ in 0..3 {
|
||||
s.create(&cx, "users", json!({ "tier": "gold" }))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let hit = s
|
||||
.find_one(&cx, "users", json!({ "tier": "gold", "$limit": 5 }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hit.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_cursor_pagination() {
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..5 {
|
||||
ids.push(s.create(&cx, "users", json!({})).await.unwrap());
|
||||
}
|
||||
ids.sort();
|
||||
|
||||
let p1 = s.list(&cx, "users", None, 2).await.unwrap();
|
||||
assert_eq!(p1.docs.len(), 2);
|
||||
assert!(p1.next_cursor.is_some());
|
||||
|
||||
let p2 = s
|
||||
.list(&cx, "users", p1.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p2.docs.len(), 2);
|
||||
|
||||
let p3 = s
|
||||
.list(&cx, "users", p2.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p3.docs.len(), 1);
|
||||
assert!(p3.next_cursor.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_emitter_does_not_block_mutations() {
|
||||
// Pins v1.1.0 contract: services hold an Arc<dyn ServiceEventEmitter>
|
||||
// and call emit().await unconditionally. The noop drops it.
|
||||
let s = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let id = s.create(&cx, "users", json!({ "x": 1 })).await.unwrap();
|
||||
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
||||
let _ = s.delete(&cx, "users", id).await.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user