Files
Oxicloud/tests/api/admin_user_ops.hurl
T
2026-08-21 23:56:25 +02:00

269 lines
11 KiB
Plaintext

# =============================================================
# OxiCloud — Baseline: admin user-mutation surface
# =============================================================
# Exercises the cluster of admin-only `PUT /api/admin/users/{id}/*`
# endpoints that operators rely on for incident response:
# - disable a compromised account
# - rotate its password
# - change its role
# - set / clear its quota
#
# Each is a one-shot mutation, but the failure mode of any one
# is severe (operator can't lock out an attacker, can't reset a
# password). Pinning them together keeps the cluster intact
# under refactors.
#
# Coverage:
# 1. Admin creates a fresh fixture user via POST /api/admin/users
# 2. Fixture user logs in successfully (baseline)
# 3. PUT /quota → fixture user's /me reports updated quota
# 4. PUT /role → fixture user becomes admin
# 5. PUT /password (admin reset) → old password no longer works,
# new password works
# 6. PUT /active=false → fixture user login → 403
# 7. PUT /active=true → fixture user login works again
# 8. Cleanup via DELETE /api/admin/users/{id}
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# 1 — Admin creates the fixture user "charlie-ops"
# Uses a name that doesn't collide with charlie in
# registration.hurl (which uses just "charlie"), so this
# file is order-independent.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/users
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"username": "charlie-ops",
"password": "OriginalPassword1!",
"email": "charlie-ops@example.com",
"role": "user"
}
HTTP 201
[Captures]
charlie_id: jsonpath "$.user.id"
# ─────────────────────────────────────────────────────────────
# 2 — Baseline: fixture user can log in with the password
# admin assigned.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "OriginalPassword1!" }
HTTP 200
[Captures]
charlie_token_v1: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# 3 — Set a non-default quota; charlie's own /me must reflect it.
# 200 MiB = 209715200 bytes — keeps the assertion exact while
# still being a believable per-user cap.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/admin/users/{{charlie_id}}/quota
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "quota_bytes": 209715200 }
HTTP 200
GET {{base_url}}/api/auth/me
Authorization: Bearer {{charlie_token_v1}}
HTTP 200
[Asserts]
jsonpath "$.full.storage_quota_bytes" == 209715200
# ─────────────────────────────────────────────────────────────
# 4 — Promote charlie to admin. After this the /me payload's
# role field must reflect the change.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/admin/users/{{charlie_id}}/role
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "role": "admin" }
HTTP 200
GET {{base_url}}/api/auth/me
Authorization: Bearer {{charlie_token_v1}}
HTTP 200
[Asserts]
jsonpath "$.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
# 5 — Admin resets charlie's password.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/admin/users/{{charlie_id}}/password
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "new_password": "AdminResetPassword2!" }
HTTP 200
# Old password no longer works. Login failures map to 403
# (AccessDenied) in this codebase — both "invalid credentials"
# and "account deactivated" go through the same error kind.
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "OriginalPassword1!" }
HTTP 403
# New password works AND the login response carries
# `force_password_change: true` — the admin-picked password is
# temporary; the SPA reads this to enter mandatory-mode and route
# the user to `/profile?forcePasswordChange=1`. See the backend
# `admin_reset_password` → `OpaquePgRepository::clear_registration`
# (or `UserPgRepository::set_force_password_change` when OPAQUE is
# off) for the atomic flag write.
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "AdminResetPassword2!" }
HTTP 200
[Captures]
charlie_token_v2: jsonpath "$.access_token"
[Asserts]
jsonpath "$.force_password_change" == true
# `require_no_password_change_pending_layer` middleware assertion:
# a random authenticated endpoint that ISN'T on the allowlist
# (`/me`, `/change-password`, `/logout`) must refuse with
# `403 PasswordChangeRequired` while the flag is set. Without
# this gate the admin-picked password would let holders reach
# files / DAV / admin via any non-SPA client.
GET {{base_url}}/api/folders
Authorization: Bearer {{charlie_token_v2}}
HTTP 403
[Asserts]
jsonpath "$.error_type" == "PasswordChangeRequired"
# Same session, but the allowlisted `/api/auth/me` DOES pass —
# the SPA needs this to detect the flag and render the mandatory
# banner. Response also mirrors the flag so a page reload sees
# the same state a fresh login would.
GET {{base_url}}/api/auth/me
Authorization: Bearer {{charlie_token_v2}}
HTTP 200
[Asserts]
jsonpath "$.force_password_change" == true
# Trying to change back to the SAME password must fail with a
# distinct `PasswordUnchanged` error_type — silently accepting
# the no-op would clear the force flag without actually rotating
# the credential, defeating the temporary-password pattern.
PUT {{base_url}}/api/auth/change-password
Authorization: Bearer {{charlie_token_v2}}
Content-Type: application/json
{ "current_password": "AdminResetPassword2!", "new_password": "AdminResetPassword2!" }
HTTP 400
[Asserts]
jsonpath "$.error_type" == "PasswordUnchanged"
# Change to a genuinely different password: succeeds AND
# `change_password` revokes all sessions (per its own contract);
# the CURRENT token stops working right after. That side effect
# is what forces the user through a fresh login where the flag
# is now cleared.
PUT {{base_url}}/api/auth/change-password
Authorization: Bearer {{charlie_token_v2}}
Content-Type: application/json
{ "current_password": "AdminResetPassword2!", "new_password": "CharliePicked3!" }
HTTP 200
# Fresh login with the user-picked password: succeeds AND the
# flag has flipped back to false, so mandatory-mode is off.
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "CharliePicked3!" }
HTTP 200
[Captures]
charlie_token_v3: jsonpath "$.access_token"
[Asserts]
jsonpath "$.force_password_change" == false
# Same random endpoint that 403'd above now succeeds — the gate
# has lifted.
GET {{base_url}}/api/folders
Authorization: Bearer {{charlie_token_v3}}
HTTP 200
# ─────────────────────────────────────────────────────────────
# 6 — Disable the account. The next login attempt must report
# 403 (account disabled) — distinct from 401 (bad creds)
# so operators can tell "I locked you out" from "you typed
# the wrong password".
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/admin/users/{{charlie_id}}/active
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "active": false }
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "CharliePicked3!" }
HTTP 403
# ─────────────────────────────────────────────────────────────
# 7 — Re-enable; login works again.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/admin/users/{{charlie_id}}/active
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "active": true }
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "charlie-ops", "password": "CharliePicked3!" }
HTTP 200
# ─────────────────────────────────────────────────────────────
# 8 — Teardown
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/admin/users/{{charlie_id}}
Authorization: Bearer {{admin_token}}
HTTP 200