Files
PiCloud/crates/manager-core/src/files_service.rs
MechaCat02 966b209d2e feat(quota): add per-app storage ceilings
An app's OWN store had no ceiling of any kind — only the group-SHARED
collections did, which is the inversion of what you'd expect. And
`authz::script_gate` returns `Ok(())` when there is no principal, so a
PUBLIC UNAUTHENTICATED route could reach `files::collection(..).create(..)`
and write blobs in a loop until the disk filled. `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`
caps ONE file at 100 MB; nothing capped the count. Same for KV keys and docs
against Postgres.

Ceilings: `PICLOUD_APP_KV_MAX_ROWS` / `PICLOUD_APP_DOCS_MAX_ROWS` (100k) and
`PICLOUD_APP_FILES_MAX_TOTAL_BYTES` (10 GiB).

They are enforced DIFFERENTLY from the group ones, on purpose — porting the
group design as-is would have been a serious regression:

  * KV/docs check a ROW count, on INSERT only, with NO lock. `kv::set` is the
    hottest write path in the system; taking a per-app advisory lock on every
    set would serialize an app's entire data plane, and a `SUM(...)` scan would
    make write cost grow with the app's stored size. What the lock buys is
    small — unlocked overshoot is bounded by write concurrency (32) against a
    ceiling of 100k, ~0.03%. These are anti-DoS rails, not billing. An update
    adds no row and is bounded by the per-value cap, so it pays nothing at all.
  * No per-app BYTE ceiling for KV/docs: every value is already capped at
    `PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows x max_value_bytes` is ALREADY a
    finite bound. A second scan would buy nothing.
  * FILES are the exception and DO lock + sum: one blob may be 100 MB, so 32
    racing uploads could overshoot by gigabytes of real disk, and a file write
    is heavy enough that the lock and the SUM are lost in the noise. Checked on
    create AND update — an update that skipped the ceiling would be a free
    bypass (the same bug just fixed for group files: grow a 1-byte file to
    100 MB, repeat).

`group_quota` is renamed `quota`: it now serves both owners, and the module
doc is where the two enforcement strategies are contrasted.

Pinned by tests/atomic_write.rs: the key ceiling refuses a new key while
still allowing an update at the ceiling (rejecting updates would brick an app
the moment it filled up — worse than the DoS the rail exists to stop), and 10
concurrent uploads cannot exceed the disk ceiling, nor can an update grow past it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:32:32 +02:00

876 lines
27 KiB
Rust

