diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index c74da06e..955b9b93 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -167,6 +167,50 @@ export async function removeDriveMemberAdmin( * Throws on non-2xx so the caller can branch on `405` (default * personal) vs `409` (non-empty) when surfacing the failure. */ +/** + * `PATCH /api/drives/{id}/quota` — admin-only shared-drive quota + * mutation (D4). `quotaBytes = null` or ≤ 0 → unlimited (the backend + * normalises 0/negative to NULL). + * + * **Refuses personal drives** with HTTP 400 — the effective cap + * comes from the owner user's `storage_quota_bytes` envelope, edit + * via `setUserQuota` (`PUT /api/admin/users/{id}/quota`) instead. + * Callers should gate the UI on `drive.kind === 'shared'` so users + * never see the refusal. + * + * **Soft-quota semantic on shrink**: a new cap below current + * `used_bytes` is accepted — the write-time gate then blocks new + * writes until the drive shrinks back under. No existing content + * is retroactively touched. Matches xfs/ext4 quota behaviour. + * + * Returns the persisted value (the backend's normalisation of the + * input) so the caller can update local state without re-fetching. + * Throws on non-2xx with the backend's error message when present. + */ +export async function updateDriveQuota( + driveId: string, + quotaBytes: number | null +): Promise { + const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/quota`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ quota_bytes: quotaBytes }) + }); + if (!res.ok) { + let detail = ''; + try { + const parsed = (await res.json()) as { error?: string; message?: string }; + detail = parsed.error ?? parsed.message ?? ''; + } catch { + /* response body wasn't JSON */ + } + throw new Error(detail || `update drive quota failed: ${res.status}`); + } + const body = (await res.json()) as { quota_bytes: number | null }; + return body.quota_bytes; +} + export async function deleteDriveAdmin(driveId: string): Promise { const res = await apiFetch(`/api/admin/drives/${encodeURIComponent(driveId)}`, { method: 'DELETE', diff --git a/frontend/src/lib/components/QuotaEditor.svelte b/frontend/src/lib/components/QuotaEditor.svelte new file mode 100644 index 00000000..3dc2aafe --- /dev/null +++ b/frontend/src/lib/components/QuotaEditor.svelte @@ -0,0 +1,159 @@ + + + +
+

+ {t('admin.quota_for', 'Quota for')} {subjectName} +

+ + {#if error} +

{error}

+ {/if} +
+ {#snippet footer()} + + + {/snippet} +
diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 0d590b92..3e797d03 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -250,6 +250,10 @@ export const OxiIcons: Record = { 512, "M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z" ], + "gauge-simple-high": [ + 512, + "M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 96c0-15.9-5.8-30.4-15.3-41.6l76.6-147.4c6.1-11.8 1.5-26.3-10.2-32.4s-26.2-1.5-32.4 10.2L262.1 288.3c-2-.2-4-.3-6.1-.3c-35.3 0-64 28.7-64 64s28.7 64 64 64s64-28.7 64-64z" + ], "github": [ 496, "M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index b10e5886..5c0f481c 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -49,6 +49,7 @@ listAllDrives, listDriveMembersAdmin, removeDriveMemberAdmin, + updateDriveQuota, type SmtpInfo, type SmtpTestResult, type StorageSettings, @@ -72,6 +73,7 @@ import Modal from '$lib/components/Modal.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; import PolicyList from '$lib/components/PolicyList.svelte'; + import QuotaEditor from '$lib/components/QuotaEditor.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; import { readPolicyBool } from '$lib/utils/drivePolicies'; @@ -617,13 +619,16 @@ quotaUnit: (1024 ** 3) as number }); - // Quota edit modal + // User-envelope quota edit modal state. Draft (unlimited flag, + // value, unit) lives inside ; the parent only tracks + // the subject (which user) and the current bytes to seed with. let quotaModal = $state<{ userId: string; username: string; - value: number; - unit: number; + initialBytes: number; } | null>(null); + let quotaModalBusy = $state(false); + let quotaModalError = $state(null); // Reset-password modal let resetModal = $state<{ userId: string; username: string } | null>(null); @@ -761,22 +766,30 @@ } function openQuota(u: User) { + quotaModalError = null; quotaModal = { userId: u.id, username: u.username || u.email, - value: - u.storage_quota_bytes > 0 ? Math.round((u.storage_quota_bytes / 1024 ** 3) * 10) / 10 : 0, - unit: 1024 ** 3 + initialBytes: u.storage_quota_bytes }; } - async function saveQuota() { + // `unlimited` maps to 0 on the wire — that's the backend + // convention for the user envelope (`quota <= 0` short-circuit + // in `eval_user_envelope`). Encoding the "unlimited" concept + // happens here, so the shared doesn't have to + // know about endpoint-specific magic values. + async function saveQuota(result: { unlimited: boolean; bytes: number }) { if (!quotaModal) return; + quotaModalBusy = true; + quotaModalError = null; try { - await setUserQuota(quotaModal.userId, Math.round(quotaModal.value * quotaModal.unit)); + await setUserQuota(quotaModal.userId, result.unlimited ? 0 : result.bytes); quotaModal = null; await loadUsers(); } catch (e) { - reportError(e); + quotaModalError = errorMessage(e); + } finally { + quotaModalBusy = false; } } @@ -947,9 +960,12 @@ function driveKindLabel(d: Drive): string { if (d.kind === 'shared') return t('admin.drive_kind_shared', 'Shared'); - return d.default_for_user - ? t('admin.drive_kind_personal_default', 'Personal (default)') - : t('admin.drive_kind_personal', 'Personal'); + return t('admin.drive_kind_personal', 'Personal'); + } + // The "(default)" annotation renders on a separate line under the + // kind badge so the Kind column stays narrow (users' request). + function driveDefaultSuffix(d: Drive): string | null { + return d.default_for_user ? t('admin.drive_kind_default_suffix', '(default)') : null; } function openDriveCreate() { @@ -1184,6 +1200,67 @@ } } + // ───────────────────────────────────────────────────────────── + // Shared-drive quota edit modal. + // + // Personal drives are refused server-side (400) because their + // effective cap is the owner user's `storage_quota_bytes` + // envelope (memory `project_user_envelope_quota_model`). The + // action button in the table doesn't render for personal drives, + // so this state only feeds shared-drive PATCH calls. + // + // The draft (value, unit, unlimited toggle) lives inside the + // shared ; the parent tracks only the subject and + // current bytes. + // ───────────────────────────────────────────────────────────── + let driveQuotaModal = $state<{ + driveId: string; + driveName: string; + initialBytes: number | null; + } | null>(null); + let driveQuotaError = $state(null); + let driveQuotaBusy = $state(false); + + function openDriveQuota(d: Drive) { + driveQuotaError = null; + driveQuotaModal = { + driveId: d.id, + driveName: d.name, + // `quota_bytes` is `number | null | undefined` on the DTO + // (optional + nullable). Both undefined and null map to + // unlimited — collapse to null for the modal. + initialBytes: d.quota_bytes ?? null + }; + } + + // `unlimited` maps to `null` on the wire for the drive endpoint + // (backend `Option::None` = "no cap") — distinct from the user + // envelope, which uses `0`. Both encodings live in their + // respective save callbacks, keeping endpoint- + // agnostic. + async function saveDriveQuota(result: { unlimited: boolean; bytes: number }) { + if (!driveQuotaModal) return; + driveQuotaBusy = true; + driveQuotaError = null; + try { + const quota_bytes = result.unlimited ? null : result.bytes; + const persisted = await updateDriveQuota(driveQuotaModal.driveId, quota_bytes); + const driveId = driveQuotaModal.driveId; + drivesList = drivesList.map((d) => + d.id === driveId ? { ...d, quota_bytes: persisted } : d + ); + // Sibling surfaces (sidebar picker, breadcrumb) read the + // cached `GET /api/drives`. Mirrors the policies-modal + // pattern above. + void drivesStore.refresh(); + driveQuotaModal = null; + } catch (e) { + driveQuotaError = errorMessage(e); + } finally { + driveQuotaBusy = false; + } + } + // Policy definitions live in `$lib/utils/drivePolicies` so the same // list drives the admin "Manage policies" modal AND the read-only // summary on `/config/drive/{uuid}`. Adding a policy is one literal- @@ -2187,7 +2264,7 @@ aria-label={t('admin.edit_quota_title', 'Edit quota')} onclick={() => openQuota(u)} > - + {#if !isOidcUser(u)} + + {#if d.kind === 'shared'} + + {:else} + + {/if} -. + "Unlimited" checkbox maps to 0 on the wire; positive value * unit + is sent verbatim to `setUserQuota`. --> + (quotaModal = null)} -> - {#if quotaModal} -
{ - e.preventDefault(); - void saveQuota(); - }} - > -

- {t('admin.quota_for', 'Quota for')} {quotaModal.username} -

- -
- {/if} - {#snippet footer()} - - - {/snippet} -
+ onsave={saveQuota} +/> + + + (driveQuotaModal = null)} + onsave={saveDriveQuota} +/> , + ) -> Result, DomainError> { + // Normalise sentinel values: `0` and negative numbers on the + // wire all mean "unlimited" — same convention the storage + // service uses on the query side (see `check_drive_quota`). + // Doing this once here (rather than in every caller) keeps the + // audit line + DB row consistent. + let quota_bytes = quota_bytes.filter(|&q| q > 0); + + let drive = self + .drive_repo + .get_by_id(drive_id) + .await + .map_err(|e| match e { + DriveRepositoryError::NotFound(_) => { + DomainError::not_found("Drive", drive_id.to_string()) + } + other => DomainError::internal_error( + "Drive", + format!("Failed to fetch drive: {other:?}"), + ), + })?; + + // Personal drives are refused with `InvalidInput` — a 400 that + // the handler doesn't need to translate specially. Audit line + // captures the attempt so an operator can see if someone is + // trying to circumvent the envelope model. + if drive.drive.kind == crate::domain::entities::drive::DriveKind::Personal { + tracing::info!( + target: "audit", + event = "drive.quota_change_rejected", + reason = "personal_drive_uses_user_envelope", + drive_id = %drive_id, + by = %caller_id, + "👮🏻‍♂️ refused quota edit on personal drive {drive_id} — use PUT /api/admin/users/{{id}}/quota", + ); + return Err(DomainError::validation_error( + "Personal drive quota is not editable here — set the owner user's storage envelope via PUT /api/admin/users/{id}/quota instead.", + )); + } + + let persisted = self + .drive_repo + .update_quota(drive_id, quota_bytes) + .await + .map_err(|e| match e { + DriveRepositoryError::NotFound(_) => { + DomainError::not_found("Drive", drive_id.to_string()) + } + other => { + DomainError::internal_error("Drive", format!("update_quota failed: {other:?}")) + } + })?; + + // Under-usage note in the audit line: an admin should be able + // to spot from `grep audit drive.quota_changed` whether the + // new cap put the drive into the "over quota, delete-only" + // state, so the numbers (used, new quota) are both present. + tracing::info!( + target: "audit", + event = "drive.quota_changed", + drive_id = %drive_id, + by = %caller_id, + new_quota_bytes = ?persisted, + used_bytes = drive.drive.used_bytes, + over_quota = persisted.map(|q| drive.drive.used_bytes > q).unwrap_or(false), + "💾 drive quota updated", + ); + + Ok(persisted) + } + /// D5 `forbid_external_sharing` for `set_member_role`. Fetches the /// data this surface has but grant_handler doesn't (drive policies + /// user flags), then defers the decision + audit + canonical error diff --git a/src/domain/repositories/drive_repository.rs b/src/domain/repositories/drive_repository.rs index 55fba8e6..b8060763 100644 --- a/src/domain/repositories/drive_repository.rs +++ b/src/domain/repositories/drive_repository.rs @@ -296,6 +296,39 @@ pub trait DriveRepository: Send + Sync + 'static { drive_id: Uuid, partial: &serde_json::Value, ) -> Result; + + /// Set the drive-level storage quota on a **shared** drive. + /// + /// `quota_bytes = None` means unlimited (matches the wire and DB + /// convention — `drives.quota_bytes` is nullable; a NULL row → the + /// storage-usage service treats it as no cap). + /// + /// **Personal drives are refused at the service layer** — their + /// effective cap comes from the owner user's + /// `users.storage_quota_bytes` envelope (see the memory + /// `project_user_envelope_quota_model`). This method does not + /// re-check the kind; the service does, and only calls the repo + /// with a validated shared-drive id. + /// + /// A newly-lowered quota can be **under** the drive's current + /// `used_bytes` — that's a deliberate soft-quota semantic. The + /// `storage_usage_service` gates NEW writes on + /// `used + delta <= quota`, so a shared drive already over its + /// freshly-reduced cap can only shrink (delete) until it comes back + /// under the limit; no existing content is retroactively touched. + /// + /// Cache invalidation mirrors `update_policies` — the user-keyed + /// readable-drive-list caches carry the quota alongside the row so + /// they'd serve stale numbers otherwise; the default-drive cache + /// carries the DriveWithRootName which also includes the quota. + /// + /// Returns the persisted post-mutation value so the caller can + /// echo it back in the audit log and API response. + async fn update_quota( + &self, + drive_id: Uuid, + quota_bytes: Option, + ) -> Result, DriveRepositoryError>; } /// Convenience: convert the canonical kind discriminator from its SQL diff --git a/src/infrastructure/repositories/pg/drive_pg_repository.rs b/src/infrastructure/repositories/pg/drive_pg_repository.rs index cbcf29be..0e5c57de 100644 --- a/src/infrastructure/repositories/pg/drive_pg_repository.rs +++ b/src/infrastructure/repositories/pg/drive_pg_repository.rs @@ -839,4 +839,38 @@ impl DriveRepository for DrivePgRepository { &raw, )) } + + async fn update_quota( + &self, + drive_id: Uuid, + quota_bytes: Option, + ) -> Result, DriveRepositoryError> { + // RETURNING gives the persisted value so the caller (service + // layer) has authoritative data for the audit line + API + // response without a second read. + let row: Option<(Option,)> = sqlx::query_as( + "UPDATE storage.drives \ + SET quota_bytes = $2, \ + updated_at = now() \ + WHERE id = $1 \ + RETURNING quota_bytes", + ) + .bind(drive_id) + .bind(quota_bytes) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| Self::map_sqlx_err("update_quota", e))?; + let persisted = row + .ok_or_else(|| DriveRepositoryError::NotFound(drive_id.to_string()))? + .0; + // Same invalidation strategy as `update_policies` — both + // user-keyed caches (`default_drive_cache`, the readable-drive + // list) carry the whole DriveWithRootName / DriveDto rows and + // would serve a stale quota otherwise. Admin-rare mutation, + // so blowing the whole cache is fine (no per-user pinpointing + // needed). + self.default_drive_cache.invalidate_all(); + self.invalidate_readable_all(); + Ok(persisted) + } } diff --git a/src/interfaces/api/handlers/drive_handler.rs b/src/interfaces/api/handlers/drive_handler.rs index de1f6681..8494bd60 100644 --- a/src/interfaces/api/handlers/drive_handler.rs +++ b/src/interfaces/api/handlers/drive_handler.rs @@ -509,3 +509,102 @@ pub async fn update_drive_policies( Err(e) => AppError::from(e).into_response(), } } + +/// Body for `PATCH /api/drives/{id}/quota` (D4). +/// +/// `quota_bytes = null` (or ≤ 0) means unlimited — matches the DB +/// convention where NULL on the row is treated as "no cap" by +/// `storage_usage_service::check_drive_quota`. The service +/// normalises 0/negative to None before writing. +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateDriveQuotaDto { + /// New quota in bytes. `null` (or omitted) or ≤ 0 → unlimited. + /// A value below the drive's current `used_bytes` is accepted + /// intentionally (soft-quota semantic — new writes gated, + /// existing content untouched; owners recover by deleting + /// until the drive comes back under the cap). + #[serde(default)] + pub quota_bytes: Option, +} + +/// `PATCH /api/drives/{id}/quota` — **OxiCloud-admin only** storage-cap +/// mutation for **shared** drives (D4). +/// +/// Personal drives are refused with `400 InvalidInput` — their +/// effective cap comes from the owner user's +/// `users.storage_quota_bytes` envelope (memory +/// `project_user_envelope_quota_model`); use +/// `PUT /api/admin/users/{id}/quota` instead. Allowing a per-personal- +/// drive quota here would fork the model into two competing paths. +/// +/// Non-admin callers receive `404` (anti-enumeration — same shape as +/// "no such drive", so a probe can't distinguish "drive doesn't +/// exist" from "quota edit is admin-only"). Matches the pattern +/// established by `update_drive_policies` above. +/// +/// **Soft-quota semantic on reduction.** A newly-lowered quota may +/// land BELOW the drive's current `used_bytes`. The write succeeds; +/// `storage_usage_service` then blocks new writes on +/// `used + delta > quota`, so owners of a shared drive that's now +/// over its freshly-reduced cap can only shrink (delete) until they +/// come back under. Existing content is never retroactively touched +/// — matches xfs `xfs_quota` / ext4 `edquota` behaviour on quota +/// shrink. +/// +/// Cache invalidation: the repo drops `readable_cache` + +/// `default_drive_cache` (both embed the whole drive row incl. +/// `quota_bytes`), matching the `update_policies` pattern. +/// +/// Audit: emits `drive.quota_changed` with `new_quota_bytes`, +/// `used_bytes`, and `over_quota` — so an operator grepping +/// `audit drive.quota_changed` can spot a shrink that landed the +/// drive in the over-quota delete-only state. +#[utoipa::path( + patch, + path = "/api/drives/{id}/quota", + params(("id" = Uuid, Path, description = "Drive UUID")), + request_body = UpdateDriveQuotaDto, + responses( + (status = 200, description = "Quota updated"), + (status = 400, description = "Personal drive — quota is envelope-managed via the owner user"), + (status = 404, description = "Drive not found OR caller is not OxiCloud admin"), + ), + security(("bearerAuth" = [])), + tag = "drives" +)] +pub async fn update_drive_quota( + State(state): State>, + auth_user: AuthUser, + Path(drive_id): Path, + axum::Json(dto): axum::Json, +) -> impl IntoResponse { + // Same admin gate + anti-enum shape as `update_drive_policies`. + // Refusing with 404 (rather than 403) means an unauthorised + // caller can't distinguish "no such drive" from "you're not + // admin" — the endpoint's existence isn't probable by error + // shape. + if auth_user.role != "admin" { + tracing::info!( + target: "audit", + event = "drive.quota_change_rejected", + reason = "not_admin", + caller_id = %auth_user.id, + drive_id = %drive_id, + "👮🏻‍♂️ quota mutation refused: caller is not OxiCloud admin", + ); + return AppError::not_found(format!("Drive {drive_id} not found")).into_response(); + } + + match state + .drive_management_service + .update_quota(auth_user.id, drive_id, dto.quota_bytes) + .await + { + Ok(persisted) => ( + StatusCode::OK, + axum::Json(serde_json::json!({ "quota_bytes": persisted })), + ) + .into_response(), + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 1d113ac6..92f4ba7d 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -460,6 +460,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { "/{id}/policies", patch(drive_handler::update_drive_policies), ) + .route("/{id}/quota", patch(drive_handler::update_drive_quota)) .route( "/{id}/members", get(drive_handler::list_drive_members).post(drive_handler::add_drive_member), diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index 4af2b7de..38bcf842 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -431,6 +431,336 @@ HTTP 200 jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32 +# ───────────────────────────────────────────────────────────── +# Steps 12-19 — D4 quota MUTATION surface +# (`PATCH /api/drives/{id}/quota`, admin-only). +# +# The enforcement side (steps 4-11 above) tested how a fixed +# quota gates writes. These steps test how an admin CHANGES the +# quota after creation — the counterpart mutation that lets +# quotas be adjusted without recreating the drive. +# +# Reuses `tight_drive_id` (100 B initial cap, used_bytes = 32 +# after Step 11's convergence) so we also exercise the soft- +# shrink case (Step 16 lowers the cap below `used_bytes = 32` +# and back — accepted, matches xfs/ext4 quota shrink behaviour). +# ───────────────────────────────────────────────────────────── + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Admin raises `tight_drive_id` quota to 1 GiB. +# Response echoes the persisted value from the RETURNING clause. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 1073741824 } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == 1073741824 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Non-admin (the drive Owner) is refused with 404. +# Anti-enumeration: same shape as "no such drive". A 403 would +# leak the endpoint's existence to any caller who can hit it. +# Matches the identical pattern on `PATCH .../policies`. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ "quota_bytes": 500 } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Verify Owner refusal was a no-op: cap is still +# 1 GiB from Step 12, not 500 B. Guards against a partial-write +# regression that could sneak a value through even after the +# handler-side admin gate rejects. +# +# Read as the drive owner, NOT admin: admin created this drive +# for `dq_owner` (Step 3) and holds no role_grant on it, so +# `/api/drives` (which returns only drives readable via role +# grants) would omit `tight_drive_id` from admin's list and the +# JSONPath filter would return no value. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].quota_bytes" == 1073741824 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Set unlimited via `null`. Passes through to the DB +# NULL that `storage_usage_service::check_drive_quota` reads as +# "no cap". Response echoes `null`. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": null } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == null + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Set unlimited via `0`. Backend normalises ≤ 0 to +# None (see the `.filter(|&q| q > 0)` in the service layer) → +# same NULL persisted, same null echoed. Guards the "0 means +# unlimited" convention shared with the write-time gate. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 0 } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == null + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Restore cap to 100 B so we can add a second file in +# Step 18 (the drive already holds 32 B; the null/0 cases above +# left it unlimited). +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 100 } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == 100 + + +# ───────────────────────────────────────────────────────────── +# Steps 18-23 — Soft-shrink semantic end-to-end: +# "Admin sets quota BELOW current usage. Owner cannot add +# new files, but CAN still delete existing ones." +# +# This is the real behavioural pin — matches how xfs / ext4 +# quotas treat a shrink: existing data is not retroactively +# touched; the enforcement gate is `used + delta > quota`, so +# new writes are blocked until the drive shrinks back under. +# +# State entering Step 18: +# tight_drive_id → quota=100 B, used=32 B (hello-copy.txt). +# ───────────────────────────────────────────────────────────── + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Re-upload hello.txt (deleted in Step 9). Captures +# its id so Step 22 can delete THIS specific file to prove the +# owner-can-still-delete half of the semantic. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{tight_root_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +soft_shrink_file_id: jsonpath "$.id" + + +POST {{base_url}}/api/admin/internal/trigger-sweep +Authorization: Bearer {{admin_token}} +[Options] +delay: 200ms + +HTTP 200 + + +# used_bytes now = 64 (hello-copy.txt at 32 + hello.txt at 32). +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 64 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Admin shrinks the quota to 16 B — well below the +# current 64 B usage. This IS accepted (soft-shrink semantic: +# `drive.md §7` — no retroactive touch, enforcement kicks in +# for new writes only). Response echoes the persisted cap. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 16 } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == 16 + + +# ───────────────────────────────────────────────────────────── +# Step 20 — Confirm the drive is now in the "over-quota, +# delete-only" state: quota = 16 B, used_bytes = 64 B. Both +# numbers must be visible in `GET /api/drives` since operators +# rely on `used_bytes > quota_bytes` as the signal to flag +# a drive for owner attention. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].quota_bytes" == 16 +jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 64 + + +# ───────────────────────────────────────────────────────────── +# Step 21 — Owner tries to upload a new file. Refused with +# `507 Insufficient Storage` — the same shape any over-quota +# write hits (uniform with Steps 4 / 6 / 8 / 11a-b above). +# Guards the "cannot add" half of the delete-only semantic. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{tight_root_id}} +file: file,fixtures/chunk-over-cap-5mb.bin; application/octet-stream + +HTTP 507 + + +# ───────────────────────────────────────────────────────────── +# Step 22 — Owner deletes hello.txt (the file captured at Step +# 18). Succeeds with `204 No Content` even though the drive is +# still over quota. This is the "but can still delete" half — +# an over-quota drive isn't frozen; owners recover by shrinking. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/files/{{soft_shrink_file_id}} +Authorization: Bearer {{owner_token}} + +HTTP 204 + + +# Empty trash so used_bytes reflects the permanent purge, not +# just the trashing (mirrors Step 9's convergence sequence). +DELETE {{base_url}}/api/trash/empty +Authorization: Bearer {{owner_token}} + +HTTP 200 + + +POST {{base_url}}/api/admin/internal/trigger-sweep +Authorization: Bearer {{admin_token}} + +HTTP 200 + + +# used_bytes dropped from 64 → 32 (hello-copy.txt still lives). +# Drive is STILL over quota (32 > 16), so the delete-only state +# persists — new writes still refused (see Step 23). +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32 +jsonpath "$[?(@.id=='{{tight_drive_id}}')].quota_bytes" == 16 + + +# ───────────────────────────────────────────────────────────── +# Step 23 — Confirm the delete-only state persists: even a +# fresh 5 MiB upload attempt is still refused with 507. The +# enforcement is on total usage vs quota, not per-write. Owner +# would need to delete hello-copy.txt too (bringing used_bytes +# to 0) OR admin would need to raise the quota back for writes +# to resume. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{tight_root_id}} +file: file,fixtures/chunk-over-cap-5mb.bin; application/octet-stream + +HTTP 507 + + +# ───────────────────────────────────────────────────────────── +# Step 24 — Admin raises the cap back to 100 B (leaves the +# drive under quota again). Confirms the escape hatch: admins +# can also lift the delete-only state without owner action. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{tight_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 100 } + +HTTP 200 +[Asserts] +jsonpath "$.quota_bytes" == 100 + + +# ───────────────────────────────────────────────────────────── +# Step 25 — Personal-drive quota edit is refused with 400 +# InvalidInput. Personal drives carry NULL `drives.quota_bytes` +# by design — the effective cap is the owner user's +# `storage_quota_bytes` envelope (memory +# `project_user_envelope_quota_model`). Allowing a per-personal- +# drive cap here would fork the enforcement model into two +# paths; the endpoint refuses cleanly with a message that +# points at the correct admin surface. +# +# Locate `dq_owner`'s default personal drive first. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +# `default_for_user` uses `#[serde(skip_serializing_if = "Option::is_none")]` +# — the field is present ONLY on the caller's default personal drive. +# The single-match filter returns a scalar, so `nth 0` breaks with +# "missing value to apply filter" (memory +# `feedback_hurl_jsonpath_filter_empty`). Body regex sidesteps that by +# anchoring on the field-adjacency pattern that Rust's `Serialize` +# preserves (id → name → kind → default_for_user). +HTTP 200 +[Captures] +dq_owner_personal_drive_id: body regex "\"id\":\"([a-f0-9-]{36})\",\"name\":\"[^\"]*\",\"kind\":\"personal\",\"default_for_user\"" + + +PATCH {{base_url}}/api/drives/{{dq_owner_personal_drive_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 500 } + +HTTP 400 +[Asserts] +# Response body carries the hint pointing at the correct +# admin endpoint. Substring check on "envelope" is deliberate — +# operator or misfired client script hitting this endpoint sees +# a self-documenting refusal instead of an opaque error. +body contains "envelope" + + +# ───────────────────────────────────────────────────────────── +# Step 26 — Non-existent drive returns 404, distinguishable +# ONLY by admin caller (a non-admin sees 404 too, per Step 13's +# anti-enum design). This is the "genuinely missing" case that +# proves the mutation isn't silently creating rows. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/00000000-0000-0000-0000-000000000000/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 42 } + +HTTP 404 + + # No cleanup tail here — `tests/api/storage_cleanup_check.sh` enumerates # every drive via `GET /api/admin/drives` and drains+deletes any that # isn't admin's default. This keeps individual Hurl tests focused on