Files
Oxicloud/tests/api/storage_multi_entry.hurl
T
Edouard Vanbelle 04e0df0c89 test(consistency): exercise the Azure backend against Azurite
Adds an Azurite service and a scenario that audits the Azure backend
through `?storage=azurite`. It is the only coverage of that code path in
the tree: `AzureBlobBackend` has unit tests for its name parser and
ordering, but nothing else speaks the protocol, and a paid account is
not an option for CI. Azurite implements the real Blob REST API, so this
exercises SharedKey signing, prefix/marker paging, and the 256-way shard
walk with its termination.

## Harness

`docker-compose.test.yml` gains an azurite service on 10000 (tmpfs, so
it dies with the stack). `spawn-db.sh` provisions the container itself,
because `AzureBlobBackend::initialize` verifies rather than creates —
signed by hand with curl + openssl rather than pulling a ~700 MB `az`
image for one PUT. Two traps are commented there: the account key is
base64 but HMAC wants raw bytes, and the canonicalized resource repeats
the account name (`/{acc}/{acc}/{container}`) because the emulator puts
in the path what real Azure puts in the host. Getting that wrong yields
403, not a hint.

The `azurite` entry is declared in `server.env` but never activated, so
the suite's active backend stays local and only this file reaches Azure.

## What it asserts, and what it cannot

A failure surfaces as `ok: false`, because an enumeration error now
fails the run rather than degrading to a per-row probe.

It deliberately asserts no finding count. The container starts empty and
the job's grace window is an hour, so a freshly-uploaded blob is skipped
in both directions by design — an audit here can only report zero, and
"zero findings" would pass whether enumeration worked or returned
nothing. The one positive assert, `scanned_count != 0`, therefore sits
on the local control, which does hold blobs; `scanned_count` accumulates
via `checkpoint`, which the empty-page early return skips.

## No cutover, deliberately

Putting real bytes in the container means `backend_migration
?storage=azurite`, which hangs on the first blob: `head_check` issues a
~40-byte ranged GET, `azure_core` 0.21 attaches
`x-ms-range-get-content-crc64` to anything under 4 MiB, Azurite 500s,
and the deterministic error is retried forever while
`migration_readonly` refuses writes app-wide. The full chain and the
rejected workaround are in the file header. The scenario is still
ordered last in `run.sh` — it is the only one needing a second service,
and the cutover comes back there once the official SDK lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 19:24:46 +02:00

118 lines
5.5 KiB
Plaintext

# =============================================================
# OxiCloud — Multi-entry storage: GET /admin/settings/storage
# =============================================================
# Pins the multi-entry projection on the admin storage endpoint
# (`docs/plan/storage-multi-entry.md` slice 6).
#
# Test env (tests/common/server.env) declares two entries:
# * local_main — active (first in _ENTRIES, no DB pointer yet)
# * s3_stub — declared, never activated (bucket doesn't exist,
# its Test button would fail — we don't click it)
#
# Coverage:
# * `entries[]` returned with both names in declared order.
# * `is_active=true` on `local_main` (boot fallback picks first
# when active_backend_name is unset).
# * `is_active=false` on `s3_stub`.
# * `active_entry_name` mirrors the active entry.
# * `migration_readonly=false` on a fresh boot.
# * `location_hint` populated per backend type (root_dir for
# Local, bucket for S3).
# * `encryption_enabled=false` on both (no `_ENCRYPTION_KEY`
# declared).
# =============================================================
# ─────────────────────────────────────────────────────────────
# Setup — admin login
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 1 — GET /api/admin/settings/storage returns the entries
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/settings/storage
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
# Two entries declared, in _ENTRIES order.
jsonpath "$.entries" count == 3
jsonpath "$.entries[0].name" == "local_main"
jsonpath "$.entries[1].name" == "s3_stub"
jsonpath "$.entries[2].name" == "azurite"
# Backend types match the declarations.
jsonpath "$.entries[0].backend" == "local"
jsonpath "$.entries[1].backend" == "s3"
jsonpath "$.entries[2].backend" == "azure"
# Active pointer: fresh DB has no `active_backend_name` row, so the
# boot fallback picks the FIRST entry in _ENTRIES.
jsonpath "$.active_entry_name" == "local_main"
jsonpath "$.entries[0].is_active" == true
jsonpath "$.entries[1].is_active" == false
jsonpath "$.entries[2].is_active" == false
# Read-only mode off on a fresh boot (no in-flight migration, no
# stale flag in DB).
jsonpath "$.migration_readonly" == false
# Location hints — Local uses OXICLOUD_STORAGE_PATH fallback (path
# is per-test-suite; assert the endpoint+bucket string on the S3
# side which is stable, per the test env config).
jsonpath "$.entries[1].location_hint" == "http://127.0.0.1:9999/oxicloud-test-stub"
# Neither entry declared an encryption key.
jsonpath "$.entries[0].encryption_enabled" == false
jsonpath "$.entries[1].encryption_enabled" == false
# ─────────────────────────────────────────────────────────────
# Step 2 — POST /admin/settings/storage/test with entry_name
# ─────────────────────────────────────────────────────────────
# Tests the multi-entry test-DTO path (slice-6 rework). local_main
# points at the real test storage dir, so health-check + round-trip
# should pass. Passing entry_name makes the server ignore the legacy
# `backend` / `s3_*` fields.
POST {{base_url}}/api/admin/settings/storage/test
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "entry_name": "local_main", "backend": "" }
HTTP 200
[Asserts]
jsonpath "$.connected" == true
# Post-K2 (storage-key-rotation): every entry is wrapped in the v1
# blob-format decorator, so `backend_type` reports the WRAPPER's kind
# in `"<wrapper>(<inner>)"` form. `local_main` is unencrypted → wrapper
# is `v1-plaintext`. Match on the inner name via `contains` so the
# assertion survives future wrapper renames.
jsonpath "$.backend_type" contains "local"
jsonpath "$.backend_type" contains "v1-plaintext"
jsonpath "$.roundtrip_passed" == true
jsonpath "$.phase_reached" == "cleanup_ok"
# ─────────────────────────────────────────────────────────────
# Step 3 — Unknown entry_name returns connected=false with a
# diagnostic message (inline error — never 4xx).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/admin/settings/storage/test
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "entry_name": "does_not_exist", "backend": "" }
HTTP 200
[Asserts]
jsonpath "$.connected" == false
jsonpath "$.message" contains "does_not_exist"
jsonpath "$.message" contains "OXICLOUD_STORAGE_ENTRIES"