diff --git a/tests/api/admin_user_ops.hurl b/tests/api/admin_user_ops.hurl new file mode 100644 index 00000000..e19e2c06 --- /dev/null +++ b/tests/api/admin_user_ops.hurl @@ -0,0 +1,186 @@ +# ============================================================= +# 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 "$.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 "$.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 "$.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. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "AdminResetPassword2!" } + +HTTP 200 +[Captures] +charlie_token_v2: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# 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": "AdminResetPassword2!" } + +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": "AdminResetPassword2!" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 8 — Teardown +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{charlie_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 diff --git a/tests/api/auth_session_lifecycle.hurl b/tests/api/auth_session_lifecycle.hurl new file mode 100644 index 00000000..df61b796 --- /dev/null +++ b/tests/api/auth_session_lifecycle.hurl @@ -0,0 +1,134 @@ +# ============================================================= +# OxiCloud — Baseline: auth session lifecycle +# ============================================================= +# Covers the refresh-token / logout / status surface that the +# existing `auth_login.hurl` deliberately leaves alone (login + +# lockout only). Browsers and the desktop NC client both rely +# on the rotate-and-revoke semantics being correct; this file +# pins them as a single end-to-end flow. +# +# Coverage: +# 1. GET /api/auth/status (no auth required, used by login page) +# 2. POST /api/auth/login (capture initial access + refresh) +# 3. POST /api/auth/refresh (rotate; capture new tokens) +# 4. New access token works on /api/auth/me +# 5. OLD refresh token rejected after rotation (session-family +# single-use enforcement) +# 6. POST /api/auth/logout (revokes the current refresh) +# 7. Refresh after logout → 401 (revocation actually took effect) +# 8. Access token still works briefly until it expires — we +# don't assert that explicitly because TTL is configurable +# and the access-token revocation semantics are documented +# as "JWT remains valid until exp"; logout only kills the +# refresh path. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 1 — Unauthenticated /api/auth/status probe. +# The login page hits this on every load; the response +# determines whether the "Create first admin" flow shows. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/status + +HTTP 200 +[Asserts] +jsonpath "$.initialized" == true +jsonpath "$.admin_count" >= 1 +jsonpath "$.registration_allowed" == true + + +# ───────────────────────────────────────────────────────────── +# 2 — Login as admin. Capture BOTH tokens — we need the +# refresh later to verify rotation semantics. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +access_v1: jsonpath "$.access_token" +refresh_v1: jsonpath "$.refresh_token" + + +# ───────────────────────────────────────────────────────────── +# 3 — Refresh: mint a new (access, refresh) pair. The refresh +# token is rotated — the response carries a NEW refresh +# that supersedes refresh_v1. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v1}}" } + +HTTP 200 +[Captures] +access_v2: jsonpath "$.access_token" +refresh_v2: jsonpath "$.refresh_token" +[Asserts] +jsonpath "$.access_token" != "{{access_v1}}" +jsonpath "$.refresh_token" != "{{refresh_v1}}" + + +# ───────────────────────────────────────────────────────────── +# 4 — The new access token works on a protected endpoint. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me +Authorization: Bearer {{access_v2}} + +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}" + + +# ───────────────────────────────────────────────────────────── +# 5 — The OLD refresh token MUST be rejected after rotation. +# This is the session-family single-use property: replay +# of a used refresh token is treated as theft and rejected. +# +# The handler's OpenAPI doc says 401, but the actual response +# is 403: the refresh service raises `ErrorKind::AccessDenied` +# which maps to HTTP 403 in this codebase. Pinning the +# observed-and-correct behavior here. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v1}}" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# 6 — Logout using the v2 refresh + v2 access. Server-side +# this revokes the session and clears auth cookies in the +# response. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/logout +Authorization: Bearer {{access_v2}} +Content-Type: application/json +{ "refresh_token": "{{refresh_v2}}" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 7 — Post-logout: the v2 refresh token is now revoked. A +# refresh attempt is rejected with 403 (same AccessDenied +# mapping as step 5). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v2}}" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# 8 — Sanity re-check: status still reports the system as +# initialized after logout (no state regression). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/status + +HTTP 200 +[Asserts] +jsonpath "$.initialized" == true diff --git a/tests/api/groups_effective_members.hurl b/tests/api/groups_effective_members.hurl new file mode 100644 index 00000000..e14b74bb --- /dev/null +++ b/tests/api/groups_effective_members.hurl @@ -0,0 +1,166 @@ +# ============================================================= +# OxiCloud — Baseline: subject-group effective-members +# ============================================================= +# Pins the transitive group-expansion contract that the ReBAC +# permissions story relies on. The interesting case is nesting: +# adding *user_X* into *Group_B*, and *Group_B* into *Group_A*, +# must make *user_X* visible from `GET /api/groups/{A}/effective-members` +# — the call site that authz lookups walk. +# +# Coverage: +# 1. Admin creates `Group_A` and `Group_B` +# 2. Admin creates fixture user `dora-eff` +# 3. PUT dora into Group_B (direct membership) +# 4. PUT Group_B into Group_A (nested membership) +# 5. GET /api/groups/{B}/members (direct only) → dora +# 6. GET /api/groups/{A}/members (direct only) → Group_B +# NOT dora (she's transitive) +# 7. GET /api/groups/{A}/effective-members → contains dora +# 8. Cleanup: remove user, remove group-member, delete groups + user +# +# This single nested scenario is the load-bearing one — if the +# transitive walk regresses, the ReBAC engine silently grants +# 0 permissions to nested members. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 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 — Create both groups. Names use kebab-case so the RFC-5321 +# local-part validator accepts them. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "eff-group-a", "description": "outer group" } + +HTTP 201 +[Captures] +group_a_id: jsonpath "$.id" + + +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "eff-group-b", "description": "inner group" } + +HTTP 201 +[Captures] +group_b_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 2 — Create the fixture user. "dora-eff" — distinct from any +# user created by other test files, so this is order-safe. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "dora-eff", + "password": "DoraPassword1!", + "email": "dora-eff@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +dora_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 3 — Put dora into Group_B (direct user member) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups/{{group_b_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "user_id": "{{dora_id}}" } + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# 4 — Put Group_B into Group_A (nested group member) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups/{{group_a_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "group_id": "{{group_b_id}}" } + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# 5 — Group_B direct membership: ONLY dora. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_b_id}}/members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 6 — Group_A direct membership: Group_B, NOT dora. The +# direct-members endpoint is non-transitive by contract; +# mixing in transitive members here would silently +# conflate the two surfaces. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_a_id}}/members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{group_b_id}}" +body not contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 7 — HEADLINE: Group_A effective-members reaches dora. +# A regression here is the canary for any change that +# breaks transitive expansion in the ReBAC layer. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_a_id}}/effective-members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 8 — Teardown. Order matters: remove the nested group-member +# before deleting Group_B, so the FK cascade doesn't get +# ahead of us; remove dora's direct membership similarly. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_a_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_b_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/admin/users/{{dora_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 diff --git a/tests/api/public_shares.hurl b/tests/api/public_shares.hurl new file mode 100644 index 00000000..34f49657 --- /dev/null +++ b/tests/api/public_shares.hurl @@ -0,0 +1,261 @@ +# ============================================================= +# OxiCloud — Baseline: public-share token surface +# ============================================================= +# Pins the legacy tokenized share flow (`/api/shares` to mint, +# `/api/s/{token}/*` to consume) — the only public-facing +# unauthenticated read surface in the product. Any regression +# in scope-enforcement here breaks the share-link feature for +# every external recipient. +# +# Coverage: +# 1. Login + seed: create a folder with a file inside. +# 2. POST /api/shares (folder share, no password) → 201 +# 3. GET /api/shares (lists ours) +# 4. GET /api/shares/{id} (single fetch) +# 5. GET /api/s/{token} (no auth) → 200 +# 6. GET /api/s/{token}/verify — not applicable +# for a password-less share, but the unauthenticated +# anonymous probe of `/api/s/{token}` already exercises +# the access path; verify is exercised in the password +# branch below. +# 7. GET /api/s/{token}/contents (no auth) → 200 +# 8. GET /api/s/{token}/file/{file_id} (no auth) → 200 + body +# 9. POST /api/shares — password-protected variant +# 10. GET /api/s/{pw_token} → 401 (password required) +# 11. POST /api/s/{pw_token}/verify wrong pw → 401 +# 12. POST /api/s/{pw_token}/verify right pw → 200 +# 13. DELETE /api/shares/{id} (no-password) → 204 +# 14. GET /api/s/{token} after revoke → 404 / 410 +# 15. Cleanup the password-share + folder. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup — admin login, seed folder + file +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "public-share-test", "parent_id": "{{admin_home_id}}" } + +HTTP 201 +[Captures] +share_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{share_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +shared_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 2 — Mint a password-less folder share +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_type": "folder" +} + +HTTP 201 +[Captures] +share_id: jsonpath "$.id" +share_token: jsonpath "$.token" +[Asserts] +jsonpath "$.has_password" == false +jsonpath "$.token" matches "^[A-Za-z0-9_-]+$" + + +# ───────────────────────────────────────────────────────────── +# 3 — The share appears in the owner's listing +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{share_id}}" + + +# ───────────────────────────────────────────────────────────── +# 4 — Single-share fetch +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/shares/{{share_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{share_id}}" +jsonpath "$.item_id" == "{{share_folder_id}}" +jsonpath "$.item_type" == "folder" + + +# ───────────────────────────────────────────────────────────── +# 5 — Public access via the token, NO auth header. This is the +# security-critical path: any auth check that creeps in +# here breaks all external recipients. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 7 — Browse the shared folder contents (no auth). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}}/contents + +HTTP 200 +[Asserts] +body contains "{{shared_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 8 — Direct file share: mint a share on the FILE itself +# (item_type=file) and access it via /api/s/{token}. +# +# KNOWN BUG: GET /api/s/{folder-token}/file/{file_id} (the +# "fetch a file from inside a shared folder" route at +# share_handler.rs:653) currently returns 500. We sidestep +# it here by sharing the file directly. When the folder-file +# path is fixed, add a new scenario asserting it returns +# 200 + body, and back-link this comment. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{shared_file_id}}", + "item_type": "file" +} + +HTTP 201 +[Captures] +file_share_id: jsonpath "$.id" +file_share_token: jsonpath "$.token" + + +GET {{base_url}}/api/s/{{file_share_token}} + +HTTP 200 +[Asserts] +jsonpath "$.item_type" == "file" + + +# ───────────────────────────────────────────────────────────── +# 9 — Mint a password-protected share on the same folder. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_type": "folder", + "password": "secret-share-password-1!" +} + +HTTP 201 +[Captures] +pw_share_id: jsonpath "$.id" +pw_share_token: jsonpath "$.token" +[Asserts] +jsonpath "$.has_password" == true + + +# ───────────────────────────────────────────────────────────── +# 10 — Anonymous probe must report "password required" without +# leaking the shared item's contents. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{pw_share_token}} + +HTTP 401 +[Asserts] +jsonpath "$.requiresPassword" == true + + +# ───────────────────────────────────────────────────────────── +# 11 — Wrong password → 401 (does NOT issue an unlock cookie). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/s/{{pw_share_token}}/verify +Content-Type: application/json +{ "password": "obviously-wrong" } + +HTTP 401 + + +# ───────────────────────────────────────────────────────────── +# 12 — Right password → 200 + Set-Cookie unlock JWT. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/s/{{pw_share_token}}/verify +Content-Type: application/json +{ "password": "secret-share-password-1!" } + +HTTP 200 +[Asserts] +header "Set-Cookie" exists + + +# ───────────────────────────────────────────────────────────── +# 13 — Revoke the password-less share +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/shares/{{share_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# 14 — After revocation the token must not resolve. Different +# server versions return 404 vs 410 depending on whether +# the row was hard-deleted or marked revoked — both are +# acceptable rejections of the token; what matters is the +# token does NOT yield a 200. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}} + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# 15 — Teardown: revoke the password share + the direct +# file-share, then delete the folder. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/shares/{{pw_share_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/shares/{{file_share_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/folders/{{share_folder_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 diff --git a/tests/api/run.sh b/tests/api/run.sh index 09997fe8..71c971e2 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -129,6 +129,7 @@ log "Running Hurl tests..." hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \ "$API_DIR/setup.hurl" \ "$API_DIR/auth_login.hurl" \ + "$API_DIR/auth_session_lifecycle.hurl" \ "$API_DIR/registration.hurl" \ "$API_DIR/nc_status_capabilities.hurl" \ "$API_DIR/nc_login_flow_v2.hurl" \ @@ -142,13 +143,17 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ "$API_DIR/contacts.hurl" \ + "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \ "$API_DIR/subject_groups.hurl" \ + "$API_DIR/groups_effective_members.hurl" \ "$API_DIR/grants_nested_groups.hurl" \ "$API_DIR/external_users.hurl" \ + "$API_DIR/search_basic.hurl" \ "$API_DIR/nc_second_user_setup.hurl" \ "$API_DIR/nc_admin_views_other_user.hurl" \ + "$API_DIR/admin_user_ops.hurl" \ "$API_DIR/chunked_upload_cap.hurl" \ "$API_DIR/nc_auth_failures.hurl" diff --git a/tests/api/search_basic.hurl b/tests/api/search_basic.hurl new file mode 100644 index 00000000..4bc306a5 --- /dev/null +++ b/tests/api/search_basic.hurl @@ -0,0 +1,162 @@ +# ============================================================= +# OxiCloud — Baseline: search surface +# ============================================================= +# Pins `/api/search` and `/api/search/suggest` plus the +# cross-user isolation property: a search MUST NEVER surface a +# file the caller doesn't own (and isn't shared with). Search +# is the kind of feature where a sloppy SQL join is exactly +# what introduces a cross-user leak — this test catches that. +# +# Requires OXICLOUD_ENABLE_SEARCH=true (set in tests/common/server.env). +# +# Coverage: +# 1. Admin uploads `unique-search-needle-aaa.txt` to her home +# 2. GET /api/search?query=unique-search-needle returns the file +# 3. GET /api/search?query=does-not-exist-xyz returns 0 files +# 4. GET /api/search/suggest?query=unique-search-needle returns +# something (suggestion-shape is allowed to be permissive) +# 5. Cross-user: bob searches "unique-search-needle" → MUST NOT +# see admin's file (security baseline) +# 6. Teardown: delete the file +# +# Bob is (re-)created inline so this file is order-independent +# with respect to nc_second_user_setup.hurl (which runs later +# in run.sh). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup — admin login + bob (re-)provisioning +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +# Anti-enum registration: 200 whether bob existed or not. +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "bob", + "email": "bob@example.com", + "password": "BobPassword1!" +} + +HTTP 200 + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob", "password": "BobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# 1 — Admin uploads `hello.txt` to a dedicated subfolder, then +# renames it to a deliberately unique name so the search +# assertion is unambiguous. The subfolder isolates this +# test from any other test that already left a `hello.txt` +# in admin's home (would otherwise 409). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "search-basic-test", "parent_id": "{{admin_home_id}}" } + +HTTP 201 +[Captures] +search_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{search_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +needle_file_id: jsonpath "$.id" + + +PUT {{base_url}}/api/files/{{needle_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "unique-search-needle-aaa.txt" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 2 — Search hits the seeded file by substring of its name. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=unique-search-needle +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.files" count >= 1 +body contains "{{needle_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 3 — A search for a phrase that can't match anything must +# return an empty result set, NOT an error. Empty-results +# is a hot path; we don't want it to start 500ing. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=does-not-exist-xyz-zzz-9999 +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.files" count == 0 +jsonpath "$.folders" count == 0 + + +# ───────────────────────────────────────────────────────────── +# 4 — Suggest returns a usable payload (shape is permissive — +# just confirm the endpoint serves 200 and isn't truncating +# to an error envelope). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search/suggest?query=unique-search-needle +Authorization: Bearer {{admin_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 5 — HEADLINE: bob MUST NOT see admin's file. If this assertion +# ever flips, the search service has a cross-user leak. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=unique-search-needle +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +body not contains "unique-search-needle" +body not contains "{{needle_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 6 — Teardown: removing the folder recursively takes the file +# with it, so a single DELETE is enough. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{search_folder_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204