2026-08-23 19:46:08 +02:00
|
|
|
|
# =============================================================
|
|
|
|
|
|
# OxiCloud – Copy-folder ref_count regression
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
# Regression test for the ref_count drift found on Ed's sandbox
|
|
|
|
|
|
# (2026-08-22) and traced to the copy-folder path. When a folder
|
|
|
|
|
|
# is copied, every file inside gets duplicated as a NEW file row
|
|
|
|
|
|
# pointing at the SAME blob(s) — dedup wins bytes-on-disk, but
|
|
|
|
|
|
# `storage.blobs.ref_count` MUST bump by the number of new refs.
|
|
|
|
|
|
# If it doesn't, dedup GC will reap a blob that a live file row
|
|
|
|
|
|
# still references → dangling reference → user gets 404 on
|
|
|
|
|
|
# download of the copied file.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Covers two paths so the CDC boundary can't hide a regression:
|
|
|
|
|
|
#
|
2026-08-23 22:18:47 +02:00
|
|
|
|
# 1. Small file (`refcount-cascade-small.txt`) → single legacy whole-
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# file blob. `storage.blobs.ref_count` counted directly on
|
|
|
|
|
|
# the file's content hash.
|
2026-08-23 22:18:47 +02:00
|
|
|
|
# 2. 2 MB file (`refcount-cascade-cdc.bin`) → FastCDC produces multiple
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# distinct chunks. Whole-file `content_hash` still resolves
|
|
|
|
|
|
# through the dedup API.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Both fixtures are DEDICATED — unique content so ref_count
|
|
|
|
|
|
# assertions are absolute (== 1, == 2). Do NOT reuse these
|
|
|
|
|
|
# fixtures in other hurl files or absolute assertions here will
|
|
|
|
|
|
# flake.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Deletion is TWO STEPS in OxiCloud:
|
|
|
|
|
|
# `DELETE /api/folders/{id}` → moves to trash (ref_count
|
|
|
|
|
|
# unchanged; children still
|
|
|
|
|
|
# reference the blob).
|
|
|
|
|
|
# `DELETE /api/trash/{id}` → permanent purge; NOW
|
|
|
|
|
|
# ref_count decrements. If it
|
|
|
|
|
|
# hits 0, blob row is deleted
|
|
|
|
|
|
# synchronously (`exists=false`).
|
|
|
|
|
|
# So the test purges trash after every folder-delete step —
|
|
|
|
|
|
# skipping that would make the assertions wrong regardless of
|
|
|
|
|
|
# whether the copy-side bug is present.
|
|
|
|
|
|
#
|
|
|
|
|
|
# `/api/dedup/check/{hash}` returns `ref_count` only for admin
|
|
|
|
|
|
# callers (regular users get `null` for anti-enumeration). This
|
|
|
|
|
|
# suite requires the admin token; setup.hurl seeds it.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Run:
|
|
|
|
|
|
# hurl --variables-file tests/api/test.env --test \
|
2026-08-23 22:18:47 +02:00
|
|
|
|
# tests/api/refcount_cascade.hurl
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# =============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Step 1 — Login (admin, required for ref_count in dedup API)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/auth/login
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"username": "{{username}}",
|
|
|
|
|
|
"password": "{{password}}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
token: jsonpath "$.access_token"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# Step 2 — Baseline sweeps: run BOTH consistency tenants that
|
|
|
|
|
|
# check ref_count invariants and capture their finding
|
|
|
|
|
|
# counts. Later checkpoints assert equality with these
|
|
|
|
|
|
# baselines instead of `== 0` — so stale findings from
|
|
|
|
|
|
# previous tests don't flunk this one; only NEW drift
|
|
|
|
|
|
# introduced by our copy/delete does.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Two tenants because there are two counters (see
|
|
|
|
|
|
# `[[bug_dual_refcount_divergence]]`):
|
|
|
|
|
|
#
|
|
|
|
|
|
# - `blobs_consistency` — reconciles
|
|
|
|
|
|
# `storage.blobs.ref_count` against its auditor
|
|
|
|
|
|
# formula. Catches chunk-level drift.
|
|
|
|
|
|
# - `manifests_consistency` — reconciles
|
|
|
|
|
|
# `storage.chunk_manifests.ref_count` against its
|
|
|
|
|
|
# auditor formula. Catches whole-file drift (the
|
|
|
|
|
|
# path the FE + `/api/dedup/check` surface reads).
|
2026-08-23 19:46:08 +02:00
|
|
|
|
#
|
|
|
|
|
|
# Trigger returns `outcome.count = stats.finding_count`
|
|
|
|
|
|
# for recoverable tenants (see
|
|
|
|
|
|
# `scheduler/recoverable.rs::JobOutcome::ok_with` in
|
|
|
|
|
|
# the Completed branch). `outcome.outcome == "ok"`
|
|
|
|
|
|
# means the run walked the whole subject; it does NOT
|
|
|
|
|
|
# mean zero findings.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/blobs_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Captures]
|
2026-08-23 20:44:46 +02:00
|
|
|
|
baseline_blobs_findings: jsonpath "$.outcome.count"
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/manifests_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
baseline_manifests_findings: jsonpath "$.outcome.count"
|
2026-08-23 19:46:08 +02:00
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
# Scenario A — small file (single legacy whole-file blob)
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A1 — Create source folder
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/folders
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "hurl-ref-source-small"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
src_small_id: jsonpath "$.id"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A2 — Upload the small fixture. Content is unique to this
|
|
|
|
|
|
# test, so ref_count starts at exactly 1 (no dedup
|
|
|
|
|
|
# collision with any other fixture in the suite).
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
[MultipartFormData]
|
|
|
|
|
|
folder_id: {{src_small_id}}
|
2026-08-23 22:18:47 +02:00
|
|
|
|
file: file,fixtures/refcount-cascade-small.txt; text/plain
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
small_file_id: jsonpath "$.id"
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.content_hash" == "2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A3 — Baseline: exactly one live reference.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A4 — Create target folder + copy source into it. The batch
|
|
|
|
|
|
# endpoint is the single entry point every FE / WebDAV
|
|
|
|
|
|
# code path funnels through.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/folders
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "hurl-ref-target-small"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
tgt_small_id: jsonpath "$.id"
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/batch/folders/copy
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"folder_ids": ["{{src_small_id}}"],
|
|
|
|
|
|
"target_folder_id": "{{tgt_small_id}}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
copy_small_root: jsonpath "$.successful[0].new_root_folder_id"
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
|
|
jsonpath "$.stats.failed" == 0
|
|
|
|
|
|
jsonpath "$.successful[0].files_copied" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A5 — COPY-SIDE ASSERTION: ref_count must be exactly 2. The
|
|
|
|
|
|
# pre-fix bug left this at 1 — a subsequent GC would then
|
|
|
|
|
|
# reap the blob out from under the copied file.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# A5b — Sweep both consistency tenants that check ref_count.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# Complements the single-hash probe above: if the copy
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# path miscounted some OTHER blob or manifest, the single-
|
|
|
|
|
|
# hash probe wouldn't catch it. Delta vs the baselines
|
|
|
|
|
|
# isolates NEW drift from ambient.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Both tenants required — one counter each; see
|
|
|
|
|
|
# [[bug_dual_refcount_divergence]] for why the copy path
|
|
|
|
|
|
# must maintain both symmetrically.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/blobs_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
2026-08-23 20:44:46 +02:00
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_blobs_findings}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/manifests_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_manifests_findings}}
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A6 — Soft-delete the copied folder tree (moves to trash).
|
|
|
|
|
|
# ref_count is EXPECTED to stay at 2 — trashed files
|
|
|
|
|
|
# still reference the blob per the `NOT is_trashed` gap
|
|
|
|
|
|
# the auditor deliberately closed (see
|
|
|
|
|
|
# `[[project_by_hash_drop_is_trashed_filter]]`). Asserting
|
|
|
|
|
|
# == 2 here makes the trash-vs-permanent boundary explicit
|
|
|
|
|
|
# so a future refactor that changed the semantics would
|
|
|
|
|
|
# surface at THIS line, not several steps downstream.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{copy_small_root}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A7 — Purge the copy folder permanently.
|
|
|
|
|
|
# `DELETE /api/trash/{id}` accepts the ORIGINAL resource
|
|
|
|
|
|
# id as the path param (verified in `trash_handler.rs::
|
|
|
|
|
|
# delete_permanently`) — no need to GET+filter the trash
|
|
|
|
|
|
# listing to translate. If soft-delete silently failed,
|
|
|
|
|
|
# the ref_count assertion two lines below catches it.
|
|
|
|
|
|
# ref_count must drop to exactly 1 (the source folder
|
|
|
|
|
|
# still holds its file). Guards the DECREMENT half of
|
|
|
|
|
|
# the invariant: a double-decrement here would go to 0
|
|
|
|
|
|
# and the next GC wipes the still-live original's blob.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{copy_small_root}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# A8 — Soft-delete + purge the SOURCE folder (last holder).
|
|
|
|
|
|
# ref_count hits 0 → blob row deleted synchronously →
|
|
|
|
|
|
# `exists == false` on the next probe. Mirror pattern to
|
|
|
|
|
|
# `dedup_blob_cleanup.hurl` step 10, applied to folder-
|
|
|
|
|
|
# scoped delete.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{src_small_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{src_small_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/2d8eb13178cff0036a22e0c3c42446061e86579f9d59ea73c8343bccc2df0fd3
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# A9 — End-of-Scenario-A sweep on BOTH tenants. Scenario A
|
|
|
|
|
|
# introduced two file rows (source + copy), then deleted
|
|
|
|
|
|
# both. Net effect on the DB is zero — so the drift count
|
|
|
|
|
|
# on each counter must be exactly the baseline.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/blobs_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
2026-08-23 20:44:46 +02:00
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_blobs_findings}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/manifests_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_manifests_findings}}
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
# Scenario B — 2 MB multi-chunk file (CDC manifest path)
|
|
|
|
|
|
#
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# Coverage: this scenario now sweeps BOTH `blobs_consistency`
|
|
|
|
|
|
# (chunk-level ref_counts on `storage.blobs.ref_count`) and
|
|
|
|
|
|
# `manifests_consistency` (whole-file ref_counts on
|
|
|
|
|
|
# `storage.chunk_manifests.ref_count`). Same-shape assertions
|
|
|
|
|
|
# as Scenario A — see the baseline capture block near the top
|
|
|
|
|
|
# of this file and [[bug_dual_refcount_divergence]] for why
|
|
|
|
|
|
# both are needed.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# =============================================================
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B1 — Source folder for the multi-chunk scenario. Isolated
|
|
|
|
|
|
# from Scenario A so deletion order can't mask a bug (e.g.
|
|
|
|
|
|
# a shared blob whose counter goes negative).
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/folders
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "hurl-ref-source-cdc"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
src_cdc_id: jsonpath "$.id"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B2 — Upload the 2 MB dedicated fixture. Deterministic per-
|
|
|
|
|
|
# position content → FastCDC produces multiple distinct
|
|
|
|
|
|
# chunks (no chunk-level dedup with any other fixture).
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/files/upload
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
[MultipartFormData]
|
|
|
|
|
|
folder_id: {{src_cdc_id}}
|
2026-08-23 22:18:47 +02:00
|
|
|
|
file: file,fixtures/refcount-cascade-cdc.bin; application/octet-stream
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
cdc_file_id: jsonpath "$.id"
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.content_hash" == "fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B3 — Baseline. If exists=false here, the whole-file hash
|
|
|
|
|
|
# isn't registered in `storage.blobs` for CDC uploads on
|
|
|
|
|
|
# this build — swap to a chunk-hash probe or a
|
|
|
|
|
|
# blobs_consistency-driven assertion. See
|
|
|
|
|
|
# `docs/plan/recovery.md`.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B4 — Target folder + folder copy.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/folders
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"name": "hurl-ref-target-cdc"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 201
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
tgt_cdc_id: jsonpath "$.id"
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/batch/folders/copy
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
Content-Type: application/json
|
|
|
|
|
|
{
|
|
|
|
|
|
"folder_ids": ["{{src_cdc_id}}"],
|
|
|
|
|
|
"target_folder_id": "{{tgt_cdc_id}}"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Captures]
|
|
|
|
|
|
copy_cdc_root: jsonpath "$.successful[0].new_root_folder_id"
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.stats.successful" == 1
|
|
|
|
|
|
jsonpath "$.successful[0].files_copied" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B5 — CDC copy-side assertion: ref_count == 2.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# B5b — Full-DB sweep on BOTH tenants after CDC copy. Multi-
|
|
|
|
|
|
# chunk path exercises the manifest side of the invariant
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# — a bug that skips one chunk out of N would leak that
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# chunk without touching the whole-file assertion above.
|
|
|
|
|
|
# `blobs_consistency` catches chunk-level drift;
|
|
|
|
|
|
# `manifests_consistency` catches whole-file drift.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/blobs_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
2026-08-23 20:44:46 +02:00
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_blobs_findings}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/manifests_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_manifests_findings}}
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B6 — Soft-delete copy: ref_count stays at 2.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{copy_cdc_root}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B7 — Purge copy from trash directly by folder id: ref_count → 1.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{copy_cdc_root}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == true
|
|
|
|
|
|
jsonpath "$.ref_count" == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# B8 — Soft-delete + purge SOURCE: ref_count → 0, blob purged.
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{src_cdc_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{src_cdc_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GET {{base_url}}/api/dedup/check/fb1e63c28bb792e0f69cd16cd7595989f83c218cf70894e07d1f811ab1dc6f83
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.exists" == false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
2026-08-23 20:44:46 +02:00
|
|
|
|
# B9 — End-of-Scenario-B sweep on BOTH tenants. All Scenario B
|
|
|
|
|
|
# rows gone; both counter drift counts must be back at
|
|
|
|
|
|
# baseline.
|
2026-08-23 19:46:08 +02:00
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/blobs_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
2026-08-23 20:44:46 +02:00
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_blobs_findings}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/manifests_consistency/trigger
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|
|
|
|
|
|
jsonpath "$.outcome.count" == {{baseline_manifests_findings}}
|
2026-08-23 19:46:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
# Cleanup — soft-delete + purge the two empty target folders so
|
|
|
|
|
|
# the run leaves nothing behind. Same direct-by-id pattern as
|
|
|
|
|
|
# above; no trash-listing filter needed.
|
|
|
|
|
|
# =============================================================
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{tgt_small_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{tgt_small_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DELETE {{base_url}}/api/folders/{{tgt_cdc_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
HTTP 204
|
|
|
|
|
|
|
|
|
|
|
|
DELETE {{base_url}}/api/trash/{{tgt_cdc_id}}
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
HTTP 200
|
2026-08-23 22:44:39 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
# Final — force `dedup_gc` synchronously so orphaned manifests +
|
|
|
|
|
|
# blobs actually get reaped and their Rust blob-lifecycle hooks
|
|
|
|
|
|
# fire (which is what deletes disk thumbnails / face embeddings
|
|
|
|
|
|
# / audio tags keyed by the whole-file hash).
|
|
|
|
|
|
#
|
|
|
|
|
|
# The trigger `trg_files_decrement_blob_ref` deliberately only
|
|
|
|
|
|
# adjusts counters (see migration `20261017000000_file_delete_
|
|
|
|
|
|
# trigger_manifest_aware.sql`) — a SQL trigger can't invoke Rust
|
|
|
|
|
|
# callbacks. Physical cleanup + hook firing lives in `dedup_gc`
|
|
|
|
|
|
# Phase 1 (see `dedup_service.rs:2660-2772`), which picks up
|
|
|
|
|
|
# manifests at ref_count <= 0 and calls
|
|
|
|
|
|
# `fire_blob_hooks(file_hash)` per reap.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Without this trigger the test would technically pass (the
|
|
|
|
|
|
# ref_count assertions all hold; the `exists == false` checks
|
|
|
|
|
|
# are user-scoped and don't need the DB row gone), but
|
|
|
|
|
|
# `storage_cleanup_check.sh` running after us would then find
|
|
|
|
|
|
# 6 orphan thumbnails on disk and fail the whole api-test run.
|
|
|
|
|
|
# Making the test self-contained keeps the diagnostic tight —
|
|
|
|
|
|
# if orphans remain after this trigger, the bug is in GC or
|
|
|
|
|
|
# hooks, not in our cleanup order.
|
|
|
|
|
|
#
|
|
|
|
|
|
# `?force=true` bypasses the orphan-grace window (safe: this
|
|
|
|
|
|
# test has no concurrent uploader that could race the reap).
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
POST {{base_url}}/api/admin/jobs/dedup_gc/trigger?force=true
|
|
|
|
|
|
Authorization: Bearer {{token}}
|
|
|
|
|
|
|
|
|
|
|
|
HTTP 200
|
|
|
|
|
|
[Asserts]
|
|
|
|
|
|
jsonpath "$.ok" == true
|
|
|
|
|
|
jsonpath "$.outcome.outcome" == "ok"
|