dd9e3b8868
ensure that a user that don't havee permission to a drive cannot search elements in this drive
note: current design: Index are associated to drive, so if a document / directory is shared,
the shared resource will not be in index for targetted user
design is explicitely as is to reduce complexity
239 lines
10 KiB
Plaintext
239 lines
10 KiB
Plaintext
# =============================================================
|
|
# 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 — CONTENT-search cross-drive isolation (docs/plan/drive.md §11).
|
|
# The cross-user check above (step 5) verifies the NAME-search
|
|
# path. The Tantivy content index is a separate code path with
|
|
# its own filter: `Must drive_id ∈ accessible_drives`. This
|
|
# block pins it.
|
|
#
|
|
# Sequence:
|
|
# 6a. Admin uploads `content-canary.txt` whose body contains
|
|
# the distinctive phrase `ContentIndexCanaryXyzzy2026Drive`.
|
|
# 6b. Wait ~2s for the async content-index worker
|
|
# (`OXICLOUD_CONTENT_SEARCH_FLUSH_INTERVAL_MS` defaults
|
|
# to 1500ms) to drain the dirty queue and apply the
|
|
# Tantivy mutation.
|
|
# 6c. Admin searches for the phrase → MUST hit the file
|
|
# (the index works).
|
|
# 6d. Bob searches for the same phrase → MUST be empty,
|
|
# AND the response shape MUST carry no hidden-count
|
|
# leak (no `total`/`hidden`/etc. field that could
|
|
# reveal "you have N matches you can't see"). The
|
|
# pivot from `Must user_id = caller` to `Must drive_id
|
|
# ∈ accessible_drives` is the §11 security primitive;
|
|
# a regression here would be a cross-drive leak.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/files/upload
|
|
Authorization: Bearer {{admin_token}}
|
|
[MultipartFormData]
|
|
folder_id: {{search_folder_id}}
|
|
file: file,fixtures/content-canary.txt; text/plain
|
|
|
|
HTTP 201
|
|
[Captures]
|
|
canary_file_id: jsonpath "$.id"
|
|
|
|
|
|
# Drain the content-index worker. 2s exceeds the 1500ms flush
|
|
# interval comfortably; raise if a slower CI machine flakes.
|
|
GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive
|
|
Authorization: Bearer {{admin_token}}
|
|
[Options]
|
|
delay: 2500ms
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Admin sees the content match — proves indexing landed.
|
|
jsonpath "$.files" count >= 1
|
|
body contains "{{canary_file_id}}"
|
|
|
|
|
|
GET {{base_url}}/api/search?query=ContentIndexCanaryXyzzy2026Drive
|
|
Authorization: Bearer {{bob_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
# Bob has no access to admin's drive → Tantivy's Must-clause
|
|
# filters every doc that doesn't carry one of Bob's drive_ids,
|
|
# so the file vanishes entirely.
|
|
jsonpath "$.files" count == 0
|
|
jsonpath "$.folders" count == 0
|
|
body not contains "{{canary_file_id}}"
|
|
body not contains "ContentIndexCanaryXyzzy2026Drive"
|
|
# Anti-enum: every count the response surfaces must reflect the
|
|
# FILTERED set — i.e. zero when the caller has no accessible
|
|
# hits. The §11 rule is "no 'you have N hidden matches' field
|
|
# anywhere". `total_count` is a legitimate pagination count and
|
|
# is OK as long as it equals the filtered total (zero here). The
|
|
# other field names below MUST stay absent: a future field
|
|
# called `hidden_count`/`filtered`/etc. that reveals matches
|
|
# Bob can't see would be the regression.
|
|
jsonpath "$.total_count" == 0
|
|
jsonpath "$.has_more" == false
|
|
jsonpath "$.hidden_count" not exists
|
|
jsonpath "$.filtered" not exists
|
|
jsonpath "$.total" not exists
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# 7 — Teardown: removing the folder recursively takes the files
|
|
# with it, so a single DELETE is enough.
|
|
# ─────────────────────────────────────────────────────────────
|
|
DELETE {{base_url}}/api/folders/{{search_folder_id}}
|
|
Authorization: Bearer {{admin_token}}
|
|
|
|
HTTP 204
|