Files
Oxicloud/tests/api/drive_read_only.hurl
T

410 lines
18 KiB
Plaintext
Raw Normal View History

2026-07-15 22:21:15 +02:00
# =============================================================
# 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 is refused. The engine's graduated
# denial returns 403 to the owner (who can Read their own
# drive) — anti-enum only kicks in for callers with no Read
# at all, whose 404 shape is exercised by the cross-tenant
# tests in `webdav_permissions.hurl` / `permissions.hurl`.
# Cases: upload, rename, delete, trash-restore, permanent
# delete, create public link, rename the drive itself.
2026-07-15 22:21:15 +02:00
# 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.full.user.id"
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# 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.full.user.id"
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# 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 → 403 (Create).
# Graduated denial: owner can Read their own frozen
# drive, so the engine returns `access_denied` → 403
# rather than the anti-enum 404 (hiding a drive from
# its owner would be absurd). Cross-tenant callers with
# no Read on the drive still see 404 by the same code
# path. The engine gate emits an audit line with
2026-07-15 22:21:15 +02:00
# `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 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 9 — Rename file A → 403 (Update). Endpoint is
2026-07-15 22:21:15 +02:00
# `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 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 10 — Delete file A → 403 (Delete).
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/trash/files/{{file_a_id}}
Authorization: Bearer {{owner_token}}
HTTP 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 11 — Restore file B from trash → 403 (Update on the
2026-07-15 22:21:15 +02:00
# soft-deleted row is a mutation like any other).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/trash/{{file_b_id}}/restore
Authorization: Bearer {{owner_token}}
HTTP 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 12 — Permanent delete of file B → 403 (Delete).
2026-07-15 22:21:15 +02:00
# 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 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 13 — Share creation → 403 (Share). Goes through
2026-07-15 22:21:15 +02:00
# `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 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# Step 14 — Grant (per-resource, not public link) → 403 (Share).
2026-07-15 22:21:15 +02:00
# 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 403
2026-07-15 22:21:15 +02:00
# ─────────────────────────────────────────────────────────────
# 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.
# ─────────────────────────────────────────────────────────────