//! `FilesServiceImpl` — wires the `FilesRepo` underneath the
//! `picloud_shared::FilesService` trait scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo), mirroring `KvServiceImpl`:
//! 1. Collection validation (empty + path-traversal) and field /
//! size-cap validation at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//! `authz::require(...)`; when it's `None` (public HTTP) we skip.
//! Cross-app isolation is unaffected — every repo call is keyed by
//! `cx.app_id`, never an argument.
//! 3. `ServiceEvent` emission after each mutation (`create` /
//! `update` / `delete`). The payload is the file **metadata**, not
//! the blob bytes (files are too big for trigger payloads).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesError,
FilesListPage, FilesService, NewFile, SdkCallCx, ServiceEventEmitter,
};
use uuid::Uuid;
use crate::atomic_write::{BestEffortFilesWriter, FilesWriter, PostgresFilesWriter};
use crate::authz::{self, AuthzRepo, Capability};
use crate::files_repo::{FilesRepo, FilesRepoError};
pub struct FilesServiceImpl {
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
writer: Arc<dyn FilesWriter>,
max_file_size_bytes: usize,
}
impl FilesServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
max_file_size_bytes: usize,
) -> Self {
Self {
writer: Arc::new(BestEffortFilesWriter::new(repo.clone(), events)),
repo,
authz,
max_file_size_bytes,
}
}
/// Swap the best-effort writer for the transactional one: the metadata row
/// and the trigger fan-out then commit together, so an outbox failure rolls
/// the metadata back (and unlinks the blob) instead of silently losing the
/// event. The host always calls this; the in-memory unit tests do not.
#[must_use]
pub fn with_atomic_writes(
mut self,
pool: sqlx::PgPool,
root: std::path::PathBuf,
max_total_bytes: u64,
) -> Self {
self.writer = Arc::new(PostgresFilesWriter::new(pool, root, max_total_bytes));
self
}
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
authz::script_gate(
&*self.authz,
cx,
Capability::AppFilesRead(cx.app_id),
|| FilesError::Forbidden,
FilesError::Backend,
)
.await
}
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
authz::script_gate(
&*self.authz,
cx,
Capability::AppFilesWrite(cx.app_id),
|| FilesError::Forbidden,
FilesError::Backend,
)
.await
}
}
/// Parse a script-supplied id. Invalid UUIDs aren't an error shape the
/// SDK exposes — for reads/deletes they simply mean "no such file".
fn parse_id(id: &str) -> Option<Uuid> {
Uuid::parse_str(id).ok()
}
impl From<FilesRepoError> for FilesError {
fn from(e: FilesRepoError) -> Self {
match e {
FilesRepoError::Corrupted => Self::Corrupted,
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
other => Self::Backend(other.to_string()),
}
}
}
#[async_trait]
impl FilesService for FilesServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
mut new: NewFile,
) -> Result<Uuid, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
new.validate(self.max_file_size_bytes)?;
// Audit 2026-06-11 C-2 — coerce dangerous render types to
// application/octet-stream after the shape checks pass.
new.content_type = sanitize_stored_content_type(&new.content_type);
self.writer.create(cx, collection, new).await
}
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.head(cx.app_id, collection, uuid).await?)
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.get(cx.app_id, collection, uuid).await?)
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
mut upd: FileUpdate,
) -> Result<(), FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
upd.validate(self.max_file_size_bytes)?;
// Audit 2026-06-11 C-2 — sanitize after the shape checks pass.
if let Some(ct) = upd.content_type.as_deref() {
upd.content_type = Some(sanitize_stored_content_type(ct));
}
let Some(uuid) = parse_id(id) else {
return Err(FilesError::NotFound);
};
if self.writer.update(cx, collection, uuid, upd).await? {
Ok(())
} else {
Err(FilesError::NotFound)
}
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(false);
};
self.writer.delete(cx, collection, uuid).await
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory FilesRepo so unit tests need neither Postgres nor a
// filesystem. The on-disk atomic-write / checksum mechanics are covered
// by the tempdir tests in `files_repo.rs`.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use crate::files_repo::FileUpdated;
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId,
ScriptId, ServiceEvent, UserId,
};
use std::collections::BTreeMap;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex;
/// In-memory FilesRepo keyed by (app, collection, id). Stores the
/// metadata + bytes together so cross-app isolation and round-trips
/// can be checked without disk.
#[derive(Default)]
struct InMemoryFilesRepo {
#[allow(clippy::type_complexity)]
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
let out = h.finalize();
let mut s = String::new();
for b in out {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[async_trait]
impl FilesRepo for InMemoryFilesRepo {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError> {
let id = Uuid::new_v4();
let now = Utc::now();
let meta = FileMeta {
id,
collection: collection.to_string(),
name: new.name.clone(),
content_type: new.content_type.clone(),
size: new.data.len() as u64,
checksum: sha256_hex(&new.data),
created_at: now,
updated_at: now,
};
self.data.lock().await.insert(
(app_id, collection.to_string(), id),
(meta.clone(), new.data),
);
Ok(meta)
}
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(m, _)| m.clone()))
}
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(_, b)| b.clone()))
}
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError> {
let mut data = self.data.lock().await;
let key = (app_id, collection.to_string(), id);
let Some((prev_meta, _)) = data.get(&key).cloned() else {
return Ok(None);
};
let now = Utc::now();
let new_meta = FileMeta {
id,
collection: collection.to_string(),
name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()),
content_type: upd
.content_type
.clone()
.unwrap_or_else(|| prev_meta.content_type.clone()),
size: upd.data.len() as u64,
checksum: sha256_hex(&upd.data),
created_at: prev_meta.created_at,
updated_at: now,
};
data.insert(key, (new_meta.clone(), upd.data));
Ok(Some(FileUpdated {
new: new_meta,
prev: prev_meta,
}))
}
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, collection.to_string(), id))
.map(|(m, _)| m))
}
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError> {
let data = self.data.lock().await;
let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
let mut metas: Vec<FileMeta> = data
.iter()
.filter(|((a, c, _), _)| *a == app_id && c == collection)
.map(|(_, (m, _))| m.clone())
.filter(|m| after.is_none_or(|a| m.id > a))
.collect();
metas.sort_by_key(|m| m.id);
let take = (limit.max(1)) as usize;
let next_cursor = if metas.len() > take {
metas.truncate(take);
metas.last().map(|m| m.id.to_string())
} else {
None
};
Ok(FilesListPage {
files: metas,
next_cursor,
})
}
}
/// Captures emitted events so tests can assert on fan-out shape.
#[derive(Default)]
struct CapturingEmitter {
events: StdMutex<Vec<ServiceEvent>>,
}
#[async_trait]
impl ServiceEventEmitter for CapturingEmitter {
async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[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 EditorAuthzRepo;
#[async_trait]
impl AuthzRepo for EditorAuthzRepo {
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,
script_id: ScriptId::new(),
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 member_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
..anon_cx(app_id)
}
}
fn svc_with(authz: Arc<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> FilesServiceImpl {
FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
authz,
emitter,
10 * 1024 * 1024,
)
}
fn svc() -> FilesServiceImpl {
svc_with(
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
)
}
fn new_file(name: &str, data: &[u8]) -> NewFile {
NewFile {
name: name.to_string(),
content_type: "application/octet-stream".to_string(),
data: data.to_vec(),
}
}
#[tokio::test]
async fn dangerous_render_content_type_is_coerced_on_create() {
// Audit 2026-06-11 C-2 closure.
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(
&cx,
"c",
NewFile {
name: "evil.html".into(),
content_type: "text/html".into(),
data: b"<script>alert(1)</script>".to_vec(),
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.content_type, "application/octet-stream");
}
#[tokio::test]
async fn dangerous_render_content_type_is_coerced_on_update() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(
&cx,
"c",
NewFile {
name: "a.bin".into(),
content_type: "application/octet-stream".into(),
data: b"x".to_vec(),
},
)
.await
.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"y".to_vec(),
name: None,
content_type: Some("image/svg+xml".into()),
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.content_type, "application/octet-stream");
}
#[tokio::test]
async fn create_then_get_head_round_trips() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "avatars", new_file("a.bin", b"hello"))
.await
.unwrap();
let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap();
assert_eq!(bytes, Some(b"hello".to_vec()));
let meta = files
.head(&cx, "avatars", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "a.bin");
assert_eq!(meta.size, 5);
assert_eq!(meta.checksum, sha256_hex(b"hello"));
}
#[tokio::test]
async fn get_and_head_missing_return_none() {
let files = svc();
let cx = anon_cx(AppId::new());
let missing = Uuid::new_v4().to_string();
assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None);
assert!(files.head(&cx, "c", &missing).await.unwrap().is_none());
// Non-UUID id is also "missing", not an error.
assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None);
}
#[tokio::test]
async fn update_replaces_content_and_keeps_metadata_when_omitted() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "c", new_file("v1.txt", b"one"))
.await
.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two!!".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "v1.txt"); // kept
assert_eq!(meta.size, 5);
assert_eq!(
files.get(&cx, "c", &id.to_string()).await.unwrap(),
Some(b"two!!".to_vec())
);
}
#[tokio::test]
async fn update_missing_throws_not_found() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.update(
&cx,
"c",
&Uuid::new_v4().to_string(),
FileUpdate {
data: b"x".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::NotFound));
}
#[tokio::test]
async fn delete_returns_was_present() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap());
}
#[tokio::test]
async fn empty_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::InvalidCollection(_)));
}
#[tokio::test]
async fn traversal_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
for bad in ["../etc", "a/b", "a..b", "x\0y"] {
let err = files
.create(&cx, bad, new_file("f", b"x"))
.await
.unwrap_err();
assert!(
matches!(err, FilesError::InvalidCollection(_)),
"expected reject for {bad:?}"
);
}
}
#[tokio::test]
async fn missing_required_fields_have_field_specific_messages() {
let files = svc();
let cx = anon_cx(AppId::new());
// name
let err = files
.create(
&cx,
"c",
NewFile {
name: " ".into(),
content_type: "text/plain".into(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("name")));
// content_type
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: String::new(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("content_type")));
// Empty `data` is NO LONGER rejected (v1.1.6 relaxation) — see
// `empty_file_round_trips`.
}
#[tokio::test]
async fn empty_file_round_trips() {
// v1.1.6: a zero-byte blob is a valid stored state (sentinels,
// placeholders). Create with empty data, then read it back.
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(
&cx,
"c",
NewFile {
name: "empty.bin".into(),
content_type: "application/octet-stream".into(),
data: vec![],
},
)
.await
.expect("empty file create should succeed");
let bytes = files.get(&cx, "c", &id.to_string()).await.unwrap();
assert_eq!(bytes, Some(Vec::new()));
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.expect("metadata present");
assert_eq!(meta.size, 0);
}
#[tokio::test]
async fn name_and_content_type_length_caps_enforced() {
let files = svc();
let cx = anon_cx(AppId::new());
let long_name = "x".repeat(256);
let err = files
.create(&cx, "c", new_file(&long_name, b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::NameTooLong(256)));
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: "x".repeat(128),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::ContentTypeTooLong(128)));
}
#[tokio::test]
async fn per_file_size_cap_enforced() {
let files = FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
8, // tiny cap
);
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("big", b"123456789"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::TooLarge { limit: 8, .. }));
}
#[tokio::test]
async fn cross_app_isolation() {
let files = 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 = files
.create(&cx_a, "shared", new_file("f", b"from-a"))
.await
.unwrap();
// app B cannot see app A's file by id.
assert_eq!(
files.get(&cx_b, "shared", &id.to_string()).await.unwrap(),
None
);
assert!(files
.head(&cx_b, "shared", &id.to_string())
.await
.unwrap()
.is_none());
let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap();
assert!(page_b.files.is_empty());
// app A still sees it.
assert!(files
.get(&cx_a, "shared", &id.to_string())
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn anonymous_cx_skips_authz() {
let files = svc(); // DenyingAuthzRepo
let cx = anon_cx(AppId::new());
// No principal → no authz check, even with a denying repo.
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn member_without_role_is_forbidden() {
let files = svc(); // DenyingAuthzRepo
let cx = member_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::Forbidden));
}
#[tokio::test]
async fn member_with_editor_role_allowed() {
let files = svc_with(
Arc::new(EditorAuthzRepo),
Arc::new(CapturingEmitter::default()),
);
let cx = member_cx(AppId::new());
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn mutations_emit_events_with_correct_prev() {
let emitter = Arc::new(CapturingEmitter::default());
let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone());
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
files.delete(&cx, "c", &id.to_string()).await.unwrap();
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3);
// create: prev is None
assert_eq!(events[0].op, "create");
assert_eq!(events[0].source, "files");
assert!(events[0].old_payload.is_none());
assert!(events[0].payload.is_some());
// update: prev is the prior metadata
assert_eq!(events[1].op, "update");
assert!(events[1].old_payload.is_some());
// delete: prev is the deleted metadata (payload == old_payload)
assert_eq!(events[2].op, "delete");
assert_eq!(events[2].payload, events[2].old_payload);
assert!(events[2].payload.is_some());
}
#[tokio::test]
async fn list_cursor_paginates() {
let files = svc();
let cx = anon_cx(AppId::new());
for i in 0..5 {
files
.create(&cx, "c", new_file(&format!("f{i}"), b"x"))
.await
.unwrap();
}
let p1 = files.list(&cx, "c", None, 2).await.unwrap();
assert_eq!(p1.files.len(), 2);
assert!(p1.next_cursor.is_some());
let p2 = files
.list(&cx, "c", p1.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p2.files.len(), 2);
let p3 = files
.list(&cx, "c", p2.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p3.files.len(), 1);
assert!(p3.next_cursor.is_none());
}
}