feat(drive): permanent deletion per drive
This commit is contained in:
@@ -111,3 +111,19 @@ export async function emptyTrash(): Promise<void> {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
|
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `DELETE /api/trash/drive/{drive_id}` — empty the trash within a
|
||||||
|
* single drive. Used by the trash page's Drive group-by, where each
|
||||||
|
* bucket header carries a per-drive Empty button so multi-drive
|
||||||
|
* owners don't have to wipe everything at once. Refused 404 when the
|
||||||
|
* caller lacks Delete on the named drive (anti-enum).
|
||||||
|
*/
|
||||||
|
export async function emptyTrashForDrive(driveId: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`/api/trash/drive/${encodeURIComponent(driveId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: getCsrfHeaders()
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`empty drive trash failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,6 +87,14 @@
|
|||||||
dateLabel?: string;
|
dateLabel?: string;
|
||||||
/** Custom renderer for the date cell (e.g. trash expiry chip). */
|
/** Custom renderer for the date cell (e.g. trash expiry chip). */
|
||||||
dateCell?: Snippet<[ResourceEntry]>;
|
dateCell?: Snippet<[ResourceEntry]>;
|
||||||
|
/**
|
||||||
|
* Optional per-bucket action button rendered alongside the swimlane
|
||||||
|
* header label. Receives the bucket key (the value `bucketOf`
|
||||||
|
* returned for the active group-by). Used by the trash page to expose
|
||||||
|
* a per-drive "Empty" affordance — the page decides which group-bys
|
||||||
|
* the action is meaningful for and returns nothing otherwise.
|
||||||
|
*/
|
||||||
|
bucketAction?: Snippet<[string]>;
|
||||||
/** Show the owner column + vignette (list view) and hover tooltip. */
|
/** Show the owner column + vignette (list view) and hover tooltip. */
|
||||||
showOwner?: boolean;
|
showOwner?: boolean;
|
||||||
/** Allow grid/list toggle (shares the app-wide view mode). */
|
/** Allow grid/list toggle (shares the app-wide view mode). */
|
||||||
@@ -131,6 +139,7 @@
|
|||||||
showDate = true,
|
showDate = true,
|
||||||
dateLabel,
|
dateLabel,
|
||||||
dateCell,
|
dateCell,
|
||||||
|
bucketAction,
|
||||||
showOwner = false,
|
showOwner = false,
|
||||||
showViewToggle = true,
|
showViewToggle = true,
|
||||||
selectable = false,
|
selectable = false,
|
||||||
@@ -434,7 +443,14 @@
|
|||||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||||
{@render listHeader()}
|
{@render listHeader()}
|
||||||
{#each sections as section (section.key)}
|
{#each sections as section (section.key)}
|
||||||
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
<div class="rl-swimlane-header" role="rowheader">
|
||||||
|
<span class="rl-swimlane-header__label">{section.label}</span>
|
||||||
|
{#if bucketAction}
|
||||||
|
<span class="rl-swimlane-header__action">
|
||||||
|
{@render bucketAction(section.key)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if filesStore.viewMode === 'list'}
|
{#if filesStore.viewMode === 'list'}
|
||||||
<!-- Window each section's rows so a large grouped list (e.g. a big
|
<!-- Window each section's rows so a large grouped list (e.g. a big
|
||||||
trash, grouped by remaining days) doesn't mount every row. The
|
trash, grouped by remaining days) doesn't mount every row. The
|
||||||
@@ -651,6 +667,16 @@
|
|||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-semibold);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
border-bottom: 1px solid var(--color-border-faint);
|
border-bottom: 1px solid var(--color-border-faint);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional per-bucket action (e.g. trash page's per-drive Empty). */
|
||||||
|
.rl-swimlane-header__action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid view date meta line. */
|
/* Grid view date meta line. */
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import {
|
import {
|
||||||
deleteTrashItem,
|
deleteTrashItem,
|
||||||
emptyTrash,
|
emptyTrash,
|
||||||
|
emptyTrashForDrive,
|
||||||
expiryChip,
|
expiryChip,
|
||||||
fetchTrashPage,
|
fetchTrashPage,
|
||||||
remainingDaysBucket,
|
remainingDaysBucket,
|
||||||
@@ -184,6 +185,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-drive empty (D2b stage 4 follow-up). The bucket key on the
|
||||||
|
// Drive group-by encodes "{rank}:{driveId}" so the natural lexical
|
||||||
|
// sort puts default-personal first; we strip the rank prefix here
|
||||||
|
// to recover the raw drive UUID. Only an Owner of the drive
|
||||||
|
// (Delete-bearing role) reaches the per-drive Empty button because
|
||||||
|
// the backend resolves a Delete-set first and refuses (404) any
|
||||||
|
// other drive.
|
||||||
|
function driveIdFromBucketKey(key: string): string {
|
||||||
|
return key.includes(':') ? (key.split(':')[1] ?? key) : key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function purgeDrive(bucketKey: string) {
|
||||||
|
const driveId = driveIdFromBucketKey(bucketKey);
|
||||||
|
const drive = drivesStore.findById(driveId);
|
||||||
|
// Owner-only check mirrors the backend gate so the UI doesn't
|
||||||
|
// surface the action for non-Owners — keeps the affordance
|
||||||
|
// honest. The bucket only appears on the page if the trash list
|
||||||
|
// already contained items the caller could see, but caller_role
|
||||||
|
// distinguishes Owner from Viewer/Editor on shared drives.
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('trash.empty_drive_title', 'Empty drive trash'),
|
||||||
|
message: t(
|
||||||
|
'trash.confirm_empty_drive',
|
||||||
|
{ name: drive?.name ?? driveId },
|
||||||
|
'Empty the trash on drive "{{name}}"? This cannot be undone.'
|
||||||
|
),
|
||||||
|
confirmText: t('trash.empty_action', 'Empty trash'),
|
||||||
|
danger: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await emptyTrashForDrive(driveId);
|
||||||
|
// Drop every entry that belonged to this drive; cheaper than a
|
||||||
|
// full refetch and matches what the user just saw.
|
||||||
|
raw = raw.filter((it) => it.drive_id !== driveId);
|
||||||
|
} catch (e) {
|
||||||
|
errorToast(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Drive group-by is the only one where a per-bucket empty
|
||||||
|
// affordance is meaningful — every other bucket key (remaining
|
||||||
|
// days, type, size, trashed time) isn't a permission scope. Hide
|
||||||
|
// the button on those group-bys.
|
||||||
|
const showPerDriveEmpty = $derived(groupBy === 'drive');
|
||||||
|
|
||||||
|
function driveCanPurge(driveId: string): boolean {
|
||||||
|
const d = drivesStore.findById(driveId);
|
||||||
|
// `caller_role === 'owner'` is the same gate the backend
|
||||||
|
// applies via Permission::Delete in the role bundle. Hide the
|
||||||
|
// button on Viewer/Editor drives so a click can't 404.
|
||||||
|
return d?.caller_role === 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Drive names for the "Drive" group-by labels — `drivesStore.load()` is
|
// Drive names for the "Drive" group-by labels — `drivesStore.load()` is
|
||||||
// idempotent (cached on the singleton) so this is essentially free.
|
// idempotent (cached on the singleton) so this is essentially free.
|
||||||
@@ -228,6 +283,23 @@
|
|||||||
{chip.label}
|
{chip.label}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
{#snippet bucketAction(bucketKey: string)}
|
||||||
|
{#if showPerDriveEmpty}
|
||||||
|
{@const driveId = driveIdFromBucketKey(bucketKey)}
|
||||||
|
{#if driveCanPurge(driveId)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-action btn-action--delete"
|
||||||
|
data-testid={`trash-empty-drive-btn-${driveId}`}
|
||||||
|
title={t('trash.empty_drive_title', 'Empty drive trash')}
|
||||||
|
aria-label={t('trash.empty_drive_title', 'Empty drive trash')}
|
||||||
|
onclick={() => purgeDrive(bucketKey)}
|
||||||
|
>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
{#snippet actions(entry)}
|
{#snippet actions(entry)}
|
||||||
<button
|
<button
|
||||||
class="btn-action"
|
class="btn-action"
|
||||||
|
|||||||
@@ -19,4 +19,14 @@ pub trait TrashUseCase: Send + Sync {
|
|||||||
|
|
||||||
/// Empty the trash for a specific user
|
/// Empty the trash for a specific user
|
||||||
async fn empty_trash(&self, user_id: Uuid) -> Result<()>;
|
async fn empty_trash(&self, user_id: Uuid) -> Result<()>;
|
||||||
|
|
||||||
|
/// Empty the trash within a single drive the caller can Delete in.
|
||||||
|
///
|
||||||
|
/// Same destructive shape as `empty_trash`, but scoped to one drive
|
||||||
|
/// — the Drive group-by on `/trash` exposes a per-row "Empty"
|
||||||
|
/// affordance so multi-drive owners can clear one drive without
|
||||||
|
/// touching the others. Refused (`NotFound`) when the caller has no
|
||||||
|
/// Delete-bearing role on the named drive (anti-enum: same shape
|
||||||
|
/// as if the drive didn't exist), or when the drive id is unknown.
|
||||||
|
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -725,6 +725,46 @@ impl TrashUseCase for TrashService {
|
|||||||
// the drives where the caller is effectively Owner (direct or via a
|
// the drives where the caller is effectively Owner (direct or via a
|
||||||
// group). Single-drive users: this resolves to just their personal
|
// group). Single-drive users: this resolves to just their personal
|
||||||
// drive, identical to the legacy `WHERE user_id = $1` scope.
|
// drive, identical to the legacy `WHERE user_id = $1` scope.
|
||||||
|
let drive_ids = self.drives_with_delete_for(user_id).await?;
|
||||||
|
if drive_ids.is_empty() {
|
||||||
|
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.clear_trash_in(&drive_ids, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
async fn empty_trash_for_drive(&self, user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||||
|
// Per-drive trash empty — the Drive group-by on `/trash` exposes
|
||||||
|
// this as a per-row affordance so multi-drive owners can clear
|
||||||
|
// one drive without touching the others. Refuses with
|
||||||
|
// `NotFound` (anti-enum) when the caller lacks Delete on the
|
||||||
|
// named drive — same shape as the user-facing drive listing
|
||||||
|
// would emit for an unknown id.
|
||||||
|
let allowed = self.drives_with_delete_for(user_id).await?;
|
||||||
|
if !allowed.contains(&drive_id) {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "trash.empty_drive_rejected",
|
||||||
|
reason = "no_delete_on_drive",
|
||||||
|
user_id = %user_id,
|
||||||
|
drive_id = %drive_id,
|
||||||
|
"👮🏻♂️ refused per-drive empty — caller lacks Delete on this drive",
|
||||||
|
);
|
||||||
|
return Err(DomainError::not_found("Drive", drive_id.to_string()));
|
||||||
|
}
|
||||||
|
info!("Emptying trash for drive {} (user {})", drive_id, user_id);
|
||||||
|
self.clear_trash_in(&[drive_id], user_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrashService {
|
||||||
|
/// Drives where the caller has `Permission::Delete` (via any role
|
||||||
|
/// bundle, direct or group-mediated). Shared by `empty_trash` and
|
||||||
|
/// `empty_trash_for_drive`; lifting the lookup out of both methods
|
||||||
|
/// keeps the two HTTP surfaces semantically consistent and avoids
|
||||||
|
/// duplicating the subject-expansion plumbing.
|
||||||
|
async fn drives_with_delete_for(&self, user_id: Uuid) -> Result<Vec<Uuid>> {
|
||||||
let (subject_types, subject_ids) = self
|
let (subject_types, subject_ids) = self
|
||||||
.authz
|
.authz
|
||||||
.expand_subject_for_listing(Subject::User(user_id))
|
.expand_subject_for_listing(Subject::User(user_id))
|
||||||
@@ -739,29 +779,32 @@ impl TrashUseCase for TrashService {
|
|||||||
format!("Failed to resolve accessible drives: {e:?}"),
|
format!("Failed to resolve accessible drives: {e:?}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let drive_ids: Vec<Uuid> = drives
|
Ok(drives
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|d| {
|
.filter(|d| {
|
||||||
d.caller_role
|
d.caller_role
|
||||||
.is_some_and(|r| r.expand().contains(&Permission::Delete))
|
.is_some_and(|r| r.expand().contains(&Permission::Delete))
|
||||||
})
|
})
|
||||||
.map(|d| d.drive.id)
|
.map(|d| d.drive.id)
|
||||||
.collect();
|
.collect())
|
||||||
|
|
||||||
if drive_ids.is_empty() {
|
|
||||||
info!("empty_trash: caller has Delete on no drive — nothing to do");
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks (thumbnail
|
/// Bulk-clear trash within the given drives, running every side
|
||||||
// cleanup, etc.) can run afterward. We use get_all_trashed_file_ids (not
|
/// effect once: trashed-file id list (for hooks), `clear_trash`
|
||||||
// get_trash_items) because the trash_items view excludes files inside a
|
/// SQL, dedup GC, content-cache invalidation, file-deleted hook.
|
||||||
// trashed folder — those files will still be deleted by clear_trash via
|
/// The two `TrashUseCase` entry points compose this with their
|
||||||
// the folder CASCADE, but their hooks would otherwise be missed.
|
/// respective drive-id scopes — call-once, no duplication.
|
||||||
|
async fn clear_trash_in(&self, drive_ids: &[Uuid], user_id: Uuid) -> Result<()> {
|
||||||
|
// Collect ALL trashed file IDs BEFORE bulk-deleting so hooks
|
||||||
|
// (thumbnail cleanup, etc.) can run afterward. We use
|
||||||
|
// `get_all_trashed_file_ids` (not `get_trash_items`) because the
|
||||||
|
// trash_items view excludes files inside a trashed folder —
|
||||||
|
// those files will still be deleted by `clear_trash` via the
|
||||||
|
// folder CASCADE, but their hooks would otherwise be missed.
|
||||||
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
let trashed_file_ids: Vec<String> = if self.file_deleted_hook.is_some() {
|
||||||
match self
|
match self
|
||||||
.trash_repository
|
.trash_repository
|
||||||
.get_all_trashed_file_ids(&drive_ids)
|
.get_all_trashed_file_ids(drive_ids)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
@@ -781,29 +824,29 @@ impl TrashUseCase for TrashService {
|
|||||||
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
|
// Folder deletion cascades (FK ON DELETE CASCADE) to child folders and
|
||||||
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
|
// their files. The PG trigger `trg_files_decrement_blob_ref` automatically
|
||||||
// decrements blob ref_counts for every deleted file row.
|
// decrements blob ref_counts for every deleted file row.
|
||||||
self.trash_repository.clear_trash(&drive_ids).await?;
|
self.trash_repository.clear_trash(drive_ids).await?;
|
||||||
|
|
||||||
// The PG trigger decremented ref_counts but cannot delete disk files or
|
// The PG trigger decremented ref_counts but cannot delete disk
|
||||||
// thumbnails. Run garbage_collect() to remove any blobs whose ref_count
|
// files or thumbnails. `garbage_collect()` removes any blobs
|
||||||
// reached 0, along with their blob-keyed thumbnail files.
|
// whose ref_count reached 0, along with their blob-keyed
|
||||||
|
// thumbnail files. Failure here is non-fatal — the rows are
|
||||||
|
// gone in any case; the next GC pass mops up.
|
||||||
if let Err(e) = self.dedup_service.garbage_collect().await {
|
if let Err(e) = self.dedup_service.garbage_collect().await {
|
||||||
warn!("empty_trash: garbage_collect failed: {:?}", e);
|
warn!("clear_trash_in: garbage_collect failed: {:?}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate content cache for all permanently deleted files.
|
|
||||||
if let Some(cc) = &self.content_cache {
|
if let Some(cc) = &self.content_cache {
|
||||||
for file_id in &trashed_file_ids {
|
for file_id in &trashed_file_ids {
|
||||||
cc.invalidate(file_id).await;
|
cc.invalidate(file_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(hook) = &self.file_deleted_hook {
|
if let Some(hook) = &self.file_deleted_hook {
|
||||||
for file_id in &trashed_file_ids {
|
for file_id in &trashed_file_ids {
|
||||||
hook.on_file_deleted(file_id);
|
hook.on_file_deleted(file_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Trash emptied for user {}", user_id);
|
info!("Trash cleared across {} drive(s) for user {}", drive_ids.len(), user_id);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,14 @@ where
|
|||||||
// via `drive_repo.list_for_subjects` + role-bundle filter.
|
// via `drive_repo.list_for_subjects` + role-bundle filter.
|
||||||
self.trash_repository.clear_trash(&[user_id]).await
|
self.trash_repository.clear_trash(&[user_id]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn empty_trash_for_drive(&self, _user_id: Uuid, drive_id: Uuid) -> Result<()> {
|
||||||
|
// Test mock — uses the passed-in drive id verbatim. Production
|
||||||
|
// checks the caller's Delete-bearing drives first and refuses
|
||||||
|
// with NotFound on a mismatch; the mock skips that and just
|
||||||
|
// clears the given drive directly.
|
||||||
|
self.trash_repository.clear_trash(&[drive_id]).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock repositories for testing
|
// Mock repositories for testing
|
||||||
|
|||||||
@@ -405,3 +405,61 @@ pub async fn empty_trash(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `DELETE /api/trash/drive/{drive_id}` — per-drive empty trash.
|
||||||
|
///
|
||||||
|
/// Same destructive shape as the all-drives `DELETE /api/trash`, but
|
||||||
|
/// scoped to a single drive the caller can Delete in. Used by the
|
||||||
|
/// `/trash` page's Drive group-by, which exposes a per-row "Empty"
|
||||||
|
/// affordance so multi-drive owners don't have to wipe everything at
|
||||||
|
/// once.
|
||||||
|
///
|
||||||
|
/// Refused with `404` (anti-enum) when the caller has no Delete-bearing
|
||||||
|
/// role on the named drive — the user-facing drive listing would emit
|
||||||
|
/// the same shape for an unknown id.
|
||||||
|
#[utoipa::path(
|
||||||
|
delete,
|
||||||
|
path = "/api/trash/drive/{drive_id}",
|
||||||
|
params(("drive_id" = Uuid, Path, description = "Drive UUID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Drive trash emptied successfully"),
|
||||||
|
(status = 404, description = "Caller lacks Delete on this drive"),
|
||||||
|
(status = 501, description = "Trash feature not enabled"),
|
||||||
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
|
tag = "trash"
|
||||||
|
)]
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
pub async fn empty_trash_for_drive(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(drive_id): Path<uuid::Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
debug!(
|
||||||
|
"Request to empty trash for drive {} by user {}",
|
||||||
|
drive_id, auth_user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let trash_service = match state.trash_service.as_ref() {
|
||||||
|
Some(service) => service,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
Json(json!({ "error": "Trash feature is not enabled" })),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match trash_service
|
||||||
|
.empty_trash_for_drive(auth_user.id, drive_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({ "success": true, "drive_id": drive_id })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => AppError::from(e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -469,6 +469,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
// when a wildcard like /{id} could otherwise capture them.
|
// when a wildcard like /{id} could otherwise capture them.
|
||||||
.route("/resources", get(trash_handler::get_trash_resources))
|
.route("/resources", get(trash_handler::get_trash_resources))
|
||||||
.route("/empty", delete(trash_handler::empty_trash))
|
.route("/empty", delete(trash_handler::empty_trash))
|
||||||
|
// Per-drive empty (D2b stage 4 / per-drive UX). Scoped
|
||||||
|
// empty of one drive's trash; refused 404 when the caller
|
||||||
|
// lacks Delete on the named drive.
|
||||||
|
.route(
|
||||||
|
"/drive/{drive_id}",
|
||||||
|
delete(trash_handler::empty_trash_for_drive),
|
||||||
|
)
|
||||||
.route("/files/{id}", delete(trash_handler::move_file_to_trash))
|
.route("/files/{id}", delete(trash_handler::move_file_to_trash))
|
||||||
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
|
.route("/folders/{id}", delete(trash_handler::move_folder_to_trash))
|
||||||
.route("/{id}/restore", post(trash_handler::restore_from_trash))
|
.route("/{id}/restore", post(trash_handler::restore_from_trash))
|
||||||
|
|||||||
+2
-1
@@ -160,7 +160,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/admin_user_ops.hurl" \
|
"$API_DIR/admin_user_ops.hurl" \
|
||||||
"$API_DIR/chunked_upload_cap.hurl" \
|
"$API_DIR/chunked_upload_cap.hurl" \
|
||||||
"$API_DIR/nc_auth_failures.hurl" \
|
"$API_DIR/nc_auth_failures.hurl" \
|
||||||
"$API_DIR/dedup_create.hurl"
|
"$API_DIR/dedup_create.hurl" \
|
||||||
|
"$API_DIR/trash_per_drive.hurl"
|
||||||
|
|
||||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — Per-drive trash empty (D2b stage 4 follow-up)
|
||||||
|
# =============================================================
|
||||||
|
# Pins the contract on `DELETE /api/trash/drive/{drive_id}` — the
|
||||||
|
# per-drive variant of `DELETE /api/trash/empty`. Scope of coverage:
|
||||||
|
#
|
||||||
|
# 1. Owner of a drive CAN empty that drive's trash → 204.
|
||||||
|
# 2. Idempotent: calling again on an empty drive still returns 204.
|
||||||
|
# 3. Scope: emptying drive A leaves drive B's trash intact.
|
||||||
|
# 4. Viewer of a shared drive → 404 (Viewer's bundle has no Delete).
|
||||||
|
# 5. Editor of a shared drive → 404 (Editor's bundle has no Delete).
|
||||||
|
# 6. Non-member of a shared drive → 404 (anti-enum).
|
||||||
|
# 7. Unknown drive UUID → 404 (same shape as no-role case; can't
|
||||||
|
# enumerate drive existence through this endpoint).
|
||||||
|
#
|
||||||
|
# The owner gate has three layers in the implementation (filter,
|
||||||
|
# membership check, UI hide); cases 4-6 protect the first two. Test 3
|
||||||
|
# (scope) is the load-bearing assertion against a regression that
|
||||||
|
# silently merges scopes.
|
||||||
|
#
|
||||||
|
# Self-contained: provisions its own users (`tpd_*` prefix) so it
|
||||||
|
# survives running alongside the rest of the API test suite.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Admin login.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
admin_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Provision `tpd_owner` (will own the shared drive and a
|
||||||
|
# personal-drive trash item that must NOT be touched).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "tpd_owner",
|
||||||
|
"password": "TpdOwnerPwd1!",
|
||||||
|
"email": "tpd_owner@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
owner_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "tpd_owner", "password": "TpdOwnerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
owner_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Capture the owner's default-personal drive + its root.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
jsonpath "$[0].default_for_user" == "{{owner_user_id}}"
|
||||||
|
[Captures]
|
||||||
|
personal_drive_id: jsonpath "$[0].id"
|
||||||
|
personal_root_id: jsonpath "$[0].root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Admin creates a shared drive owned directly by `tpd_owner`.
|
||||||
|
# Direct-user-owner keeps the test self-contained (no group
|
||||||
|
# plumbing needed); the membership-API path is exercised
|
||||||
|
# separately by `drives_membership.hurl`.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"kind": "shared",
|
||||||
|
"name": "tpd-shared",
|
||||||
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_drive_id: jsonpath "$.id"
|
||||||
|
shared_root_id: jsonpath "$.root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Seed one file in each drive, then trash both. We end up
|
||||||
|
# with two trash entries the owner can see: one per drive.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{personal_root_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
personal_file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{shared_root_id}}
|
||||||
|
file: file,fixtures/hello-copy.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/files/{{personal_file_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/files/{{shared_file_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# Both files are now trashed; trash listing carries one row per drive.
|
||||||
|
GET {{base_url}}/api/trash/resources
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[*].drive_id" contains "{{personal_drive_id}}"
|
||||||
|
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Test 1 + Test 3: Owner empties the shared drive's trash;
|
||||||
|
# the personal drive's trash item is untouched (scope check).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
GET {{base_url}}/api/trash/resources
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[*].drive_id" contains "{{personal_drive_id}}"
|
||||||
|
jsonpath "$.items[*].drive_id" not contains "{{shared_drive_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — Test 2: idempotent on an already-empty drive.
|
||||||
|
# No trash items left in the shared drive, but the owner
|
||||||
|
# still holds Delete on it, so the response is 200.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Test 7: unknown drive id → 404.
|
||||||
|
# The endpoint refuses with the same shape it uses for "no
|
||||||
|
# Delete on this drive" so callers can't enumerate which
|
||||||
|
# drive UUIDs exist via this endpoint.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/trash/drive/00000000-0000-0000-0000-000000000000
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 9 — Provision a Viewer of the shared drive (`tpd_viewer`),
|
||||||
|
# then assert the per-drive empty refuses for Viewer / Editor
|
||||||
|
# / non-member callers. Each refusal is 404 (anti-enum).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "tpd_viewer",
|
||||||
|
"password": "TpdViewerPwd1!",
|
||||||
|
"email": "tpd_viewer@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
viewer_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "tpd_viewer", "password": "TpdViewerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
viewer_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# Owner grants Viewer role on the shared drive.
|
||||||
|
POST {{base_url}}/api/drives/{{shared_drive_id}}/members
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"subject": { "type": "user", "id": "{{viewer_user_id}}" },
|
||||||
|
"role": "viewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# Seed a trash item in the shared drive so the negative tests can't
|
||||||
|
# pass via the "drive happens to be empty" trivial path. The owner
|
||||||
|
# trashes a new file; the Viewer/Editor/non-member attempts that
|
||||||
|
# follow must still refuse — the scope check is on permission, not
|
||||||
|
# on whether work would be done.
|
||||||
|
POST {{base_url}}/api/files/upload
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[MultipartFormData]
|
||||||
|
folder_id: {{shared_root_id}}
|
||||||
|
file: file,fixtures/hello.txt; text/plain
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
canary_file_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/files/{{canary_file_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# Test 4 — Viewer cannot empty the drive's trash.
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 10 — Test 5: Editor cannot either.
|
||||||
|
# Promote tpd_viewer to Editor; same refusal. Confirms
|
||||||
|
# `Delete` isn't in the Editor bundle.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{viewer_user_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "role": "editor" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{viewer_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 11 — Test 6: non-member of the drive cannot empty its trash.
|
||||||
|
# Fresh user with no grant on the shared drive whatsoever.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "tpd_outsider",
|
||||||
|
"password": "TpdOutsiderPwd1!",
|
||||||
|
"email": "tpd_outsider@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "tpd_outsider", "password": "TpdOutsiderPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
outsider_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{outsider_token}}
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# Trash row is still there — none of the negative attempts purged it.
|
||||||
|
GET {{base_url}}/api/trash/resources
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.items[*].drive_id" contains "{{shared_drive_id}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 12 — Cleanup: drop the canary, then the shared drive itself
|
||||||
|
# (D3b's delete-drive guard refuses non-empty drives, so
|
||||||
|
# clearing trash + the live tree first is required).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/trash/drive/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
Reference in New Issue
Block a user