Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.
Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
(-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
(picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
to in-process subscribers after the durable outbox commit (best-effort,
panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
+ pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
badge, flip confirmation).
v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).
Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
834 lines
26 KiB
Rust
834 lines
26 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::{
|
|
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")));
|
|
// 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());
|
|
}
|
|
}
|