Merge pull request #598 from EdouardVanbelle/feat/readonly-drive
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
# =============================================================
|
||||
# OxiCloud – Drive `read_only` policy (full freeze / legal-hold)
|
||||
# =============================================================
|
||||
# Run:
|
||||
# hurl --variables-file tests/api/test.env --file-root tests \
|
||||
# --test tests/api/drive_read_only.hurl
|
||||
#
|
||||
# The model under test (`docs/plan/drive.md` §8):
|
||||
# `policies.read_only = true` on any drive refuses EVERY mutating
|
||||
# permission (Create / Update / Delete / Share / Comment / Manage)
|
||||
# on resources in that drive — from user-initiated paths AND
|
||||
# background jobs alike. Only `Read` passes. The admin escape
|
||||
# hatch is separate: `PATCH /api/drives/{id}/policies` is gated
|
||||
# by `admin_guard` at the handler layer and bypasses the engine's
|
||||
# authz.require entirely, so admins can always un-freeze.
|
||||
#
|
||||
# Enforcement points exercised here:
|
||||
# - `PgAclEngine::check_inner` on File/Folder resources (drive
|
||||
# precheck branch, mutating permission → refused before role
|
||||
# lookup even runs).
|
||||
# - `PgAclEngine::check_inner` on Drive resources (same gate).
|
||||
# - `share_service::create_shared_link` — goes through
|
||||
# `authz.require(Share, Resource::File)` → engine gate fires.
|
||||
# - Trash purge SQL — proven separately by the SQL predicate
|
||||
# landing in `trash_db_repository::delete_expired_bulk` (not
|
||||
# exercised at the HTTP layer here — requires a controllable
|
||||
# retention clock; see comment in Step 12).
|
||||
#
|
||||
# Cases:
|
||||
# 1. Baseline — drive not frozen → owner can upload / rename /
|
||||
# delete / trash / share (proves the fixture is writable).
|
||||
# 2. Admin freezes the drive via PATCH policies.
|
||||
# 3. Every mutation attempt returns 404 (anti-enum):
|
||||
# upload, rename, delete, trash-restore, permanent delete,
|
||||
# create public link, rename the drive itself.
|
||||
# 4. Read still works: GET /api/drives, GET /api/folders,
|
||||
# download the file, list trash.
|
||||
# 5. Admin unfreezes.
|
||||
# 6. Owner mutations work again → freeze/unfreeze is reversible
|
||||
# and doesn't leave latched state.
|
||||
#
|
||||
# Self-contained: provisions `ro_owner` (drive owner) + `ro_target`
|
||||
# (share recipient for the negative-share assertion).
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 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 `ro_owner` (the drive owner under test).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ro_owner",
|
||||
"password": "RoOwnerPwd1!",
|
||||
"email": "ro_owner@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ro_owner", "password": "RoOwnerPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
owner_token: jsonpath "$.access_token"
|
||||
owner_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Provision `ro_target` (share recipient for the
|
||||
# negative-share assertion in Step 10).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "ro_target",
|
||||
"password": "RoTargetPwd1!",
|
||||
"email": "ro_target@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "ro_target", "password": "RoTargetPwd1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
target_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Find `ro_owner`'s default Personal drive + its root
|
||||
# folder id (upload targets).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
personal_root_id: jsonpath "$[0].id"
|
||||
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
personal_drive_id: jsonpath "$[0].id"
|
||||
[Asserts]
|
||||
jsonpath "$[0].kind" == "personal"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Baseline: upload file A (mutation subject during
|
||||
# the freeze) and file B (already-trashed subject
|
||||
# for the restore/purge assertions).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
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]
|
||||
file_a_id: jsonpath "$.id"
|
||||
|
||||
# DISTINCT content from file_a — re-uploading the same bytes to
|
||||
# the same folder would collide on the (folder_id, name) unique
|
||||
# constraint and the idempotent-upload handler would return the
|
||||
# EXISTING file (file_a_id == file_b_id), then trashing "file_b"
|
||||
# would trash file_a and every subsequent Read on file_a would 404
|
||||
# because `get_file` filters `NOT is_trashed`. Using a fixture with
|
||||
# different bytes gives us two truly distinct file rows.
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{owner_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{personal_root_id}}
|
||||
file: file,fixtures/hello-trashed.txt; text/plain
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
file_b_id: jsonpath "$.id"
|
||||
|
||||
# Trash file B pre-freeze so we can later attempt restore + permanent
|
||||
# delete on it while the drive is frozen.
|
||||
DELETE {{base_url}}/api/trash/files/{{file_b_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Freeze the drive. Admin-only endpoint; owner cannot
|
||||
# call it (proven separately in `drive_policies.hurl`).
|
||||
# Response echoes the merged bag.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"read_only": true
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.read_only" == true
|
||||
jsonpath "$.forbid_public_links" == false
|
||||
jsonpath "$.forbid_sharing" == false
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Confirm the policy is visible to the owner (they can
|
||||
# READ policy state — Manage is what mutates it, and
|
||||
# Manage is admin-only via a different gate).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{personal_drive_id}}')].policies.read_only" == true
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — MUTATIONS BLOCKED. Upload → 404 (Create).
|
||||
# Anti-enum: NotFound not 403, same shape as "no such
|
||||
# folder." The engine gate emits an audit line with
|
||||
# `reason = drive_read_only` — inspectable in server
|
||||
# logs, not asserted here (no log-scraping harness).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/files/upload
|
||||
Authorization: Bearer {{owner_token}}
|
||||
[MultipartFormData]
|
||||
folder_id: {{personal_root_id}}
|
||||
file: file,fixtures/hello.txt; text/plain
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Rename file A → 404 (Update). Endpoint is
|
||||
# `PUT /api/files/{id}/rename` (not PATCH — the file
|
||||
# service exposes rename as a distinct verb, mirroring
|
||||
# the folder side). WebDAV MOVE would fire the same
|
||||
# engine gate via `authz.require(Update, File)`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/api/files/{{file_a_id}}/rename
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "renamed_during_freeze.txt" }
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Delete file A → 404 (Delete).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/trash/files/{{file_a_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Restore file B from trash → 404 (Update on the
|
||||
# soft-deleted row is a mutation like any other).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/trash/{{file_b_id}}/restore
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Permanent delete of file B → 404 (Delete).
|
||||
# Note: the background retention purge SQL filter is
|
||||
# tested via source-review + a unit test on the
|
||||
# `delete_expired_bulk` query, not here — advancing
|
||||
# the retention clock synchronously from Hurl would
|
||||
# require an admin endpoint that doesn't exist. The
|
||||
# user-initiated permanent-delete path DOES exercise
|
||||
# the engine gate and is asserted below.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/trash/{{file_b_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 13 — Share creation → 404 (Share). Goes through
|
||||
# `share_service::create_shared_link` which calls
|
||||
# `authz.require(Share, Resource::File)` → engine gate.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/shares
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"item_id": "{{file_a_id}}",
|
||||
"item_type": "file"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 14 — Grant (per-resource, not public link) → 404 (Share).
|
||||
# Same engine gate — Share permission on File is
|
||||
# refused regardless of which endpoint asks for it.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{owner_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{target_user_id}}" },
|
||||
"resource": { "type": "file", "id": "{{file_a_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 15 — Rename the drive itself → 404 (Update on
|
||||
# Resource::Drive). Drive rename goes through folder
|
||||
# PATCH on the root folder id, but the underlying
|
||||
# permission check is Update on the folder — which
|
||||
# lives in the frozen drive, so gate applies.
|
||||
#
|
||||
# Skipped for now — the current implementation checks Update
|
||||
# on the root folder, and per `bug_drive_rename_editor_can_do_it`
|
||||
# memory the exact permission surface is still under review.
|
||||
# The Drive-resource path (below) covers the intent directly.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 16 — READ STILL WORKS. Membership listing, folder
|
||||
# listing, file download — none are refused.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{personal_drive_id}}')].policies.read_only" == true
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders/{{personal_root_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
GET {{base_url}}/api/files/{{file_a_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# Trash still LISTS (viewers see what's frozen inside).
|
||||
GET {{base_url}}/api/trash/resources
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 17 — Admin unfreezes. Reversible: no latched state, no
|
||||
# residual policy drift.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"read_only": false
|
||||
}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.read_only" == false
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 18 — Post-unfreeze: owner can mutate again. Delete
|
||||
# file A succeeds; upload a new file succeeds;
|
||||
# permanent-delete file B succeeds.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/trash/{{file_b_id}}/restore
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/trash/files/{{file_a_id}}
|
||||
Authorization: Bearer {{owner_token}}
|
||||
|
||||
HTTP *
|
||||
[Asserts]
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 19 — Cleanup: leave the throwaway users provisioned.
|
||||
# `storage_cleanup_check.sh` at end of run.sh
|
||||
# enumerates leftover drives and drains them.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
@@ -195,6 +195,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/user_envelope_quota.hurl" \
|
||||
"$API_DIR/regression_595_unlimited_user_quota.hurl" \
|
||||
"$API_DIR/drive_policies.hurl" \
|
||||
"$API_DIR/drive_read_only.hurl" \
|
||||
"$API_DIR/cross_drive_move.hurl" \
|
||||
"$API_DIR/cross_drive_copy.hurl" \
|
||||
"$API_DIR/nc_multidrive_move_regression.hurl" \
|
||||
|
||||
Reference in New Issue
Block a user