Files
Oxicloud/tests/api/derived_blob_copy.hurl
T
Edouard Vanbelle 80d5372131 test(api): cover WebP/JPEG negotiation, which nothing exercised
from_accept returns JPEG unless Accept contains image/webp, and no
thumbnail test sent the header — curl defaults to */*, which does not
match. So the whole suite ran on JPEG and the WebP path was never
exercised over HTTP, despite being what background generation writes and
what the derived tier was built around. The gap was invisible because
the JPEG results were all correct.

Three assertions, one property each.

Content-Type proves negotiation happened: thumbnail_content_type sniffs
the body with `infer` rather than echoing the request, so image/webp
cannot be right by accident — serve JPEG bytes down the WebP path and it
reads image/jpeg and fails.

Differing bytes prove they are genuinely two artifacts rather than one
served twice.

Differing ETags prove the validators are separate. `variant` has carried
the format only since 20261022000000; before that a JPEG request could
match the WebP row and be served the wrong codec, and a shared validator
is exactly how a cache would then hand either to either. A final
conditional request confirms each codec revalidates against its own.

Together these also cover the per-format variant keying that lets one
source hold both codecs — the prerequisite for JPEG clients ever leaving
the sidecar, and therefore for step 10e.

Note on placement: thumb_etag stays in step 4's capture block, beside
thumb_bytes. Every later request omits Accept and so negotiates JPEG, so
the validator must be the JPEG one — captured after the new block it
would describe a different codec than the bytes next to it, and the
copy assertions compare against both.
2026-08-30 13:41:05 +02:00

419 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================
# OxiCloud – Derived blobs survive a copy, and are SHARED not duplicated
# =============================================================
# Guards ONE property, the one that was actually broken:
#
# **A copy takes a real blob reference, via BOTH copy paths.**
#
# `storage.copy_file_satellites` (migration `20261019000000`) is the single
# home for that, called by the single-file path and by
# `storage.copy_folder_tree`. The tree path previously bumped
# `storage.blobs` only — which matched nothing for a manifest-backed file,
# so a folder copy took NO reference, and deleting the original reaped
# bytes the copy still needed. Steps 6 and 9 assert the ref_count; step 11
# purges the original, runs GC, and requires both copies to still serve.
#
# ── What this file does NOT prove, and why it cannot ─────────────────────
#
# It does not prove the copy SHARES the original's `content_derived_blobs`
# row rather than getting its own. Two reasons, and neither is fixable by
# adding assertions here:
#
# 1. Duplication is impossible by construction, so there is nothing to
# catch. The PK is `(source_hash, kind, variant)` and a copy carries the
# SAME `source_hash`, so a second INSERT conflicts — and
# `store_derived_blob` is already `ON CONFLICT DO NOTHING`. The schema
# enforces the property; no runtime behaviour can violate it.
#
# 2. Which tier served a thumbnail is invisible over HTTP. Stored derived
# blob, moka RAM cache, and a fresh re-render all return identical bytes
# with identical status — rendering is deterministic in the source bytes
# and the variant. The copy is in fact a moka hit (that cache is keyed on
# `(source_hash, size, format)`, which the copy shares), so it never
# reaches the derived tier at all in this test.
#
# The `bytes ==` assertions below therefore establish that the pipeline is
# deterministic and that the copies are readable — NOT that the derived
# tier was consulted. Read-path tier selection is observable only from
# inside the process, so it belongs in a Rust unit test over
# `ThumbnailService::get_cached_thumbnail`, not here.
#
# By the same limitation, step 11 proves the SOURCE content survived GC. It
# does not prove the derived blob survived: had GC reaped it, the server
# would re-render from the still-alive source and still answer 200.
#
# Coverage note: `dedup-test.jpg` is single-chunk, so `file_hash` equals its
# lone chunk's hash — the aliasing case whose `NOT EXISTS` guard stops one
# reference being counted at both levels. The multi-chunk fan-out (where
# file_hash names a manifest that is NOT a chunk) differs only in that the
# hashes differ; it has no thumbnail-capable fixture at this size, so it is
# covered at the SQL level rather than here.
#
# Prerequisites: setup.hurl must have run (admin user exists).
#
# Run:
# hurl --variables-file tests/api/test.env --file-root tests \
# --test tests/api/derived_blob_copy.hurl
# =============================================================
# ─────────────────────────────────────────────────────────────
# 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 – Source and destination folders
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/folders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hurl-derived-src"
}
HTTP 201
[Captures]
src_folder_id: jsonpath "$.id"
POST {{base_url}}/api/folders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "hurl-derived-dst"
}
HTTP 201
[Captures]
dst_folder_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 3 – Upload the source image
#
# `content_hash` is captured rather than hardcoded so the test does not
# break if the fixture is ever regenerated.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/files/upload
Authorization: Bearer {{token}}
[MultipartFormData]
folder_id: {{src_folder_id}}
file: file,fixtures/dedup-test.jpg; image/jpeg
HTTP 201
[Captures]
orig_file_id: jsonpath "$.id"
orig_file_name: jsonpath "$.name"
blob_hash: jsonpath "$.content_hash"
[Asserts]
jsonpath "$.content_hash" isString
# One file holds the blob.
GET {{base_url}}/api/dedup/check/{{blob_hash}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.exists" == true
jsonpath "$.ref_count" == 1
# ─────────────────────────────────────────────────────────────
# Step 4 – Render the thumbnail. THIS is what creates the derived blob:
# `content_derived_blobs(source_hash = blob_hash, 'thumbnail', …)`.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
Authorization: Bearer {{token}}
HTTP 200
[Captures]
thumb_bytes: bytes
# The JPEG validator, since every later request omits `Accept` and so
# negotiates JPEG too. Captured here rather than after step 4b, or it
# would belong to a different codec than the bytes beside it.
thumb_etag: header "ETag"
# ─────────────────────────────────────────────────────────────
# Step 4b – Codec negotiation: WebP and JPEG are separate artifacts.
#
# Every other request in the suite omits `Accept`, and curl defaults to
# `*/*`, which `ThumbnailFormat::from_accept` maps to JPEG — so without
# this case the WebP path is never exercised at all, despite being what
# background generation writes and what the derived tier was built
# around.
#
# The three assertions are one property each:
#
# Content-Type — negotiation actually happened (it is byte-sniffed
# from the body, so it cannot be right by accident).
# bytes — the two really are different artifacts.
# ETag — the validators are distinct. `variant` carries the
# format since migration 20261022000000; before that a
# JPEG request could match the WebP row and be served
# the wrong codec, and a shared validator is how a
# cache would then hand either to either.
#
# Together they also cover the per-format variant keying that lets one
# source hold both codecs — the prerequisite for JPEG clients ever
# leaving the sidecar.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
Authorization: Bearer {{token}}
Accept: image/webp
HTTP 200
[Captures]
webp_bytes: bytes
webp_etag: header "ETag"
[Asserts]
header "Content-Type" == "image/webp"
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
Authorization: Bearer {{token}}
Accept: image/jpeg
HTTP 200
[Asserts]
header "Content-Type" == "image/jpeg"
bytes != {{webp_bytes}}
header "ETag" != "{{webp_etag}}"
# Each codec revalidates against its OWN validator.
GET {{base_url}}/api/files/{{orig_file_id}}/thumbnail/preview
Authorization: Bearer {{token}}
Accept: image/webp
If-None-Match: {{webp_etag}}
HTTP 304
# ─────────────────────────────────────────────────────────────
# Step 5 – Single-file copy into the destination folder
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/batch/files/copy
Authorization: Bearer {{token}}
Content-Type: application/json
{
"file_ids": ["{{orig_file_id}}"],
"target_folder_id": "{{dst_folder_id}}"
}
HTTP 200
[Captures]
file_copy_id: jsonpath "$.successful[0].id"
[Asserts]
jsonpath "$.successful[0].id" != "{{orig_file_id}}"
# ─────────────────────────────────────────────────────────────
# Step 6 – The copy took a reference.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/dedup/check/{{blob_hash}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.exists" == true
jsonpath "$.ref_count" == 2
# ─────────────────────────────────────────────────────────────
# Step 7 – The copy is readable, renders the same bytes, and carries the
# SAME ETag as the original.
#
# The ETag is keyed on the content hash, which the copy shares. Two
# different files agreeing on an ETag is the one externally visible
# consequence of content-keying — a file-id-keyed ETag could not produce
# it. The 304 below is the payoff: a client that already holds the
# original's thumbnail does not refetch it for the copy.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
bytes == {{thumb_bytes}}
header "ETag" == "{{thumb_etag}}"
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
Authorization: Bearer {{token}}
If-None-Match: {{thumb_etag}}
HTTP 304
[Asserts]
header "ETag" == "{{thumb_etag}}"
# ─────────────────────────────────────────────────────────────
# Step 8 – Folder copy — the OTHER copy path, through
# `storage.copy_folder_tree` → `copy_file_satellites`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/batch/folders/copy
Authorization: Bearer {{token}}
Content-Type: application/json
{
"folder_ids": ["{{src_folder_id}}"],
"target_folder_id": "{{dst_folder_id}}"
}
HTTP 200
[Captures]
tree_root_id: jsonpath "$.successful[0].new_root_folder_id"
[Asserts]
jsonpath "$.stats.failed" == 0
GET {{base_url}}/api/files?folder_id={{tree_root_id}}
Authorization: Bearer {{token}}
HTTP 200
[Captures]
tree_copy_id: jsonpath "$[0].id"
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].name" == "{{orig_file_name}}"
jsonpath "$[0].id" != "{{orig_file_id}}"
jsonpath "$[0].content_hash" == "{{blob_hash}}"
# ─────────────────────────────────────────────────────────────
# Step 9 – Three references now. Before `copy_file_satellites` the tree
# path contributed nothing here and this stayed at 2.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/dedup/check/{{blob_hash}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.ref_count" == 3
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
bytes == {{thumb_bytes}}
# ─────────────────────────────────────────────────────────────
# Step 10 – Permanently delete the ORIGINAL.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/files/{{orig_file_id}}
Authorization: Bearer {{token}}
HTTP 204
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{token}}
HTTP 200
[Captures]
trash_orig_id: jsonpath "$.items[?(@.resource.id == '{{orig_file_id}}')].resource.id"
DELETE {{base_url}}/api/trash/{{trash_orig_id}}
Authorization: Bearer {{token}}
HTTP 200
# Two copies remain, so the content must too.
GET {{base_url}}/api/dedup/check/{{blob_hash}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.exists" == true
jsonpath "$.ref_count" == 2
# ─────────────────────────────────────────────────────────────
# Step 11 – Run GC, then prove both copies still work.
#
# This is the assertion the whole file exists for. If either copy had
# failed to take a reference, the original's deletion would have walked
# the count to 0 and GC would have reaped the SOURCE CONTENT — leaving
# these 5xx. That was a real, shipped bug on the folder-copy path.
#
# Scope: this proves the source content survived. It says nothing about
# whether the derived blob survived, because a reaped derived blob is
# re-rendered transparently from the live source. See the header.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/jobs/dedup_gc/trigger
Authorization: Bearer {{token}}
[Options]
delay: 500ms
HTTP 200
GET {{base_url}}/api/files/{{file_copy_id}}/thumbnail/preview
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
bytes == {{thumb_bytes}}
GET {{base_url}}/api/files/{{tree_copy_id}}/thumbnail/preview
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
bytes == {{thumb_bytes}}
# ─────────────────────────────────────────────────────────────
# Step 12 – Teardown. Hurl files share one database within run.sh, so
# everything created here must go, including from trash.
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/folders/{{src_folder_id}}
Authorization: Bearer {{token}}
HTTP 204
DELETE {{base_url}}/api/folders/{{dst_folder_id}}
Authorization: Bearer {{token}}
HTTP 204
GET {{base_url}}/api/trash/resources
Authorization: Bearer {{token}}
HTTP 200
[Captures]
trash_src_id: jsonpath "$.items[?(@.resource.id == '{{src_folder_id}}')].resource.id"
trash_dst_id: jsonpath "$.items[?(@.resource.id == '{{dst_folder_id}}')].resource.id"
DELETE {{base_url}}/api/trash/{{trash_src_id}}
Authorization: Bearer {{token}}
HTTP 200
DELETE {{base_url}}/api/trash/{{trash_dst_id}}
Authorization: Bearer {{token}}
HTTP 200