05dfda9aa3
per today: admin only can update quota
shared drive can have quota updated (personal drive's quota belong to user's quota)
768 lines
32 KiB
Plaintext
768 lines
32 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — D4 per-drive quota enforcement
|
|
# =============================================================
|
|
# Pins the upload-time per-drive quota refusal. Scope:
|
|
#
|
|
# 1. Quota = 100 B on a shared drive; uploading a 5 MiB file →
|
|
# `507 Insufficient Storage`. Refusal happens BEFORE the file
|
|
# row is registered (no orphan blob, no usage drift).
|
|
# 2. A small file (32 B) under the same quota → `201`. The fire-
|
|
# and-forget delta hook bumps `drives.used_bytes`; the next
|
|
# `GET /api/drives` lists the new value.
|
|
# 3. After consuming most of the quota, a second small file that
|
|
# would push us over → `507`. Confirms the check uses the
|
|
# cached `used_bytes`, not just file size in isolation.
|
|
# 4. Unlimited quota (`quota_bytes` omitted at create) accepts the
|
|
# same 5 MiB upload that case 1 refused → `201`.
|
|
# 5. Per-user quota is unaffected — uploading to the user's own
|
|
# default Personal drive (no per-drive cap) still works.
|
|
#
|
|
# The check is layered on top of the existing per-user quota
|
|
# (`storage_usage_service::check_storage_quota`) — both run at the
|
|
# multipart handler; either refusal yields 507.
|
|
#
|
|
# Self-contained: provisions `dq_owner` and a fresh shared drive
|
|
# per case so this can run alongside the rest of the API 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 `dq_owner` (drive owner under test).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/users
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"username": "dq_owner",
|
|
"password": "DqOwnerPwd1!",
|
|
"email": "dq_owner@example.com",
|
|
"role": "user"
|
|
}
|
|
|
|
HTTP 201
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "dq_owner", "password": "DqOwnerPwd1!" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
owner_token: jsonpath "$.access_token"
|
|
owner_user_id: jsonpath "$.user.id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Admin creates a shared drive with a tiny 100-byte quota
|
|
# and `dq_owner` as direct user-Owner.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "dq-tight",
|
|
"owner": { "type": "user", "id": "{{owner_user_id}}" },
|
|
"quota_bytes": 100
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
tight_drive_id: jsonpath "$.id"
|
|
tight_root_id: jsonpath "$.root_folder_id"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Case 1: 5 MiB upload to the 100-byte drive → 507.
|
|
# Refused at the multipart handler before the file row is
|
|
# registered; the blob is discarded by `discard_ingested`.
|
|
# ─────────────────────────────────────────────────────────────
|
|
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 5 — Case 2: 32-byte upload fits under the 100-byte cap.
|
|
# ─────────────────────────────────────────────────────────────
|
|
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]
|
|
small_file_id: jsonpath "$.id"
|
|
|
|
|
|
# Force freshness on `drives.used_bytes`:
|
|
# 1. The fire-and-forget delta hook may not have landed yet
|
|
# (200 ms delay to let the tokio task register — see
|
|
# `bug_trigger_sweep_vs_spawn_hook_race`).
|
|
# 2. Force a reconciliation sweep. That's the ONLY path that
|
|
# invalidates `readable_cache` / `default_drive_cache` after
|
|
# Ed's 2026-07-17 design call: the sweep is the escape hatch
|
|
# for tests / operators that need immediate cache freshness;
|
|
# per-write invalidation would nuke the cache on every upload.
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Case 3: a SECOND small file that would push usage past
|
|
# the cap is refused. With `hello.txt` at 32 bytes already
|
|
# on the drive, the next 32-byte upload projects to
|
|
# 32 + 32 + 32 (header overhead negligible) — far under
|
|
# 100 — and IS accepted. Then a 5 MiB upload remains over
|
|
# quota: 507. This protects the "uses cached used_bytes"
|
|
# invariant: the check isn't just `size < quota`.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{owner_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{tight_root_id}}
|
|
file: file,fixtures/hello-copy.txt; text/plain
|
|
|
|
HTTP 201
|
|
|
|
|
|
# `used_bytes` climbs to 64 (32 + 32). Same trigger-sweep pattern
|
|
# as the first assertion — the delta is fire-and-forget and the
|
|
# listing cache lags until the sweep invalidates it.
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 64
|
|
|
|
|
|
# The fire-and-forget delta hook updates `drives.used_bytes`; the
|
|
# subsequent 5 MiB attempt still fails (5 MiB > 100 alone). This
|
|
# assertion holds regardless of whether the previous delta has
|
|
# landed in cache or not — `5_242_880 > 100` either way.
|
|
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
|
|
|
|
|
|
# `used_bytes` is unchanged — the failed upload didn't charge the
|
|
# drive. (Cumulative usage is still 64; the 5 MiB write never
|
|
# registered a row.) Trigger the sweep again to guarantee cache
|
|
# freshness — the 5 MiB attempt was refused pre-write so no
|
|
# delta was queued, but the previous sweep's invalidation was
|
|
# consumed by the intervening GET which re-populated the cache
|
|
# with the pre-refused-write value. Sweep + re-check for
|
|
# determinism.
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 64
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — Case 4: unlimited drive accepts the same 5 MiB upload.
|
|
# Confirms the `quota_bytes IS NULL` short-circuit.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/drives
|
|
Authorization: Bearer {{admin_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"kind": "shared",
|
|
"name": "dq-unlimited",
|
|
"owner": { "type": "user", "id": "{{owner_user_id}}" }
|
|
}
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
unlimited_root_id: jsonpath "$.root_folder_id"
|
|
unlimited_drive_id: jsonpath "$.id"
|
|
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{owner_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{unlimited_root_id}}
|
|
file: file,fixtures/chunk-over-cap-5mb.bin; application/octet-stream
|
|
|
|
HTTP 201
|
|
|
|
|
|
# Unlimited drive's `used_bytes` climbs to the file's exact size
|
|
# (5 MiB = 5_242_880 bytes). Trigger-sweep pattern (see above).
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 200ms
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{unlimited_drive_id}}')].used_bytes" == 5242880
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 9 — Post-delete sweep convergence.
|
|
# By design the per-drive `used_bytes` counter is NOT
|
|
# decremented on permanent delete (mirrors the existing
|
|
# per-user quota design: deletes drift, the periodic sweep
|
|
# reconciles). To prove the sweep actually closes the
|
|
# drift, we:
|
|
# a) Trash the 32-byte file in the unlimited drive
|
|
# (well, both: hello.txt + hello-copy.txt are in the
|
|
# tight drive; the 5 MiB is in the unlimited one).
|
|
# b) Permanently delete via empty-trash.
|
|
# c) Trigger the reconciliation sweep on demand —
|
|
# `/api/admin/internal/trigger-sweep` is gated by
|
|
# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`
|
|
# (set in `tests/common/server.env`).
|
|
# d) `GET /api/drives` now shows the corrected counter.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/files/{{small_file_id}}
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 204
|
|
|
|
|
|
# Permanent purge — empty caller's trash entirely.
|
|
DELETE {{base_url}}/api/trash/empty
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# Sweep is fire-and-forget on a ticker (default 600 s). Run it now
|
|
# so the assertion below is deterministic instead of polling.
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
# After the sweep, `tight_drive.used_bytes` has dropped from 64 to
|
|
# 32 (hello.txt purged, hello-copy.txt still live). `unlimited`
|
|
# stays at 5 MiB (nothing trashed there).
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32
|
|
jsonpath "$[?(@.id=='{{unlimited_drive_id}}')].used_bytes" == 5242880
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 10 — `/api/admin/internal/*` is admin-only and disabled by
|
|
# default. The gate-off case is covered by the absence of
|
|
# the route in production configs; here we just confirm a
|
|
# non-admin caller is refused even when the feature is on.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 403
|
|
|
|
|
|
# Trigger-GC reachable too — assert it returns the freed-blob
|
|
# summary shape. Hard count is non-deterministic (depends on the
|
|
# grace window vs the test's elapsed time), so we only check the
|
|
# response shape.
|
|
POST {{base_url}}/api/admin/internal/trigger-gc
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.ok" == true
|
|
jsonpath "$.blobs_deleted" exists
|
|
jsonpath "$.bytes_freed" exists
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 11 — Pre-flight quota gate on MOVE and COPY.
|
|
#
|
|
# Silent gap before 2026-07-06:
|
|
# `move_file_with_perms` / `move_folder_with_perms`
|
|
# / `copy_file_with_perms` / `copy_folder_tree_with_perms`
|
|
# never called `check_drive_quota` on the destination.
|
|
# A user could bypass a tight drive's cap by uploading
|
|
# to their unlimited personal drive first and MOVE-ing
|
|
# (or COPY-ing) into the tight drive afterwards.
|
|
#
|
|
# Fix landed in the service layer, so both REST + WebDAV +
|
|
# NC WebDAV surfaces got the check for free. This step
|
|
# locks in the 507 shape on the REST path:
|
|
#
|
|
# a) MOVE a 5 MiB file from unlimited → tight → 507.
|
|
# b) COPY a 5 MiB file from unlimited → tight → 507.
|
|
# c) Sanity — same MOVE targeted at unlimited still 200.
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
# Capture the 5 MiB file id currently living in the unlimited drive
|
|
# (uploaded at Step 7). We'll try to relocate it into the 100-byte
|
|
# tight drive.
|
|
GET {{base_url}}/api/files?folder_id={{unlimited_root_id}}
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
big_file_id: jsonpath "$[0].id"
|
|
|
|
|
|
# 11a — MOVE 5 MiB file into the tight (100-byte quota) drive.
|
|
# Refused at the service pre-check: 5_242_880 + 32 > 100.
|
|
PUT {{base_url}}/api/files/{{big_file_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folder_id": "{{tight_root_id}}"
|
|
}
|
|
|
|
HTTP 507
|
|
|
|
|
|
# 11b — COPY same file into tight drive. Same refusal shape as MOVE
|
|
# — COPY creates a NEW file row that counts against
|
|
# `drives.used_bytes` even when blob dedup means no new bytes
|
|
# hit the store. Batch endpoint lives under `/api/batch/…`,
|
|
# not `/api/files/…`.
|
|
POST {{base_url}}/api/batch/files/copy
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"file_ids": ["{{big_file_id}}"],
|
|
"target_folder_id": "{{tight_root_id}}"
|
|
}
|
|
|
|
# Batch envelope: 200 all-ok, 206 partial, 400 all-failed. Our
|
|
# single-item batch has one quota-refused item → 400 with the
|
|
# failure in the `.failed[]` array (per `BatchOperationResponse`).
|
|
HTTP 400
|
|
[Asserts]
|
|
jsonpath "$.stats.failed" == 1
|
|
jsonpath "$.stats.successful" == 0
|
|
jsonpath "$.failed[0].id" == "{{big_file_id}}"
|
|
jsonpath "$.failed[0].error" exists
|
|
|
|
|
|
# 11c — Sanity: the file MOVE isn't universally broken. Targeting
|
|
# the unlimited drive's own root succeeds (it's already
|
|
# there, but MOVE is idempotent for same-parent — service
|
|
# returns 200 without re-doing storage work).
|
|
PUT {{base_url}}/api/files/{{big_file_id}}/move
|
|
Authorization: Bearer {{owner_token}}
|
|
Content-Type: application/json
|
|
{
|
|
"folder_id": "{{unlimited_root_id}}"
|
|
}
|
|
|
|
HTTP 200
|
|
|
|
|
|
# `used_bytes` on the tight drive is unchanged — the two refused
|
|
# operations above never wrote anything. Trigger-sweep so the
|
|
# check reads live SQL (see the class doc on the earlier
|
|
# sweep + GET pair for the design rationale).
|
|
POST {{base_url}}/api/admin/internal/trigger-sweep
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 200
|
|
|
|
|
|
GET {{base_url}}/api/drives
|
|
Authorization: Bearer {{owner_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
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
|
|
# their assertions instead of teardown.
|