`with_events()` and `with_atomic_writes()` both install a writer, last call wins. Appending them in the wrong order in the host would swap the transactional writer back out for the best-effort one — silently reverting the write-path invariant AND dropping the per-group quota enforcement that lives in it, with nothing in the type system to catch it. The services now carry an `atomic` flag: once `with_atomic_writes` has run, `with_events` warns and no-ops instead of clobbering it. Also enforce the group byte ceiling in `BestEffortGroupFilesWriter::update`, so the non-transactional writer can't be used as an unmetered bypass and a unit test can catch its removal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
761 lines
26 KiB
Rust
761 lines
26 KiB
Rust
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
|
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
|
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
|
|
//!
|
|
//! Layers added over the raw repo:
|
|
//!
|
|
//! 1. Empty-collection rejection.
|
|
//! 2. **Owner resolution** (the structural isolation boundary): the collection
|
|
//! name is resolved to the nearest ancestor group that declares it shared,
|
|
//! walking the calling app's chain. No declaration on the chain → a clean
|
|
//! `CollectionNotShared`, BEFORE any authz or storage access.
|
|
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
|
|
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
|
//! (anonymous fails closed — shared mutation always needs an authenticated
|
|
//! editor+ on the owning group).
|
|
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
|
|
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
|
|
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
|
|
//! triggers on the owning group, under the writer app (`emit_shared`).
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use picloud_shared::{
|
|
GroupId, GroupKvError, GroupKvService, KvListPage, NoopEventEmitter, SdkCallCx,
|
|
ServiceEventEmitter,
|
|
};
|
|
|
|
use crate::atomic_write::{
|
|
BestEffortGroupKvWriter, GroupKvTarget, GroupKvWriter, PostgresGroupKvWriter,
|
|
};
|
|
use crate::authz::{self, AuthzRepo, Capability};
|
|
use crate::group_collection_repo::GroupCollectionResolver;
|
|
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
|
|
use crate::kv_service::kv_max_value_bytes_from_env;
|
|
use crate::quota::GroupWriteQuota;
|
|
|
|
/// The registry `kind` this service resolves.
|
|
const KIND_KV: &str = "kv";
|
|
|
|
pub struct GroupKvServiceImpl {
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_value_bytes: usize,
|
|
/// §11.6 per-group quota: max total keys across the group's shared-KV
|
|
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
|
|
/// NEW key.
|
|
max_rows: u64,
|
|
/// §11.6 M4 per-group quota: max total stored bytes across the group's
|
|
/// shared-KV collections (`PICLOUD_GROUP_KV_MAX_TOTAL_BYTES`). Checked on the
|
|
/// projected total (old value subtracted, new added) so a same/smaller update
|
|
/// near the cap is still allowed.
|
|
max_total_bytes: u64,
|
|
/// Mutations: the quota decision, the row write, and the shared-trigger
|
|
/// fan-out, which for the host all commit in one advisory-locked
|
|
/// transaction. Reads stay on `repo`.
|
|
writer: Arc<dyn GroupKvWriter>,
|
|
/// Set once [`Self::with_atomic_writes`] has installed the transactional
|
|
/// writer. `with_events` refuses to overwrite it — silently downgrading a
|
|
/// service back to non-transactional writes (and dropping its quota
|
|
/// enforcement) because a builder call was appended in the wrong order is
|
|
/// exactly the kind of regression the type system will not catch.
|
|
atomic: bool,
|
|
}
|
|
|
|
impl GroupKvServiceImpl {
|
|
#[must_use]
|
|
pub fn new(
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
) -> Self {
|
|
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
|
|
}
|
|
|
|
/// Wire the event emitter (§11.6 shared-collection triggers) on the
|
|
/// best-effort writer. Without this — or [`Self::with_atomic_writes`], which
|
|
/// supersedes it — shared writes fire nothing.
|
|
#[must_use]
|
|
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
|
if self.atomic {
|
|
tracing::warn!(
|
|
"with_events() called after with_atomic_writes() — ignored; the \
|
|
transactional writer already resolves and writes the fan-out itself"
|
|
);
|
|
return self;
|
|
}
|
|
self.writer = Arc::new(BestEffortGroupKvWriter::new(self.repo.clone(), events));
|
|
self
|
|
}
|
|
|
|
/// Use the transactional writer: the quota reads, the row write, and the
|
|
/// shared-trigger fan-out all run on ONE connection under a per-group
|
|
/// advisory lock. That makes the quota an actual bound (concurrent writers
|
|
/// to a group serialize, so one sees the other's row) and makes an emit
|
|
/// failure roll the write back instead of silently losing the event.
|
|
///
|
|
/// Supersedes [`Self::with_events`] — the transactional writer resolves and
|
|
/// writes the fan-out itself and needs no injected emitter.
|
|
#[must_use]
|
|
pub fn with_atomic_writes(mut self, pool: sqlx::PgPool) -> Self {
|
|
self.writer = Arc::new(PostgresGroupKvWriter::new(pool));
|
|
self.atomic = true;
|
|
self
|
|
}
|
|
|
|
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
|
|
/// the env-var default.
|
|
#[must_use]
|
|
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
|
|
self.max_rows = max_rows;
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_max_value_bytes(
|
|
repo: Arc<dyn GroupKvRepo>,
|
|
resolver: Arc<dyn GroupCollectionResolver>,
|
|
authz: Arc<dyn AuthzRepo>,
|
|
max_value_bytes: usize,
|
|
) -> Self {
|
|
Self {
|
|
writer: Arc::new(BestEffortGroupKvWriter::new(
|
|
repo.clone(),
|
|
Arc::new(NoopEventEmitter),
|
|
)),
|
|
atomic: false,
|
|
max_rows: crate::quota::group_kv_max_rows_from_env(),
|
|
max_total_bytes: crate::quota::group_kv_max_total_bytes_from_env(),
|
|
repo,
|
|
resolver,
|
|
authz,
|
|
max_value_bytes,
|
|
}
|
|
}
|
|
|
|
/// Override the per-group total-bytes quota (§11.6 M4). Mainly for tests.
|
|
#[must_use]
|
|
pub fn with_max_total_bytes(mut self, max_total_bytes: u64) -> Self {
|
|
self.max_total_bytes = max_total_bytes;
|
|
self
|
|
}
|
|
|
|
/// The structural boundary: resolve the collection to its owning group on
|
|
/// the calling app's chain. `CollectionNotShared` when no ancestor group
|
|
/// declares it — the same outcome a foreign app gets, by construction.
|
|
async fn owning_group(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
) -> Result<GroupId, GroupKvError> {
|
|
if collection.is_empty() {
|
|
return Err(GroupKvError::InvalidCollection);
|
|
}
|
|
self.resolver
|
|
.resolve_owning_group(cx.app_id, collection, KIND_KV)
|
|
.await
|
|
.map_err(|e| GroupKvError::Backend(e.to_string()))?
|
|
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
|
|
}
|
|
|
|
/// The ceilings a shared-collection write must fit under.
|
|
fn quota(&self) -> GroupWriteQuota {
|
|
GroupWriteQuota {
|
|
max_rows: self.max_rows,
|
|
max_total_bytes: self.max_total_bytes,
|
|
max_value_bytes: self.max_value_bytes,
|
|
}
|
|
}
|
|
|
|
/// Enforce the per-value byte cap. The per-GROUP byte quota is measured
|
|
/// canonically in SQL inside the writing transaction — see
|
|
/// `group_quota::check_group_write`.
|
|
fn check_value_size(&self, value: &serde_json::Value) -> Result<(), GroupKvError> {
|
|
let encoded_len = serde_json::to_vec(value)
|
|
.map(|v| v.len())
|
|
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
|
|
if encoded_len > self.max_value_bytes {
|
|
return Err(GroupKvError::ValueTooLarge {
|
|
limit: self.max_value_bytes,
|
|
actual: encoded_len,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
|
authz::script_gate(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::GroupKvRead(group_id),
|
|
|| GroupKvError::Forbidden,
|
|
GroupKvError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
|
// Fails closed for an anonymous principal: shared mutation always needs
|
|
// an authenticated editor+ on the owning group.
|
|
authz::script_gate_require_principal(
|
|
&*self.authz,
|
|
cx,
|
|
Capability::GroupKvWrite(group_id),
|
|
|| GroupKvError::Forbidden,
|
|
GroupKvError::Backend,
|
|
)
|
|
.await
|
|
}
|
|
}
|
|
|
|
impl From<GroupKvRepoError> for GroupKvError {
|
|
fn from(e: GroupKvRepoError) -> Self {
|
|
Self::Backend(e.to_string())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupKvService for GroupKvServiceImpl {
|
|
async fn get(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.get(group_id, collection, key).await?)
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<(), GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_value_size(&value)?;
|
|
self.check_write(cx, group_id).await?;
|
|
self.writer
|
|
.set(
|
|
cx,
|
|
GroupKvTarget {
|
|
group_id,
|
|
collection,
|
|
key,
|
|
},
|
|
value,
|
|
self.quota(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn set_if(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
expected: Option<serde_json::Value>,
|
|
new: serde_json::Value,
|
|
) -> Result<bool, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_value_size(&new)?;
|
|
self.check_write(cx, group_id).await?;
|
|
self.writer
|
|
.set_if(
|
|
cx,
|
|
GroupKvTarget {
|
|
group_id,
|
|
collection,
|
|
key,
|
|
},
|
|
expected,
|
|
new,
|
|
self.quota(),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_write(cx, group_id).await?;
|
|
self.writer
|
|
.delete(
|
|
cx,
|
|
GroupKvTarget {
|
|
group_id,
|
|
collection,
|
|
key,
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.has(group_id, collection, key).await?)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
collection: &str,
|
|
cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvError> {
|
|
let group_id = self.owning_group(cx, collection).await?;
|
|
self.check_read(cx, group_id).await?;
|
|
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::authz::{AuthzError, AuthzRepo};
|
|
use picloud_shared::{
|
|
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
|
UserId,
|
|
};
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use tokio::sync::Mutex;
|
|
|
|
#[derive(Default)]
|
|
struct InMemoryGroupKvRepo {
|
|
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupKvRepo for InMemoryGroupKvRepo {
|
|
async fn get(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.get(&(group_id, collection.to_string(), key.to_string()))
|
|
.cloned())
|
|
}
|
|
|
|
async fn set(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
value: serde_json::Value,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.insert((group_id, collection.to_string(), key.to_string()), value))
|
|
}
|
|
|
|
async fn set_if(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
expected: Option<serde_json::Value>,
|
|
new: serde_json::Value,
|
|
) -> Result<bool, GroupKvRepoError> {
|
|
let mut data = self.data.lock().await;
|
|
let k = (group_id, collection.to_string(), key.to_string());
|
|
let matches = match (&expected, data.get(&k)) {
|
|
(None, None) => true,
|
|
(Some(exp), Some(cur)) => exp == cur,
|
|
_ => false,
|
|
};
|
|
if matches {
|
|
data.insert(k, new);
|
|
}
|
|
Ok(matches)
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
|
Ok(self
|
|
.data
|
|
.lock()
|
|
.await
|
|
.remove(&(group_id, collection.to_string(), key.to_string())))
|
|
}
|
|
|
|
async fn has(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
) -> Result<bool, GroupKvRepoError> {
|
|
Ok(self.data.lock().await.contains_key(&(
|
|
group_id,
|
|
collection.to_string(),
|
|
key.to_string(),
|
|
)))
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
_cursor: Option<&str>,
|
|
limit: u32,
|
|
) -> Result<KvListPage, GroupKvRepoError> {
|
|
let data = self.data.lock().await;
|
|
let mut keys: Vec<String> = data
|
|
.iter()
|
|
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
|
.map(|((_, _, k), _)| k.clone())
|
|
.collect();
|
|
keys.sort();
|
|
keys.truncate((limit as usize).max(1));
|
|
Ok(KvListPage {
|
|
keys,
|
|
next_cursor: None,
|
|
})
|
|
}
|
|
|
|
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
|
let data = self.data.lock().await;
|
|
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
|
|
}
|
|
|
|
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
|
let data = self.data.lock().await;
|
|
Ok(data
|
|
.iter()
|
|
.filter(|((g, _, _), _)| *g == group_id)
|
|
.map(|(_, v)| serde_json::to_vec(v).map_or(0, |b| b.len() as u64))
|
|
.sum())
|
|
}
|
|
|
|
/// Same projection the Postgres impl computes in SQL, but with this
|
|
/// mock's own (compact) metric: every OTHER row in the group, plus the
|
|
/// new value — i.e. `used - old + new`, measured consistently.
|
|
async fn projected_total_bytes(
|
|
&self,
|
|
group_id: GroupId,
|
|
collection: &str,
|
|
key: &str,
|
|
new_value: &serde_json::Value,
|
|
) -> Result<u64, GroupKvRepoError> {
|
|
let data = self.data.lock().await;
|
|
let target = (group_id, collection.to_string(), key.to_string());
|
|
let others: u64 = data
|
|
.iter()
|
|
.filter(|(k, _)| k.0 == group_id && **k != target)
|
|
.map(|(_, v)| serde_json::to_vec(v).map_or(0, |b| b.len() as u64))
|
|
.sum();
|
|
let new_len = serde_json::to_vec(new_value).map_or(0, |b| b.len() as u64);
|
|
Ok(others + new_len)
|
|
}
|
|
}
|
|
|
|
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
|
/// "not shared with you".
|
|
#[derive(Default)]
|
|
struct FakeResolver {
|
|
map: HashMap<(AppId, String), GroupId>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl GroupCollectionResolver for FakeResolver {
|
|
async fn resolve_owning_group(
|
|
&self,
|
|
app_id: AppId,
|
|
name: &str,
|
|
_kind: &str,
|
|
) -> Result<Option<GroupId>, sqlx::Error> {
|
|
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
|
}
|
|
}
|
|
|
|
#[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)
|
|
}
|
|
}
|
|
|
|
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
|
SdkCallCx {
|
|
app_id,
|
|
script_id: ScriptId::new(),
|
|
principal,
|
|
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() -> Principal {
|
|
Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Owner,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}
|
|
}
|
|
|
|
fn member_no_role() -> Principal {
|
|
Principal {
|
|
user_id: AdminUserId::new(),
|
|
instance_role: InstanceRole::Member,
|
|
scopes: None,
|
|
app_binding: None,
|
|
}
|
|
}
|
|
|
|
fn svc(resolver: FakeResolver) -> GroupKvServiceImpl {
|
|
GroupKvServiceImpl::new(
|
|
Arc::new(InMemoryGroupKvRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
|
|
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
|
|
// cap is rejected, but updating an existing key (net-zero) still works.
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "catalog".into()), group);
|
|
let kv = GroupKvServiceImpl::new(
|
|
Arc::new(InMemoryGroupKvRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
)
|
|
.with_max_rows(2);
|
|
let cx = cx_with(app, Some(owner()));
|
|
|
|
kv.set(&cx, "catalog", "a", serde_json::json!(1))
|
|
.await
|
|
.unwrap();
|
|
kv.set(&cx, "catalog", "b", serde_json::json!(2))
|
|
.await
|
|
.unwrap();
|
|
// A third distinct key exceeds the cap.
|
|
let err = kv
|
|
.set(&cx, "catalog", "c", serde_json::json!(3))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(
|
|
err,
|
|
GroupKvError::QuotaExceeded {
|
|
limit: 2,
|
|
actual: 2
|
|
}
|
|
),
|
|
"got {err:?}"
|
|
);
|
|
// Updating an existing key is exempt (net-zero).
|
|
kv.set(&cx, "catalog", "a", serde_json::json!(11))
|
|
.await
|
|
.expect("update of an existing key must be allowed at the cap");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn per_group_byte_quota_uses_the_projected_total() {
|
|
// §11.6 M4: the byte ceiling is checked on the PROJECTED total, so a
|
|
// write that would push the group over is rejected, but a same/smaller
|
|
// update near the cap is still allowed (unlike a naive used+new check).
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "catalog".into()), group);
|
|
// A 20-byte ceiling; each `"xxxxx"` value encodes to 7 bytes ("\"xxxxx\"").
|
|
let kv = GroupKvServiceImpl::new(
|
|
Arc::new(InMemoryGroupKvRepo::default()),
|
|
Arc::new(resolver),
|
|
Arc::new(DenyingAuthzRepo),
|
|
)
|
|
.with_max_rows(1000)
|
|
.with_max_total_bytes(20);
|
|
let cx = cx_with(app, Some(owner()));
|
|
|
|
kv.set(&cx, "catalog", "a", serde_json::json!("xxxxx"))
|
|
.await
|
|
.unwrap(); // used = 7
|
|
kv.set(&cx, "catalog", "b", serde_json::json!("xxxxx"))
|
|
.await
|
|
.unwrap(); // used = 14
|
|
// A third 7-byte value → projected 21 > 20 → rejected.
|
|
let err = kv
|
|
.set(&cx, "catalog", "c", serde_json::json!("xxxxx"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, GroupKvError::TotalBytesQuotaExceeded { limit: 20, .. }),
|
|
"got {err:?}"
|
|
);
|
|
// A same-size update of an existing key stays within budget (projected
|
|
// = 14 - 7 + 7 = 14 ≤ 20) — the delta rule, not used+new.
|
|
kv.set(&cx, "catalog", "a", serde_json::json!("yyyyy"))
|
|
.await
|
|
.expect("a same-size update near the cap must be allowed");
|
|
// Growing an existing value past the cap is still rejected.
|
|
let err = kv
|
|
.set(&cx, "catalog", "a", serde_json::json!("zzzzzzzzzzzzzzz"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(err, GroupKvError::TotalBytesQuotaExceeded { .. }),
|
|
"got {err:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unrelated_app_gets_collection_not_shared() {
|
|
// app_a's chain declares "catalog"; app_b's does not.
|
|
let app_a = AppId::new();
|
|
let app_b = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app_a, "catalog".into()), group);
|
|
let kv = svc(resolver);
|
|
|
|
// app_a (owner principal) can write+read.
|
|
let cx_a = cx_with(app_a, Some(owner()));
|
|
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
kv.get(&cx_a, "catalog", "k").await.unwrap(),
|
|
Some(serde_json::json!(1))
|
|
);
|
|
|
|
// app_b is off-chain — the name does not resolve.
|
|
let cx_b = cx_with(app_b, Some(owner()));
|
|
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn reads_open_writes_require_auth() {
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "catalog".into()), group);
|
|
let kv = svc(resolver);
|
|
|
|
// Seed a value as owner.
|
|
let owner_cx = cx_with(app, Some(owner()));
|
|
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Anonymous (principal=None) READ is allowed (reads-open).
|
|
let anon = cx_with(app, None);
|
|
assert_eq!(
|
|
kv.get(&anon, "catalog", "k").await.unwrap(),
|
|
Some(serde_json::json!("v"))
|
|
);
|
|
|
|
// Anonymous WRITE fails closed.
|
|
let err = kv
|
|
.set(&anon, "catalog", "k", serde_json::json!("x"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::Forbidden));
|
|
|
|
// Authenticated member with no group role: write forbidden.
|
|
let member = cx_with(app, Some(member_no_role()));
|
|
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::Forbidden));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn empty_collection_rejected_before_resolve() {
|
|
let kv = svc(FakeResolver::default());
|
|
let cx = cx_with(AppId::new(), None);
|
|
let err = kv.get(&cx, "", "k").await.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::InvalidCollection));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_if_is_compare_and_swap_and_writes_fail_closed() {
|
|
let app = AppId::new();
|
|
let group = GroupId::new();
|
|
let mut resolver = FakeResolver::default();
|
|
resolver.map.insert((app, "catalog".into()), group);
|
|
let kv = svc(resolver);
|
|
let cx = cx_with(app, Some(owner()));
|
|
|
|
// Insert-if-absent, then swap-if-equal (as the per-app path).
|
|
assert!(kv
|
|
.set_if(&cx, "catalog", "k", None, serde_json::json!(1))
|
|
.await
|
|
.unwrap());
|
|
assert!(!kv
|
|
.set_if(&cx, "catalog", "k", None, serde_json::json!(2))
|
|
.await
|
|
.unwrap());
|
|
assert!(!kv
|
|
.set_if(
|
|
&cx,
|
|
"catalog",
|
|
"k",
|
|
Some(serde_json::json!(9)),
|
|
serde_json::json!(3)
|
|
)
|
|
.await
|
|
.unwrap());
|
|
assert!(kv
|
|
.set_if(
|
|
&cx,
|
|
"catalog",
|
|
"k",
|
|
Some(serde_json::json!(1)),
|
|
serde_json::json!(3)
|
|
)
|
|
.await
|
|
.unwrap());
|
|
assert_eq!(
|
|
kv.get(&cx, "catalog", "k").await.unwrap(),
|
|
Some(serde_json::json!(3))
|
|
);
|
|
|
|
// A shared-collection CAS still fails closed for an anonymous writer.
|
|
let anon = cx_with(app, None);
|
|
let err = kv
|
|
.set_if(&anon, "catalog", "k", None, serde_json::json!(4))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(matches!(err, GroupKvError::Forbidden));
|
|
}
|
|
}
|