Files
Oxicloud/tests/api/dedup_create.hurl
T
Edouard Vanbelle 68abb891d7 feat(by-hash): allow /by-hash even if blob is trashed
- permit reuse of blob where file is trashed (by-hash and chunked)
    - add hurl test on /api/files/by-hash
    - add anti enumeration of blob (404 is always blob_not_owned_by_caller)
2026-06-16 22:41:55 +02:00

296 lines
14 KiB
Plaintext

# =============================================================
# OxiCloud — Upload precheck (`POST /api/files/by-hash`)
# =============================================================
# Validates the content_hash precheck that lets a client skip a
# body upload when the server already has the content for THIS
# caller. The request shape mirrors `FileDto.content_hash` — the
# same 64-char lowercase BLAKE3 hex digest the server returned
# on a previous upload.
#
# Scope-critical security rule (server-enforced): the precheck
# returns 201 only when the calling user already owns at least
# one file with the same BLAKE3 hash. Cross-user matches are
# invisible — hash probing cannot leak other tenants' content.
#
# Coverage:
# 1. Upload hello.txt normally → 201, content_hash captured
# 2. Precheck with the matching content_hash → 201, new
# file_id but SAME content_hash (dedup hit)
# 3. Precheck with a fake/random hash → 404
# 4. Malformed content_hash (non-hex chars) → 400
# 5. Wrong-length content_hash → 400
#
# Fixture: tests/fixtures/hello-copy.txt (32 bytes; same content as
# hello.txt). We seed with hello-copy.txt — NOT hello.txt — because
# several earlier tests in run.sh (trash, chunked_upload_cap, …)
# upload hello.txt into the home folder and don't reliably purge it,
# which would collide with our seed step here (409). The filename
# `hello-copy.txt` is unique to this test.
# BLAKE3 hex: b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Login
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
HTTP 200
[Captures]
token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 2 — Resolve home folder
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/folders
Authorization: Bearer {{token}}
HTTP 200
[Captures]
home_folder_id: jsonpath "$[0].id"
# ─────────────────────────────────────────────────────────────
# Step 3 — Seed: upload hello-copy.txt via the normal multipart
# path. Captures the BLAKE3 server-side `content_hash`
# that the precheck step then matches against.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{home_folder_id}}
file: file,fixtures/hello-copy.txt; text/plain
HTTP 201
[Captures]
seed_file_id: jsonpath "$.id"
seed_content_hash: jsonpath "$.content_hash"
[Asserts]
jsonpath "$.name" == "hello-copy.txt"
jsonpath "$.size" == 32
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
# ─────────────────────────────────────────────────────────────
# Step 4 — Duplicate-name probe (incidental but useful): try
# uploading the same fixture again, same name, same
# folder → 409 Conflict. This catches the (folder_id,
# name, user_id) WHERE NOT is_trashed unique index in
# `storage.files`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{home_folder_id}}
file: file,fixtures/hello-copy.txt; text/plain
HTTP 409
# ─────────────────────────────────────────────────────────────
# Step 5 — Precheck HIT: caller now owns the BLAKE3 of
# hello-copy.txt, so by-hash with that hash must
# 201 and return a NEW file_id (the metadata row is
# new — the blob is shared).
#
# Expectations:
# - status: 201
# - file_id differs from the seed
# - content_hash matches the seed (proves dedup hit)
# - filename is the one we requested
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hello-precheck-bypass.txt",
"folder_id": "{{home_folder_id}}",
"hash": "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
}
HTTP 201
[Captures]
copy_file_id: jsonpath "$.id"
copy_content_hash: jsonpath "$.content_hash"
[Asserts]
jsonpath "$.name" == "hello-precheck-bypass.txt"
jsonpath "$.size" == 32
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
# ─────────────────────────────────────────────────────────────
# Step 6 — Same hash, different filename, in a NEW request →
# should hit again. Confirms the precheck isn't a
# one-shot bound to the seed file.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hello-precheck-bypass-2.txt",
"folder_id": "{{home_folder_id}}",
"hash": "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
}
HTTP 201
[Asserts]
jsonpath "$.name" == "hello-precheck-bypass-2.txt"
jsonpath "$.content_hash" == "b2208c5dc33ff951227bd0c139f5eccb04105d6da6a7519ee23f7bc00a17bb5a"
# ─────────────────────────────────────────────────────────────
# Step 7 — Precheck MISS with a random/never-uploaded hash →
# 404 with `error: blob_not_owned_by_caller` and an
# `upload_path` hint. The 404 shape is the
# anti-enumeration boundary: same response whether the
# hash doesn't exist anywhere or exists but is owned
# by another user.
#
# The fake hash below is just 32 bytes of 0xFF (well-
# formed BLAKE3 shape, vanishingly unlikely to collide).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "should-not-create.bin",
"folder_id": "{{home_folder_id}}",
"hash": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}
HTTP 404
[Asserts]
jsonpath "$.error" == "blob_not_owned_by_caller"
jsonpath "$.upload_path" == "/api/files/upload"
# ─────────────────────────────────────────────────────────────
# Step 8 — Malformed content_hash (non-hex chars) → 400.
# Defensive parse: typos shouldn't silently disable
# the integrity check.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "should-not-create.bin",
"folder_id": "{{home_folder_id}}",
"hash": "this-is-not-hex-content-hash-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 9 — Wrong-length content_hash → 400. A 64-char string
# is required (32-byte BLAKE3); anything else (here a
# SHA-1-sized 40-char hex) is rejected at the input
# boundary before the lookup happens.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "should-not-create.bin",
"folder_id": "{{home_folder_id}}",
"hash": "a94a8fef8c5d539e7e0f9c4b4d2c93e88f44f3f8"
}
HTTP 400
# ─────────────────────────────────────────────────────────────
# Step 10 — Trashed-blob precheck setup. Upload a fixture whose
# BLAKE3 is GUARANTEED unique to this test (the
# `hello-trashed.txt` fixture isn't referenced anywhere
# else under tests/), so the only file owning the blob
# after step 11 will be the one we trash.
#
# Captures `trashed_seed_id` + `trashed_seed_hash`
# which steps 11 and 12 consume.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{home_folder_id}}
file: file,fixtures/hello-trashed.txt; text/plain
HTTP 201
[Captures]
trashed_seed_id: jsonpath "$.id"
trashed_seed_hash: jsonpath "$.content_hash"
[Asserts]
jsonpath "$.name" == "hello-trashed.txt"
jsonpath "$.size" == 35
# ─────────────────────────────────────────────────────────────
# Step 11 — Trash the seed (soft-delete). The blob's ref_count
# stays > 0 because the file row still exists with
# is_trashed = true. Pre-change behaviour would have
# hidden the blob from `/api/files/by-hash`; the new
# behaviour treats trashed-file ownership as ownership.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{trashed_seed_id}}
Authorization: Bearer {{token}}
# Either 200 or 204 depending on whether the server returns a
# body. The trash service uses soft-delete (UPDATE is_trashed = true),
# not a hard delete, so the blob row is untouched.
HTTP *
# ─────────────────────────────────────────────────────────────
# Step 12 — Precheck HIT on a trashed-only owner. Must succeed
# (201) and return a NEW live file pointing at the same
# blob — even though the only OTHER reference to the
# blob is a trashed file. Verifies the dropped
# `AND NOT f.is_trashed` filter in
# `user_owns_blob_reference` and `pin_claimable_chunks`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/by-hash
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "restored-via-by-hash.txt",
"folder_id": "{{home_folder_id}}",
"hash": "{{trashed_seed_hash}}"
}
HTTP 201
[Captures]
trashed_copy_id: jsonpath "$.id"
[Asserts]
jsonpath "$.name" == "restored-via-by-hash.txt"
jsonpath "$.size" == 35
jsonpath "$.content_hash" == "{{trashed_seed_hash}}"
# ─────────────────────────────────────────────────────────────
# Step 13 — Cleanup. Trash every file created by this script;
# the next run starts clean. Seed (step 4) + copies
# (steps 5+6) share one blob; trashed seed (step 10)
# + by-hash restore (step 12) share another. Trashing
# all four lets the GC ref-count both blobs to zero.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{seed_file_id}}
Authorization: Bearer {{token}}
HTTP *
DELETE {{base_url}}/api/files/{{copy_file_id}}
Authorization: Bearer {{token}}
HTTP *
DELETE {{base_url}}/api/files/{{trashed_copy_id}}
Authorization: Bearer {{token}}
HTTP *