security(/api/dedup): normalize dedup admin routes into /api/admin

/dedup/stats       -> /api/admin/dedup/stats
    /dedup/recalculate -> /api/admin/dedup/recalculate
This commit is contained in:
Edouard Vanbelle
2026-07-17 21:33:47 +02:00
parent e0156a43f5
commit c2b5d9fe2e
7 changed files with 192 additions and 38 deletions
+132
View File
@@ -0,0 +1,132 @@
# =============================================================
# OxiCloud — Dedup admin gate + URL move
# =============================================================
# Regression pin for AuthZ audit #24 + #25 (2026-07-12).
#
# `dedup_handler.rs` previously rolled its own admin check on
# `/api/dedup/stats` and `/api/dedup/recalculate` — a bespoke
# `if auth_user.role != "admin" { 403 with hand-rolled JSON }`
# with no audit line on rejection. That's the same drift class
# the admin middleware layer refactor closed elsewhere on
# 2026-07-17.
#
# Fix:
# 1. Both endpoints moved to `/api/admin/dedup/*` where the
# `/api/admin` middleware gate covers them by construction.
# URL declares admin intent up front.
# 2. Inline role check removed from the handlers — reaching
# them at all means the caller is admin.
# 3. `recalculate` emits `dedup.integrity_recalculated` on
# success (audit #25). Not asserted here (no log-scrape
# harness in Hurl); the shape is pinned in the handler
# code and covered by the `audit` tracing target contract.
#
# This test pins:
# * Admin can hit both endpoints at the new URL → 200.
# * Non-admin (bob) hits both → 403 (middleware layer).
# * The OLD URLs `/api/dedup/stats` and `/api/dedup/recalculate`
# are no longer registered → 404. Trips if someone
# re-introduces the routes to `dedup_router` without also
# removing them from `admin_handler::admin_routes()`.
# =============================================================
# ─────────────────────────────────────────────────────────────
# 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"
# Anti-enum registration.
POST {{base_url}}/api/auth/register
Content-Type: application/json
{
"username": "dedup_bob",
"email": "dedup_bob@example.com",
"password": "DedupBobPassword1!"
}
HTTP 200
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "dedup_bob", "password": "DedupBobPassword1!" }
HTTP 200
[Captures]
bob_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — Admin can hit the new URL. `stats` returns a
# `StatsResponse`-shaped body.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
jsonpath "$.bytes_saved" isNumber
jsonpath "$.total_logical_bytes" isNumber
jsonpath "$.total_physical_bytes" isNumber
# ─────────────────────────────────────────────────────────────
# Step 2 — Admin can trigger the integrity recalculation.
# Response shape mirrors `stats`. Server-side, this
# also emits the `dedup.integrity_recalculated` audit
# event (not asserted from Hurl).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.unique_blobs" isNumber
jsonpath "$.total_references" isNumber
# ─────────────────────────────────────────────────────────────
# Step 3 — Bob (non-admin) is denied. The `/api/admin/*`
# middleware layer emits `AuthError::AccessDenied` →
# 403. No hand-rolled 403 body from the handler; the
# handler doesn't even run.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/dedup/stats
Authorization: Bearer {{bob_token}}
HTTP 403
POST {{base_url}}/api/admin/dedup/recalculate
Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 4 — The old URLs are no longer registered. Trips if a
# future refactor re-adds them to `dedup_router` without
# removing them from `admin_handler::admin_routes()` (or
# vice versa). Anti-enum catch-all in the `/api/*` router
# returns 404 for unknown paths.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/dedup/stats
Authorization: Bearer {{admin_token}}
HTTP 404
POST {{base_url}}/api/dedup/recalculate
Authorization: Bearer {{admin_token}}
HTTP 404
+1 -1
View File
@@ -14,7 +14,7 @@
# (proves blob NOT prematurely deleted — bug 3 detection)
# 4. Permanently delete file 2 → blob and thumbnail cleaned up
#
# NOTE: The /api/dedup/stats endpoint counts CDC chunk rows in
# NOTE: The /api/admin/dedup/stats endpoint counts CDC chunk rows in
# storage.blobs and derives bytes_saved from chunk_manifests.
# Both tables may be 0 when the CDC path is disabled or the
# server uses the legacy blob path — so we avoid stats-based
+1
View File
@@ -164,6 +164,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
"$API_DIR/recent.hurl" \
"$API_DIR/batch_folder_copy.hurl" \
"$API_DIR/dedup_blob_cleanup.hurl" \
"$API_DIR/dedup_admin_gate.hurl" \
"$API_DIR/default_caldav_carddav.hurl" \
"$API_DIR/dav_error_mapping.hurl" \
"$API_DIR/carddav_vcard_properties.hurl" \