feat(v1.1.5): files SDK + files:* triggers
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>
This commit is contained in:
817
crates/manager-core/src/files_service.rs
Normal file
817
crates/manager-core/src/files_service.rs
Normal file
@@ -0,0 +1,817 @@
|
||||
//! `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::{
|
||||
validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService,
|
||||
NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::files_repo::{FileUpdated, FilesRepo, FilesRepoError};
|
||||
|
||||
pub struct FilesServiceImpl {
|
||||
repo: Arc<dyn FilesRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
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 {
|
||||
repo,
|
||||
authz,
|
||||
events,
|
||||
max_file_size_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| FilesError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppFilesWrite(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FilesError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort `ServiceEvent` emission. A failed emit is logged but
|
||||
/// never rolls back the (already-durable) file write.
|
||||
async fn emit(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
meta: &FileMeta,
|
||||
old: Option<&FileMeta>,
|
||||
) {
|
||||
let payload = serde_json::to_value(meta).ok();
|
||||
let old_payload = old.and_then(|m| serde_json::to_value(m).ok());
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "files",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(meta.id.to_string()),
|
||||
payload,
|
||||
old_payload,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "files", op, "event emit failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
new: NewFile,
|
||||
) -> Result<Uuid, FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
new.validate(self.max_file_size_bytes)?;
|
||||
let meta = self.repo.create(cx.app_id, collection, new).await?;
|
||||
self.emit(cx, "create", collection, &meta, None).await;
|
||||
Ok(meta.id)
|
||||
}
|
||||
|
||||
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,
|
||||
upd: FileUpdate,
|
||||
) -> Result<(), FilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
upd.validate(self.max_file_size_bytes)?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Err(FilesError::NotFound);
|
||||
};
|
||||
match self.repo.update(cx.app_id, collection, uuid, upd).await? {
|
||||
Some(FileUpdated { new, prev }) => {
|
||||
self.emit(cx, "update", collection, &new, Some(&prev)).await;
|
||||
Ok(())
|
||||
}
|
||||
None => 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);
|
||||
};
|
||||
match self.repo.delete(cx.app_id, collection, uuid).await? {
|
||||
Some(meta) => {
|
||||
// On delete, the top-level metadata AND `prev` both carry
|
||||
// the deleted row (per docs/v1.1.x design + the brief).
|
||||
self.emit(cx, "delete", collection, &meta, Some(&meta))
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
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 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 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")));
|
||||
// data
|
||||
let err = files
|
||||
.create(
|
||||
&cx,
|
||||
"c",
|
||||
NewFile {
|
||||
name: "f".into(),
|
||||
content_type: "text/plain".into(),
|
||||
data: vec![],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, FilesError::MissingField("data")));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user