feat(drive): personal drives have the user's quota in commun
This commit is contained in:
+148
-54
@@ -314,7 +314,9 @@ For reference, the equivalent (broken) one-CTE form looks like:
|
|||||||
WITH new_drive AS (
|
WITH new_drive AS (
|
||||||
INSERT INTO storage.drives
|
INSERT INTO storage.drives
|
||||||
(kind, default_for_user, quota_bytes, policies)
|
(kind, default_for_user, quota_bytes, policies)
|
||||||
VALUES ('personal', $user_id, $quota, '{}'::jsonb)
|
VALUES ('personal', $user_id, NULL, '{}'::jsonb) -- personal drives carry
|
||||||
|
-- NULL quota; the cap is
|
||||||
|
-- the user envelope, §7
|
||||||
RETURNING id
|
RETURNING id
|
||||||
),
|
),
|
||||||
new_root AS (
|
new_root AS (
|
||||||
@@ -366,10 +368,12 @@ The fix is the four-step transaction described above. Rust:
|
|||||||
|
|
||||||
```rust
|
```rust
|
||||||
let mut tx = pool.begin().await?;
|
let mut tx = pool.begin().await?;
|
||||||
|
// Personal drives carry NULL quota_bytes — the cap is the user envelope
|
||||||
|
// (`auth.users.storage_quota_bytes`, §7), not the per-drive column.
|
||||||
let drive_id: Uuid = sqlx::query_scalar(
|
let drive_id: Uuid = sqlx::query_scalar(
|
||||||
r#"INSERT INTO storage.drives (kind, default_for_user, quota_bytes)
|
r#"INSERT INTO storage.drives (kind, default_for_user, quota_bytes)
|
||||||
VALUES ('personal', $1, $2) RETURNING id"#,
|
VALUES ('personal', $1, NULL) RETURNING id"#,
|
||||||
).bind(owner).bind(quota).fetch_one(&mut *tx).await?;
|
).bind(owner).fetch_one(&mut *tx).await?;
|
||||||
|
|
||||||
let folder_id: Uuid = sqlx::query_scalar(
|
let folder_id: Uuid = sqlx::query_scalar(
|
||||||
r#"INSERT INTO storage.folders
|
r#"INSERT INTO storage.folders
|
||||||
@@ -450,7 +454,7 @@ that try to bypass it now hit a DB-level wall.
|
|||||||
| Per-resource grant outward | yes (subject to drive policies) | yes | yes |
|
| Per-resource grant outward | yes (subject to drive policies) | yes | yes |
|
||||||
| Cross-drive move | yes (subject to `forbid_cross_drive_move`) | yes | yes |
|
| Cross-drive move | yes (subject to `forbid_cross_drive_move`) | yes | yes |
|
||||||
| Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no |
|
| Kind conversion | no — always default-personal | yes → may be promoted to `kind='shared'` later (drops the single-user restriction, picks up members) | no |
|
||||||
| Change `quota_bytes` | **OxiCloud admin only** (not the drive owner — §7) | **OxiCloud admin only** | **OxiCloud admin only** |
|
| Change `quota_bytes` | N/A — `drives.quota_bytes` is NULL for personal drives. The envelope is `auth.users.storage_quota_bytes` (admin-only — §7) | N/A — same | **OxiCloud admin only** (not the drive owner — §7) |
|
||||||
|
|
||||||
### 4. Roles → permission bundles
|
### 4. Roles → permission bundles
|
||||||
|
|
||||||
@@ -493,7 +497,7 @@ against the same table.
|
|||||||
|
|
||||||
| Event | Behaviour |
|
| Event | Behaviour |
|
||||||
|---|---|
|
|---|---|
|
||||||
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=<new_user>`, `quota_bytes=<OXICLOUD_DEFAULT_QUOTA_BYTES>`) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=<user>, resource_type='drive', resource_id=<drive>, role='owner')`) — **all four writes in one CTE statement** (§3), atomic against server crash. |
|
| New internal user registers | Auto-create a default personal drive (`kind='personal'`, `default_for_user=<new_user>`, `quota_bytes=NULL` — the envelope lives on `auth.users.storage_quota_bytes`, see §7) + its root folder (`name='Personal'`, `parent_id=NULL`, drive_id pinned) + the Owner role_grant (`role_grants(subject_type='user', subject_id=<user>, resource_type='drive', resource_id=<drive>, role='owner')`) — **all four writes in one transaction** (§3), atomic against server crash. |
|
||||||
| External user invited (magic-link only) | **No personal drive created.** External users are grant-only recipients with no storage. |
|
| External user invited (magic-link only) | **No personal drive created.** External users are grant-only recipients with no storage. |
|
||||||
| External user converts to internal (future flow) | Default personal drive created at conversion time. |
|
| External user converts to internal (future flow) | Default personal drive created at conversion time. |
|
||||||
| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole owner `role_grants` row points at the user) are deleted by an application-layer pass in the same transaction. `role_grants` rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. |
|
| User deleted | **Default** personal drive cascade-deletes via `ON DELETE CASCADE` on `default_for_user`. **Secondary** personal drives (`kind='personal' AND default_for_user IS NULL` and whose sole owner `role_grants` row points at the user) are deleted by an application-layer pass in the same transaction. `role_grants` rows referencing the deleted user are removed from all shared drives. If a removal would leave a shared drive with zero owners, deletion is refused — admin must transfer first. |
|
||||||
@@ -506,67 +510,157 @@ against the same table.
|
|||||||
|
|
||||||
### 7. Quota model
|
### 7. Quota model
|
||||||
|
|
||||||
The per-user `auth.users.storage_quota_bytes` field is **migrated to
|
Two ceilings, two different jobs:
|
||||||
the user's personal drive's `quota_bytes`** in one step, then the
|
|
||||||
column is deprecated (kept for one release cycle as a no-op, dropped
|
|
||||||
in a later migration).
|
|
||||||
|
|
||||||
After the cutover:
|
- **Per-user envelope** — `auth.users.storage_quota_bytes` stays
|
||||||
- Every drive owns its quota. Files inside a drive count against that
|
as the canonical "how much can this user store on this server."
|
||||||
drive's `used_bytes` only.
|
It caps the sum of `used_bytes` across **every personal drive
|
||||||
- A user who collaborates in a 1 TB shared drive sees their personal
|
the user owns** (default + any secondaries — see §2). Shared
|
||||||
drive's quota as "their" quota; the shared drive's quota is owned
|
drives never count against any user envelope.
|
||||||
by the team.
|
- **Per-drive ceiling** — `drives.quota_bytes` is a per-drive cap
|
||||||
- New drives default to a tenant-configured `OXICLOUD_DEFAULT_DRIVE_QUOTA_BYTES`
|
that applies **only to shared drives**. For personal drives
|
||||||
setting (separate env var, replacing today's per-user equivalent).
|
this column is `NULL` (unlimited at the drive layer); the
|
||||||
|
effective cap comes from the user envelope.
|
||||||
|
|
||||||
`used_bytes` is maintained incrementally on every file insert/delete
|
Why the asymmetry: a user's personal storage is a single budget
|
||||||
(plus a periodic reconciliation job to fix drift, similar to the
|
that the operator has agreed to provide; splitting it into
|
||||||
existing per-user accounting).
|
sub-quotas per personal drive is a sub-quota UX trap (users now
|
||||||
|
have to plan how to allocate "their" bytes between drives they
|
||||||
|
own). A shared drive's quota IS the team's resource budget, owned
|
||||||
|
by the operator, set independently.
|
||||||
|
|
||||||
|
#### Upload gate
|
||||||
|
|
||||||
|
Pre-upload, both checks run, in order:
|
||||||
|
|
||||||
|
1. **Drive cap** (`drives.quota_bytes`) — skipped when NULL.
|
||||||
|
Always skipped for personal drives by virtue of the NULL
|
||||||
|
convention; applies for shared drives.
|
||||||
|
2. **User envelope** (`auth.users.storage_quota_bytes`) — runs
|
||||||
|
only when the target drive is personal. The check sums
|
||||||
|
`used_bytes` across the caller's personal drives (or, fast
|
||||||
|
path while no secondaries exist on the UI, reads the cached
|
||||||
|
`auth.users.storage_used_bytes`).
|
||||||
|
|
||||||
|
For shared-drive uploads, only the per-drive check applies and the
|
||||||
|
user envelope is untouched — collaborating in a 1 TB shared drive
|
||||||
|
costs no personal bytes.
|
||||||
|
|
||||||
|
#### `used_bytes` accounting
|
||||||
|
|
||||||
|
Maintained incrementally on every file insert/delete in
|
||||||
|
`storage.drives.used_bytes`. The user-side cached counter
|
||||||
|
(`auth.users.storage_used_bytes`) is updated **only when the
|
||||||
|
target drive is personal** — the per-upload delta hook reads
|
||||||
|
`drives.kind` from the same query that already fetches
|
||||||
|
`drives.used_bytes` for the drive-cap check, so the hot path adds
|
||||||
|
zero round-trips.
|
||||||
|
|
||||||
|
A periodic reconciliation job rebuilds both counters from ground
|
||||||
|
truth:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Per-drive: unchanged from today.
|
||||||
|
UPDATE storage.drives SET used_bytes = (
|
||||||
|
SELECT COALESCE(SUM(size), 0) FROM storage.files
|
||||||
|
WHERE drive_id = d.id AND NOT is_trashed
|
||||||
|
) d;
|
||||||
|
|
||||||
|
-- Per-user: sum of personal-drive used_bytes owned by the user.
|
||||||
|
UPDATE auth.users u SET storage_used_bytes = COALESCE((
|
||||||
|
SELECT SUM(d.used_bytes)
|
||||||
|
FROM storage.drives d
|
||||||
|
JOIN storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive' AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
AND g.subject_type = 'user' AND g.subject_id = u.id
|
||||||
|
WHERE d.kind = 'personal'
|
||||||
|
), 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Fast-path variant while only default personals are exposed:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE auth.users u SET storage_used_bytes = COALESCE((
|
||||||
|
SELECT used_bytes FROM storage.drives WHERE default_for_user = u.id
|
||||||
|
), 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Reconciliation runs on the maintenance pool — never blocks
|
||||||
|
uploads. Drift between deltas and the sweep is bounded by the
|
||||||
|
sweep interval (default 10 min).
|
||||||
|
|
||||||
#### Quota mutation is OxiCloud-admin only
|
#### Quota mutation is OxiCloud-admin only
|
||||||
|
|
||||||
Changing `drives.quota_bytes` is **not** in the drive `owner` role
|
Changing `drives.quota_bytes` (shared drives only) is **not** in
|
||||||
bundle (§4). It requires the tenant-level OxiCloud admin role
|
the drive `owner` role bundle (§4). It requires the tenant-level
|
||||||
(`auth.users.role = 'admin'`), checked at
|
OxiCloud admin role (`auth.users.role = 'admin'`), checked at
|
||||||
`PATCH /api/admin/drives/{id}/quota` — the only callsite that
|
`PATCH /api/admin/drives/{id}/quota`. Drive owners can rename,
|
||||||
mutates the column. Drive owners can rename, edit policies, and
|
edit policies, and manage members; they cannot self-grant
|
||||||
manage members; they cannot self-grant capacity.
|
capacity.
|
||||||
|
|
||||||
|
Changing `auth.users.storage_quota_bytes` (the personal envelope)
|
||||||
|
is likewise admin-only — same surface and audit pattern as today.
|
||||||
|
|
||||||
Why this seam matters:
|
Why this seam matters:
|
||||||
|
|
||||||
- **Resource allocation is a tenant concern, not a drive
|
- **Resource allocation is a tenant concern.** Storage bytes are
|
||||||
concern.** Storage bytes are a finite system resource the
|
a finite system resource the operator pays for. The drive owner
|
||||||
operator pays for. The drive owner is empowered over the
|
is empowered over the drive's *use*; the admin is empowered
|
||||||
drive's *use*; the admin is empowered over its *budget*. Same
|
over its *budget*.
|
||||||
separation that exists today between a user and the operator
|
- **Privilege-escalation seam closed.** Without the per-drive
|
||||||
who set `OXICLOUD_DEFAULT_QUOTA_BYTES`.
|
carve-out, an Owner of a shared drive could raise its quota.
|
||||||
- **Privilege-escalation seam closed.** Without this carve-out,
|
Without the per-user carve-out, any internal user could raise
|
||||||
any user with a personal drive (= every internal user) could
|
their own envelope by virtue of owning their personal drive.
|
||||||
raise their own quota by virtue of being its sole owner —
|
|
||||||
trivially defeating the quota system.
|
|
||||||
- **Shared-drive coherence.** A shared drive's quota is set by
|
- **Shared-drive coherence.** A shared drive's quota is set by
|
||||||
the operator at provisioning; subsequent capacity requests go
|
the operator at provisioning; subsequent capacity requests go
|
||||||
through the admin, not the drive's group owners. Keeps the
|
through the admin, not the drive's group owners.
|
||||||
capacity decision auditable and out of intra-team politics.
|
|
||||||
|
|
||||||
The admin endpoint is the same surface the operator uses today to
|
Audit log emits `drive.quota_changed` (shared drives) and
|
||||||
change `auth.users.storage_quota_bytes`; D4 simply re-targets the
|
`user.quota_changed` (envelope) with `granted_by=<admin_user_id>`
|
||||||
write at `storage.drives.quota_bytes`. Audit log emits
|
and the old/new values.
|
||||||
`drive.quota_changed` with `granted_by=<admin_user_id>` and the
|
|
||||||
old/new values, mirroring the existing user-quota change event.
|
|
||||||
|
|
||||||
**Chunk dedup vs per-drive quota.** With the CDC chunk store landed
|
#### Multiple personal drives — schema-ready, no public surface
|
||||||
in v0.7.0 (see `delta_upload_service`, `upload_ingest`, instant
|
|
||||||
upload by hash), a single chunk can be referenced by files in
|
The schema and service layer treat personal drives as "any
|
||||||
multiple drives. The accounting decision: **each drive counts the
|
personal drive owned by a user counts against the envelope," so
|
||||||
file's logical size in full against its own `used_bytes`** — dedup
|
secondary personal drives (`kind='personal' AND
|
||||||
savings are server-side only and never visible in the per-drive
|
default_for_user IS NULL`) just work the day they ship. Today
|
||||||
quota number. This matches the existing per-user blob-dedup model
|
there is **no public API surface to create them** — the only
|
||||||
and avoids the alternative "pro-rated quota" trap (which makes
|
`POST /api/drives` flow creates shared drives, and personal-drive
|
||||||
quota math depend on cross-drive content and breaks the user's
|
provisioning happens at user registration via the lifecycle hook
|
||||||
mental model of "I have 1 TB free"). Reconciliation job sums file
|
(§6). The capability matrix (§3) keeps the secondary column for
|
||||||
sizes per drive, not chunk allocations.
|
the migration backfill path and for the future, but it is not
|
||||||
|
user-reachable.
|
||||||
|
|
||||||
|
When secondary personals are eventually exposed (e.g. a "Vault"
|
||||||
|
end-to-end-encrypted drive kind, or a "Work" silo with a stricter
|
||||||
|
policy bag), the quota model needs no change — the sum-of-personal
|
||||||
|
formula already accounts for them.
|
||||||
|
|
||||||
|
#### Chunk dedup vs per-drive quota
|
||||||
|
|
||||||
|
With the CDC chunk store landed in v0.7.0 (see
|
||||||
|
`delta_upload_service`, `upload_ingest`, instant upload by hash),
|
||||||
|
a single chunk can be referenced by files in multiple drives. The
|
||||||
|
accounting decision: **each drive counts the file's logical size
|
||||||
|
in full against its own `used_bytes`** — dedup savings are
|
||||||
|
server-side only and never visible in the per-drive quota number.
|
||||||
|
This matches the existing per-user blob-dedup model and avoids
|
||||||
|
the "pro-rated quota" trap (which makes quota math depend on
|
||||||
|
cross-drive content and breaks the user's mental model of "I have
|
||||||
|
1 TB free"). Reconciliation sums file sizes per drive, not chunk
|
||||||
|
allocations.
|
||||||
|
|
||||||
|
#### Migration
|
||||||
|
|
||||||
|
One-shot at deploy: NULL out `drives.quota_bytes` for every
|
||||||
|
`kind='personal'` row (D4 backfilled them from
|
||||||
|
`auth.users.storage_quota_bytes` for the original "every drive
|
||||||
|
owns its quota" plan). Then run the new reconciliation sweep once
|
||||||
|
to resync `auth.users.storage_used_bytes` to "sum of personal
|
||||||
|
drives" (excludes any shared-drive bytes the old delta path may
|
||||||
|
have charged to it). Both steps idempotent.
|
||||||
|
|
||||||
### 8. Policies (JSONB, extensible)
|
### 8. Policies (JSONB, extensible)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- Switch personal-drive quota semantics from "every drive owns its quota"
|
||||||
|
-- to "user envelope on the SUM of personal-drive `used_bytes`".
|
||||||
|
-- See docs/plan/drive.md §7.
|
||||||
|
--
|
||||||
|
-- Two idempotent steps:
|
||||||
|
-- 1. NULL `drives.quota_bytes` for every `kind='personal'` row. After this
|
||||||
|
-- migration the column is meaningful only for shared drives; personal
|
||||||
|
-- drives' cap is `auth.users.storage_quota_bytes`.
|
||||||
|
-- 2. Resync `auth.users.storage_used_bytes` to the sum-of-personal-drives
|
||||||
|
-- formula. Prior deltas may have over-counted by including shared-drive
|
||||||
|
-- uploads in the user counter; this snaps every user back to the new
|
||||||
|
-- envelope. Same shape the periodic sweep uses going forward.
|
||||||
|
--
|
||||||
|
-- Both statements `IS DISTINCT FROM`-guarded so reruns are cheap no-ops on
|
||||||
|
-- already-migrated databases. The order — NULL first, then resync — doesn't
|
||||||
|
-- matter for correctness but follows the doc's narrative.
|
||||||
|
|
||||||
|
-- 1. Drop per-drive quotas for personal drives (no-op for already-NULL rows).
|
||||||
|
UPDATE storage.drives
|
||||||
|
SET quota_bytes = NULL
|
||||||
|
WHERE kind = 'personal'
|
||||||
|
AND quota_bytes IS NOT NULL;
|
||||||
|
|
||||||
|
-- 2. Resync user-side cached counter to the new sum-of-personal-drives
|
||||||
|
-- semantics. Mirrors `update_all_users_storage_usage` in
|
||||||
|
-- `storage_usage_service.rs`. External users excluded (no storage).
|
||||||
|
UPDATE auth.users u
|
||||||
|
SET storage_used_bytes = COALESCE(t.total, 0)
|
||||||
|
FROM auth.users u2
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT g.subject_id AS user_id,
|
||||||
|
SUM(d.used_bytes)::bigint AS total
|
||||||
|
FROM storage.drives d
|
||||||
|
JOIN storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
AND g.subject_type = 'user'
|
||||||
|
WHERE d.kind = 'personal'
|
||||||
|
GROUP BY g.subject_id
|
||||||
|
) t ON t.user_id = u2.id
|
||||||
|
WHERE u.id = u2.id
|
||||||
|
AND NOT u2.is_external
|
||||||
|
AND u.storage_used_bytes IS DISTINCT FROM COALESCE(t.total, 0);
|
||||||
@@ -15,7 +15,7 @@ use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
|
|||||||
use crate::infrastructure::services::dedup_service::DedupService;
|
use crate::infrastructure::services::dedup_service::DedupService;
|
||||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||||
use tracing::{info, warn};
|
use tracing::{Instrument, info, warn};
|
||||||
|
|
||||||
/// Service for file upload operations.
|
/// Service for file upload operations.
|
||||||
///
|
///
|
||||||
@@ -326,21 +326,39 @@ impl FileUploadService {
|
|||||||
};
|
};
|
||||||
let delta = file.size as i64;
|
let delta = file.size as i64;
|
||||||
|
|
||||||
// Per-user delta — unchanged from `b5b80549` / `fbbae541`.
|
let owner = file
|
||||||
if let Some(owner) = file
|
|
||||||
.owner_id
|
.owner_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
{
|
let folder = file
|
||||||
|
.folder_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
|
|
||||||
|
// Per-user delta — only when the target drive is `kind='personal'`.
|
||||||
|
// The user envelope (`auth.users.storage_quota_bytes`) caps the SUM
|
||||||
|
// of `used_bytes` across the user's personal drives; shared-drive
|
||||||
|
// uploads do NOT count against any user. See
|
||||||
|
// `docs/plan/drive.md` §7.
|
||||||
|
//
|
||||||
|
// The discrimination happens in one SQL statement via an EXISTS
|
||||||
|
// subquery on the folder's drive kind — no extra round-trip vs
|
||||||
|
// the unconditional delta. Without a folder id (root-level
|
||||||
|
// upload — folder service refuses these) the user-side delta is
|
||||||
|
// simply skipped; the sweep reconciles regardless.
|
||||||
|
if let (Some(owner), Some(folder)) = (owner, folder) {
|
||||||
let service_clone = Arc::clone(storage_service);
|
let service_clone = Arc::clone(storage_service);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
if let Err(e) = service_clone
|
if let Err(e) = service_clone
|
||||||
.add_user_storage_usage_delta(owner, delta)
|
.add_user_storage_usage_delta_if_personal(owner, folder, delta)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to bump storage usage for {owner}: {e}");
|
warn!("Failed to bump user storage for {owner} (folder {folder}): {e}");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-drive delta (D4) — same fire-and-forget shape, resolves
|
// Per-drive delta (D4) — same fire-and-forget shape, resolves
|
||||||
@@ -349,20 +367,19 @@ impl FileUploadService {
|
|||||||
// quota check and the picker quota bar read; drift from
|
// quota check and the picker quota bar read; drift from
|
||||||
// deletes / trash is reconciled by the same sweep that handles
|
// deletes / trash is reconciled by the same sweep that handles
|
||||||
// user-side drift.
|
// user-side drift.
|
||||||
if let Some(folder) = file
|
if let Some(folder) = folder {
|
||||||
.folder_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| Uuid::parse_str(s).ok())
|
|
||||||
{
|
|
||||||
let service_clone = Arc::clone(storage_service);
|
let service_clone = Arc::clone(storage_service);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(
|
||||||
|
async move {
|
||||||
if let Err(e) = service_clone
|
if let Err(e) = service_clone
|
||||||
.add_drive_storage_usage_delta_by_folder(folder, delta)
|
.add_drive_storage_usage_delta_by_folder(folder, delta)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
warn!("Failed to bump drive usage for folder {folder}: {e}");
|
warn!("Failed to bump drive usage for folder {folder}: {e}");
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
.in_current_span(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,13 +39,22 @@ impl StorageUsageService {
|
|||||||
/// (was three: user lookup + SUM + UPDATE). NOT called on the request
|
/// (was three: user lookup + SUM + UPDATE). NOT called on the request
|
||||||
/// path — only by the per-upload background update and the sweep.
|
/// path — only by the per-upload background update and the sweep.
|
||||||
pub async fn update_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
pub async fn update_user_storage_usage(&self, user_id: Uuid) -> Result<i64, DomainError> {
|
||||||
|
// User envelope = SUM of `drives.used_bytes` across personal
|
||||||
|
// drives owned by the user (see `docs/plan/drive.md` §7). Shared
|
||||||
|
// drives don't count. Ownership is canonical via `role_grants`.
|
||||||
let total_usage: Option<i64> = sqlx::query_scalar(
|
let total_usage: Option<i64> = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
UPDATE auth.users u
|
UPDATE auth.users u
|
||||||
SET storage_used_bytes = COALESCE((
|
SET storage_used_bytes = COALESCE((
|
||||||
SELECT SUM(f.size)::bigint
|
SELECT SUM(d.used_bytes)::bigint
|
||||||
FROM storage.files f
|
FROM storage.drives d
|
||||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
JOIN storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
AND g.subject_type = 'user'
|
||||||
|
AND g.subject_id = u.id
|
||||||
|
WHERE d.kind = 'personal'), 0)
|
||||||
WHERE u.id = $1
|
WHERE u.id = $1
|
||||||
RETURNING u.storage_used_bytes
|
RETURNING u.storage_used_bytes
|
||||||
"#,
|
"#,
|
||||||
@@ -77,9 +86,15 @@ impl StorageUsageService {
|
|||||||
r#"
|
r#"
|
||||||
UPDATE auth.users u
|
UPDATE auth.users u
|
||||||
SET storage_used_bytes = COALESCE((
|
SET storage_used_bytes = COALESCE((
|
||||||
SELECT SUM(f.size)::bigint
|
SELECT SUM(d.used_bytes)::bigint
|
||||||
FROM storage.files f
|
FROM storage.drives d
|
||||||
WHERE f.user_id = u.id AND NOT f.is_trashed), 0)
|
JOIN storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
AND g.subject_type = 'user'
|
||||||
|
AND g.subject_id = u.id
|
||||||
|
WHERE d.kind = 'personal'), 0)
|
||||||
WHERE u.username = $1
|
WHERE u.username = $1
|
||||||
RETURNING u.storage_used_bytes
|
RETURNING u.storage_used_bytes
|
||||||
"#,
|
"#,
|
||||||
@@ -128,6 +143,50 @@ impl StorageUsageService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Conditional user-side delta: only fires when the target folder's
|
||||||
|
/// drive is `kind='personal'`. See `docs/plan/drive.md` §7.
|
||||||
|
///
|
||||||
|
/// The new quota model: `auth.users.storage_quota_bytes` is the cap on
|
||||||
|
/// the SUM of `used_bytes` across the user's personal drives. Shared
|
||||||
|
/// drives never count against any user envelope. The upload hot path
|
||||||
|
/// reads `drives.kind` from the same JOIN that already runs for the
|
||||||
|
/// drive cap check; firing this conditional delta instead of the
|
||||||
|
/// unconditional [`Self::add_user_storage_usage_delta`] keeps the
|
||||||
|
/// counter aligned with that envelope semantics. Idempotent + clamped
|
||||||
|
/// at zero, same as the unconditional sibling.
|
||||||
|
///
|
||||||
|
/// Implementation note: the EXISTS subquery is two indexed PK probes
|
||||||
|
/// (folder by id, drive by id) so the personal/shared discrimination
|
||||||
|
/// adds no real cost vs. the unconditional update.
|
||||||
|
pub async fn add_user_storage_usage_delta_if_personal(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
folder_id: Uuid,
|
||||||
|
delta: i64,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE auth.users u
|
||||||
|
SET storage_used_bytes = GREATEST(0, u.storage_used_bytes + $2)
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM storage.folders f
|
||||||
|
JOIN storage.drives d ON d.id = f.drive_id
|
||||||
|
WHERE f.id = $3
|
||||||
|
AND d.kind = 'personal'
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(delta)
|
||||||
|
.bind(folder_id)
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("StorageUsage", format!("usage delta if personal: {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Incrementally adjust one drive's cached `storage.drives.used_bytes`
|
/// Incrementally adjust one drive's cached `storage.drives.used_bytes`
|
||||||
/// by `delta` bytes — same shape as
|
/// by `delta` bytes — same shape as
|
||||||
/// [`Self::add_user_storage_usage_delta`]: single statement, no
|
/// [`Self::add_user_storage_usage_delta`]: single statement, no
|
||||||
@@ -312,15 +371,17 @@ impl StorageUsageService {
|
|||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
debug!("Running scheduled storage-usage reconciliation");
|
debug!("Running scheduled storage-usage reconciliation");
|
||||||
if let Err(e) = service.update_all_users_storage_usage().await {
|
// Drive sweep runs FIRST: the user-side sweep below
|
||||||
error!("Scheduled user storage-usage reconciliation failed: {}", e);
|
// reads `drives.used_bytes` (the per-drive sum) to
|
||||||
}
|
// compute its own counter, so the drive counter must
|
||||||
// Drive sweep runs alongside the user sweep — same
|
// be honest first. Failure of one is logged but
|
||||||
// cadence, same maintenance pool. Failure is logged
|
// doesn't skip the other or the next tick.
|
||||||
// but doesn't skip the next tick.
|
|
||||||
if let Err(e) = service.update_all_drives_storage_usage().await {
|
if let Err(e) = service.update_all_drives_storage_usage().await {
|
||||||
error!("Scheduled drive storage-usage reconciliation failed: {}", e);
|
error!("Scheduled drive storage-usage reconciliation failed: {}", e);
|
||||||
}
|
}
|
||||||
|
if let Err(e) = service.update_all_users_storage_usage().await {
|
||||||
|
error!("Scheduled user storage-usage reconciliation failed: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -358,16 +419,33 @@ impl StorageUsagePort for StorageUsageService {
|
|||||||
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError> {
|
||||||
debug!("Starting storage-usage reconciliation sweep");
|
debug!("Starting storage-usage reconciliation sweep");
|
||||||
|
|
||||||
|
// User envelope = SUM of `drives.used_bytes` across the user's
|
||||||
|
// personal drives. Shared drives don't count against any user
|
||||||
|
// (`docs/plan/drive.md` §7). The drive-side sweep runs FIRST
|
||||||
|
// (`start_reconciliation_job`) so `drives.used_bytes` is
|
||||||
|
// already honest by the time we read it here.
|
||||||
|
//
|
||||||
|
// Ownership lookup uses `role_grants` (canonical per §1) so
|
||||||
|
// both the user's default personal AND any secondary
|
||||||
|
// personals owned via Owner grants are summed. Secondaries
|
||||||
|
// aren't user-creatable today, but a backfill or admin path
|
||||||
|
// can produce them — covering that surface from day one.
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE auth.users u
|
UPDATE auth.users u
|
||||||
SET storage_used_bytes = COALESCE(t.total, 0)
|
SET storage_used_bytes = COALESCE(t.total, 0)
|
||||||
FROM auth.users u2
|
FROM auth.users u2
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
SELECT user_id, SUM(size)::bigint AS total
|
SELECT g.subject_id AS user_id,
|
||||||
FROM storage.files
|
SUM(d.used_bytes)::bigint AS total
|
||||||
WHERE NOT is_trashed
|
FROM storage.drives d
|
||||||
GROUP BY user_id
|
JOIN storage.role_grants g
|
||||||
|
ON g.resource_type = 'drive'
|
||||||
|
AND g.resource_id = d.id
|
||||||
|
AND g.role = 'owner'
|
||||||
|
AND g.subject_type = 'user'
|
||||||
|
WHERE d.kind = 'personal'
|
||||||
|
GROUP BY g.subject_id
|
||||||
) t ON t.user_id = u2.id
|
) t ON t.user_id = u2.id
|
||||||
WHERE u.id = u2.id
|
WHERE u.id = u2.id
|
||||||
AND NOT u2.is_external
|
AND NOT u2.is_external
|
||||||
|
|||||||
@@ -2072,15 +2072,21 @@ pub async fn internal_trigger_sweep(
|
|||||||
.into_response();
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = svc.update_all_users_storage_usage().await {
|
// Order matches the periodic ticker (`start_reconciliation_job`):
|
||||||
return AppError::internal_error(format!("user sweep failed: {e}")).into_response();
|
// drive sweep first because the user sweep reads `drives.used_bytes`
|
||||||
}
|
// (sum-of-personal-drives — `docs/plan/drive.md` §7). Running them
|
||||||
|
// in the other order makes the user counter freeze on the previous
|
||||||
|
// tick's drive numbers — invisible in steady state but breaks any
|
||||||
|
// Hurl that trashes + sweeps within one call.
|
||||||
if let Err(e) = svc.update_all_drives_storage_usage().await {
|
if let Err(e) = svc.update_all_drives_storage_usage().await {
|
||||||
return AppError::internal_error(format!("drive sweep failed: {e}")).into_response();
|
return AppError::internal_error(format!("drive sweep failed: {e}")).into_response();
|
||||||
}
|
}
|
||||||
|
if let Err(e) = svc.update_all_users_storage_usage().await {
|
||||||
|
return AppError::internal_error(format!("user sweep failed: {e}")).into_response();
|
||||||
|
}
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
Json(serde_json::json!({ "ok": true, "ran": ["users", "drives"] })),
|
Json(serde_json::json!({ "ok": true, "ran": ["drives", "users"] })),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,11 +471,16 @@ pub async fn get_current_user(
|
|||||||
|
|
||||||
// Storage usage is served from the cached `storage_used_bytes` column —
|
// Storage usage is served from the cached `storage_used_bytes` column —
|
||||||
// it is NOT recomputed here. Recomputing on this hot endpoint meant an
|
// it is NOT recomputed here. Recomputing on this hot endpoint meant an
|
||||||
// O(N) `SUM(size)` over all the user's files plus an `UPDATE` of
|
// O(N) `SUM(size)` plus an `UPDATE` of `auth.users` on every single call
|
||||||
// `auth.users` on every single call (one of the most frequent endpoints).
|
// (one of the most frequent endpoints). The cached value is kept current
|
||||||
// The cached value is kept current by the per-upload update and a periodic
|
// by the per-upload update and a periodic background reconciliation sweep
|
||||||
// background reconciliation sweep
|
|
||||||
// (see `StorageUsageService::start_reconciliation_job`).
|
// (see `StorageUsageService::start_reconciliation_job`).
|
||||||
|
//
|
||||||
|
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
|
||||||
|
// of `used_bytes` across the user's personal drives only. Shared drives
|
||||||
|
// never count against this envelope — collaborating in a team drive
|
||||||
|
// costs no personal bytes. The matching cap is
|
||||||
|
// `storage_quota_bytes` (admin-only mutation).
|
||||||
let user = auth_service
|
let user = auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.get_user_by_id(user_id)
|
.get_user_by_id(user_id)
|
||||||
|
|||||||
+2
-1
@@ -162,7 +162,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$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" \
|
"$API_DIR/trash_per_drive.hurl" \
|
||||||
"$API_DIR/drive_quota.hurl"
|
"$API_DIR/drive_quota.hurl" \
|
||||||
|
"$API_DIR/user_envelope_quota.hurl"
|
||||||
|
|
||||||
#bash "$API_DIR/dedup_bulk_upload.sh"
|
#bash "$API_DIR/dedup_bulk_upload.sh"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud – User envelope quota (sum of personal drives)
|
||||||
|
# =============================================================
|
||||||
|
# Run:
|
||||||
|
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||||
|
# --test tests/api/user_envelope_quota.hurl
|
||||||
|
#
|
||||||
|
# The model under test (`docs/plan/drive.md` §7):
|
||||||
|
# `auth.users.storage_quota_bytes` caps the SUM of `used_bytes`
|
||||||
|
# across the user's PERSONAL drives only. Shared drives never
|
||||||
|
# count against any user envelope.
|
||||||
|
#
|
||||||
|
# Cases:
|
||||||
|
# 1. Baseline — fresh user: `/me.storage_used_bytes == 0`.
|
||||||
|
# 2. Shared-drive upload does NOT touch the envelope —
|
||||||
|
# `/me.storage_used_bytes` stays 0 after upload + sweep.
|
||||||
|
# 3. Personal-drive upload DOES bump the envelope —
|
||||||
|
# `/me.storage_used_bytes == file_size` after upload + sweep.
|
||||||
|
# 4. Sweep self-heals — after trashing the personal file and
|
||||||
|
# `trigger-sweep`, `/me.storage_used_bytes` returns to 0.
|
||||||
|
#
|
||||||
|
# `trigger-sweep` is the deterministic synchronisation point:
|
||||||
|
# it runs the drive-side sweep then the user-side sweep
|
||||||
|
# (`StorageUsageService::start_reconciliation_job`), so both
|
||||||
|
# cached counters are authoritative ground-truth by the time
|
||||||
|
# the assertion fires. Gated by
|
||||||
|
# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`
|
||||||
|
# (set in `tests/common/server.env`).
|
||||||
|
#
|
||||||
|
# Self-contained: provisions `ue_owner` so it can run alongside
|
||||||
|
# the rest of the 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 `ue_owner` (user envelope under test).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "ue_owner",
|
||||||
|
"password": "UeOwnerPwd1!",
|
||||||
|
"email": "ue_owner@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "ue_owner", "password": "UeOwnerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
owner_token: jsonpath "$.access_token"
|
||||||
|
owner_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Fetch the user's default Personal drive root folder.
|
||||||
|
# `GET /api/folders` returns root folders for the
|
||||||
|
# caller; for a fresh user that's a single entry — the
|
||||||
|
# Personal drive's root provisioned by
|
||||||
|
# `PersonalDriveLifecycleHook`.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
personal_root_id: jsonpath "$[0].id"
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$" count == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Baseline. Fresh user's envelope is zero.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.storage_used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Admin creates a shared drive with `ue_owner` as
|
||||||
|
# direct user-Owner. No per-drive quota (NULL = unlim).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"kind": "shared",
|
||||||
|
"name": "ue-shared",
|
||||||
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_drive_id: jsonpath "$.id"
|
||||||
|
shared_root_id: jsonpath "$.root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Case 2: upload hello.txt (32 B) to the SHARED drive.
|
||||||
|
# The drive's `used_bytes` will move; the user envelope
|
||||||
|
# must NOT.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# Wait for the drive-side fire-and-forget delta to settle.
|
||||||
|
# Acts as the synchronisation point: by the time `drives.used_bytes`
|
||||||
|
# reflects the upload, the sibling user-side delta task spawned in
|
||||||
|
# the same call has had its chance to run too.
|
||||||
|
GET {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[Options]
|
||||||
|
retry: 10
|
||||||
|
retry-interval: 200ms
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$[?(@.id=='{{shared_drive_id}}')].used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Force the user-side sweep to run, authoritative ground-truth.
|
||||||
|
# If the delta path incorrectly fired the user counter, the sweep
|
||||||
|
# would still correct it back to 0 (the new SQL excludes shared
|
||||||
|
# drives) — this also validates the sweep formula.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# Envelope untouched by the shared upload. Both delta path and
|
||||||
|
# sweep path agree on `0` for a user with no personal-drive
|
||||||
|
# content.
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.storage_used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — Case 3: upload hello.txt (32 B) to the user's own
|
||||||
|
# default Personal drive. The envelope MUST move now.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# Retry until the user-side delta lands. If the conditional-fire
|
||||||
|
# logic is broken (delta never fires for personal), retries time
|
||||||
|
# out at `0` and the test fails — this is the regression catch.
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
[Options]
|
||||||
|
retry: 10
|
||||||
|
retry-interval: 200ms
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.storage_used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# Confirm the sweep agrees with the delta — both code paths must
|
||||||
|
# give the same number.
|
||||||
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.storage_used_bytes" == 32
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Case 4: trash + empty the personal file, then sweep.
|
||||||
|
# Per-drive (and per-user) counters are NOT decremented
|
||||||
|
# on delete (same design as the per-drive quota model);
|
||||||
|
# the sweep is the correctness backstop. Asserts it
|
||||||
|
# actually closes the drift back to 0.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/files/{{personal_file_id}}
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{owner_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.storage_used_bytes" == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 9 — Cleanup. Deleting `ue_owner` cascades through
|
||||||
|
# `default_for_user` (default Personal drive + root
|
||||||
|
# folder + files) and removes the `role_grants` rows
|
||||||
|
# tying them to the shared drive. The shared drive
|
||||||
|
# itself is owned by admin (the creator) and gets
|
||||||
|
# drained by `storage_cleanup_check.sh` later.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{owner_user_id}}
|
||||||
|
Authorization: Bearer {{admin_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
Reference in New Issue
Block a user