chore(test): add new API coverage
ensure better API test coverage on important routes 1. tests/api/public_shares.hurl — create a share token, verify, list contents, fetch a file, fetch a folder zip, then revoke and re-verify with the token. Same pattern as grants.hurl. ~30 min, biggest security ROI. 2. tests/api/auth_session_lifecycle.hurl — login → refresh → use new token → logout → refresh-rejected → login-again. Covers the session-family invalidation contract. 3. tests/api/admin_user_ops.hurl — admin disables / re-enables / changes role / resets password / sets quota for a fixture user. Five POSTs. 4. tests/api/groups_effective_members.hurl — nested groups: A contains B contains user X; effective-members returns X. Two scenarios, but it's the ReBAC contract under the Drive refactor. 5. tests/api/search_basic.hurl — upload foo.txt, search "foo", get the result; cross-user: bob can't search alice's foo.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user