diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ccaa391..eeed56d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,15 +218,22 @@ jobs: - name: Set execute bit on pre-built binary run: chmod +x target/release/oxicloud - - name: Install Hurl and b3sum + - name: Install Hurl, b3sum, and xq env: HURL_MAJOR: "8" + # sibprogrammer/xq — standalone Go binary, real XPath via libxml2. + # Pinned to match the dev-box version so the test scripts can rely + # on syntax stability. Bump in lockstep with the dev install. + XQ_VERSION: "1.3.0" run: | HURL_VERSION=$(curl -fsSL -H "Authorization: Bearer ${{ github.token }}" \ https://api.github.com/repos/Orange-OpenSource/hurl/releases \ | jq -r "map(select(.tag_name | startswith(\"${HURL_MAJOR}.\"))) | first | .tag_name") curl -fLO "https://github.com/Orange-OpenSource/hurl/releases/download/${HURL_VERSION}/hurl_${HURL_VERSION}_amd64.deb" sudo apt-get install -y "./hurl_${HURL_VERSION}_amd64.deb" b3sum + curl -fLO "https://github.com/sibprogrammer/xq/releases/download/v${XQ_VERSION}/xq_${XQ_VERSION}_linux_amd64.tar.gz" + tar -xzf "xq_${XQ_VERSION}_linux_amd64.tar.gz" xq + sudo install -m 0755 xq /usr/local/bin/xq - name: Run Hurl API tests run: bash tests/api/run.sh diff --git a/docs/plan/BASELINE_TESTS_NC_WEBDAV.md b/docs/plan/BASELINE_TESTS_NC_WEBDAV.md new file mode 100644 index 00000000..5c97310e --- /dev/null +++ b/docs/plan/BASELINE_TESTS_NC_WEBDAV.md @@ -0,0 +1,439 @@ +# NextCloud + WebDAV E2E baseline test plan + +> Purpose: establish a regression baseline before the **Drive** (multi-chroot) +> implementation lands. Every scenario in this document must pass on the +> current branch (no chroot / no Drive). On the Drive branch, every +> scenario must still pass when the URL form uses the bare-username +> default-drive shape; multi-drive scenarios are *additive*, never replace +> these baselines. + +--- + +## 1. Scope & non-goals + +### In scope + +- NextCloud surface + - Status + capabilities (`/status.php`, `/index.php/204`, `/ocs/v{1,2}.php/cloud/capabilities`) + - Login Flow v2 (`/index.php/login/v2`, `…/poll`) + - OCS user-info + provisioning + sharees-autocomplete shape + - WebDAV files (`/remote.php/dav/files/{user}/…`): OPTIONS, PROPFIND, GET, HEAD, PUT, MKCOL, DELETE, MOVE, COPY, PROPPATCH, REPORT + - Chunked uploads (`/remote.php/dav/uploads/{user}/{upload_id}/…`) + - Trashbin DAV (`/remote.php/dav/trashbin/{user}/…`) + - Avatar + preview +- Native WebDAV (`/webdav/…`): the same verbs plus LOCK / UNLOCK +- Cross-user isolation (security baseline) +- Auth failures + per-(account, IP) lockout + external-user rejection +- **Content-integrity round-trip**: when a file is uploaded via WebDAV/NC, its + server-stored `content_hash` (exposed by the REST API) must equal the BLAKE3 + of the bytes the client uploaded. +- **Collection-href trailing slash** — documented past regression (NC desktop + aborts PROPFIND parse if a collection href doesn't end `/`). + +### Out of scope for v1 + +- File sharing / sharees (deferred — separate refactor in flight). +- CalDAV / CardDAV protocols. +- OIDC login flow. +- WOPI editor integration. +- Performance / load testing. + +--- + +## 2. Test infrastructure + +- **Hurl** (existing) for REST + JSON shapes — `tests/api/*.hurl`. +- **bash + curl** for WebDAV (custom verbs + XML) — `tests/webdav/*.sh`. +- New shared helper proposed: `tests/webdav/lib/dav_helpers.sh` — + DRY curl wrappers (depth header, multi-status XML grep, `` / + `` extraction, BLAKE3 of a local fixture). +- BLAKE3 dependency: `b3sum` (already used by `dedup_create.hurl`); install + via `apt install b3sum` or `brew install b3sum`. +- Fixtures (most already exist under `tests/fixtures/`): + - `hello.txt` — 32 B + - `hello-copy.txt` — 32 B, same content as hello.txt + - `image.png` — small PNG (need to confirm / add) + - `medium-1mb.bin` — 1 MB random bytes (generate on the fly if missing) + - `large-10mb.bin` — 10 MB random bytes (generated on the fly, gitignored) + +### Test user fixtures + +Two users seeded by `tests/api/setup.hurl`: +- `admin` (admin role) — used in groups A–N +- `bob` (regular user) — used only in group O (cross-user isolation) + +Both register with strong passwords, log in once via the JWT flow, and +mint an app password (`POST /api/auth/app-passwords`) so subsequent +Basic Auth against the NC surface uses an app password — matches how +NC desktop authenticates after Login Flow v2. + +--- + +## 3. How to run + wipe state + +```bash +# Wipe DB + storage from scratch (recommended for first baseline run) +docker compose down -v +rm -rf tests/api/storage +bash tests/api/run.sh # runs the Hurl suite end-to-end + +# Run only the WebDAV/NC scripts after the Hurl seed has run +bash tests/webdav/run_all.sh # new aggregator script — TODO +``` + +> Ed: yes, please wipe DB + storage for the first baseline capture. +> Subsequent runs after each batch are cumulative and idempotent. + +Environment overrides used by tests (set in `tests/common/server.env`): + +| Variable | Test value | Why | +|---|---|---| +| `OXICLOUD_MAX_UPLOAD_SIZE` | 10 GiB | Need enough headroom for F7 (10 MB) without hitting the cap | +| `OXICLOUD_CHUNK_MAX_BYTES` | 4 MiB | Small enough that J7 (over-cap chunk) can trigger 413 without huge fixtures | +| `OXICLOUD_DIRECT_PUT_MAX_BYTES` | 1 GiB | Standard | +| `OXICLOUD_NEXTCLOUD_ENABLED` | true | Mounts the NC router | +| `OXICLOUD_TRUST_PROXY_HEADERS` | false | Tests assert direct-client IP, not X-Forwarded-For-spoofed | + +--- + +## 4. Scenarios + +### Group A — Status & capabilities (4 scenarios) + +**Purpose**: NC client refuses to even attempt sync if these endpoints +return the wrong shape. Catches namespace / serialiser regressions. + +| ID | Step | Assertions | +|---|---|---| +| A1 | `GET /status.php` (no auth) | 200; JSON `installed: true`, contains `version`, `versionstring`, `productname` | +| A2 | `GET /index.php/204` (no auth) | 204; empty body. (NC mobile connectivity probe.) | +| A3 | `GET /ocs/v1.php/cloud/capabilities?format=json` (no auth) | 200; OCS envelope `meta.statuscode == 100`; `data.capabilities.core.webdav-root` is set; `data.capabilities.files.bigfilechunking == true` | +| A4 | `GET /ocs/v2.php/cloud/capabilities?format=json` | 200; OCS envelope `meta.statuscode == 200`; same payload shape as A3 | + +--- + +### Group B — Login Flow v2 (5 scenarios) + +**Purpose**: this is how NC desktop bootstraps an app password without +ever seeing the user's real password. Breaking it means no new desktop +client can pair. + +| ID | Step | Assertions | +|---|---|---| +| B1 | `POST /index.php/login/v2` (no auth) | 200; JSON `{ login: "https:///login/v2/grant?token=…", poll: { token: "…", endpoint: "…/login/v2/poll" } }` | +| B2 | `POST /index.php/login/v2/poll` with token from B1, *before* a grant happens | 404 (NC convention: 404 = "not yet" until the user actually grants) | +| B3 | Simulate the browser grant (`POST` the device-auth-grant endpoint with the token, authenticated as `admin`) | 200 / 204 / whatever the existing flow returns | +| B4 | `POST /index.php/login/v2/poll` after grant | 200; JSON `{ server: "", loginName: "admin", appPassword: "" }`. The returned app password works for Basic Auth in C1. | +| B5 | `POST /index.php/login/v2/poll` with an expired / unknown token | 404 | + +--- + +### Group C — OCS user-info + provisioning (5 scenarios) + +**Purpose**: NC desktop reads `data.id` from `/ocs/v{1,2}.php/cloud/user` +and splices it into every subsequent DAV URL. A regression here → NC +client builds the wrong DAV paths and 100% of subsequent syncs fail. + +| ID | Step | Assertions | +|---|---|---| +| C1 | `GET /ocs/v1.php/cloud/user?format=json` Basic Auth `admin:` | 200; OCS `statuscode: 100`; `data.id == "admin"`; `data.display-name`, `data.displayname`, `data.email` present; `data.quota.{used,total,free,relative}` present | +| C2 | `GET /ocs/v2.php/cloud/user?format=json` Basic Auth | 200; OCS `statuscode: 200`; rest identical to C1 | +| C3 | `GET /ocs/v1.php/cloud/users/admin?format=json` Basic Auth `admin:` | 200; full profile including `groups`, `lastLogin`, `backend`, `quota` | +| C4 | `GET /ocs/v1.php/cloud/users/bob?format=json` Basic Auth `admin:` (where `admin` IS admin) | 200 (admin can read anyone) — OR 403 if policy says "admin-but-not-superadmin", document whichever behavior is current | +| C5 | `GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?format=json&search=ad&itemType=file` | 200; envelope has `data.exact.{users,groups}` arrays + `data.users` array (may all be empty — shape matters more than content) | + +--- + +### Group D — NC WebDAV: OPTIONS + PROPFIND read (10 scenarios) + +**Purpose**: sync client's first action on every cycle. Includes a +dedicated trailing-slash regression test (D8/D9/D10) — past bug where +collection hrefs didn't end `/` aborted NC desktop with +`Invalid href "<…>" expected starting with ""`. + +| ID | Step | Assertions | +|---|---|---| +| D1 | `OPTIONS /remote.php/dav/files/admin/` Basic Auth | 200; header `DAV: 1, 3`; header `Allow` lists OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH | +| D2 | `PROPFIND /remote.php/dav/files/admin/` `Depth: 0` | 207; multistatus has exactly 1 ``; href is `/remote.php/dav/files/admin/` (trailing `/`); has `` and `` | +| D3 | `PROPFIND /remote.php/dav/files/admin/` `Depth: 1` on **empty home** | 207; exactly 1 `` (collection only) | +| D4 | Upload 2 files (`a.txt`, `b.txt`) + create subfolder `sub/` via MKCOL → `PROPFIND Depth: 1` on home | 207; 4 `` entries; files have `` matching their byte count; folder href ends `/`; all 4 have `` | +| D5 | `PROPFIND /remote.php/dav/files/admin/nonexistent` `Depth: 0` | 404 | +| D6 | `PROPFIND` on a **file** `Depth: 0` | 207; 1 ``; href does NOT end `/`; has ``; `` is empty (not ``) | +| D7 | `PROPFIND Depth: infinity` on a 3-level tree (`/sub1/sub2/file.txt`) | 207; all descendants present (root + sub1 + sub2 + file) | +| **D8** | **PROPFIND on a SUBDIRECTORY** (not the home root) `Depth: 0` | **207; the subdirectory's own `` href ends with `/`** (regression guard — NC desktop aborts otherwise) | +| **D9** | **PROPFIND on a SUBDIRECTORY** `Depth: 1` containing 2 files + 2 subfolders | **207; 5 responses total. The subdir's OWN href ends `/`. The 2 subfolder responses' hrefs both end `/`. The 2 file responses' hrefs do NOT end `/`. This catches the mixed-collection regression.** | +| **D10** | **PROPFIND on home `Depth: 1` containing mixed content** (3 files + 2 folders) | **207; 6 responses. Hrefs validated per type: collections always `/`, files never `/`. Check the OWN entry (admin/) also ends `/`. This catches both regressions in one shot.** | + +**Implementation hint for D8/D9/D10**: parse the multistatus XML and +for each ``, pair its `` against its +``. Assertion: if `` is present → +href MUST end `/`; if not → href MUST NOT end `/`. Loop and assert. + +| D11 | `PROPFIND` with malformed XML body (e.g. truncated tag) | 400 | + +--- + +### Group E — NC WebDAV: GET / HEAD / Range (6 scenarios) + +**Purpose**: downloads + conditional GETs. Catches stale-content +regressions (the `file_id → blob_hash` cache invalidation bug fixed in +`f4ce4092`). + +| ID | Step | Assertions | +|---|---|---| +| E1 | Upload 32 B text → `GET` it | 200; body equals upload; `Content-Type: text/plain`; `ETag` header present and quoted (`"…"`); `Last-Modified` present; `Content-Length: 32` | +| E2 | `HEAD` on E1's file | Same headers as E1; empty body; no Content-Length disagreement | +| E3 | `GET` on non-existent path | 404 | +| E4 | `GET` on a collection | Whatever OxiCloud returns today (likely 200 with empty body or 404) — pin the current behavior and document | +| E5 | Upload 1 MB random → `GET` with `Range: bytes=0-1023` | 206; body is exactly 1024 bytes; `Content-Range: bytes 0-1023/1048576`; `Accept-Ranges: bytes` | +| E6 | `GET` with `If-None-Match: ""` matching the stored ETag | 304; empty body; no Content-Length | + +--- + +### Group F — NC WebDAV: PUT / MKCOL + BLAKE3 round-trip (10 scenarios) + +**Purpose**: file + folder creation. Includes the +**content-hash integrity check** Ed requested: server's stored +`content_hash` (REST API) must equal the local BLAKE3 of the bytes +the client uploaded. + +| ID | Step | Assertions | +|---|---|---| +| F1 | `PUT /remote.php/dav/files/admin/new.txt` body `hello` | 201; `ETag` + `oc-etag` headers; body empty; `oc-fileid` header present | +| F2 | `GET` F1's file | body `hello`; ETag matches F1's | +| F3 | `PUT` overwrite F1 with new content `goodbye` | 204; NEW ETag (different from F1) | +| F4 | After F3, `GET` → body `goodbye` (catches the file_id→blob_hash stale-cache regression) | +| F5 | `PUT` with `If-None-Match: *` on existing path | 412 | +| F6 | `PUT` with `If-Match: ""` | 412 | +| F7 | `PUT` 10 MB random binary → assert `GET` returns same bytes (integrity over streaming) | +| **F8** | **BLAKE3 round-trip (small file)**: locally compute `b3sum hello.txt` → `PUT` via NC → after PUT, extract the file's id (from `oc-fileid` header or PROPFIND), then `GET /api/files/{id}` (REST API, JWT-auth as admin) → assert the returned `FileDto.content_hash` field equals the local `b3sum` value | content_hash matches BLAKE3 of uploaded bytes | +| **F9** | **BLAKE3 round-trip (10 MB streamed file)**: same as F8 but with the 10 MB fixture — exercises the streaming hash-on-write path | content_hash matches | +| F10 | `MKCOL /remote.php/dav/files/admin/newfolder/` | 201; subsequent PROPFIND sees it with `` and trailing-slash href | +| F11 | `MKCOL` where parent missing | 409 | +| F12 | `MKCOL` on existing folder | 405 | + +--- + +### Group G — NC WebDAV: MOVE / COPY / DELETE (9 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| G1 | Setup `a.txt` → `MOVE` with `Destination: http:///remote.php/dav/files/admin/b.txt` | 201 (new) or 204; PROPFIND home: `b.txt` present, `a.txt` absent | +| G2 | `MOVE` file to a different folder | 201/204; file at destination; gone from source folder | +| G3 | `MOVE` with `Destination` whose URL-encoded segments contain ` `, `#`, `%` | succeeds; resulting name correctly decoded (verify via PROPFIND) | +| G4 | `MOVE` to existing path with `Overwrite: F` | 412 | +| G5 | `MOVE` to existing path with `Overwrite: T` | 204; replaces | +| G6 | `MOVE` folder (recursive subtree) | 201/204; full subtree visible at new location; gone from old | +| G7 | `COPY` file with `Destination` | 201; source still present; destination has identical content + new ETag | +| G8 | `DELETE` file | 204; `GET` → 404; trashbin PROPFIND (group K) sees it | +| G9 | `DELETE` folder | 204; recursive, all descendants also gone (`GET` on any descendant → 404) | + +--- + +### Group H — NC WebDAV: PROPPATCH (favorites) (3 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| H1 | `PROPPATCH` on a file, body sets `1` | 207 multistatus; status row says `HTTP/1.1 200 OK` for `oc:favorite` | +| H2 | After H1, `REPORT /remote.php/dav/files/admin/` with `` body filtering on `1` | 207; multistatus contains the file from H1 | +| H3 | `PROPPATCH` `0` to unset → `REPORT` favorites | 207; file no longer in favorites list | + +--- + +### Group I — NC WebDAV: REPORT (favorites filter + search) (4 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| I1 | `REPORT` favorites filter on empty home | 207; empty multistatus (no ``) | +| I2 | `REPORT` favorites filter with 3 favorited files | 207; exactly 3 responses; each has favorited file href + correct trailing-slash semantics | +| I3 | `REPORT` `` for `where name contains "foo"` on a home with `foo.txt`, `bar.txt`, `foobar.txt` | 207; responses for `foo.txt` and `foobar.txt`, NOT for `bar.txt` | +| I4 | `REPORT` search with `2` and 5 candidates | 207; exactly 2 responses | + +--- + +### Group J — NC chunked uploads + BLAKE3 round-trip (10 scenarios) + +**Purpose**: the most fragile NC subsurface — gets hammered by sync +clients on every large upload. Includes BLAKE3 integrity on assembly. + +| ID | Step | Assertions | +|---|---|---| +| J1 | `MKCOL /remote.php/dav/uploads/admin/sess-001/` | 201 | +| J2 | `PUT /remote.php/dav/uploads/admin/sess-001/00000001` body 5 KB | 201 | +| J3 | `PUT .../00000002` body 5 KB different content | 201 | +| J4 | `PROPFIND /remote.php/dav/uploads/admin/sess-001/` `Depth: 1` | 207; 3 responses (collection + 2 chunks); collection href ends `/`; chunk hrefs don't; chunks have `` matching upload sizes | +| J5 | `MOVE /remote.php/dav/uploads/admin/sess-001/.file` `Destination: /remote.php/dav/files/admin/assembled.bin` | 201; response has `ETag` + `oc-etag` headers | +| J6 | `GET /remote.php/dav/files/admin/assembled.bin` | 200; body length == sum of chunks; bytes match concatenation of J2 + J3 | +| **J7** | **BLAKE3 round-trip on assembled file**: local BLAKE3 of `concat(chunk1, chunk2)` → after J5, lookup `assembled.bin`'s id and `GET /api/files/{id}` (REST) → assert `FileDto.content_hash` == local BLAKE3 | matches (proves the hash-on-write during assembly produces the canonical BLAKE3) | +| J8 | New session, `PUT` chunk larger than `OXICLOUD_CHUNK_MAX_BYTES` (4 MiB in test env) | 413 | +| J9 | New session, MKCOL → `DELETE /remote.php/dav/uploads/admin/sess-002/` | 204; subsequent PROPFIND on `/uploads/admin/sess-002/` returns 404 | +| J10 | After J5, `PROPFIND /remote.php/dav/uploads/admin/sess-001/` | 404 (the session is purged after `.file` assembly) | + +--- + +### Group K — Trashbin DAV (5 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| K1 | After G8, `PROPFIND /remote.php/dav/trashbin/admin/trash/` `Depth: 1` | 207; ≥2 responses (collection + at least the deleted item); each item has `` with original path | +| K2 | `MOVE /remote.php/dav/trashbin/admin/trash/` `Destination: /remote.php/dav/files/admin/restored.txt` | 201; restored at destination | +| K3 | Delete a file → `DELETE /remote.php/dav/trashbin/admin/trash/` (permanent) | 204; trashbin PROPFIND no longer lists it | +| K4 | Delete 3 files → `DELETE /remote.php/dav/trashbin/admin/trash` (empty all) | 204; trashbin PROPFIND has only the collection | +| K5 | `MOVE` from trash to a destination where a same-named file already exists | pin current behavior (412? rename suffix? whichever it does today) | + +--- + +### Group L — Avatar + preview (3 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| L1 | `GET /index.php/avatar/admin/64` | 200 (image bytes, Content-Type `image/*`) OR 404 — pin current behavior | +| L2 | `GET /index.php/core/preview?fileId=&x=128&y=128` for an image file | 200 with image OR 404 if preview-on-demand is off — pin behavior | +| L3 | `GET /index.php/avatar/nonexistent/64` | 404 | + +--- + +### Group M — Native WebDAV `/webdav/…` (8 scenarios) + +**Purpose**: rclone, WebDAV-mounted clients, third-party tools. The +native surface has different chroot semantics (implicit home folder) +and **advertises LOCK** (Class 2). + +| ID | Step | Assertions | +|---|---|---| +| M1 | `OPTIONS /webdav/` | 200; header `DAV: 1, 2`; `Allow` lists `LOCK`, `UNLOCK` | +| **M2** | **`PROPFIND /webdav/` `Depth: 1` containing 2 files + 2 subfolders** | **207; 5 responses; collections end `/`, files don't (same trailing-slash regression guard as D9/D10 on the native surface)** | +| M3 | `PUT /webdav/sample.txt` 5 KB body | 201; ETag header | +| M4 | `GET /webdav/sample.txt` with `Range: bytes=0-9` | 206; first 10 bytes; correct Content-Range | +| M5 | `MOVE /webdav/sample.txt` `Destination: /webdav/moved.txt` | 201/204; verify via PROPFIND | +| M6 | `MKCOL /webdav/sub/` | 201; PROPFIND lists it with trailing-slash href | +| M7 | `DELETE /webdav/sub/` | 204; PROPFIND no longer lists it | +| M8 | `COPY /webdav/a.txt` `Destination: /webdav/b.txt` | 201; both exist with same content | + +--- + +### Group N — LOCK / UNLOCK on `/webdav/` (3 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| N1 | `LOCK /webdav/locked.txt` body `` with `test`, header `Timeout: Second-60` | 200; response body has `opaquelocktoken:…`; `Lock-Token` header set | +| N2 | `PUT /webdav/locked.txt` from a different lock context (no `If: ()`) | 423 Locked | +| N3 | `UNLOCK /webdav/locked.txt` with header `Lock-Token: ` | 204; subsequent `PUT` (no `If` header) succeeds | + +--- + +### Group O — Cross-user isolation (security baseline) (4 scenarios) + +Setup: `alice` and `bob`, each with home folder + a file `secret.txt`. + +| ID | Step | Assertions | +|---|---|---| +| O1 | Auth as `alice`, `PROPFIND /remote.php/dav/files/bob/` `Depth: 0` | 403 | +| O2 | Auth as `alice`, `GET /remote.php/dav/files/alice/../bob/secret.txt` | 400 (path traversal rejected at the DAV path validator) | +| O3 | Auth as `alice`, `MOVE /remote.php/dav/files/alice/x.txt` `Destination: /remote.php/dav/files/bob/x.txt` | 403 | +| O4 | Auth as `alice`, `PROPFIND /remote.php/dav/files/alice/` `Depth: 1` after bob has uploaded `bob-only.txt` | none of bob's files appear in the response | + +--- + +### Group P — Auth failure / lockout / external user (5 scenarios) + +| ID | Step | Assertions | +|---|---|---| +| P1 | `PROPFIND /remote.php/dav/files/admin/` no `Authorization` header | 401; `WWW-Authenticate: Basic realm="OxiCloud"` | +| P2 | Same with wrong password | 401; audit log emits `target=audit event=auth.login_rejected reason=bad_password` (or NC-specific equivalent) | +| P3 | 6 wrong attempts within the lockout window from `IP1` (test env: window 60 s, threshold 5) → 7th attempt **with correct password** | 401 / 429 (locked); audit log emits `target=audit event=auth.nc_basic_rejected reason=account_ip_locked` | +| P4 | Continuing P3: same correct credentials from a different `IP2` | 200 (per-IP scope; the #323 regression guard) | +| P5 | Auth a user flagged `is_external=true` (admin SQL fixture) | 401; audit log emits `target=audit event=auth.nc_basic_rejected reason=external_user` | + +--- + +## 5. What this catches when Drive lands + +For each scenario above, the **bare-username default-drive path** must +behave identically on the Drive branch. The Drive PR will then add (as +*additive* groups, not replacements): + +- **D' / J' / K' multi-drive PROPFIND/upload/trash**: same as D / J / K + but with URL `/remote.php/dav/files/admin~/…`. Responses' + hrefs must echo the composite form `admin~`. +- **Drive-mismatch suite**: auth as `admin~A`, URL says `admin~B` → 403; + same for missing or unauthorized drive UUIDs. +- **Mixed-mode suite**: bare URL while auth was composite → 403; composite + URL while auth was bare → 403 (pending exact policy). + +If A–P all stay green on the Drive branch with default-drive (bare) +URLs, the multi-drive refactor didn't regress the legacy path. + +--- + +## 6. Suggested implementation order + +1. **Batch 1**: A + B + C + P (auth bootstrap + identity + failure modes). + Small, foundational, unblocks every later batch. +2. **Batch 2**: D + E (PROPFIND + GET — the read surface NC client touches + first on every sync cycle). **Includes D8/D9/D10 trailing-slash guards.** +3. **Batch 3**: F + G + K (write + mutate + trash — the actual file + mutation surface). **Includes F8/F9 BLAKE3 round-trip checks.** +4. **Batch 4**: J (chunked uploads — most fragile + most-touched-by-Drive + work). **Includes J7 BLAKE3 round-trip.** +5. **Batch 5**: H + I + M + N (favorites, search, native DAV, LOCK). + **Includes M2 native-DAV trailing-slash guard.** +6. **Batch 6**: O + L (cross-user isolation, avatars). + +Each batch is independently runnable; batch 1+2+3 alone gives meaningful +regression signal even if the rest hasn't shipped. + +--- + +## 7. Open questions / TODOs before implementation + +- **C4**: confirm whether OxiCloud's policy says "admin can read any user + profile" (200) or "admin can only read self" (403). Pin behavior before + writing the assertion. +- **E4**: pin behavior of `GET` on a collection (currently appears to be + 200 with empty body — confirm and document). +- **K5**: pin behavior of restoring a trashed item to a path with a same-named existing file (rename? 412? overwrite?). +- **L1 / L2**: pin avatar + preview behavior (200 vs 404 by default). +- **Sharees autocomplete (C5)**: confirm `itemType=file` is the + parameter NC desktop sends, vs `itemType=0`. +- **Lockout threshold in test env**: standardise to a small value (5 + attempts / 60 s window) via `OXICLOUD_AUTH_*` env vars in + `tests/common/server.env` so P3/P4 are deterministic. + +--- + +## 8. Test data shape summary + +| Fixture | Size | Source | Used in | +|---|---|---|---| +| `tests/fixtures/hello.txt` | 32 B | existing | D, E, F, G, K | +| `tests/fixtures/hello-copy.txt` | 32 B (same content) | existing | (dedup tests, not needed here) | +| `tests/fixtures/medium-1mb.bin` | 1 MiB | random, generated by run.sh | E5, F7, M4 | +| `tests/fixtures/large-10mb.bin` | 10 MiB | random, gitignored, generated by run.sh | F7, F9 | +| `tests/fixtures/chunk-pair-a.bin` | 5 KiB | random | J2 | +| `tests/fixtures/chunk-pair-b.bin` | 5 KiB | random | J3 | +| `tests/fixtures/chunk-over-cap-5mb.bin` | 5 MiB | existing (gitignored, generated) | J8 | +| `tests/fixtures/small-image.png` | small | TBD (need to add or skip L2) | L2 | + +--- + +## 9. Regression-signal matrix + +Quick reference for "if this test fails, which past bug am I rediscovering": + +| Failing test | Likely root cause | +|---|---| +| D8 / D9 / D10 / M2 (collection hrefs without trailing `/`) | href-builder regressed past `nc_collection_href` invariant | +| F4 (GET after overwrite shows old content) | file_id→blob_hash cache invalidation regressed (the bug fixed in `f4ce4092`) | +| F8 / F9 / J7 (content_hash mismatch) | hash-on-write streaming pipeline produced wrong BLAKE3 (or the wrong field is on FileDto — the `etag` vs `content_hash` split regression from `0135930d`) | +| C1 / C2 (`data.id` doesn't echo expected username) | OCS handler regressed (e.g. NC desktop won't build correct DAV paths on the next sync) | +| D2 / D6 (resourcetype/href mismatch on collection-vs-file) | adapter regressed the file-vs-collection distinction | +| O1 / O3 (cross-user write succeeds) | **AuthZ regression** — security boundary broken | +| P3 / P4 (lockout scope wrong) | #323 per-(account, IP) lockout regressed back to per-account-only | +| P5 (external user logged in via NC) | external-user gate regressed | + +--- + +*Maintainer note: when a scenario passes that was previously failing, +update the test (don't delete the assertion) — the regression-signal +matrix is more useful when each row stays alive as a checkbox.* diff --git a/src/main.rs b/src/main.rs index 72982094..d3625e80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -120,8 +120,63 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result { #[tokio::main] async fn main() -> Result<(), Box> { - // Load .env file if present (for local development) - dotenvy::dotenv().ok(); + // Minimal CLI: + // --version Print version + branch + commit hash and exit. + // --config Load env from this file. When given, the default + // `./.env` probe is INTENTIONALLY skipped — tests + // use this to isolate from a developer's repo-root + // `.env`, and operators get a reproducible "this + // file and nothing else" boot. + let mut args = std::env::args().skip(1); + let mut config_path: Option = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "--version" | "-V" => { + println!( + "OxiCloud v{} (branch={} commit={})", + env!("CARGO_PKG_VERSION"), + env!("GIT_BRANCH"), + env!("GIT_HASH"), + ); + return Ok(()); + } + "--config" => { + let Some(p) = args.next() else { + eprintln!("--config requires a path argument"); + std::process::exit(2); + }; + config_path = Some(p); + } + "--help" | "-h" => { + println!( + "OxiCloud v{}\n\nUSAGE:\n oxicloud [--config ]\n oxicloud --version\n oxicloud --help\n", + env!("CARGO_PKG_VERSION"), + ); + return Ok(()); + } + other => { + eprintln!("Unknown argument: {other}"); + eprintln!("Try `oxicloud --help`."); + std::process::exit(2); + } + } + } + + match config_path { + Some(ref path) => { + // Explicit file → hard error on a missing/unreadable path. + // Silent fallback would defeat the purpose of pinning the + // config source. + if let Err(e) = dotenvy::from_filename(path) { + eprintln!("failed to load --config {path}: {e}"); + std::process::exit(2); + } + } + None => { + // Default dev-convenience probe at CWD/.env. + dotenvy::dotenv().ok(); + } + } // Initialize tracing tracing_subscriber::registry() diff --git a/tests/api/admin_user_ops.hurl b/tests/api/admin_user_ops.hurl new file mode 100644 index 00000000..e19e2c06 --- /dev/null +++ b/tests/api/admin_user_ops.hurl @@ -0,0 +1,186 @@ +# ============================================================= +# OxiCloud — Baseline: admin user-mutation surface +# ============================================================= +# Exercises the cluster of admin-only `PUT /api/admin/users/{id}/*` +# endpoints that operators rely on for incident response: +# - disable a compromised account +# - rotate its password +# - change its role +# - set / clear its quota +# +# Each is a one-shot mutation, but the failure mode of any one +# is severe (operator can't lock out an attacker, can't reset a +# password). Pinning them together keeps the cluster intact +# under refactors. +# +# Coverage: +# 1. Admin creates a fresh fixture user via POST /api/admin/users +# 2. Fixture user logs in successfully (baseline) +# 3. PUT /quota → fixture user's /me reports updated quota +# 4. PUT /role → fixture user becomes admin +# 5. PUT /password (admin reset) → old password no longer works, +# new password works +# 6. PUT /active=false → fixture user login → 403 +# 7. PUT /active=true → fixture user login works again +# 8. Cleanup via DELETE /api/admin/users/{id} +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 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" + + +# ───────────────────────────────────────────────────────────── +# 1 — Admin creates the fixture user "charlie-ops" +# Uses a name that doesn't collide with charlie in +# registration.hurl (which uses just "charlie"), so this +# file is order-independent. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "charlie-ops", + "password": "OriginalPassword1!", + "email": "charlie-ops@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +charlie_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 2 — Baseline: fixture user can log in with the password +# admin assigned. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "OriginalPassword1!" } + +HTTP 200 +[Captures] +charlie_token_v1: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# 3 — Set a non-default quota; charlie's own /me must reflect it. +# 200 MiB = 209715200 bytes — keeps the assertion exact while +# still being a believable per-user cap. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/admin/users/{{charlie_id}}/quota +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "quota_bytes": 209715200 } + +HTTP 200 + +GET {{base_url}}/api/auth/me +Authorization: Bearer {{charlie_token_v1}} + +HTTP 200 +[Asserts] +jsonpath "$.storage_quota_bytes" == 209715200 + + +# ───────────────────────────────────────────────────────────── +# 4 — Promote charlie to admin. After this the /me payload's +# role field must reflect the change. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/admin/users/{{charlie_id}}/role +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "role": "admin" } + +HTTP 200 + +GET {{base_url}}/api/auth/me +Authorization: Bearer {{charlie_token_v1}} + +HTTP 200 +[Asserts] +jsonpath "$.role" == "admin" + + +# ───────────────────────────────────────────────────────────── +# 5 — Admin resets charlie's password. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/admin/users/{{charlie_id}}/password +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "new_password": "AdminResetPassword2!" } + +HTTP 200 + +# Old password no longer works. Login failures map to 403 +# (AccessDenied) in this codebase — both "invalid credentials" +# and "account deactivated" go through the same error kind. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "OriginalPassword1!" } + +HTTP 403 + +# New password works. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "AdminResetPassword2!" } + +HTTP 200 +[Captures] +charlie_token_v2: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# 6 — Disable the account. The next login attempt must report +# 403 (account disabled) — distinct from 401 (bad creds) +# so operators can tell "I locked you out" from "you typed +# the wrong password". +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/admin/users/{{charlie_id}}/active +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "active": false } + +HTTP 200 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "AdminResetPassword2!" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# 7 — Re-enable; login works again. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/admin/users/{{charlie_id}}/active +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "active": true } + +HTTP 200 + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "charlie-ops", "password": "AdminResetPassword2!" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 8 — Teardown +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{charlie_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 diff --git a/tests/api/auth_session_lifecycle.hurl b/tests/api/auth_session_lifecycle.hurl new file mode 100644 index 00000000..df61b796 --- /dev/null +++ b/tests/api/auth_session_lifecycle.hurl @@ -0,0 +1,134 @@ +# ============================================================= +# OxiCloud — Baseline: auth session lifecycle +# ============================================================= +# Covers the refresh-token / logout / status surface that the +# existing `auth_login.hurl` deliberately leaves alone (login + +# lockout only). Browsers and the desktop NC client both rely +# on the rotate-and-revoke semantics being correct; this file +# pins them as a single end-to-end flow. +# +# Coverage: +# 1. GET /api/auth/status (no auth required, used by login page) +# 2. POST /api/auth/login (capture initial access + refresh) +# 3. POST /api/auth/refresh (rotate; capture new tokens) +# 4. New access token works on /api/auth/me +# 5. OLD refresh token rejected after rotation (session-family +# single-use enforcement) +# 6. POST /api/auth/logout (revokes the current refresh) +# 7. Refresh after logout → 401 (revocation actually took effect) +# 8. Access token still works briefly until it expires — we +# don't assert that explicitly because TTL is configurable +# and the access-token revocation semantics are documented +# as "JWT remains valid until exp"; logout only kills the +# refresh path. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 1 — Unauthenticated /api/auth/status probe. +# The login page hits this on every load; the response +# determines whether the "Create first admin" flow shows. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/status + +HTTP 200 +[Asserts] +jsonpath "$.initialized" == true +jsonpath "$.admin_count" >= 1 +jsonpath "$.registration_allowed" == true + + +# ───────────────────────────────────────────────────────────── +# 2 — Login as admin. Capture BOTH tokens — we need the +# refresh later to verify rotation semantics. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +access_v1: jsonpath "$.access_token" +refresh_v1: jsonpath "$.refresh_token" + + +# ───────────────────────────────────────────────────────────── +# 3 — Refresh: mint a new (access, refresh) pair. The refresh +# token is rotated — the response carries a NEW refresh +# that supersedes refresh_v1. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v1}}" } + +HTTP 200 +[Captures] +access_v2: jsonpath "$.access_token" +refresh_v2: jsonpath "$.refresh_token" +[Asserts] +jsonpath "$.access_token" != "{{access_v1}}" +jsonpath "$.refresh_token" != "{{refresh_v1}}" + + +# ───────────────────────────────────────────────────────────── +# 4 — The new access token works on a protected endpoint. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/me +Authorization: Bearer {{access_v2}} + +HTTP 200 +[Asserts] +jsonpath "$.username" == "{{username}}" + + +# ───────────────────────────────────────────────────────────── +# 5 — The OLD refresh token MUST be rejected after rotation. +# This is the session-family single-use property: replay +# of a used refresh token is treated as theft and rejected. +# +# The handler's OpenAPI doc says 401, but the actual response +# is 403: the refresh service raises `ErrorKind::AccessDenied` +# which maps to HTTP 403 in this codebase. Pinning the +# observed-and-correct behavior here. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v1}}" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# 6 — Logout using the v2 refresh + v2 access. Server-side +# this revokes the session and clears auth cookies in the +# response. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/logout +Authorization: Bearer {{access_v2}} +Content-Type: application/json +{ "refresh_token": "{{refresh_v2}}" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 7 — Post-logout: the v2 refresh token is now revoked. A +# refresh attempt is rejected with 403 (same AccessDenied +# mapping as step 5). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/refresh +Content-Type: application/json +{ "refresh_token": "{{refresh_v2}}" } + +HTTP 403 + + +# ───────────────────────────────────────────────────────────── +# 8 — Sanity re-check: status still reports the system as +# initialized after logout (no state regression). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/auth/status + +HTTP 200 +[Asserts] +jsonpath "$.initialized" == true diff --git a/tests/api/grants_nested_groups.hurl b/tests/api/grants_nested_groups.hurl index 0acf11a1..e11c6282 100644 --- a/tests/api/grants_nested_groups.hurl +++ b/tests/api/grants_nested_groups.hurl @@ -365,7 +365,7 @@ Authorization: Bearer {{henry_token}} HTTP 200 [Asserts] jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].resource_type" == "folder" -jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].permissions" includes "read" +jsonpath "$.items[?(@.resource.id=='{{perm_folder_id}}')].permissions" contains "read" # ── Mutations still denied (Viewer has no Update/Create/Delete) ─ POST {{base_url}}/api/folders diff --git a/tests/api/groups_effective_members.hurl b/tests/api/groups_effective_members.hurl new file mode 100644 index 00000000..e14b74bb --- /dev/null +++ b/tests/api/groups_effective_members.hurl @@ -0,0 +1,166 @@ +# ============================================================= +# OxiCloud — Baseline: subject-group effective-members +# ============================================================= +# Pins the transitive group-expansion contract that the ReBAC +# permissions story relies on. The interesting case is nesting: +# adding *user_X* into *Group_B*, and *Group_B* into *Group_A*, +# must make *user_X* visible from `GET /api/groups/{A}/effective-members` +# — the call site that authz lookups walk. +# +# Coverage: +# 1. Admin creates `Group_A` and `Group_B` +# 2. Admin creates fixture user `dora-eff` +# 3. PUT dora into Group_B (direct membership) +# 4. PUT Group_B into Group_A (nested membership) +# 5. GET /api/groups/{B}/members (direct only) → dora +# 6. GET /api/groups/{A}/members (direct only) → Group_B +# NOT dora (she's transitive) +# 7. GET /api/groups/{A}/effective-members → contains dora +# 8. Cleanup: remove user, remove group-member, delete groups + user +# +# This single nested scenario is the load-bearing one — if the +# transitive walk regresses, the ReBAC engine silently grants +# 0 permissions to nested members. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 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" + + +# ───────────────────────────────────────────────────────────── +# 1 — Create both groups. Names use kebab-case so the RFC-5321 +# local-part validator accepts them. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "eff-group-a", "description": "outer group" } + +HTTP 201 +[Captures] +group_a_id: jsonpath "$.id" + + +POST {{base_url}}/api/groups +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "eff-group-b", "description": "inner group" } + +HTTP 201 +[Captures] +group_b_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 2 — Create the fixture user. "dora-eff" — distinct from any +# user created by other test files, so this is order-safe. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "dora-eff", + "password": "DoraPassword1!", + "email": "dora-eff@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +dora_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 3 — Put dora into Group_B (direct user member) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups/{{group_b_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "user_id": "{{dora_id}}" } + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# 4 — Put Group_B into Group_A (nested group member) +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/groups/{{group_a_id}}/members +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "group_id": "{{group_b_id}}" } + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# 5 — Group_B direct membership: ONLY dora. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_b_id}}/members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 6 — Group_A direct membership: Group_B, NOT dora. The +# direct-members endpoint is non-transitive by contract; +# mixing in transitive members here would silently +# conflate the two surfaces. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_a_id}}/members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{group_b_id}}" +body not contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 7 — HEADLINE: Group_A effective-members reaches dora. +# A regression here is the canary for any change that +# breaks transitive expansion in the ReBAC layer. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/groups/{{group_a_id}}/effective-members +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{dora_id}}" + + +# ───────────────────────────────────────────────────────────── +# 8 — Teardown. Order matters: remove the nested group-member +# before deleting Group_B, so the FK cascade doesn't get +# ahead of us; remove dora's direct membership similarly. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/groups/{{group_a_id}}/members/group/{{group_b_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_b_id}}/members/user/{{dora_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_a_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/groups/{{group_b_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/admin/users/{{dora_id}} +Authorization: Bearer {{admin_token}} +HTTP 200 diff --git a/tests/api/nc_admin_views_other_user.hurl b/tests/api/nc_admin_views_other_user.hurl new file mode 100644 index 00000000..e17dd204 --- /dev/null +++ b/tests/api/nc_admin_views_other_user.hurl @@ -0,0 +1,107 @@ +# ============================================================= +# OxiCloud — Baseline: admin views another user's OCS profile +# ============================================================= +# C4 from BASELINE_TESTS_NC_WEBDAV.md. +# +# Deferred from Batch 1 because it needed the bob fixture +# that `nc_second_user_setup.hurl` now provides. Pins the +# behaviour of the existing rule in +# `interfaces/nextcloud/ocs_handler.rs::user_provisioning_response`: +# +# if user.username != userid && user.role != "admin" { +# return Json(ocs_err(403, ...)).into_response(); +# } +# +# i.e. you can read your own profile always; you can read +# anyone's profile if you're admin. Bob is not admin, so bob +# CANNOT read admin's profile (the symmetric assertion). +# +# Uses admin's app password for Basic Auth (same pattern as +# `nc_ocs_user_info.hurl`). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup 1 — JWT login as admin + mint NC app password. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_jwt: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{admin_jwt}} +Content-Type: application/json +{ "label": "nc_admin_views_other_user hurl test" } + +HTTP 200 +[Captures] +admin_nc_user: jsonpath "$.username" +admin_nc_pw: jsonpath "$.password" +admin_nc_pw_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Setup 2 — JWT login as bob + mint NC app password. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob", "password": "BobPassword1!" } + +HTTP 200 +[Captures] +bob_jwt: jsonpath "$.access_token" + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{bob_jwt}} +Content-Type: application/json +{ "label": "nc_admin_views_other_user hurl test (bob)" } + +HTTP 200 +[Captures] +bob_nc_user: jsonpath "$.username" +bob_nc_pw: jsonpath "$.password" +bob_nc_pw_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# C4-positive — admin CAN read bob's OCS provisioning profile +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v1.php/cloud/users/bob?format=json +[BasicAuth] +{{admin_nc_user}}: {{admin_nc_pw}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.statuscode" == 100 +jsonpath "$.ocs.data.id" == "bob" +jsonpath "$.ocs.data.email" == "bob@example.com" + + +# ───────────────────────────────────────────────────────────── +# C4-symmetric — bob (non-admin) CANNOT read admin's profile +# (proves the admin-only branch isn't a no-op) +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json +[BasicAuth] +{{bob_nc_user}}: {{bob_nc_pw}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.statuscode" == 403 +jsonpath "$.ocs.meta.status" == "failure" + + +# ───────────────────────────────────────────────────────────── +# Teardown — revoke both app passwords. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{admin_nc_pw_id}} +Authorization: Bearer {{admin_jwt}} +HTTP 200 + +DELETE {{base_url}}/api/auth/app-passwords/{{bob_nc_pw_id}} +Authorization: Bearer {{bob_jwt}} +HTTP 200 diff --git a/tests/api/nc_auth_failures.hurl b/tests/api/nc_auth_failures.hurl new file mode 100644 index 00000000..3033d933 --- /dev/null +++ b/tests/api/nc_auth_failures.hurl @@ -0,0 +1,282 @@ +# ============================================================= +# OxiCloud — Baseline: NC Basic Auth failure modes +# ============================================================= +# Group P from BASELINE_TESTS_NC_WEBDAV.md. +# +# Coverage: +# P1 — no Authorization header → 401 + WWW-Authenticate +# P2 — wrong password (real user) → 401 +# P3 — N wrong attempts from same IP against a THROWAWAY +# username trip the per-(account,IP) lockout +# P4 — per-IP lockout scope (the #323 regression guard): +# 6 wrong attempts from spoofed X-Forwarded-For: IP1 +# lock (admin, IP1), but the SAME correct credential +# from spoofed X-Forwarded-For: IP2 still succeeds. +# Depends on OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 in +# tests/common/server.env so the server honours the +# X-Forwarded-For header on localhost. +# +# Deliberately NOT covered here: +# P5 — External user attempts NC Basic Auth. Externals can't +# mint app passwords in the first place (the upstream gate +# is asserted in tests/api/external_users.hurl), so the +# in-middleware belt-and-braces check is unreachable via +# a black-box HTTP test. Verified by code inspection. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup 1 — JWT login (needed to mint the app password for P4's +# positive control). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Setup 2 — Mint admin's NC app password. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_auth_failures P4 positive control" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# P1 — PROPFIND without any Authorization header +# → 401 with `WWW-Authenticate: Basic realm="OxiCloud"`. +# NC desktop relies on this challenge to know it should +# offer credentials at all. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ + +HTTP 401 +[Asserts] +header "WWW-Authenticate" contains "Basic" + + +# ───────────────────────────────────────────────────────────── +# P2 — Wrong password against a real user +# → 401 with the same WWW-Authenticate challenge. +# Anti-enumeration: response shape identical whether the +# user exists or not. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +[BasicAuth] +{{username}}: definitely-wrong-password + +HTTP 401 +[Asserts] +header "WWW-Authenticate" contains "Basic" + + +# ───────────────────────────────────────────────────────────── +# P3 — Lockout trip +# +# Consecutive bad-credential attempts against a throwaway +# username trip the per-(account, IP) lockout. The default +# threshold is 5 (`OXICLOUD_LOCKOUT_MAX_FAILURES = 5`) and +# `tests/common/server.env` does NOT override it — the only +# raised values in that env are the broader login / refresh / +# register rate-limits, which prevent the rate-limiter from +# firing AHEAD of the lockout under sustained test traffic. +# The lockout itself still engages on the 6th attempt. +# +# Once engaged, every subsequent attempt for the same +# (account, IP) pair short-circuits to 401 from the lockout +# check, BEFORE app-password verification runs. The audit log +# fires `WARN account_temporarily_locked` from +# `login_lockout_service.rs` when the cap is hit — useful +# correlation signal during a real run. +# +# A throwaway username (`nc-lockout-probe-…`) is used because +# the lockout is keyed by (username, IP); locking a throwaway +# pair never poisons admin's auth path, so downstream Hurl +# tests in run.sh that authenticate as admin keep working. +# +# Limitation of a black-box HTTP probe: the wire response is +# 401 with the same WWW-Authenticate header whether the 401 +# comes from "lockout engaged" or "still just rejecting bad +# creds" — both look the same on the wire. Verification of +# the lockout-engaged branch specifically lives in the unit +# tests (`login_lockout_service.rs::tests`). What this Hurl +# test guards is that 7 consecutive attempts keep returning +# the same 401 shape (no 500s, no header drift), and the +# server-side audit log confirms the lockout engaged at 5. +# ───────────────────────────────────────────────────────────── + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-1 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-2 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-3 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-4 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-5 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-6 + +HTTP 401 + +# 7th attempt: account+IP is now locked at the middleware level. +# Continues to return 401. +PROPFIND {{base_url}}/remote.php/dav/files/nc-lockout-probe-001/ +[BasicAuth] +nc-lockout-probe-001: bad-7 + +HTTP 401 +[Asserts] +header "WWW-Authenticate" contains "Basic" + + +# ───────────────────────────────────────────────────────────── +# P4 — Per-IP lockout scope (#323 regression guard) +# +# Goal: prove that locking out (admin, IP1) does NOT lock out +# (admin, IP2) — the lockout is keyed by *both* parts, not by +# username alone. This was the gap the reporter demonstrated: +# an attacker spoofing X-Forwarded-For could lock a legitimate +# user out from their own IP. The fix scoped the key. +# +# Mechanic: +# 1. Pre-check: admin's app password works from the test's +# default client IP (127.0.0.1, no X-Forwarded-For). +# 2. 6 wrong attempts with X-Forwarded-For: 10.0.0.1 trip the +# lockout for (admin, 10.0.0.1). +# 3. CORRECT app password with X-Forwarded-For: 10.0.0.1 → +# 401 (still locked from THIS IP — positive demonstration +# that the lockout actually engaged, not just chance). +# 4. CORRECT app password with X-Forwarded-For: 10.0.0.2 → +# 207 (NOT locked from this IP — the load-bearing +# assertion of P4). +# +# Why this doesn't break the rest of the suite: every other +# test runs from the default client IP (127.0.0.1) without +# X-Forwarded-For, so (admin, 127.0.0.1) is untouched. The +# lockouts placed here are on (admin, 10.0.0.1) and +# (admin, 10.0.0.2 — released by the success), neither of +# which any other test touches. +# +# Requires: OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 in +# tests/common/server.env. +# ───────────────────────────────────────────────────────────── + +# ── Pre-check: app password works with no X-Forwarded-For ─── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 207 + + +# ── Step 1: burn the lockout for (admin, 10.0.0.1) ────────── + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-1 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-2 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-3 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-4 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-5 + +HTTP 401 + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{username}}: p4-bad-6 + +HTTP 401 + + +# ── Step 2: CORRECT app password from IP1 — still 401 ────── +# This is the positive demonstration that the lockout engaged. +# If this were 207, the lockout would not have fired and the +# subsequent IP2 success wouldn't prove anything. +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.1 +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 401 + + +# ── Step 3: CORRECT app password from IP2 — 207 ──────────── +# The load-bearing assertion of P4: a successful auth from a +# DIFFERENT spoofed source IP proves the lockout was scoped +# to (admin, 10.0.0.1) and not to admin alone. +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/ +X-Forwarded-For: 10.0.0.2 +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 207 + + +# ───────────────────────────────────────────────────────────── +# Teardown — Revoke the app password we minted. Keeps the +# app-passwords table clean across re-runs. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} + +HTTP 200 diff --git a/tests/api/nc_avatar_preview.hurl b/tests/api/nc_avatar_preview.hurl new file mode 100644 index 00000000..5b7e5c0a --- /dev/null +++ b/tests/api/nc_avatar_preview.hurl @@ -0,0 +1,109 @@ +# ============================================================= +# OxiCloud — Baseline: NC avatar + preview +# ============================================================= +# Group L from BASELINE_TESTS_NC_WEBDAV.md (3 scenarios). +# +# All NC routes (including avatars and previews) sit behind the +# `basic_auth_middleware` wired in `interfaces/nextcloud/ +# routes.rs:174`. Even though the avatar payload is described as +# "decorative, not security-critical" in `avatar_handler.rs`, +# the request itself still requires a valid Basic Auth identity. +# Both L1 and L3 therefore mint an app password and pass it. +# +# L1 / L3 pin the avatar handler's actual contract: once +# authenticated, it ALWAYS returns 200 — stored profile image +# when present, SVG-with-initials otherwise (including for +# users that don't exist at all). +# +# L2 hits the authenticated preview endpoint with a fake file +# id and pins the not-found path. A positive-path preview test +# would require seeding an image file and resolving its NC +# numeric id, which is more setup than this baseline needs. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup 1 — JWT login (gives us a Bearer token to mint the +# app password used by the avatar Basic Auth + the +# preview JWT auth). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Setup 2 — Mint an app password for the L1/L3 Basic Auth. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_avatar_preview hurl test" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# L1 — Avatar for an existing user (admin) always 200 +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/index.php/avatar/{{username}}/64 +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +header "Content-Type" startsWith "image/" + + +# ───────────────────────────────────────────────────────────── +# L3 — Avatar for a nonexistent user +# +# Pinned current behaviour: 200 with the SVG-initials fallback. +# The handler explicitly comments "decorative, not security- +# critical" — it never returns 404 for an unknown name; it +# renders initials from whatever string the caller passed. +# RFC strictness would suggest 404 here, but NC desktop / Web +# UI happily render the SVG. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/index.php/avatar/nonexistent-user-deadbeef/64 +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +header "Content-Type" startsWith "image/" + + +# ───────────────────────────────────────────────────────────── +# L2 — Preview of a non-existent file id → 404 +# +# fileId=99999999 is well below any real NC id we'd ever +# assign, so this exercises the "file not found" branch +# without depending on a seeded image fixture. +# +# Auth: same Basic Auth as L1/L3 — the NC `/index.php/*` +# surface is uniformly behind `basic_auth_middleware`, so Bearer +# JWT is rejected at the middleware boundary before the handler +# even sees the request. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/index.php/core/preview?fileId=99999999&x=128&y=128 +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Teardown — revoke the app password. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} +HTTP 200 diff --git a/tests/api/nc_login_flow_v2.hurl b/tests/api/nc_login_flow_v2.hurl new file mode 100644 index 00000000..96df065d --- /dev/null +++ b/tests/api/nc_login_flow_v2.hurl @@ -0,0 +1,74 @@ +# ============================================================= +# OxiCloud — Baseline: NC Login Flow v2 +# ============================================================= +# Group B from BASELINE_TESTS_NC_WEBDAV.md. +# +# Login Flow v2 is how NC desktop / iOS / Android bootstrap an +# app password without ever seeing the user's real password. +# Breaking the JSON shape returned by `/index.php/login/v2` or +# `…/poll` means no new desktop client can pair. +# +# Coverage: +# B1 — POST /index.php/login/v2 returns { login, poll: {…} } +# B2 — POST …/login/v2/poll before grant → 404 +# B5 — POST …/login/v2/poll with unknown / expired token → 404 +# +# Deliberately deferred: +# B3 — Simulate the browser-side grant. This requires a +# multi-step interaction with the grant page (HTML form +# POST) that is awkward in Hurl and tied to the device- +# auth-grant internals. Covered separately by +# tests/webdav/ once the bash tooling for browser +# simulation lands. +# B4 — Poll AFTER grant. Same dependency as B3. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# B1 — POST /index.php/login/v2 (no auth) +# → 200 with JSON `{ login: , poll: { token, endpoint } }`. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/index.php/login/v2 + +HTTP 200 +[Captures] +poll_token: jsonpath "$.poll.token" +poll_endpoint: jsonpath "$.poll.endpoint" +login_url: jsonpath "$.login" +[Asserts] +jsonpath "$.poll.token" exists +jsonpath "$.poll.endpoint" exists +jsonpath "$.login" exists +# The login URL embeds the flow token as a PATH segment +# (`/login/v2/flow/`), not a query param — that's what +# the NC desktop client follows after init. +jsonpath "$.login" contains "/login/v2/flow/" + + +# ───────────────────────────────────────────────────────────── +# B2 — POST /login/v2/poll BEFORE the user grants +# → 404 (NC convention: "not yet ready"). +# +# The token here is the one captured in B1, so the server +# recognises the flow exists; it just hasn't been granted +# yet. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: {{poll_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# B5 — POST /login/v2/poll with an unknown / never-issued token +# → 404. Same shape as B2 — server doesn't distinguish +# "wrong token" from "right token, not granted yet" on the +# wire (anti-enumeration: a probe can't tell which flows +# exist). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: nonexistent-token-deadbeef-cafef00d + +HTTP 404 diff --git a/tests/api/nc_ocs_user_info.hurl b/tests/api/nc_ocs_user_info.hurl new file mode 100644 index 00000000..e7e3b600 --- /dev/null +++ b/tests/api/nc_ocs_user_info.hurl @@ -0,0 +1,160 @@ +# ============================================================= +# OxiCloud — Baseline: OCS user-info + provisioning +# ============================================================= +# Group C from BASELINE_TESTS_NC_WEBDAV.md. +# +# /ocs/v{1,2}.php/cloud/user is what NC desktop reads after +# Login Flow v2 to learn its `data.id` — and that exact string +# is then spliced into every subsequent DAV path +# (`/remote.php/dav/files/{id}/…`). A regression in this shape +# breaks 100% of subsequent syncs. +# +# Coverage: +# C1 — GET /ocs/v1.php/cloud/user → statuscode 100 + payload +# C2 — GET /ocs/v2.php/cloud/user → statuscode 200 + payload +# C3 — GET /ocs/v1.php/cloud/users/admin (self provisioning) +# C5 — GET /ocs/v2.php/apps/files_sharing/api/v1/sharees shape +# +# Deferred: +# C4 — admin reading another user's provisioning profile. +# Needs a second user fixture wired into setup.hurl. +# Tracked as a TODO in BASELINE_TESTS_NC_WEBDAV.md §7. +# +# Setup pattern: this file mints its own app password inline +# (steps 1–2) so it is self-contained and resilient to test +# ordering. The mint uses the JWT-authenticated REST API +# (`POST /api/auth/app-passwords`); the NC-side username +# returned by the response is exactly the value the NC client +# would use as the HTTP Basic Auth username. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup 1 — JWT login (gives us a Bearer token to mint the +# app password). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Setup 2 — Mint an app password for this test file. The +# response carries the plaintext password (shown +# exactly once) and the username to use in Basic Auth. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_ocs_user_info hurl test" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" +[Asserts] +jsonpath "$.password" matches "^oxicloud-" + + +# ───────────────────────────────────────────────────────────── +# C1 — GET /ocs/v1.php/cloud/user +# data.id is what NC client splices into DAV URLs. +# +# Note: `handle_user_info` returns `statuscode: 200` +# regardless of /v1.php vs /v2.php (unlike capabilities, +# which switches on ocs_version). C1 and C2 therefore +# both assert 200 here — this is the actual server +# behaviour; if either diverges in future, this is the +# pin that catches it. +# +# Hurl JSONPath doesn't accept `-` in dotted form, so the +# assertion goes against `displayname` (the unhyphenated +# alias the handler also emits) rather than `display-name`. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v1.php/cloud/user?format=json +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.status" == "ok" +jsonpath "$.ocs.meta.statuscode" == 200 +jsonpath "$.ocs.meta.message" == "OK" +jsonpath "$.ocs.data.enabled" == true +jsonpath "$.ocs.data.id" == "{{username}}" +jsonpath "$.ocs.data.email" == "{{email}}" +jsonpath "$.ocs.data.displayname" exists +jsonpath "$.ocs.data.quota.used" exists +jsonpath "$.ocs.data.quota.total" exists +jsonpath "$.ocs.data.quota.free" exists +jsonpath "$.ocs.data.quota.relative" exists + + +# ───────────────────────────────────────────────────────────── +# C2 — GET /ocs/v2.php/cloud/user +# Same payload shape; OCS v2 envelope reports statuscode 200. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v2.php/cloud/user?format=json +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.status" == "ok" +jsonpath "$.ocs.meta.statuscode" == 200 +jsonpath "$.ocs.data.id" == "{{username}}" +jsonpath "$.ocs.data.email" == "{{email}}" + + +# ───────────────────────────────────────────────────────────── +# C3 — GET /ocs/v1.php/cloud/users/{userid} (self lookup) +# Full provisioning profile: groups, lastLogin, backend. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v1.php/cloud/users/{{username}}?format=json +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.statuscode" == 100 +jsonpath "$.ocs.data.id" == "{{username}}" +jsonpath "$.ocs.data.email" == "{{email}}" +jsonpath "$.ocs.data.groups" exists +jsonpath "$.ocs.data.backend" exists +jsonpath "$.ocs.data.lastLogin" exists + + +# ───────────────────────────────────────────────────────────── +# C5 — GET /ocs/v2.php/apps/files_sharing/api/v1/sharees +# Sharees autocomplete shape — NC desktop uses this to +# populate the share-dialog. Even with no shares set up +# yet, the envelope + array slots must exist. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v2.php/apps/files_sharing/api/v1/sharees?format=json&search=ad&itemType=file +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.status" == "ok" +jsonpath "$.ocs.meta.statuscode" == 200 +jsonpath "$.ocs.data.exact" exists +jsonpath "$.ocs.data.exact.users" exists +jsonpath "$.ocs.data.exact.groups" exists +jsonpath "$.ocs.data.users" exists + + +# ───────────────────────────────────────────────────────────── +# Teardown — Revoke the app password we minted (captured `ap_id` +# from the create response, no list+lookup needed). +# Keeps the test surface clean across re-runs. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} + +HTTP 200 diff --git a/tests/api/nc_second_user_setup.hurl b/tests/api/nc_second_user_setup.hurl new file mode 100644 index 00000000..05fd69f6 --- /dev/null +++ b/tests/api/nc_second_user_setup.hurl @@ -0,0 +1,54 @@ +# ============================================================= +# OxiCloud — Baseline: second-user fixture (bob) +# ============================================================= +# (Re)creates the `bob` user so the Group O cross-user +# isolation scenarios (and Group C's C4 admin-reads-another- +# user case) have a real second principal to test against. +# +# Run-order: this file runs AFTER `external_users.hurl`, which +# deletes bob at its end, and AFTER `permissions.hurl`, which +# creates+uses bob via the admin API. By the time this file +# runs, bob may or may not exist — the anti-enumeration +# registration endpoint returns 200 either way, so this is +# safe in both states. The login step that follows is the +# actual existence assertion: if bob can log in, downstream +# bob-dependent tests (nc_admin_views_other_user.hurl here +# and test_nc_cross_user_isolation.sh in the webdav suite) +# will work. +# +# Bob's credentials are hardcoded fixtures (not env-driven) so +# downstream test files don't need to coordinate via test.env. +# Bob's password is unique per fixture; no overlap with admin. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Register bob via the email-anti-enumeration flow. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "bob", + "email": "bob@example.com", + "password": "BobPassword1!" +} + +HTTP 200 +[Asserts] +jsonpath "$.message" contains "request received" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Confirm bob can log in (proves registration +# actually landed the account — the anti-enum response +# alone wouldn't tell us). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob", "password": "BobPassword1!" } + +HTTP 200 +[Asserts] +jsonpath "$.access_token" exists +jsonpath "$.user.username" == "bob" +jsonpath "$.user.email" == "bob@example.com" diff --git a/tests/api/nc_status_capabilities.hurl b/tests/api/nc_status_capabilities.hurl new file mode 100644 index 00000000..af4b711e --- /dev/null +++ b/tests/api/nc_status_capabilities.hurl @@ -0,0 +1,71 @@ +# ============================================================= +# OxiCloud — Baseline: NC status + capabilities +# ============================================================= +# Group A from BASELINE_TESTS_NC_WEBDAV.md (4 scenarios). +# +# These four endpoints are what every NC client probes first. +# If any of them returns the wrong shape, NC desktop refuses to +# even attempt sync, so the regression signal is loud and early. +# +# Anonymous (no auth) — these endpoints must be public. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# A1 — GET /status.php +# Used by NC client for "is this server installed?". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/status.php + +HTTP 200 +[Asserts] +jsonpath "$.installed" == true +jsonpath "$.maintenance" == false +jsonpath "$.version" exists +jsonpath "$.versionstring" exists +jsonpath "$.productname" == "OxiCloud" + + +# ───────────────────────────────────────────────────────────── +# A2 — GET /index.php/204 +# NC mobile connectivity probe. Must be 204, empty body. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/index.php/204 + +HTTP 204 +[Asserts] +bytes count == 0 + + +# ───────────────────────────────────────────────────────────── +# A3 — GET /ocs/v1.php/cloud/capabilities +# OCS v1 envelope must report statuscode 100. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v1.php/cloud/capabilities?format=json + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.status" == "ok" +jsonpath "$.ocs.meta.statuscode" == 100 +jsonpath "$.ocs.meta.message" == "OK" +jsonpath "$.ocs.data.version.major" exists +jsonpath "$.ocs.data.capabilities" exists +jsonpath "$.ocs.data.capabilities.theming.name" == "OxiCloud" + + +# ───────────────────────────────────────────────────────────── +# A4 — GET /ocs/v2.php/cloud/capabilities +# OCS v2 envelope must report statuscode 200 (200 ≡ 100 +# semantically — different status enum across versions). +# Payload shape otherwise identical to v1. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/ocs/v2.php/cloud/capabilities?format=json + +HTTP 200 +[Asserts] +jsonpath "$.ocs.meta.status" == "ok" +jsonpath "$.ocs.meta.statuscode" == 200 +jsonpath "$.ocs.meta.message" == "OK" +jsonpath "$.ocs.data.version.major" exists +jsonpath "$.ocs.data.capabilities" exists +jsonpath "$.ocs.data.capabilities.theming.name" == "OxiCloud" diff --git a/tests/api/public_shares.hurl b/tests/api/public_shares.hurl new file mode 100644 index 00000000..34f49657 --- /dev/null +++ b/tests/api/public_shares.hurl @@ -0,0 +1,261 @@ +# ============================================================= +# OxiCloud — Baseline: public-share token surface +# ============================================================= +# Pins the legacy tokenized share flow (`/api/shares` to mint, +# `/api/s/{token}/*` to consume) — the only public-facing +# unauthenticated read surface in the product. Any regression +# in scope-enforcement here breaks the share-link feature for +# every external recipient. +# +# Coverage: +# 1. Login + seed: create a folder with a file inside. +# 2. POST /api/shares (folder share, no password) → 201 +# 3. GET /api/shares (lists ours) +# 4. GET /api/shares/{id} (single fetch) +# 5. GET /api/s/{token} (no auth) → 200 +# 6. GET /api/s/{token}/verify — not applicable +# for a password-less share, but the unauthenticated +# anonymous probe of `/api/s/{token}` already exercises +# the access path; verify is exercised in the password +# branch below. +# 7. GET /api/s/{token}/contents (no auth) → 200 +# 8. GET /api/s/{token}/file/{file_id} (no auth) → 200 + body +# 9. POST /api/shares — password-protected variant +# 10. GET /api/s/{pw_token} → 401 (password required) +# 11. POST /api/s/{pw_token}/verify wrong pw → 401 +# 12. POST /api/s/{pw_token}/verify right pw → 200 +# 13. DELETE /api/shares/{id} (no-password) → 204 +# 14. GET /api/s/{token} after revoke → 404 / 410 +# 15. Cleanup the password-share + folder. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Setup — admin login, seed folder + file +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "public-share-test", "parent_id": "{{admin_home_id}}" } + +HTTP 201 +[Captures] +share_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{share_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +shared_file_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# 2 — Mint a password-less folder share +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_type": "folder" +} + +HTTP 201 +[Captures] +share_id: jsonpath "$.id" +share_token: jsonpath "$.token" +[Asserts] +jsonpath "$.has_password" == false +jsonpath "$.token" matches "^[A-Za-z0-9_-]+$" + + +# ───────────────────────────────────────────────────────────── +# 3 — The share appears in the owner's listing +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +body contains "{{share_id}}" + + +# ───────────────────────────────────────────────────────────── +# 4 — Single-share fetch +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/shares/{{share_id}} +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{share_id}}" +jsonpath "$.item_id" == "{{share_folder_id}}" +jsonpath "$.item_type" == "folder" + + +# ───────────────────────────────────────────────────────────── +# 5 — Public access via the token, NO auth header. This is the +# security-critical path: any auth check that creeps in +# here breaks all external recipients. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 7 — Browse the shared folder contents (no auth). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}}/contents + +HTTP 200 +[Asserts] +body contains "{{shared_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 8 — Direct file share: mint a share on the FILE itself +# (item_type=file) and access it via /api/s/{token}. +# +# KNOWN BUG: GET /api/s/{folder-token}/file/{file_id} (the +# "fetch a file from inside a shared folder" route at +# share_handler.rs:653) currently returns 500. We sidestep +# it here by sharing the file directly. When the folder-file +# path is fixed, add a new scenario asserting it returns +# 200 + body, and back-link this comment. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{shared_file_id}}", + "item_type": "file" +} + +HTTP 201 +[Captures] +file_share_id: jsonpath "$.id" +file_share_token: jsonpath "$.token" + + +GET {{base_url}}/api/s/{{file_share_token}} + +HTTP 200 +[Asserts] +jsonpath "$.item_type" == "file" + + +# ───────────────────────────────────────────────────────────── +# 9 — Mint a password-protected share on the same folder. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/shares +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_type": "folder", + "password": "secret-share-password-1!" +} + +HTTP 201 +[Captures] +pw_share_id: jsonpath "$.id" +pw_share_token: jsonpath "$.token" +[Asserts] +jsonpath "$.has_password" == true + + +# ───────────────────────────────────────────────────────────── +# 10 — Anonymous probe must report "password required" without +# leaking the shared item's contents. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{pw_share_token}} + +HTTP 401 +[Asserts] +jsonpath "$.requiresPassword" == true + + +# ───────────────────────────────────────────────────────────── +# 11 — Wrong password → 401 (does NOT issue an unlock cookie). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/s/{{pw_share_token}}/verify +Content-Type: application/json +{ "password": "obviously-wrong" } + +HTTP 401 + + +# ───────────────────────────────────────────────────────────── +# 12 — Right password → 200 + Set-Cookie unlock JWT. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/s/{{pw_share_token}}/verify +Content-Type: application/json +{ "password": "secret-share-password-1!" } + +HTTP 200 +[Asserts] +header "Set-Cookie" exists + + +# ───────────────────────────────────────────────────────────── +# 13 — Revoke the password-less share +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/shares/{{share_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# 14 — After revocation the token must not resolve. Different +# server versions return 404 vs 410 depending on whether +# the row was hard-deleted or marked revoked — both are +# acceptable rejections of the token; what matters is the +# token does NOT yield a 200. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/s/{{share_token}} + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# 15 — Teardown: revoke the password share + the direct +# file-share, then delete the folder. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/shares/{{pw_share_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/shares/{{file_share_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 + +DELETE {{base_url}}/api/folders/{{share_folder_id}} +Authorization: Bearer {{admin_token}} +HTTP 204 diff --git a/tests/api/run.sh b/tests/api/run.sh index 80bac0d6..71c971e2 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -62,24 +62,44 @@ OXICLOUD_SERVER_PORT=$SERVER_PORT OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage" set +a -# ensure storage is empty before starting -echo "Wipe $OXICLOUD_STORAGE_PATH to ensure clean startup" -rm -rf "$OXICLOUD_STORAGE_PATH" -mkdir -p "$OXICLOUD_STORAGE_PATH" +# ensure storage is empty before starting (regex-gated rm -rf) +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" # ── 3. Start OxiCloud server ────────────────────────────────────────────────── BUILD_TARGET="${BUILD_TARGET:-debug}" OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" -if [[ -x "$OXICLOUD_BIN" ]]; then - log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." - "$OXICLOUD_BIN" & -else - log "Building and starting OxiCloud server on port $SERVER_PORT..." - cd "$REPO_ROOT" - cargo run & +# Build synchronously (no time cap — clean builds take minutes) BEFORE +# starting the server, so the `/ready` poll below only times what we +# actually want it to time: server startup, not compilation. Earlier +# this ran `cargo run &` directly, which conflated the two and tripped +# the 120 s readiness timeout on any `cargo clean` run. +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET) — this can take a few minutes after \`cargo clean\`..." + # Cargo's debug profile is the implicit default (`cargo build` alone) + # — there is NO `--profile debug` flag (it would error). Only the + # release path needs an explicit flag. + case "$BUILD_TARGET" in + debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;; + release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;; + *) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;; + esac fi + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + die "Build completed but $OXICLOUD_BIN is missing — wrong BUILD_TARGET?" +fi + +log "Starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." +# `--config` pins the env file the binary reads AND suppresses the default +# `.env` probe in main.rs, so a developer's repo-root `.env` can never leak +# into a test run. Bash also sourced the same file above, so anything the +# test harness itself reads via $OXICLOUD_* stays available; dotenvy won't +# override those already-exported values. +"$OXICLOUD_BIN" --config "$COMMON/server.env" & SERVER_PID=$! log "Waiting for server at $base_url..." wait_for_http "$base_url/ready" 120 @@ -100,10 +120,21 @@ fi # ── 4. Run Hurl tests ───────────────────────────────────────────────────────── log "Running Hurl tests..." +# NC baseline tests (groups A + B + C from BASELINE_TESTS_NC_WEBDAV.md) +# are interleaved early because they use a separate code surface and +# their failures should not be masked by later test regressions. +# The auth-failure / lockout file (group P) runs LAST — it locks out +# a throwaway username so admin Basic Auth stays usable for everything +# above it. hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \ "$API_DIR/setup.hurl" \ "$API_DIR/auth_login.hurl" \ + "$API_DIR/auth_session_lifecycle.hurl" \ "$API_DIR/registration.hurl" \ + "$API_DIR/nc_status_capabilities.hurl" \ + "$API_DIR/nc_login_flow_v2.hurl" \ + "$API_DIR/nc_ocs_user_info.hurl" \ + "$API_DIR/nc_avatar_preview.hurl" \ "$API_DIR/files-folders.hurl" \ "$API_DIR/favorites.hurl" \ "$API_DIR/trash.hurl" \ @@ -112,12 +143,19 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ "$API_DIR/contacts.hurl" \ + "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \ "$API_DIR/subject_groups.hurl" \ + "$API_DIR/groups_effective_members.hurl" \ "$API_DIR/grants_nested_groups.hurl" \ "$API_DIR/external_users.hurl" \ - "$API_DIR/chunked_upload_cap.hurl" + "$API_DIR/search_basic.hurl" \ + "$API_DIR/nc_second_user_setup.hurl" \ + "$API_DIR/nc_admin_views_other_user.hurl" \ + "$API_DIR/admin_user_ops.hurl" \ + "$API_DIR/chunked_upload_cap.hurl" \ + "$API_DIR/nc_auth_failures.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/search_basic.hurl b/tests/api/search_basic.hurl new file mode 100644 index 00000000..4bc306a5 --- /dev/null +++ b/tests/api/search_basic.hurl @@ -0,0 +1,162 @@ +# ============================================================= +# OxiCloud — Baseline: search surface +# ============================================================= +# Pins `/api/search` and `/api/search/suggest` plus the +# cross-user isolation property: a search MUST NEVER surface a +# file the caller doesn't own (and isn't shared with). Search +# is the kind of feature where a sloppy SQL join is exactly +# what introduces a cross-user leak — this test catches that. +# +# Requires OXICLOUD_ENABLE_SEARCH=true (set in tests/common/server.env). +# +# Coverage: +# 1. Admin uploads `unique-search-needle-aaa.txt` to her home +# 2. GET /api/search?query=unique-search-needle returns the file +# 3. GET /api/search?query=does-not-exist-xyz returns 0 files +# 4. GET /api/search/suggest?query=unique-search-needle returns +# something (suggestion-shape is allowed to be permissive) +# 5. Cross-user: bob searches "unique-search-needle" → MUST NOT +# see admin's file (security baseline) +# 6. Teardown: delete the file +# +# Bob is (re-)created inline so this file is order-independent +# with respect to nc_second_user_setup.hurl (which runs later +# in run.sh). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# 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" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Captures] +admin_home_id: jsonpath "$[0].id" + + +# Anti-enum registration: 200 whether bob existed or not. +POST {{base_url}}/api/auth/register +Content-Type: application/json +{ + "username": "bob", + "email": "bob@example.com", + "password": "BobPassword1!" +} + +HTTP 200 + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "bob", "password": "BobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# 1 — Admin uploads `hello.txt` to a dedicated subfolder, then +# renames it to a deliberately unique name so the search +# assertion is unambiguous. The subfolder isolates this +# test from any other test that already left a `hello.txt` +# in admin's home (would otherwise 409). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "search-basic-test", "parent_id": "{{admin_home_id}}" } + +HTTP 201 +[Captures] +search_folder_id: jsonpath "$.id" + + +POST {{base_url}}/api/files/upload +Authorization: Bearer {{admin_token}} +[MultipartFormData] +folder_id: {{search_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +needle_file_id: jsonpath "$.id" + + +PUT {{base_url}}/api/files/{{needle_file_id}}/rename +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "name": "unique-search-needle-aaa.txt" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 2 — Search hits the seeded file by substring of its name. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=unique-search-needle +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.files" count >= 1 +body contains "{{needle_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 3 — A search for a phrase that can't match anything must +# return an empty result set, NOT an error. Empty-results +# is a hot path; we don't want it to start 500ing. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=does-not-exist-xyz-zzz-9999 +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$.files" count == 0 +jsonpath "$.folders" count == 0 + + +# ───────────────────────────────────────────────────────────── +# 4 — Suggest returns a usable payload (shape is permissive — +# just confirm the endpoint serves 200 and isn't truncating +# to an error envelope). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search/suggest?query=unique-search-needle +Authorization: Bearer {{admin_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# 5 — HEADLINE: bob MUST NOT see admin's file. If this assertion +# ever flips, the search service has a cross-user leak. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/search?query=unique-search-needle +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +body not contains "unique-search-needle" +body not contains "{{needle_file_id}}" + + +# ───────────────────────────────────────────────────────────── +# 6 — Teardown: removing the folder recursively takes the file +# with it, so a single DELETE is enough. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{search_folder_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 047d5dcf..d0dd1c8e 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -151,6 +151,43 @@ log "API confirms trash is empty." THUMB_FILES=$(find "$STORAGE_PATH/.thumbnails" -type f 2>/dev/null || true) BLOB_FILES=$(find "$STORAGE_PATH/.blobs" -type f 2>/dev/null || true) +if [[ -n "$THUMB_FILES" || -n "$BLOB_FILES" ]]; then + # Async thumbnail/blob workers may still be flushing writes from the + # last test's uploads when the cleanup phase reaches this point — + # particularly on fast CI runners where the test loop outpaces the + # worker. Poll for up to 5 s and exit the loop the moment storage + # drains. TODO: replace with a deterministic worker-drain signal + # (e.g. queue depth on /ready) when one exists. + log "Thumb/blob leftovers detected — polling for async worker drain (race guard)" + for attempt in 1 2 3 4 5; do + sleep 1 + THUMB_FILES=$(find "$STORAGE_PATH/.thumbnails" -type f 2>/dev/null || true) + BLOB_FILES=$(find "$STORAGE_PATH/.blobs" -type f 2>/dev/null || true) + [[ -z "$THUMB_FILES" && -z "$BLOB_FILES" ]] && break + log " attempt $attempt: still present, retrying..." + done +fi + +# Chunked-upload spool. After every chunked-upload session is either +# completed (assembled + promoted) or aborted, this dir MUST be empty +# — a leftover chunk file means a session-cleanup path forgot its +# `remove_dir_all`, which under sustained sync workloads is the +# classic "disk fills up over the weekend" failure mode. +# +# `.uploads/` is the default chunked-upload root when +# `OXICLOUD_CHUNK_DIR` is unset (see `common/di.rs`). REST sessions +# land under `.uploads//`; NC sessions land under +# `.uploads/nextcloud///`. +# +# We deliberately do NOT check the direct-PUT spool dir here: when +# `OXICLOUD_UPLOAD_TEMP_DIR` is unset (the default in the test env) +# it falls back to the OS temp dir (`/tmp/…`) which is shared with +# the rest of the system and would produce false positives. To +# extend the check to direct-PUT, set OXICLOUD_UPLOAD_TEMP_DIR in +# tests/common/server.env to a path under $STORAGE_PATH and add it +# to the find list below. +UPLOAD_FILES=$(find "$STORAGE_PATH/.uploads" -type f 2>/dev/null || true) + if [[ -n "$THUMB_FILES" ]]; then THUMB_COUNT=$(echo "$THUMB_FILES" | wc -l | tr -d ' ') log "Leftover thumbnail files ($THUMB_COUNT):" @@ -165,4 +202,11 @@ if [[ -n "$BLOB_FILES" ]]; then fail "$BLOB_COUNT blob file(s) remain on disk after full cleanup" fi -log "OK — no blobs or thumbnails remain on disk." +if [[ -n "$UPLOAD_FILES" ]]; then + UPLOAD_COUNT=$(echo "$UPLOAD_FILES" | wc -l | tr -d ' ') + log "Leftover chunked-upload files ($UPLOAD_COUNT):" + echo "$UPLOAD_FILES" + fail "$UPLOAD_COUNT chunked-upload file(s) remain in .uploads after full cleanup" +fi + +log "OK — no blobs, thumbnails, or chunked-upload leftovers remain on disk." diff --git a/tests/common/server.env b/tests/common/server.env index 8d5d36f8..979625d5 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -36,9 +36,9 @@ OXICLOUD_CHUNK_MAX_BYTES=4194304 OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304 # grow up limits for tests -OXICLOUD_RATE_LIMIT_REFRESH_MAX=360 -OXICLOUD_RATE_LIMIT_LOGIN_MAX=360 -OXICLOUD_RATE_LIMIT_REGISTER_MAX=360 +OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 # Magic-link / external-users flow (PR 9). The mock SMTP captures every # outbound message in-process so external_users.hurl can retrieve the @@ -58,3 +58,6 @@ OXICLOUD_ALLOW_EXTERNAL_USERS=true OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3 OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2 OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50 + +# permits IP spoofing for tests +OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 diff --git a/tests/common/wipe-storage.sh b/tests/common/wipe-storage.sh new file mode 100644 index 00000000..5ac8d1c5 --- /dev/null +++ b/tests/common/wipe-storage.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Shared helper: wipe a test-storage directory. +# +# Source this file (don't execute it): +# +# source "$COMMON/wipe-storage.sh" +# wipe_storage "$OXICLOUD_STORAGE_PATH" +# +# The path must end in `tests//storage` (lowercase alphanumeric +# ``). Anything else is rejected — defends against a typo'd or +# unexpanded env var feeding the wrong path to `rm -rf`. Examples that +# pass: +# +# /home/dev/oxicloud/tests/api/storage +# /home/dev/oxicloud/tests/webdav/storage +# /home/dev/oxicloud/tests/e2e/storage +# +# Examples that the sanity check rejects (each would refuse the wipe): +# +# / — no tests//storage suffix +# $HOME — same +# tests/api — missing /storage +# /tests/storage — missing the segment +# +# Postgres state is wiped by `spawn-db.sh` (`docker compose down -v`); +# this helper is the filesystem equivalent. + +wipe_storage() { + local path="$1" + + if [[ -z "$path" ]]; then + echo "[wipe_storage] ERROR: missing path arg" >&2 + return 1 + fi + + # Sanity check: must end in tests//storage where is + # lowercase alphanumeric. Stops `rm -rf` from ever running against + # an unexpected expansion of a callerʼs path. + if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then + echo "[wipe_storage] ERROR: '$path' does not match .../tests//storage — refusing to wipe" >&2 + return 1 + fi + + echo "[wipe_storage] Wipe $path to ensure clean startup" + rm -rf "$path" + mkdir -p "$path" +} diff --git a/tests/webdav/lib/dav_helpers.sh b/tests/webdav/lib/dav_helpers.sh new file mode 100755 index 00000000..f5911269 --- /dev/null +++ b/tests/webdav/lib/dav_helpers.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# Shared WebDAV / NC test helpers. +# +# Source order from a test_*.sh: +# +# source test.env +# source common.sh +# source lib/dav_helpers.sh +# +# Depends on the following globals being already set: +# $base_url, $username, $email, $password (from test.env) +# $TOKEN (from `oxicloud_login`) +# +# Sets / exports: +# $APP_PASS (from `mint_app_password`) +# +# Functions provided: +# mint_app_password — mints an NC-compatible app password +# using the JWT and stores it in $APP_PASS +# nc_curl … — `curl` wrapper with Basic Auth pinned to +# the admin app password +# api_curl … — `curl` wrapper with the JWT bearer +# count_responses BODY — count `` children in a +# multistatus body +# extract_href_for BODY SUBPATH +# — extract the first `` value whose +# path ends with SUBPATH +# assert_collection_hrefs_have_trailing_slash BODY +# — for every `` in BODY that +# contains ``, asserts that +# the `` ends with `/`; otherwise +# asserts it does NOT end with `/`. This is +# the guard against the past regression where +# NC desktop aborted PROPFIND parsing because +# a folder href was emitted without trailing +# slash (RFC 4918 §5.2). +# api_create_folder LABEL PARENT_ID +# — POST /api/folders, captures id into $LAST_FOLDER_ID +# api_upload_file PATH FOLDER_ID +# — POST /api/files/upload, captures id into +# $LAST_FILE_ID and the content_hash into +# $LAST_FILE_CONTENT_HASH +# api_delete_folder ID +# — DELETE /api/folders/{id} (soft-delete to trash) +# api_empty_trash — DELETE /api/trash/empty + +# Global counters and pass/fail helpers that each test_*.sh may opt +# into. Tests that set their own PASS/FAIL can ignore these. +PASS=${PASS:-0} +FAIL=${FAIL:-0} +pass() { PASS=$(( PASS + 1 )); echo " PASS: $*"; } +fail() { FAIL=$(( FAIL + 1 )); echo " FAIL: $*" >&2; exit 1; } + +mint_app_password() { + local response + response=$(curl -s -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"label\":\"$(basename "${BASH_SOURCE[1]:-test}")\"}" \ + "$base_url/api/auth/app-passwords") + APP_PASS=$(jq -r '.password // empty' <<< "$response" 2>/dev/null || echo "") + [[ -n "$APP_PASS" ]] || fail "Could not mint app password (response: $response)" +} + +# All NC-surface curls go through this helper so the Basic-Auth +# header (app password, not the user's login password) is applied +# uniformly. The NC handler rejects login-password Basic Auth. +nc_curl() { + curl -s -u "$username:$APP_PASS" "$@" +} + +# REST API curls use the JWT bearer. +api_curl() { + curl -s -H "Authorization: Bearer $TOKEN" "$@" +} + +# Native `/webdav/...` surface uses JWT bearer (same as REST), in +# contrast to the NC `/remote.php/dav/files/...` surface which uses +# Basic Auth with an app password (`nc_curl`). Same auth pattern as +# `tests/webdav/test_dedup_webdav_multichunk.sh`'s inline +# webdav_put/webdav_delete helpers, lifted here so Batch-5+ +# native-DAV tests can stay DRY. +dav_curl() { + curl -s -H "Authorization: Bearer $TOKEN" "$@" +} + +# Count `` (or ``) children in a multistatus +# body. Case-insensitive on the namespace prefix because OxiCloud's +# two DAV surfaces use different cases: the NC handler emits +# `` (lowercase prefix), while the native `/webdav/` +# handler emits `` (uppercase prefix). RFC 4918 §14 only +# requires the URI to be `"DAV:"` — the prefix label is the +# implementer's choice. +# +# Always exits 0 — `grep -o` returns 1 when nothing matches, which +# under `set -euo pipefail` would abort the caller before the count +# is even examined. `|| true` here lets the helper return "0" for +# an empty / non-multistatus body so the calling test can assert +# explicitly on it. +count_responses() { + { grep -oiE '<[dD]:response>' <<< "$1" || true; } | wc -l | tr -d ' ' +} + +# Extract the first `` whose path contains the given suffix. +# Returns the raw href as-emitted by the server (no URL decoding); +# the suffix match is done on the raw form. +# +# Always exits 0 — empty stdout means "no href matched". Callers +# under `set -euo pipefail` rely on this: `VAR=$(extract_href_for …)` +# would otherwise abort the whole script when grep finds nothing, +# which is wrong for tests that legitimately want to assert +# "this href is absent" (D7 pinning Depth:infinity = Depth:1). +extract_href_for() { + local body="$1" suffix="$2" + # `grep -F … || true` swallows the no-match exit code (1) while + # preserving real errors (2 → propagates because the outer + # pipeline still has pipefail visibility into earlier stages). + # Case-insensitive on the prefix — see `count_responses`. + grep -oiE '<[dD]:href>[^<]+' <<< "$body" \ + | sed -E 's|^<[dD]:href>([^<]+)$|\1|' \ + | { grep -F "$suffix" || true; } \ + | head -n 1 +} + +# Extract the `` from a `` block whose body +# contains the given substring anywhere (e.g. an +# `` for trashbin responses, where the href +# itself only carries the opaque trash UUID — the filename only +# appears in the nc-namespace elements). +# +# Returns the raw href, or empty when no block matches. Same +# `pipefail`-safe semantics as `extract_href_for`. +extract_response_href_containing() { + local body="$1" needle="$2" + # Case-insensitive on the prefix — see `count_responses`. + awk -v needle="$needle" ' + BEGIN { RS="" } + index($0, needle) > 0 && match($0, /<[dD]:href>[^<]+<\/[dD]:href>/) { + m = substr($0, RSTART, RLENGTH) + sub(/^<[dD]:href>/, "", m) + sub(/<\/[dD]:href>$/, "", m) + print m + exit + } + ' <<< "$body" +} + +# Validate trailing-slash semantics across every in a +# multistatus body. The function walks the body once and, per +# response, asserts: +# +# - if contains , the matching +# MUST end with '/' +# - otherwise the MUST NOT end with '/' +# +# Exits via `fail` on the first violation. This is the regression +# guard for the NC desktop "Invalid href" parse error. +assert_collection_hrefs_have_trailing_slash() { + local body="$1" label="${2:-multistatus}" + # Use awk's RS to chunk the body by or + # (case-insensitive on the prefix — see `count_responses` for why + # both casings matter). For each chunk, find the href and check + # for the collection marker. + local pairs + pairs=$(awk ' + BEGIN { RS = "" } + /<[dD]:response>/ { + href = "" + is_coll = 0 + if (match($0, /<[dD]:href>[^<]+<\/[dD]:href>/)) { + m = substr($0, RSTART, RLENGTH) + # Strip the surrounding / tags + # (either case). + sub(/^<[dD]:href>/, "", m) + sub(/<\/[dD]:href>$/, "", m) + href = m + } + if ($0 ~ /<[dD]:collection\/>/) is_coll = 1 + if (href != "") print is_coll "|" href + } + ' <<< "$body") + + local line is_coll href violation=0 + while IFS='|' read -r is_coll href; do + [[ -z "$href" ]] && continue + if [[ "$is_coll" == "1" ]]; then + if [[ "$href" != */ ]]; then + echo " FAIL[$label]: collection href without trailing slash: '$href'" >&2 + violation=1 + fi + else + if [[ "$href" == */ ]]; then + echo " FAIL[$label]: non-collection href with trailing slash: '$href'" >&2 + violation=1 + fi + fi + done <<< "$pairs" + if [[ "$violation" -ne 0 ]]; then + fail "[$label] trailing-slash semantics violated (see above)" + fi +} + +# ── REST API setup helpers ──────────────────────────────────────────────────── + +# POST /api/folders. Reads: +# $1 — folder name +# $2 — parent folder id (optional; passed verbatim as parent_id) +# Sets: +# $LAST_FOLDER_ID — id of the new folder +api_create_folder() { + local name="$1" parent_id="${2:-}" + local body + if [[ -n "$parent_id" ]]; then + body=$(jq -n --arg n "$name" --arg p "$parent_id" \ + '{name:$n, parent_id:$p}') + else + body=$(jq -n --arg n "$name" '{name:$n}') + fi + local response + # `/api/folders/` is the SINGLE-folder create (CreateFolderDto). + # `/api/folders/create` is the BATCH endpoint — different DTO, + # accepts an array; using it here returns 422 with an empty body + # (which is what the original version of this helper was hitting + # and failing on). + response=$(api_curl -X POST \ + -H "Content-Type: application/json" \ + -d "$body" \ + "$base_url/api/folders") + LAST_FOLDER_ID=$(jq -r '.id // empty' <<< "$response") + [[ -n "$LAST_FOLDER_ID" ]] || fail "api_create_folder '$name': no id (response: $response)" +} + +# POST /api/files/upload (multipart). Reads: +# $1 — local fixture path +# $2 — folder id (the file will be uploaded into this folder) +# Sets: +# $LAST_FILE_ID — id of the new file +# $LAST_FILE_CONTENT_HASH — content_hash from the response +api_upload_file() { + local fixture="$1" folder_id="$2" + local response + response=$(api_curl -X POST \ + -F "folder_id=$folder_id" \ + -F "file=@$fixture" \ + "$base_url/api/files/upload") + LAST_FILE_ID=$(jq -r '.id // empty' <<< "$response") + LAST_FILE_CONTENT_HASH=$(jq -r '.content_hash // empty' <<< "$response") + [[ -n "$LAST_FILE_ID" ]] || fail "api_upload_file '$fixture': no id (response: $response)" +} + +api_delete_folder() { + local id="$1" + api_curl -X DELETE "$base_url/api/folders/$id" > /dev/null +} + +api_delete_file() { + local id="$1" + api_curl -X DELETE "$base_url/api/files/$id" > /dev/null +} + +api_empty_trash() { + api_curl -X DELETE "$base_url/api/trash/empty" > /dev/null +} + +# Wipe every child of the user's home folder + empty the trash, via +# the REST API. The home folder itself is preserved (it's a root +# folder, untouchable anyway). +# +# Useful as a defensive `wipe_home_folder` call at the START of any +# test that depends on a clean home state, so cross-test +# contamination from earlier scripts (e.g. orphans left by handlers +# that 500-leak on conflict, or pinned-bug scenarios that +# deliberately leave half-cleaned state) never poisons later +# assertions. Requires `$HOME_FOLDER_ID` to be set first via +# `resolve_home_folder_id`. +wipe_home_folder() { + [[ -n "${HOME_FOLDER_ID:-}" ]] \ + || fail "wipe_home_folder: HOME_FOLDER_ID is unset — call resolve_home_folder_id first" + # `/listing` (NOT `/contents`) is the endpoint that returns the + # `.files[]` / `.folders[]` arrays we iterate here — same one + # `tests/api/storage_cleanup_check.sh` uses for the equivalent + # full-tree wipe before its disk-audit step. + local listing + listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing") + # Delete every direct child file (recursive contents go with the + # file's row). Errors are swallowed because the test that called + # us doesn't care WHY a leftover was unreachable — it just wants + # the slate clean. + while IFS= read -r fid; do + [[ -z "$fid" || "$fid" == "null" ]] && continue + api_curl -X DELETE "$base_url/api/files/$fid" > /dev/null 2>&1 || true + done < <(jq -r '.files[]?.id // empty' <<< "$listing") + # Then every direct child folder (recursive subtree goes with). + while IFS= read -r fid; do + [[ -z "$fid" || "$fid" == "null" ]] && continue + api_curl -X DELETE "$base_url/api/folders/$fid" > /dev/null 2>&1 || true + done < <(jq -r '.folders[]?.id // empty' <<< "$listing") + # Finally permanently delete everything in trash so the row-level + # `is_trashed` orphans the upstream tests left behind don't make + # *us* leak chunks/blobs into storage_cleanup_check's audit. + api_empty_trash +} + +# Resolve the user's home folder id (parent_id IS NULL, first entry). +# Sets: +# $HOME_FOLDER_ID +resolve_home_folder_id() { + local response + response=$(api_curl "$base_url/api/folders") + HOME_FOLDER_ID=$(jq -r '.[0].id // empty' <<< "$response") + [[ -n "$HOME_FOLDER_ID" ]] || fail "Could not resolve home folder id (response: $response)" +} diff --git a/tests/webdav/run.sh b/tests/webdav/run.sh index 62a88363..cfdc1656 100755 --- a/tests/webdav/run.sh +++ b/tests/webdav/run.sh @@ -62,20 +62,30 @@ OXICLOUD_SERVER_PORT=$SERVER_PORT OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/api/storage" set +a -mkdir -p "$OXICLOUD_STORAGE_PATH" +# ensure storage is empty before starting (regex-gated rm -rf). +# Previously this script only ran `mkdir -p`, so a standalone webdav run +# inherited state from a prior api run — now both runners wipe uniformly. +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" # ── 3. Start OxiCloud server ────────────────────────────────────────────────── BUILD_TARGET="${BUILD_TARGET:-debug}" OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" +# `--config` pins the env file the binary reads AND suppresses the default +# `.env` probe in main.rs, so a developer's repo-root `.env` can never leak +# into a test run. Bash also sourced the same file above, so anything the +# test harness itself reads via $OXICLOUD_* stays available; dotenvy won't +# override those already-exported values. if [[ -x "$OXICLOUD_BIN" ]]; then log "Starting pre-built OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..." - "$OXICLOUD_BIN" & + "$OXICLOUD_BIN" --config "$COMMON/server.env" & else log "Building and starting OxiCloud server on port $SERVER_PORT..." cd "$REPO_ROOT" - cargo run & + cargo run -- --config "$COMMON/server.env" & fi SERVER_PID=$! log "Waiting for server at $base_url..." diff --git a/tests/webdav/test_chunked_upload_dedup.sh b/tests/webdav/test_chunked_upload_dedup.sh index c9cd10d7..877e0d4b 100755 --- a/tests/webdav/test_chunked_upload_dedup.sh +++ b/tests/webdav/test_chunked_upload_dedup.sh @@ -43,8 +43,8 @@ dedup_check() { curl -s -H "Authorization: Bearer $TOKEN" "$base_url/api/dedup/c purge_from_trash() { local name="$1" local tid - tid=$(rest_get "/api/trash" \ - | jq -r --arg n "$name" 'first(.[] | select(.name == $n) | .id) // empty') + tid=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$name" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$tid" ]] && rest_delete "/api/trash/$tid" > /dev/null || true } diff --git a/tests/webdav/test_dedup_webdav_multichunk.sh b/tests/webdav/test_dedup_webdav_multichunk.sh index dd742afc..68e0b7af 100755 --- a/tests/webdav/test_dedup_webdav_multichunk.sh +++ b/tests/webdav/test_dedup_webdav_multichunk.sh @@ -73,8 +73,8 @@ dedup_check() { curl -s -H "Authorization: Bearer $TOKEN" "$base_url/api/dedup/c purge_from_trash() { local name="$1" local tid - tid=$(rest_get "/api/trash" \ - | jq -r --arg n "$name" 'first(.[] | select(.name == $n) | .id) // empty') + tid=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$name" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$tid" ]] && rest_delete "/api/trash/$tid" > /dev/null || true } @@ -179,8 +179,8 @@ echo " step 4: trash + permanently delete $FILE_A..." ST=$(rest_delete "/api/files/$FILE_A_ID") [[ "$ST" == "204" ]] || fail "DELETE $FILE_A expected 204, got $ST" -TRASH_A=$(rest_get "/api/trash" \ - | jq -r --arg n "$FILE_A" 'first(.[] | select(.name == $n) | .id) // empty') +TRASH_A=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$FILE_A" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$TRASH_A" ]] || fail "File A not found in trash" ST=$(rest_delete "/api/trash/$TRASH_A") [[ "$ST" == "200" ]] || fail "Permanent delete file A expected 200, got $ST" @@ -206,8 +206,8 @@ echo " step 6: trash + permanently delete $FILE_B..." ST=$(rest_delete "/api/files/$FILE_B_ID") [[ "$ST" == "204" ]] || fail "DELETE $FILE_B expected 204, got $ST" -TRASH_B=$(rest_get "/api/trash" \ - | jq -r --arg n "$FILE_B" 'first(.[] | select(.name == $n) | .id) // empty') +TRASH_B=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$FILE_B" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$TRASH_B" ]] || fail "File B not found in trash" ST=$(rest_delete "/api/trash/$TRASH_B") [[ "$ST" == "200" ]] || fail "Permanent delete file B expected 200, got $ST" diff --git a/tests/webdav/test_dedup_webdav_ref_count.sh b/tests/webdav/test_dedup_webdav_ref_count.sh index e1bf400a..a8a9420e 100755 --- a/tests/webdav/test_dedup_webdav_ref_count.sh +++ b/tests/webdav/test_dedup_webdav_ref_count.sh @@ -113,8 +113,8 @@ for REMOTE in "$FILE_A" "$FILE_B"; do echo " cleanup: deleting existing $REMOTE (id=$EXISTING_ID)" rest_delete "/api/files/$EXISTING_ID" > /dev/null fi - STALE=$(rest_get "/api/trash" \ - | jq -r --arg n "$REMOTE" 'first(.[] | select(.name == $n) | .id) // empty') + STALE=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$REMOTE" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') if [[ -n "$STALE" ]]; then echo " cleanup: purging $REMOTE from trash (id=$STALE)" rest_delete "/api/trash/$STALE" > /dev/null @@ -221,8 +221,8 @@ echo " cleanup..." for REMOTE in "$FILE_A" "$FILE_B"; do ST=$(webdav_delete "$REMOTE") [[ "$ST" == "204" ]] || fail "WebDAV DELETE $REMOTE expected 204, got $ST" - TRASH_ITEM=$(rest_get "/api/trash" \ - | jq -r --arg n "$REMOTE" 'first(.[] | select(.name == $n) | .id) // empty') + TRASH_ITEM=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$REMOTE" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') if [[ -n "$TRASH_ITEM" ]]; then rest_delete "/api/trash/$TRASH_ITEM" > /dev/null fi diff --git a/tests/webdav/test_native_webdav_lifecycle.sh b/tests/webdav/test_native_webdav_lifecycle.sh new file mode 100755 index 00000000..828d22bf --- /dev/null +++ b/tests/webdav/test_native_webdav_lifecycle.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: Native /webdav/ + LOCK / UNLOCK +# ============================================================= +# Groups M + N from BASELINE_TESTS_NC_WEBDAV.md (11 scenarios). +# +# The native `/webdav/...` surface is the protocol layer +# rclone, davfs2, Cyberduck, Office (via WebDAV mount), and +# the other generic-DAV ecosystem use. It differs from the +# NC `/remote.php/dav/files/{user}/...` surface in several +# baseline-worthy ways: +# +# - Auth: JWT bearer (not Basic Auth with an app password) +# - Chroot: implicit "the user's home folder", NO {user} +# URL segment to validate +# - DAV class advertisement: `1, 2` (incl. Class 2 LOCK) +# versus NC's `1, 3` +# +# Coverage: +# M1 — OPTIONS / advertises DAV 1, 2 + Allow includes LOCK +# M2 — PROPFIND Depth: 1 trailing-slash semantics (the +# same regression guard as D9/D10 on the NC surface, +# run again on this surface) +# M3 — PUT sample.txt → 201 +# M4 — Range GET (bytes=0-9) → 206 +# M5 — MOVE sample.txt → moved.txt +# M6 — MKCOL sub/ → 201 +# M7 — DELETE sub/ → 204 +# M8 — COPY a.txt → b.txt (pin whatever current is — native +# COPY may or may not be implemented) +# N1 — LOCK locked.txt → 200 + Lock-Token header +# N2 — PUT locked.txt without If: from a different +# context → 423 Locked +# N3 — UNLOCK with the token → 204; subsequent PUT succeeds +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== Native /webdav/ + LOCK / UNLOCK (Groups M + N baseline) ===" +echo + +oxicloud_login +resolve_home_folder_id +wipe_home_folder + +DAV_BASE="$base_url/webdav" +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"; wipe_home_folder 2>/dev/null || true' EXIT + +# ───────────────────────────────────────────────────────────── +# M1 — OPTIONS advertises DAV 1, 2 + Allow includes LOCK +# ───────────────────────────────────────────────────────────── +echo " M1: OPTIONS /webdav/ → DAV: 1, 2 + Allow includes LOCK/UNLOCK" +HEADERS=$(dav_curl -i -X OPTIONS "$DAV_BASE/" | tr -d '\r') +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS") +[[ "$STATUS" == "200" ]] \ + || fail "M1: OPTIONS expected 200, got $STATUS" +grep -qi '^dav:.*1.*2' <<< "$HEADERS" \ + || fail "M1: missing 'DAV: 1, 2' header (native surface should advertise Class 2)" +grep -qi '^allow:.*LOCK' <<< "$HEADERS" || fail "M1: Allow missing LOCK" +grep -qi '^allow:.*UNLOCK' <<< "$HEADERS" || fail "M1: Allow missing UNLOCK" +pass "M1: OPTIONS advertises DAV 1, 2 + Allow includes LOCK/UNLOCK" + +# ───────────────────────────────────────────────────────────── +# Fixture setup via REST so M2 has stable mixed content. +# We deliberately PROPFIND a dedicated *sub-folder* of home, +# not bare `/webdav/`, because: +# +# 1. The native handler's `resolve_webdav_path` only fires +# when the URL subpath is non-empty (gated by +# `!path.is_empty() && method.as_str() != "OPTIONS"`). +# PROPFIND on bare `/webdav/` therefore returns the +# user's root-collections list, not the contents of +# their home folder. +# 2. The number of root collections varies per environment +# (default home + anything else the system or earlier +# tests created), so asserting a fixed count there is +# brittle. A test-owned sub-folder is fully under our +# control. +# +# Inside `m2-probe/`: 2 files + 2 sub-folders → PROPFIND +# Depth: 1 yields exactly 5 responses (self + 4 children) and +# exercises both the file-href (no trailing slash) and the +# folder-href (trailing slash) branches of the same +# regression guard that catches D9/D10 on the NC surface. +# ───────────────────────────────────────────────────────────── +api_create_folder "m2-probe" "$HOME_FOLDER_ID" +M2_PROBE_ID="$LAST_FOLDER_ID" +echo "alpha" > "$FIXTURE_DIR/m2-alpha.txt" +echo "beta" > "$FIXTURE_DIR/m2-beta.txt" +api_upload_file "$FIXTURE_DIR/m2-alpha.txt" "$M2_PROBE_ID" +api_upload_file "$FIXTURE_DIR/m2-beta.txt" "$M2_PROBE_ID" +api_create_folder "m2-foldA" "$M2_PROBE_ID" +api_create_folder "m2-foldB" "$M2_PROBE_ID" + +# ───────────────────────────────────────────────────────────── +# M2 — PROPFIND Depth: 1 trailing-slash semantics (mixed) +# ───────────────────────────────────────────────────────────── +echo " M2: PROPFIND /webdav/m2-probe/ Depth: 1 (mixed children — trailing-slash regression guard)" +BODY=$(dav_curl -X PROPFIND -H "Depth: 1" "$DAV_BASE/m2-probe/") +N=$(count_responses "$BODY") +# Collection (1) + 2 files + 2 folders = 5 +[[ "$N" == "5" ]] \ + || fail "M2: expected 5 responses (collection + 2 files + 2 folders), got $N" +assert_collection_hrefs_have_trailing_slash "$BODY" "M2" +pass "M2: 5 responses, trailing-slash semantics correct on native /webdav/ surface" + +# ───────────────────────────────────────────────────────────── +# M3 — PUT a new file +# +# Pinned current behaviour: the native handler always returns +# 204 NO_CONTENT regardless of new-vs-overwrite. The NC handler +# differentiates (201 for new, 204 for overwrite, see F1/F3) +# but the native one in `interfaces/api/handlers/webdav_handler.rs::handle_put` +# unconditionally builds a 204 response on success (line ~1026 +# at time of writing). RFC 4918 §9.7.1 actually allows either +# — both indicate success — so this is current behaviour, not +# a bug. NC desktop / generic DAV clients accept both. +# ───────────────────────────────────────────────────────────── +# M3-M8 use ROOT-level paths (just `/webdav/`) NOT nested +# under `m2-probe/`. Why: there's a real bug in the native PUT +# handler where a PUT to `/webdav/m2-probe/foo.txt` writes the +# file with `folder_id=NULL` (`get_parent_folder_id` doesn't +# correctly look up REST-created parent folders), so the file +# effectively ends up at root. The lenient GET path +# (`get_file_by_path`) still finds it, but the strict +# `resolve_path_for_user` (which MOVE / COPY / DELETE use) does +# not. PUT then GET works on nested paths; PUT then MOVE 404s. +# Pinning this as KNOWN BUG at M5 below. +# +# Until that's fixed, M3-M8 use root-level paths so the rest of +# the lifecycle (which the existing test_dedup_webdav_* scripts +# also exercise at root) actually validates. + +echo " M3: PUT /webdav/m3-sample.txt (pinned: native always 204, NC would be 201 on new)" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary 'sample contents — exactly 31 bytes' \ + "$DAV_BASE/m3-sample.txt") +case "$STATUS" in + 204) + pass "M3: native PUT new → 204 (pinned current behaviour; differs from NC's 201/204 split)" + ;; + 201) + fail "M3: native PUT now returns 201 for new — handler differentiates new-vs-overwrite. Update pin if intentional." + ;; + *) + fail "M3: unexpected status $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# M4 — Range GET bytes=0-9 → 206 + 10 bytes +# ───────────────────────────────────────────────────────────── +echo " M4: GET /webdav/m3-sample.txt with Range: bytes=0-9 → 206 + 10 bytes" +HEADERS=$(dav_curl -D - -o /dev/null -H "Range: bytes=0-9" "$DAV_BASE/m3-sample.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "206" ]] \ + || fail "M4: Range GET expected 206, got $STATUS" +BODY_SIZE=$(dav_curl -H "Range: bytes=0-9" "$DAV_BASE/m3-sample.txt" | wc -c | tr -d ' ') +[[ "$BODY_SIZE" == "10" ]] \ + || fail "M4: Range body size expected 10, got $BODY_SIZE" +pass "M4: Range bytes=0-9 → 206 + 10 bytes" + +# ───────────────────────────────────────────────────────────── +# M5 — MOVE sample.txt → moved.txt +# ───────────────────────────────────────────────────────────── +# M5 — root-level MOVE +# +# After all the nested-path diagnostics above (which surfaced the +# bug pinned in the M3 comment), this assertion finally tests the +# code path where it should actually work: root-level MOVE of a +# file PUT at root. If even this 404s, the bug is broader and +# native MOVE is unusable, not just nested. +echo " M5: MOVE /webdav/m3-sample.txt → /webdav/m5-moved.txt" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $DAV_BASE/m5-moved.txt" \ + "$DAV_BASE/m3-sample.txt") +case "$STATUS" in + 201|204) + pass "M5: root-level MOVE → $STATUS" + ;; + 404) + # KNOWN BUG: native MOVE returns 404 on a file that was + # PUT at the same path, even at root level. The strict + # `resolve_path_for_user` SQL query doesn't match what + # the PUT's `save_file_from_temp_with_dedup` stored — + # most likely because the WebDAV dispatcher's path + # prepending (`resolve_webdav_path` → "My Folder - X/foo") + # doesn't match the user's actual home folder path + # field in the DB. Same root cause makes nested MOVE + # (see M3 comment) unusable too. + # + # Where the fix lives: + # `interfaces/api/handlers/webdav_handler.rs::handle_move` + # currently calls `resolver.resolve_path_for_user`. It + # should either: + # (a) fall back to `file_retrieval_service.get_file_by_path` + # (the same lookup GET uses successfully), or + # (b) normalise the source path through the same + # transformer the PUT writes through. + pass "M5: root-level MOVE → 404 (KNOWN BUG: resolve_path_for_user mismatch — pinned)" + ;; + *) + fail "M5: unexpected status $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# M6 — MKCOL sub/ → 201 +# ───────────────────────────────────────────────────────────── +echo " M6: MKCOL /webdav/m6-sub/ → 201" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X MKCOL "$DAV_BASE/m6-sub/") +[[ "$STATUS" == "201" ]] \ + || fail "M6: MKCOL expected 201, got $STATUS" +pass "M6: native MKCOL → 201" + +# ───────────────────────────────────────────────────────────── +# M7 — DELETE sub/ → 204 +# ───────────────────────────────────────────────────────────── +echo " M7: DELETE /webdav/m6-sub/ → 204" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X DELETE "$DAV_BASE/m6-sub/") +case "$STATUS" in + 204) pass "M7: native DELETE → 204" ;; + 404) + # If DELETE also hits the resolve_path_for_user 404 trap + # (it uses the same resolver), pin as same root-cause + # KNOWN BUG. + pass "M7: native DELETE → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5 — pinned)" + ;; + *) fail "M7: unexpected status $STATUS" ;; +esac + +# ───────────────────────────────────────────────────────────── +# M8 — COPY a.txt → b.txt (pin whatever current behaviour is) +# ───────────────────────────────────────────────────────────── +# M8 source depends on whether M5 MOVE actually worked. If M5 was +# pinned as KNOWN BUG (404), the source for M8 is still +# m3-sample.txt at root, not m5-moved.txt. +echo " M8: COPY native source → /webdav/m8-copy.txt" +M8_SOURCE_URL="$DAV_BASE/m3-sample.txt" +# If M5 actually moved the file, the source name changed. +if dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m5-moved.txt" | grep -q "207"; then + M8_SOURCE_URL="$DAV_BASE/m5-moved.txt" +fi +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X COPY \ + -H "Destination: $DAV_BASE/m8-copy.txt" \ + "$M8_SOURCE_URL") +case "$STATUS" in + 201|204) + # Confirm source still exists (COPY != MOVE). + SRC_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$M8_SOURCE_URL") + DST_STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$DAV_BASE/m8-copy.txt") + [[ "$SRC_STATUS" == "207" ]] \ + || fail "M8: COPY removed source ($SRC_STATUS instead of 207) — that's MOVE behaviour, not COPY" + [[ "$DST_STATUS" == "207" ]] \ + || fail "M8: destination not present after COPY ($DST_STATUS)" + pass "M8: native COPY → $STATUS, source preserved, destination present" + ;; + 405) + pass "M8: native COPY → 405 METHOD_NOT_ALLOWED — handler not implemented, pinned" + ;; + 404) + pass "M8: native COPY → 404 (KNOWN BUG: same resolve_path_for_user mismatch as M5/M7 — pinned)" + ;; + 500) + # KNOWN BUG: the COPY file branch at + # `interfaces/api/handlers/webdav_handler.rs::handle_copy` + # line ~1639 passes `(file.id, user.id, target_folder_id)` + # to `copy_file_with_perms` — no destination NAME. The + # copy therefore lands in the target folder under the + # SOURCE's name, ignoring the rename the client requested. + # When source and destination resolve to the same folder + # (common for root-level COPY), this collides with the + # source itself → AlreadyExists → leaks as 500. + # + # Where the fix lives: same handler — either + # (a) extend `copy_file_with_perms` to accept an + # optional new name (the folder-tree branch on + # line ~1591 already passes a name into + # `copy_folder_tree_with_perms`), or + # (b) follow the copy with a `rename_file_with_perms` + # call if `dest_filename != source.name` (mirrors + # what MOVE does at line ~1347). + pass "M8: native COPY → 500 (KNOWN BUG: dest filename discarded, collides with source — pinned)" + ;; + *) + fail "M8: unexpected COPY status $STATUS" + ;; +esac + +# ═════════════════════════════════════════════════════════════ +# Group N — LOCK / UNLOCK +# ═════════════════════════════════════════════════════════════ + +# Set up a file for the lock scenarios. +dav_curl -o /dev/null -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary 'lockable contents' \ + "$DAV_BASE/n-locked.txt" > /dev/null + +LOCK_BODY=' + + + + baseline-test-owner +' + +# ───────────────────────────────────────────────────────────── +# N1 — LOCK → 200 with Lock-Token header +# ───────────────────────────────────────────────────────────── +echo " N1: LOCK /webdav/n-locked.txt → 200 + Lock-Token header" +HEADERS=$(dav_curl -D - -o /dev/null -X LOCK \ + -H "Content-Type: application/xml" \ + -H "Timeout: Second-60" \ + --data "$LOCK_BODY" \ + "$DAV_BASE/n-locked.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "200" ]] \ + || fail "N1: LOCK expected 200, got $STATUS" +LOCK_TOKEN=$(grep -i '^lock-token:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r<>') +[[ -n "$LOCK_TOKEN" ]] \ + || fail "N1: LOCK response missing Lock-Token header" +pass "N1: LOCK → 200 + Lock-Token=$LOCK_TOKEN" + +# ───────────────────────────────────────────────────────────── +# N2 — PUT without the lock token → 423 Locked +# ───────────────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────── +# N2 — PUT to a locked file without the token +# +# RFC 4918 §9.10.4 + §6: a writeable resource under an +# exclusive lock MUST reject conflicting writes with 423 +# Locked. OxiCloud's native handler currently does NOT consult +# the lock store before writing — LOCK just produces a token, +# and any PUT/DELETE/MOVE/PROPPATCH succeeds regardless. The +# class-2 DAV advertisement in M1 is therefore aspirational: +# the protocol surface exists, the enforcement doesn't. +# +# Where the fix lives: +# `interfaces/api/handlers/webdav_handler.rs::handle_put` (and +# the mutator paths in handle_delete / handle_move / handle_copy / +# handle_proppatch) — each needs to check the WebDAV lock service +# for an active lock on the target path and reject with 423 if +# the request doesn't carry a matching `If: ()` header. +# The lock store itself already records tokens — confirmed by N1 +# capturing one — so the gap is purely on the read-side check. +# ───────────────────────────────────────────────────────────── +echo " N2: PUT /webdav/n-locked.txt without If:() — pinned: lock not enforced (RFC would 423)" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary 'tampered contents' \ + "$DAV_BASE/n-locked.txt") +case "$STATUS" in + 204) + pass "N2: PUT succeeded despite active lock → 204 (KNOWN BUG: lock not enforced — pinned)" + ;; + 423) + fail "N2: server now returns 423 Locked. Lock enforcement was added — update this pin to assert == 423." + ;; + *) + fail "N2: unexpected status $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# N3 — UNLOCK with token → 204; subsequent PUT succeeds +# ───────────────────────────────────────────────────────────── +echo " N3: UNLOCK /webdav/n-locked.txt + follow-up PUT succeeds" +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X UNLOCK \ + -H "Lock-Token: <$LOCK_TOKEN>" \ + "$DAV_BASE/n-locked.txt") +[[ "$STATUS" == "204" ]] \ + || fail "N3: UNLOCK expected 204, got $STATUS" +# Now PUT without any token — should succeed since lock is released. +STATUS=$(dav_curl -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary 'post-unlock contents' \ + "$DAV_BASE/n-locked.txt") +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "N3: post-unlock PUT expected 201/204, got $STATUS" +pass "N3: UNLOCK → 204 + subsequent PUT succeeds ($STATUS)" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_chunked_upload_assembly_blake3.sh b/tests/webdav/test_nc_chunked_upload_assembly_blake3.sh new file mode 100755 index 00000000..ff2113ca --- /dev/null +++ b/tests/webdav/test_nc_chunked_upload_assembly_blake3.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC chunked-upload assembly + BLAKE3 +# ============================================================= +# Group J from BASELINE_TESTS_NC_WEBDAV.md. +# +# Headline assertion: +# J7 — BLAKE3 round-trip on the ASSEMBLED file. Local b3sum +# of `concat(chunk1, chunk2)` must equal the server's +# FileDto.content_hash after the chunked-MOVE-to-`.file` +# assembly step. Proves the streaming hash-on-write +# during chunked assembly produces the canonical BLAKE3 +# — the same `content_hash` field that F8/F9 validate +# for the direct-PUT path. F8/F9 + J7 together cover +# every path a file's content_hash gets computed on. +# +# Scope split vs existing scripts: +# - `test_nextcloud_chunked_upload_propfind.sh` already +# covers J1-J4 (MKCOL session, PUT chunks, PROPFIND-resume). +# - `test_nextcloud_chunked_upload_cap.sh` already covers +# J8 (chunk-over-cap → 413). +# - This script picks up the rest of the lifecycle: +# J5 — MOVE `.file` to destination (assembly) +# J6 — GET assembled file: bytes match concat +# J7 — BLAKE3 round-trip on assembled (HEADLINE) +# J9 — DELETE on a separate session (abort) → 204 +# J10 — PROPFIND on the J5 session AFTER assembly → 404 +# +# `xq` is used for the J4-style PROPFIND assertions so the +# tests can XPath-query namespaced multistatus XML instead of +# parsing it with awk/sed. See the install line in +# `.github/workflows/ci.yml`. +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC chunked-upload assembly + BLAKE3 (Group J baseline) ===" +echo + +# Preflight: xq required for the multistatus XPath checks. Pinned to +# sibprogrammer/xq (Go binary, real XPath via libxml2). +command -v xq >/dev/null 2>&1 \ + || fail "preflight: xq required (sibprogrammer/xq) — install via 'brew install xq' or the CI release tarball" +command -v b3sum >/dev/null 2>&1 \ + || fail "preflight: b3sum required for the J7 round-trip — install via 'brew install b3sum' or 'apt install b3sum'" + +oxicloud_login +mint_app_password +resolve_home_folder_id +wipe_home_folder + +NC_FILES_BASE="$base_url/remote.php/dav/files/$username" +NC_UPLOAD_BASE="$base_url/remote.php/dav/uploads/$username" + +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"; \ + nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/j-assembled.bin" 2>/dev/null || true; \ + api_empty_trash 2>/dev/null || true' EXIT + +# ── Fixture: two chunks of known random content ────────────────────────────── +# 5 KB + 7 KB — sized to verify the assembly handles unequal-size +# chunks correctly (the wire ordering is by chunk number, not size). +CHUNK1_PATH="$FIXTURE_DIR/chunk1.bin" +CHUNK2_PATH="$FIXTURE_DIR/chunk2.bin" +ASSEMBLED_LOCAL="$FIXTURE_DIR/concat.bin" + +dd if=/dev/urandom of="$CHUNK1_PATH" bs=1024 count=5 status=none +dd if=/dev/urandom of="$CHUNK2_PATH" bs=1024 count=7 status=none +cat "$CHUNK1_PATH" "$CHUNK2_PATH" > "$ASSEMBLED_LOCAL" + +CHUNK1_LEN=$(wc -c < "$CHUNK1_PATH" | tr -d ' ') +CHUNK2_LEN=$(wc -c < "$CHUNK2_PATH" | tr -d ' ') +ASSEMBLED_LEN=$(wc -c < "$ASSEMBLED_LOCAL" | tr -d ' ') +ASSEMBLED_LOCAL_HASH=$(b3sum --no-names "$ASSEMBLED_LOCAL" | awk '{print $1}') + +# Unique session id per run. +SESSION_ID="j-assembly-$(date +%s)-$$" +SESS_BASE="$NC_UPLOAD_BASE/$SESSION_ID" + +# Defensive idempotent cleanup of any stale session from a previous run. +nc_curl -o /dev/null -X DELETE "$SESS_BASE" > /dev/null 2>&1 || true + +# ───────────────────────────────────────────────────────────── +# Setup — Create the session and PUT both chunks. These mirror +# what the dedicated J1-J3 script does; we redo them so this +# file is self-contained for the assembly + BLAKE3 checks. +# ───────────────────────────────────────────────────────────── +echo " setup: MKCOL session $SESSION_ID + PUT 2 chunks" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$SESS_BASE") +[[ "$STATUS" == "201" ]] \ + || fail "setup: MKCOL expected 201, got $STATUS" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$CHUNK1_PATH" \ + "$SESS_BASE/00000001") +[[ "$STATUS" == "201" ]] \ + || fail "setup: PUT chunk 00000001 expected 201, got $STATUS" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$CHUNK2_PATH" \ + "$SESS_BASE/00000002") +[[ "$STATUS" == "201" ]] \ + || fail "setup: PUT chunk 00000002 expected 201, got $STATUS" + +pass "setup: session created with 2 chunks ($CHUNK1_LEN + $CHUNK2_LEN bytes)" + +# ───────────────────────────────────────────────────────────── +# J4-ish sanity check — PROPFIND the session via xq. +# +# We don't re-test J1-J3 in detail (covered by +# test_nextcloud_chunked_upload_propfind.sh), but a single +# PROPFIND-via-xq here gives us: +# - early failure if the chunks didn't actually land +# - smoke test that xq is operational in this environment +# before J7 depends on it +# ───────────────────────────────────────────────────────────── +echo " J4-sanity: PROPFIND session via xq → 3 entries" +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$SESS_BASE") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "3" ]] \ + || fail "J4-sanity: PROPFIND expected 3 responses, got '$N' (body: $BODY)" +# Confirm both chunk content-lengths are correct in the XML. +CHUNK1_REPORTED=$(xq -x "//*[local-name()='response'][.//*[local-name()='href' and contains(text(), '/00000001')]]//*[local-name()='getcontentlength']/text()" <<< "$BODY" | tr -d '\r\n ') +CHUNK2_REPORTED=$(xq -x "//*[local-name()='response'][.//*[local-name()='href' and contains(text(), '/00000002')]]//*[local-name()='getcontentlength']/text()" <<< "$BODY" | tr -d '\r\n ') +[[ "$CHUNK1_REPORTED" == "$CHUNK1_LEN" ]] \ + || fail "J4-sanity: chunk 00000001 reported $CHUNK1_REPORTED bytes, expected $CHUNK1_LEN" +[[ "$CHUNK2_REPORTED" == "$CHUNK2_LEN" ]] \ + || fail "J4-sanity: chunk 00000002 reported $CHUNK2_REPORTED bytes, expected $CHUNK2_LEN" +pass "J4-sanity: PROPFIND reports 3 responses + correct chunk sizes via xq" + +# ───────────────────────────────────────────────────────────── +# J5 — MOVE `.file` to destination (assembly) +# ───────────────────────────────────────────────────────────── +echo " J5: MOVE $SESSION_ID/.file → /j-assembled.bin" +HEADERS=$(nc_curl -D - -o /dev/null -X MOVE \ + -H "Destination: $NC_FILES_BASE/j-assembled.bin" \ + "$SESS_BASE/.file") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "201" ]] \ + || fail "J5: assembly MOVE expected 201, got $STATUS" +# ETag + oc-etag headers should be present on the assembly response. +grep -qi '^etag:' <<< "$HEADERS" \ + || fail "J5: assembly response missing ETag" +grep -qi '^oc-etag:' <<< "$HEADERS" \ + || fail "J5: assembly response missing oc-etag" +pass "J5: assembly MOVE → 201 + ETag/oc-etag headers" + +# ───────────────────────────────────────────────────────────── +# J6 — GET assembled file: length + bytes match concatenation +# ───────────────────────────────────────────────────────────── +echo " J6: GET assembled file matches local concat (bytes + length)" +ASSEMBLED_REMOTE="$FIXTURE_DIR/assembled-remote.bin" +nc_curl -o "$ASSEMBLED_REMOTE" "$NC_FILES_BASE/j-assembled.bin" +REMOTE_LEN=$(wc -c < "$ASSEMBLED_REMOTE" | tr -d ' ') +[[ "$REMOTE_LEN" == "$ASSEMBLED_LEN" ]] \ + || fail "J6: byte count mismatch — remote $REMOTE_LEN vs local $ASSEMBLED_LEN" +cmp -s "$ASSEMBLED_LOCAL" "$ASSEMBLED_REMOTE" \ + || fail "J6: assembled bytes differ from concat(chunk1, chunk2)" +pass "J6: assembled file is byte-identical to concat(chunk1, chunk2)" + +# ───────────────────────────────────────────────────────────── +# J7 — BLAKE3 round-trip on assembled (HEADLINE) +# +# REST API exposes FileDto.content_hash (BLAKE3 hex) for every +# file row. The server computes this via hash-on-write during +# the chunked assembly path — same blake3::Hasher::update +# stream as the direct-PUT path, but driven by the chunk +# concatenation loop in +# `infrastructure/services/nextcloud_chunked_upload_service.rs::assemble`. +# Equality with the LOCAL b3sum of the same byte sequence +# proves the assembly path produces the canonical BLAKE3 the +# dedup / lifecycle hooks downstream key on. +# ───────────────────────────────────────────────────────────── +echo " J7: BLAKE3 round-trip on assembled file (HEADLINE)" +# Find the assembled file's id via the REST listing. +listing=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing") +ASSEMBLED_ID=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .id' <<< "$listing") +SERVER_HASH=$(jq -r '.files[]? | select(.name == "j-assembled.bin") | .content_hash' <<< "$listing") +[[ -n "$ASSEMBLED_ID" && "$ASSEMBLED_ID" != "null" ]] \ + || fail "J7: assembled file not visible via REST listing" +[[ -n "$SERVER_HASH" && "$SERVER_HASH" != "null" ]] \ + || fail "J7: REST listing returned empty content_hash for assembled file" +[[ "$SERVER_HASH" == "$ASSEMBLED_LOCAL_HASH" ]] \ + || fail "J7: BLAKE3 mismatch — server '$SERVER_HASH' vs local '$ASSEMBLED_LOCAL_HASH' (assembly hash-on-write regression?)" +pass "J7: assembled content_hash matches local b3sum ($ASSEMBLED_LOCAL_HASH)" + +# ───────────────────────────────────────────────────────────── +# J10 — PROPFIND on the session AFTER assembly → 404 +# +# Per the assembly contract, completing the MOVE to `.file` +# purges the session. A subsequent PROPFIND on the same +# session URL must return 404 (the resume-info is gone). If a +# regression starts returning 207 here, NC clients would +# loop-retry chunks against an already-assembled file. +# ───────────────────────────────────────────────────────────── +echo " J10: PROPFIND on session AFTER assembly → 404" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$SESS_BASE") +[[ "$STATUS" == "404" ]] \ + || fail "J10: post-assembly PROPFIND expected 404, got $STATUS" +pass "J10: session purged after assembly (PROPFIND 404)" + +# ───────────────────────────────────────────────────────────── +# J9 — DELETE on a FRESH session (abort path) → 204 +# ───────────────────────────────────────────────────────────── +echo " J9: DELETE on a fresh (un-assembled) session → 204" +ABORT_SESSION="j-abort-$(date +%s)-$$" +ABORT_BASE="$NC_UPLOAD_BASE/$ABORT_SESSION" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$ABORT_BASE") +[[ "$STATUS" == "201" ]] \ + || fail "J9 setup: MKCOL expected 201, got $STATUS" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PUT \ + --data-binary "@$CHUNK1_PATH" \ + "$ABORT_BASE/00000001") +[[ "$STATUS" == "201" ]] \ + || fail "J9 setup: PUT chunk expected 201, got $STATUS" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$ABORT_BASE") +[[ "$STATUS" == "204" ]] \ + || fail "J9: DELETE session expected 204, got $STATUS" + +# Confirm it's gone. +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$ABORT_BASE") +[[ "$STATUS" == "404" ]] \ + || fail "J9: PROPFIND after DELETE expected 404, got $STATUS" +pass "J9: aborted session DELETE → 204 + subsequent PROPFIND 404" + +# ── Cleanup ────────────────────────────────────────────────────────────────── + +echo " cleanup" +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/j-assembled.bin" || true +api_empty_trash || true +pass "cleanup done" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_cross_user_isolation.sh b/tests/webdav/test_nc_cross_user_isolation.sh new file mode 100755 index 00000000..9f675f61 --- /dev/null +++ b/tests/webdav/test_nc_cross_user_isolation.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC cross-user isolation (security) +# ============================================================= +# Group O from BASELINE_TESTS_NC_WEBDAV.md (4 scenarios). +# +# Security baseline. The NC handlers must enforce the +# (username, app-password) auth identity against every URL +# path's `{user}` segment — alice authenticated via her app +# password must NEVER read, write, or even enumerate files +# belonging to bob, regardless of clever URL crafting. +# +# Depends on `tests/api/nc_second_user_setup.hurl` having +# created the bob fixture earlier in run.sh. +# +# Coverage: +# O1 — PROPFIND bob's home folder while auth'd as alice → 403 +# O2 — Path-traversal attempt +# (`/dav/files/alice/../bob/...`) → 400 +# O3 — MOVE alice's file → bob's home → 403 / 4xx +# O4 — Alice's PROPFIND of her own home returns ONLY her +# files (no bob files leak across) +# +# Notes on phrasing of pass conditions: +# * For O1 / O3 the "rejection" status code may legitimately +# be 403 (URL/auth user mismatch — the cross-check in the +# middleware) or 401 (Basic Auth challenge). Both are +# acceptable; what matters is that the request does NOT +# succeed. +# * For O2 the path-traversal rejection is asserted at the +# `reject_path_traversal` helper in the dispatcher; if +# that returns 400, good. If somehow it sneaks through to +# a real lookup, we'd see a 404, which we treat as a fail +# because it implies the dispatcher accepted the traversal. +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC cross-user isolation (Group O baseline) ===" +echo + +oxicloud_login # logs in as admin (= "alice" in the scenario phrasing) +mint_app_password # admin's app password — referenced as alice's +ALICE_APP_PASS="$APP_PASS" +ALICE_USERNAME="$username" +resolve_home_folder_id +wipe_home_folder + +# ── Register bob + mint his app password ───────────────────────────────────── +# tests/webdav/run.sh spawns its own postgres + server, so the bob fixture +# created by tests/api/nc_second_user_setup.hurl in the api suite is NOT +# visible here. Register him inline. The endpoint is anti-enumeration mode +# (uniform 200 on success or "already exists"); the real existence check is +# the login below — if it returns a JWT, the account is usable. + +BOB_USERNAME="bob" +BOB_LOGIN_PW="BobPassword1!" +BOB_EMAIL="bob@example.com" + +curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$BOB_USERNAME\",\"email\":\"$BOB_EMAIL\",\"password\":\"$BOB_LOGIN_PW\"}" \ + "$base_url/api/auth/register" > /dev/null + +BOB_LOGIN_RESP=$(curl -s -X POST -H "Content-Type: application/json" \ + -d "{\"username\":\"$BOB_USERNAME\",\"password\":\"$BOB_LOGIN_PW\"}" \ + "$base_url/api/auth/login") +BOB_JWT=$(jq -r '.access_token // empty' <<< "$BOB_LOGIN_RESP" 2>/dev/null || echo "") +[[ -n "$BOB_JWT" ]] \ + || fail "preflight: bob login failed after inline registration. response=$BOB_LOGIN_RESP" + +BOB_APP_RESP=$(curl -s -X POST \ + -H "Authorization: Bearer $BOB_JWT" \ + -H "Content-Type: application/json" \ + -d '{"label":"nc_cross_user_isolation test (bob)"}' \ + "$base_url/api/auth/app-passwords") +BOB_APP_PASS=$(jq -r '.password // empty' <<< "$BOB_APP_RESP") +BOB_APP_PASS_ID=$(jq -r '.id // empty' <<< "$BOB_APP_RESP") +[[ -n "$BOB_APP_PASS" ]] \ + || fail "preflight: could not mint bob app password (response: $BOB_APP_RESP)" + +# Seed each user's home folder with a probe file so O3/O4 have +# something to ask about. Alice's seed goes via REST (`api_upload_file` +# already targets her home). Bob's seed goes via NC PUT under his +# Basic-Auth identity, since we want it owned by bob. +ALICE_FIXTURE_DIR=$(mktemp -d) +echo "alice's secret" > "$ALICE_FIXTURE_DIR/alice-secret.txt" +api_upload_file "$ALICE_FIXTURE_DIR/alice-secret.txt" "$HOME_FOLDER_ID" + +curl -s -u "$BOB_USERNAME:$BOB_APP_PASS" -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary 'bob secret' \ + "$base_url/remote.php/dav/files/$BOB_USERNAME/bob-secret.txt" > /dev/null + +trap 'rm -rf "$ALICE_FIXTURE_DIR"; \ + curl -s -X DELETE -H "Authorization: Bearer $BOB_JWT" \ + "$base_url/api/auth/app-passwords/$BOB_APP_PASS_ID" >/dev/null 2>&1 || true; \ + wipe_home_folder 2>/dev/null || true' EXIT + +NC_FILES_ALICE="$base_url/remote.php/dav/files/$ALICE_USERNAME" +NC_FILES_BOB="$base_url/remote.php/dav/files/$BOB_USERNAME" + +# ───────────────────────────────────────────────────────────── +# O1 — PROPFIND bob's home while auth'd as alice → 403 +# ───────────────────────────────────────────────────────────── +echo " O1: alice PROPFINDs /dav/files/bob/ → 403" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \ + -X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/") +case "$STATUS" in + 403|401) + pass "O1: cross-user PROPFIND rejected ($STATUS)" + ;; + *) + fail "O1: cross-user PROPFIND should be 403/401, got $STATUS — alice may be reading bob's home!" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# O2 — Path traversal in URL → 400 +# ───────────────────────────────────────────────────────────── +echo " O2: path traversal /dav/files/$ALICE_USERNAME/../$BOB_USERNAME/bob-secret.txt → 400" +# Send the traversal in raw form (curl --path-as-is keeps `..` instead +# of letting curl normalise the URL client-side). +STATUS=$(curl -s --path-as-is -o /dev/null -w "%{http_code}" \ + -u "$ALICE_USERNAME:$ALICE_APP_PASS" \ + -X PROPFIND -H "Depth: 0" \ + "$NC_FILES_ALICE/../$BOB_USERNAME/bob-secret.txt") +case "$STATUS" in + 400|403) + pass "O2: path traversal rejected ($STATUS)" + ;; + *) + fail "O2: path-traversal expected 400/403, got $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# O3 — MOVE alice's file → bob's home → reject +# ───────────────────────────────────────────────────────────── +echo " O3: alice MOVEs alice-secret.txt → bob's home → rejected" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$ALICE_USERNAME:$ALICE_APP_PASS" \ + -X MOVE \ + -H "Destination: $NC_FILES_BOB/alice-secret-stolen.txt" \ + "$NC_FILES_ALICE/alice-secret.txt") +case "$STATUS" in + 403|401|400) + pass "O3: cross-user MOVE rejected ($STATUS)" + ;; + 201|204) + # Confirm the destination actually landed in bob's home before + # we call this a security failure — the request might have + # legitimately failed silently elsewhere. + DST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -u "$BOB_USERNAME:$BOB_APP_PASS" \ + -X PROPFIND -H "Depth: 0" "$NC_FILES_BOB/alice-secret-stolen.txt") + if [[ "$DST_STATUS" == "207" ]]; then + fail "O3: SECURITY REGRESSION — alice's file was successfully moved into bob's home (status $STATUS, dest PROPFIND $DST_STATUS)" + else + pass "O3: MOVE returned $STATUS but destination is NOT in bob's home — effectively rejected" + fi + ;; + *) + fail "O3: unexpected status $STATUS — cross-user MOVE should be rejected" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# O4 — Alice's home PROPFIND must contain alice-secret.txt, +# must NOT contain bob-secret.txt +# ───────────────────────────────────────────────────────────── +echo " O4: alice's home PROPFIND contains alice-secret.txt only (not bob-secret)" +BODY=$(curl -s -u "$ALICE_USERNAME:$ALICE_APP_PASS" \ + -X PROPFIND -H "Depth: 1" "$NC_FILES_ALICE/") +grep -q 'alice-secret.txt' <<< "$BODY" \ + || fail "O4: alice-secret.txt missing from alice's home PROPFIND" +if grep -q 'bob-secret.txt' <<< "$BODY"; then + fail "O4: SECURITY REGRESSION — bob-secret.txt leaked into alice's home PROPFIND" +fi +pass "O4: alice's home contains only her own file; no bob leakage" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_favorites_and_report.sh b/tests/webdav/test_nc_favorites_and_report.sh new file mode 100755 index 00000000..2c32a3f8 --- /dev/null +++ b/tests/webdav/test_nc_favorites_and_report.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC PROPPATCH favorites + REPORT +# ============================================================= +# Groups H + I from BASELINE_TESTS_NC_WEBDAV.md (7 scenarios). +# Combined because they exercise the same two-step round-trip: +# set a favorite via PROPPATCH (H), confirm it surfaces in +# the REPORT favorites filter (I). +# +# Coverage: +# H1 — PROPPATCH oc:favorite=1 on a file → 207 +# H2 — same file appears in REPORT favorites filter +# H3 — PROPPATCH oc:favorite=0 → file removed from favorites +# I1 — REPORT favorites on empty home → empty multistatus +# I2 — REPORT favorites with 3 marked files → 3 entries +# I3 — REPORT searchrequest LIKE %foo% returns matching files +# I4 — REPORT searchrequest with nresults caps the result count +# +# xq is used wherever counting / per-response extraction would +# be brittle with awk (namespaced multistatus + filter-rules +# body). +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC PROPPATCH favorites + REPORT (Groups H + I baseline) ===" +echo + +command -v xq >/dev/null 2>&1 || fail "preflight: xq required" + +oxicloud_login +mint_app_password +resolve_home_folder_id +wipe_home_folder + +NC_FILES_BASE="$base_url/remote.php/dav/files/$username" +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"; wipe_home_folder 2>/dev/null || true' EXIT + +# ── Fixture setup via REST ─────────────────────────────────────────────────── +# Five files for I3 / I4 search assertions: foo.txt, foobar.txt, bar.txt, +# foobaz.txt, qux.txt — three contain "foo", two don't. +for name in foo.txt foobar.txt bar.txt foobaz.txt qux.txt; do + echo "content of $name" > "$FIXTURE_DIR/$name" + api_upload_file "$FIXTURE_DIR/$name" "$HOME_FOLDER_ID" +done + +# Three of the five will be favorited for H/I scenarios. Capture their +# REST ids so we can sanity-check the favorite state via the API later. +# foo.txt + foobar.txt + qux.txt = 3 favorites. + +# ───────────────────────────────────────────────────────────── +# I1 — REPORT favorites on empty-favorites state → empty +# ───────────────────────────────────────────────────────────── +echo " I1: REPORT favorites filter (none marked yet) → empty multistatus" +FILTER_BODY=' + + + 1 + +' + +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$FILTER_BODY" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "0" ]] \ + || fail "I1: REPORT favorites (empty) expected 0 responses, got '$N'" +pass "I1: empty-favorites REPORT returns 0 entries" + +# ───────────────────────────────────────────────────────────── +# H1 — PROPPATCH oc:favorite=1 on foo.txt → 207 +# ───────────────────────────────────────────────────────────── +PROPPATCH_FAV1=' + + 1 +' + +PROPPATCH_FAV0=' + + 0 +' + +echo " H1: PROPPATCH oc:favorite=1 on /foo.txt → 207" +HEADERS=$(nc_curl -D - -o /dev/null -X PROPPATCH \ + -H "Content-Type: application/xml" \ + --data "$PROPPATCH_FAV1" \ + "$NC_FILES_BASE/foo.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "207" ]] \ + || fail "H1: PROPPATCH expected 207, got $STATUS" +pass "H1: PROPPATCH oc:favorite=1 → 207" + +# ───────────────────────────────────────────────────────────── +# H2 — foo.txt now appears in REPORT favorites filter +# ───────────────────────────────────────────────────────────── +echo " H2: REPORT favorites filter now includes /foo.txt" +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$FILTER_BODY" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "1" ]] \ + || fail "H2: expected 1 favorited entry, got '$N'" +# xq doesn't accept function-returning XPaths (e.g. boolean(...)) +# at top level — it expects node selection. Plain grep is fine for +# substring-presence checks against an already-fetched body. +grep -q '/foo.txt' <<< "$BODY" \ + || fail "H2: favorites response does not contain /foo.txt" +pass "H2: REPORT favorites contains /foo.txt" + +# ───────────────────────────────────────────────────────────── +# H3 — PROPPATCH oc:favorite=0 removes the favorite +# ───────────────────────────────────────────────────────────── +echo " H3: PROPPATCH oc:favorite=0 removes /foo.txt from favorites" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPPATCH \ + -H "Content-Type: application/xml" \ + --data "$PROPPATCH_FAV0" \ + "$NC_FILES_BASE/foo.txt") +[[ "$STATUS" == "207" ]] \ + || fail "H3: PROPPATCH unfavorite expected 207, got $STATUS" +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$FILTER_BODY" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "0" ]] \ + || fail "H3: after unfavorite expected 0 entries, got '$N'" +pass "H3: PROPPATCH oc:favorite=0 → file removed from favorites" + +# ───────────────────────────────────────────────────────────── +# I2 — REPORT favorites with 3 marked files → 3 entries +# ───────────────────────────────────────────────────────────── +echo " I2: REPORT favorites with 3 marked files → 3 responses" +for fname in foo.txt foobar.txt qux.txt; do + STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPPATCH \ + -H "Content-Type: application/xml" \ + --data "$PROPPATCH_FAV1" \ + "$NC_FILES_BASE/$fname") + [[ "$STATUS" == "207" ]] \ + || fail "I2 setup: PROPPATCH on $fname expected 207, got $STATUS" +done +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$FILTER_BODY" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "3" ]] \ + || fail "I2: expected 3 favorited entries, got '$N'" +pass "I2: 3 favorited files appear in REPORT response" + +# ───────────────────────────────────────────────────────────── +# I3 — REPORT searchrequest LIKE %foo% returns matching files +# ───────────────────────────────────────────────────────────── +echo " I3: REPORT searchrequest LIKE %foo% → foo.txt + foobar.txt + foobaz.txt" +SEARCH_BODY=' + + + + /remote.php/dav/files/'"$username"'/ + + + + %foo% + + + +' + +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$SEARCH_BODY" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "3" ]] \ + || fail "I3: search expected 3 'foo'-containing matches, got '$N'" +# Confirm bar.txt is NOT in the search results. Plain grep — same +# reason as H2 above (xq doesn't take function-returning XPaths). +# Use `grep -v` style: assert that bar.txt does NOT appear. +if grep -q '/bar.txt' <<< "$BODY"; then + fail "I3: bar.txt erroneously matched 'foo' search" +fi +pass "I3: search returns exactly the 'foo'-containing files (bar.txt + qux.txt excluded)" + +# ───────────────────────────────────────────────────────────── +# I4 — REPORT searchrequest with nresults cap +# ───────────────────────────────────────────────────────────── +echo " I4: REPORT searchrequest with 2 caps at 2" +SEARCH_BODY_LIMITED=' + + + + /remote.php/dav/files/'"$username"'/ + + + + %foo% + + + 2 + +' + +BODY=$(nc_curl -X REPORT -H "Content-Type: application/xml" \ + --data "$SEARCH_BODY_LIMITED" "$NC_FILES_BASE/") +N=$(xq -x 'count(//*[local-name()="response"])' <<< "$BODY" | tr -d '\r\n ') +[[ "$N" == "2" ]] \ + || fail "I4: nresults=2 should cap result count at 2, got '$N'" +pass "I4: 2 correctly caps the response count" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_get_head_range.sh b/tests/webdav/test_nc_get_head_range.sh new file mode 100755 index 00000000..4e15770b --- /dev/null +++ b/tests/webdav/test_nc_get_head_range.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC WebDAV GET / HEAD / Range +# ============================================================= +# Group E from BASELINE_TESTS_NC_WEBDAV.md (6 scenarios). +# +# Sequence: +# E1 GET a small file — 200 + ETag + Last-Modified + Content-Type +# E2 HEAD same file — same headers, empty body +# E3 GET non-existent — 404 +# E4 GET a collection — pin current behavior +# E5 GET a 1 MB file with Range — 206 + Content-Range +# E6 GET with If-None-Match: → 304 +# +# Catches the file_id→blob_hash cache stale-content regression +# (commit f4ce4092): if the cache returned an old blob_hash, +# E2/E3 reads of an overwritten file would serve stale content. +# That specific overwrite scenario is covered by Group F (PUT); +# this group establishes the read-side baseline. +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC WebDAV GET / HEAD / Range (Group E baseline) ===" +echo + +oxicloud_login +mint_app_password +resolve_home_folder_id +wipe_home_folder # defensive against cross-test contamination + +NC_FILES_BASE="$base_url/remote.php/dav/files/$username" + +GET_FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$GET_FIXTURE_DIR"' EXIT + +# Small fixture — exact known content for E1 / E2 / E6. +SMALL_CONTENT="hello from group E baseline" +SMALL_PATH="$GET_FIXTURE_DIR/small.txt" +printf '%s' "$SMALL_CONTENT" > "$SMALL_PATH" +SMALL_LEN=$(wc -c < "$SMALL_PATH" | tr -d ' ') + +# 1 MB random binary — E5 Range request. +LARGE_PATH="$GET_FIXTURE_DIR/medium-1mb.bin" +dd if=/dev/urandom of="$LARGE_PATH" bs=1024 count=1024 status=none +LARGE_LEN=$(wc -c < "$LARGE_PATH" | tr -d ' ') + +# Upload both via REST so this test only exercises the GET surface. +api_upload_file "$SMALL_PATH" "$HOME_FOLDER_ID" +SMALL_FILE_ID="$LAST_FILE_ID" + +api_upload_file "$LARGE_PATH" "$HOME_FOLDER_ID" +LARGE_FILE_ID="$LAST_FILE_ID" + +SMALL_URL="$NC_FILES_BASE/$(basename "$SMALL_PATH")" +LARGE_URL="$NC_FILES_BASE/$(basename "$LARGE_PATH")" + +# A subfolder for E4 (GET on a collection). +api_create_folder "get-collection-probe" "$HOME_FOLDER_ID" +SUBFOLDER_ID="$LAST_FOLDER_ID" +SUBFOLDER_URL="$NC_FILES_BASE/get-collection-probe/" + +trap 'rm -rf "$GET_FIXTURE_DIR"; \ + api_delete_file "$SMALL_FILE_ID" 2>/dev/null || true; \ + api_delete_file "$LARGE_FILE_ID" 2>/dev/null || true; \ + api_delete_folder "$SUBFOLDER_ID" 2>/dev/null || true; \ + api_empty_trash 2>/dev/null || true' EXIT + +# ───────────────────────────────────────────────────────────── +# E1 — GET small file → 200 + headers + body +# ───────────────────────────────────────────────────────────── +echo " E1: GET small file" +RESPONSE=$(nc_curl -i "$SMALL_URL") +STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE" | tr -d '\r') +[[ "$STATUS" == "200" ]] \ + || fail "E1: expected 200, got $STATUS" +# Headers section ends at the first blank line. +HEADERS=$(awk 'BEGIN{p=1} /^\r?$/{p=0} p' <<< "$RESPONSE" | tr -d '\r') +BODY=$(awk 'BEGIN{p=0} p; /^\r?$/{p=1}' <<< "$RESPONSE" | tr -d '\r') +grep -qi '^content-type:' <<< "$HEADERS" \ + || fail "E1: missing Content-Type header" +grep -qi '^content-length:' <<< "$HEADERS" \ + || fail "E1: missing Content-Length header" +grep -qi '^etag:' <<< "$HEADERS" \ + || fail "E1: missing ETag header" +grep -qi '^last-modified:' <<< "$HEADERS" \ + || fail "E1: missing Last-Modified header" +CLEN=$(grep -i '^content-length:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r') +[[ "$CLEN" == "$SMALL_LEN" ]] \ + || fail "E1: Content-Length mismatch — expected $SMALL_LEN, got $CLEN" +# Body equals the uploaded bytes. +ACTUAL_BODY=$(nc_curl "$SMALL_URL") +[[ "$ACTUAL_BODY" == "$SMALL_CONTENT" ]] \ + || fail "E1: body mismatch — got '$ACTUAL_BODY', expected '$SMALL_CONTENT'" +# Capture the ETag for E6. +E1_ETAG=$(grep -i '^etag:' <<< "$HEADERS" | awk '{print $2}' | tr -d '\r') +pass "E1: GET small file — 200 + Content-Type/Length/ETag/Last-Modified, body matches" + +# ───────────────────────────────────────────────────────────── +# E2 — HEAD same file → same headers, empty body +# ───────────────────────────────────────────────────────────── +echo " E2: HEAD small file" +RESPONSE=$(nc_curl -I "$SMALL_URL") +STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE" | tr -d '\r') +[[ "$STATUS" == "200" ]] \ + || fail "E2: expected 200, got $STATUS" +grep -qi '^content-length:' <<< "$RESPONSE" \ + || fail "E2: missing Content-Length on HEAD" +grep -qi '^etag:' <<< "$RESPONSE" \ + || fail "E2: missing ETag on HEAD" +# `curl -I` body should be empty. +BODY_SIZE=$(nc_curl -I "$SMALL_URL" -w '%{size_download}' -o /dev/null) +[[ "$BODY_SIZE" == "0" ]] \ + || fail "E2: HEAD body must be empty, got $BODY_SIZE bytes" +pass "E2: HEAD small file — same headers, empty body" + +# ───────────────────────────────────────────────────────────── +# E3 — GET non-existent file → 404 +# ───────────────────────────────────────────────────────────── +echo " E3: GET non-existent file → 404" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" \ + "$NC_FILES_BASE/this-does-not-exist-$(date +%s).txt") +[[ "$STATUS" == "404" ]] \ + || fail "E3: expected 404, got $STATUS" +pass "E3: non-existent → 404" + +# ───────────────────────────────────────────────────────────── +# E4 — GET on a collection +# Per BASELINE doc §7 "Open questions", pin whatever the +# current behavior is (200 vs 404). Both are acceptable +# values for NC; the regression we care about is "did the +# shape change". The assertion below records the current +# behavior so any future drift is caught. +# ───────────────────────────────────────────────────────────── +echo " E4: GET on a collection — pin current behavior" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" "$SUBFOLDER_URL") +case "$STATUS" in + 200|404) + pass "E4: GET on collection returns $STATUS (pinned)" + ;; + *) + fail "E4: unexpected status $STATUS (expected 200 or 404)" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# E5 — Range request on 1 MB file +# +# Must use GET (not HEAD) — `handle_head` in the NC surface +# doesn't receive the request headers and never invokes the +# Range-response path. Range against HEAD silently returns +# 200 with all headers, which masks the Range support and +# would falsely pass an E5 written with `curl -I`. Use +# `-D - -o /dev/null` instead: defaults to GET, dumps the +# response headers to stdout, throws the body away — gives +# us the status + Content-Range without downloading the slice. +# ───────────────────────────────────────────────────────────── +echo " E5: GET with Range: bytes=0-1023 on 1 MB file → 206" +RESPONSE_HEADERS=$(nc_curl -D - -o /dev/null -H "Range: bytes=0-1023" "$LARGE_URL") +STATUS=$(awk 'NR==1{print $2}' <<< "$RESPONSE_HEADERS" | tr -d '\r') +[[ "$STATUS" == "206" ]] \ + || fail "E5: expected 206, got $STATUS" +grep -qi "^content-range:.*0-1023/$LARGE_LEN" <<< "$RESPONSE_HEADERS" \ + || fail "E5: missing or wrong Content-Range header" +# Actually fetch the slice and verify byte count. +BODY_SIZE=$(nc_curl -H "Range: bytes=0-1023" "$LARGE_URL" | wc -c | tr -d ' ') +[[ "$BODY_SIZE" == "1024" ]] \ + || fail "E5: Range body size expected 1024, got $BODY_SIZE" +pass "E5: Range bytes=0-1023 — 206 + Content-Range correct + 1024 bytes" + +# ───────────────────────────────────────────────────────────── +# E6 — GET with If-None-Match matching the stored ETag → 304 +# ───────────────────────────────────────────────────────────── +echo " E6: GET with If-None-Match matching ETag → 304" +[[ -n "$E1_ETAG" ]] \ + || fail "E6: precondition — E1 should have captured an ETag" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" \ + -H "If-None-Match: $E1_ETAG" "$SMALL_URL") +[[ "$STATUS" == "304" ]] \ + || fail "E6: If-None-Match matching expected 304, got $STATUS (etag was $E1_ETAG)" +pass "E6: If-None-Match matches → 304" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_move_copy_delete_trash.sh b/tests/webdav/test_nc_move_copy_delete_trash.sh new file mode 100755 index 00000000..b3057d3b --- /dev/null +++ b/tests/webdav/test_nc_move_copy_delete_trash.sh @@ -0,0 +1,443 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC WebDAV MOVE / COPY / DELETE + Trashbin +# ============================================================= +# Groups G + K from BASELINE_TESTS_NC_WEBDAV.md (14 scenarios). +# Combined into one file because G8 (DELETE) feeds K1-K4 +# (trashbin lifecycle) — same fixtures, shared lifecycle. +# +# Pinned behaviour notes: +# G4 / G5 — `Overwrite` request header is NOT honoured by the +# NC MOVE handler today. Both `Overwrite: F` and +# `Overwrite: T` succeed identically. Asserted as +# current-behaviour pins so any future "we now +# honour Overwrite" change is caught. +# G7 — COPY method is not dispatched by handle_nc_webdav, +# so it falls through to METHOD_NOT_ALLOWED (405). +# Pinned; a future COPY implementation will flip +# this to 201/204 and the test will trip. +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC WebDAV MOVE / COPY / DELETE + Trashbin (Groups G + K baseline) ===" +echo + +oxicloud_login +mint_app_password +resolve_home_folder_id +wipe_home_folder # defensive against cross-test contamination + +NC_FILES_BASE="$base_url/remote.php/dav/files/$username" +NC_TRASH_BASE="$base_url/remote.php/dav/trashbin/$username/trash" + +FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$FIXTURE_DIR"; \ + nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g-leftover/" 2>/dev/null || true; \ + nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g7-source.txt" 2>/dev/null || true; \ + api_empty_trash 2>/dev/null || true' EXIT + +# ── Helper: PUT a small file via NC for fixture setup ──────────────────────── +put_nc_file() { + local name="$1" content="$2" + printf '%s' "$content" | nc_curl -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary @- \ + "$NC_FILES_BASE/$name" > /dev/null +} + +# ── Helper: PROPFIND status for a path (for "exists / 404" assertions) ─────── +nc_status_propfind_depth0() { + nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" "$1" +} + +# ───────────────────────────────────────────────────────────── +# G1 — MOVE file to new name (rename) +# ───────────────────────────────────────────────────────────── +echo " G1: MOVE rename a.txt → b.txt" +put_nc_file "g1-a.txt" "G1 contents" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/g1-b.txt" \ + "$NC_FILES_BASE/g1-a.txt") +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "G1: MOVE rename expected 201/204, got $STATUS" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-b.txt")" == "207" ]] \ + || fail "G1: destination g1-b.txt missing after MOVE" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-a.txt")" == "404" ]] \ + || fail "G1: source g1-a.txt still present after MOVE" +pass "G1: MOVE rename — destination present, source gone" + +# ───────────────────────────────────────────────────────────── +# G2 — MOVE file to different folder +# ───────────────────────────────────────────────────────────── +echo " G2: MOVE file into a subfolder" +nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g2-folder/" > /dev/null +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/g2-folder/g1-b.txt" \ + "$NC_FILES_BASE/g1-b.txt") +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "G2: MOVE into subfolder expected 201/204, got $STATUS" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g2-folder/g1-b.txt")" == "207" ]] \ + || fail "G2: g2-folder/g1-b.txt not at new path" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g1-b.txt")" == "404" ]] \ + || fail "G2: source g1-b.txt still present after MOVE to subfolder" +pass "G2: MOVE into subfolder — file at destination, gone from source" + +# ───────────────────────────────────────────────────────────── +# G3 — Destination header with URL-encoded special chars +# ───────────────────────────────────────────────────────────── +echo " G3: MOVE with URL-encoded destination (space + #)" +put_nc_file "g3-src.txt" "G3 contents" +# Filename "name with #hash.txt" → URL-encoded. +ENCODED_NAME="name%20with%20%23hash.txt" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/$ENCODED_NAME" \ + "$NC_FILES_BASE/g3-src.txt") +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "G3: encoded MOVE expected 201/204, got $STATUS" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/$ENCODED_NAME")" == "207" ]] \ + || fail "G3: encoded destination not found" +pass "G3: URL-encoded destination decoded correctly" + +# ───────────────────────────────────────────────────────────── +# G4 / G5 — Overwrite header behaviour (pinned: not honoured) +# ───────────────────────────────────────────────────────────── +echo " G4: MOVE with Overwrite: F to an existing path (pinned: SERVER BUG — leaks 500)" +put_nc_file "g4-src.txt" "G4 source" +put_nc_file "g4-dest.txt" "G4 destination (should remain)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/g4-dest.txt" \ + -H "Overwrite: F" \ + "$NC_FILES_BASE/g4-src.txt") +case "$STATUS" in + 500) + # KNOWN BUG: the NC MOVE handler doesn't intercept + # `Overwrite: F` and doesn't map the domain-layer + # `AlreadyExists` to 412. It tries to rename, the + # storage layer 409s "name already taken", and the + # handler bubbles that up as 500. NC desktop will + # interpret 500 as "server transient error" and + # retry, which masks the real conflict. + # + # The right fix is in `interfaces/nextcloud/webdav_handler.rs::handle_move`: + # check `Overwrite: F` BEFORE attempting the rename, return + # 412 on collision; OR when Overwrite is omitted/T, delete + # the destination first (replace semantics, → 204). + pass "G4: Overwrite: F → 500 (KNOWN BUG: should be 412 per RFC 4918 §9.9.4 — pinned)" + ;; + 412) + fail "G4: server now correctly returns 412 for Overwrite: F. Bug is fixed — update this pin to assert == 412." + ;; + 201|204) + fail "G4: server now silently overwrites despite Overwrite: F (status $STATUS) — this would be a *different* bug; RFC requires 412." + ;; + *) + fail "G4: unexpected status $STATUS" + ;; +esac + +echo " G5: MOVE with Overwrite: T to an existing path (pinned: SERVER BUG — leaks 500)" +put_nc_file "g5-src.txt" "G5 source" +put_nc_file "g5-dest.txt" "G5 destination (to be replaced)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/g5-dest.txt" \ + -H "Overwrite: T" \ + "$NC_FILES_BASE/g5-src.txt") +case "$STATUS" in + 500) + # Same root cause as G4: the handler doesn't consider the + # `Overwrite` header at all. With `Overwrite: T` it SHOULD + # delete the destination first and proceed (→ 204), but + # today it bubbles up the storage-layer "Already Exists". + pass "G5: Overwrite: T → 500 (KNOWN BUG: should be 204 per RFC 4918 §9.9.4 — pinned)" + ;; + 204) + fail "G5: server now correctly returns 204 for Overwrite: T. Bug is fixed — update this pin to assert == 204." + ;; + *) + fail "G5: unexpected status $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# G6 — MOVE a folder (subtree) +# ───────────────────────────────────────────────────────────── +echo " G6: MOVE folder (recursive subtree)" +nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g6-tree/" > /dev/null +put_nc_file "g6-tree/inside.txt" "G6 inside contents" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/g6-tree-moved/" \ + "$NC_FILES_BASE/g6-tree/") +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "G6: folder MOVE expected 201/204, got $STATUS" +# Subtree intact at new location. +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g6-tree-moved/inside.txt")" == "207" ]] \ + || fail "G6: nested file missing after folder MOVE" +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g6-tree/")" == "404" ]] \ + || fail "G6: source folder still present after MOVE" +pass "G6: folder MOVE relocates the whole subtree" + +# ───────────────────────────────────────────────────────────── +# G7 — COPY method (pinned: not implemented → 405) +# ───────────────────────────────────────────────────────────── +echo " G7: COPY method (pinned: handler not implemented → 405)" +put_nc_file "g7-source.txt" "G7 contents" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X COPY \ + -H "Destination: $NC_FILES_BASE/g7-copy.txt" \ + "$NC_FILES_BASE/g7-source.txt") +case "$STATUS" in + 405) + pass "G7: COPY → 405 METHOD_NOT_ALLOWED (handler not implemented) — pinned" + ;; + 201|204) + fail "G7: COPY now succeeds ($STATUS) — handler was implemented; update pin and add positive assertions." + ;; + *) + fail "G7: unexpected status $STATUS" + ;; +esac + +# ───────────────────────────────────────────────────────────── +# G8 — DELETE a file → 204 + GET 404 + appears in trash +# +# This step feeds K1: the deleted item must surface in the +# trashbin PROPFIND below. +# ───────────────────────────────────────────────────────────── +echo " G8: DELETE file → 204, GET 404, trashbin lists it" +put_nc_file "g8-doomed.txt" "G8 doomed contents" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g8-doomed.txt") +[[ "$STATUS" == "204" ]] \ + || fail "G8: DELETE expected 204, got $STATUS" +GET_STATUS=$(nc_curl -o /dev/null -w "%{http_code}" "$NC_FILES_BASE/g8-doomed.txt") +[[ "$GET_STATUS" == "404" ]] \ + || fail "G8: GET after DELETE expected 404, got $GET_STATUS" +pass "G8: DELETE → 204 + GET 404" + +# ───────────────────────────────────────────────────────────── +# G9 — DELETE a folder (pinned: SERVER BUG — descendants orphan) +# +# Current behaviour: the NC DELETE handler calls +# `trash_svc.move_to_trash(&folder.id, "folder", …)`. That +# flips the folder row's `is_trashed=true`, but descendant +# files / subfolders are NOT recursively trashed at the row +# level. The folder itself becomes invisible (PROPFIND on the +# folder URL → 404, correct), but every descendant remains +# directly queryable via PROPFIND on its full path. That's +# data-integrity weird: clients can still GET/PUT/DELETE the +# descendants even though their parent collection is "gone". +# +# Why this is a bug: +# - NC desktop's tree walk PROPFINDs the descendants via the +# parent's response; the parent being 404 stops descent and +# the orphans never get noticed → eventual drift between +# server state and client cache. +# - Trash restore expects to recreate the folder + reattach +# descendants; with descendants still "live" the restore +# path will collide on their names. +# +# Where the fix needs to live: +# `application/services/trash_service.rs::move_to_trash` (or +# the folder-write repository it delegates to) — when a +# folder is trashed, recursively mark its descendants +# is_trashed=true (or rely on a SQL trigger on the parent +# FK cascade). +# +# Test posture: pin the orphan behaviour. The folder→404 part +# is the only correct half. When the fix lands, the +# descendant assertions below will trip and you can flip them +# to strict 404. +# ───────────────────────────────────────────────────────────── +echo " G9: DELETE folder (pinned: descendants currently orphan — KNOWN BUG)" +nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/" > /dev/null +nc_curl -o /dev/null -X MKCOL "$NC_FILES_BASE/g9-tree/inner/" > /dev/null +put_nc_file "g9-tree/file.txt" "G9 file" +put_nc_file "g9-tree/inner/deep.txt" "G9 deep" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_FILES_BASE/g9-tree/") +[[ "$STATUS" == "204" ]] \ + || fail "G9: folder DELETE expected 204, got $STATUS" + +# Folder itself: correctly 404. +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/")" == "404" ]] \ + || fail "G9: folder still present after DELETE — that part should always be 404" + +# Descendants: pin the current (buggy) "still alive" status. +# Either current 207 (bug) or future 404 (fix) is acceptable; +# anything else means something has drifted unexpectedly. +CHILD_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/file.txt") +DEEP_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/g9-tree/inner/deep.txt") +if [[ "$CHILD_STATUS" == "207" && "$DEEP_STATUS" == "207" ]]; then + pass "G9: descendants still reachable (file=207, deep=207) — KNOWN BUG pinned: move_to_trash isn't recursive at the row level" +elif [[ "$CHILD_STATUS" == "404" && "$DEEP_STATUS" == "404" ]]; then + fail "G9: descendants now correctly 404 (file=$CHILD_STATUS, deep=$DEEP_STATUS) — bug is fixed, flip this case to strict 404 assertions." +else + fail "G9: mixed/unexpected descendant statuses (file=$CHILD_STATUS, deep=$DEEP_STATUS) — pin needs review" +fi + +# ═════════════════════════════════════════════════════════════ +# Group K — Trashbin DAV (depends on G8's deletion above) +# ═════════════════════════════════════════════════════════════ + +# ───────────────────────────────────────────────────────────── +# K1 — PROPFIND trashbin: g8-doomed.txt present with the +# original-location property. +# ───────────────────────────────────────────────────────────── +echo " K1: PROPFIND trashbin lists g8-doomed.txt" +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/") +grep -q 'g8-doomed' <<< "$BODY" \ + || fail "K1: g8-doomed.txt not in trashbin PROPFIND" +grep -q '' <<< "$BODY" \ + || fail "K1: trashbin response missing " +pass "K1: trashbin shows g8-doomed.txt with original-location" + +# Extract the trashed item id (last segment of the href). +# Trashbin hrefs are `/remote.php/dav/trashbin/{user}/trash/{uuid}` +# — they don't carry the filename, so we match the surrounding +# `` block by `g8-doomed…` and +# pull THAT block's href. +TRASHED_HREF=$(extract_response_href_containing "$BODY" "g8-doomed") +[[ -n "$TRASHED_HREF" ]] || fail "K1: could not extract trashed href for g8-doomed.txt" +TRASHED_ID=$(basename "$TRASHED_HREF") +[[ -n "$TRASHED_ID" ]] || fail "K1: could not extract trashed item id from href '$TRASHED_HREF'" + +# ───────────────────────────────────────────────────────────── +# K2 — MOVE from trash → 201 (restore to ORIGINAL location) +# +# Pinned current behaviour: the trashbin MOVE handler IGNORES +# the `Destination` request header. It extracts the trash UUID +# from the URL path and calls `trash_service.restore_item(id, +# user_id)`, which restores the file to its *original* +# location, not to the URL the client requested. So even +# though we send `Destination: /restored-g8.txt`, the file +# ends up back at `/g8-doomed.txt`. +# +# This isn't necessarily a bug — many NC servers treat +# trashbin MOVE as "restore to where it was" rather than as +# arbitrary relocation. The NC desktop client doesn't rely on +# the Destination here. But the wire shape diverges from +# RFC 4918 §9.9, so it's worth pinning so a future drift in +# either direction surfaces. +# ───────────────────────────────────────────────────────────── +echo " K2: MOVE from trash (Destination ignored, restores to ORIGINAL path /g8-doomed.txt)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/restored-g8.txt" \ + "$NC_TRASH_BASE/$TRASHED_ID") +case "$STATUS" in + 201|204) pass "K2: trash MOVE → $STATUS (restore initiated)" ;; + *) fail "K2: trash MOVE expected 201/204, got $STATUS" ;; +esac +# Pin: file is at its ORIGINAL path, NOT at the requested Destination. +[[ "$(nc_status_propfind_depth0 "$NC_FILES_BASE/g8-doomed.txt")" == "207" ]] \ + || fail "K2: file should have been restored to original /g8-doomed.txt — not found there" +DEST_STATUS=$(nc_status_propfind_depth0 "$NC_FILES_BASE/restored-g8.txt") +[[ "$DEST_STATUS" == "404" ]] \ + || fail "K2: Destination header is now honoured (status $DEST_STATUS at requested dest) — behaviour changed; flip K2/K3 to RFC 4918 MOVE semantics." + +# ───────────────────────────────────────────────────────────── +# K3 — Delete then permanently delete via trashbin DELETE +# +# K2 restored the file to its original path `/g8-doomed.txt` +# (not `/restored-g8.txt`, see K2's pin), so we delete from +# there to send it back to trash, then permanently delete via +# the trashbin DELETE method. +# ───────────────────────────────────────────────────────────── +echo " K3: trashbin DELETE permanently removes an item" +# Delete the just-restored file → goes back to trash. +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/g8-doomed.txt" +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/") +TRASHED_HREF=$(extract_response_href_containing "$BODY" "g8-doomed") +TRASHED_ID=$(basename "$TRASHED_HREF") +[[ -n "$TRASHED_ID" && "$TRASHED_HREF" != "" ]] \ + || fail "K3: g8-doomed.txt not in trash after re-delete (no matching block)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_TRASH_BASE/$TRASHED_ID") +[[ "$STATUS" == "204" ]] \ + || fail "K3: trash DELETE expected 204, got $STATUS" +# Confirm it's gone from trash now. +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/") +grep -q 'g8-doomed' <<< "$BODY" \ + && fail "K3: g8-doomed still in trash after permanent DELETE" +pass "K3: trashbin DELETE permanently removes the item" + +# ───────────────────────────────────────────────────────────── +# K4 — Empty all trash +# ───────────────────────────────────────────────────────────── +echo " K4: DELETE on trash root empties everything" +# Seed a few items +put_nc_file "k4-a.txt" "k4 a" +put_nc_file "k4-b.txt" "k4 b" +put_nc_file "k4-c.txt" "k4 c" +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-a.txt" +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-b.txt" +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k4-c.txt" + +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X DELETE "$NC_TRASH_BASE") +[[ "$STATUS" == "204" ]] \ + || fail "K4: empty-trash expected 204, got $STATUS" + +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/") +N=$(count_responses "$BODY") +# After empty, only the trash collection itself remains. +[[ "$N" == "1" ]] \ + || fail "K4: trash should have 1 response (collection only), got $N" +pass "K4: empty-trash removes every item" + +# ───────────────────────────────────────────────────────────── +# K5 — Restore-collision behaviour (pinned) +# +# Restore a trashed item to a path where a same-named file +# already exists. Pin whichever behaviour the server has today +# (rename-suffix? 412? overwrite?) so a future change is loud. +# ───────────────────────────────────────────────────────────── +echo " K5: MOVE from trash to a colliding path — pin current behaviour" +put_nc_file "k5-conflict.txt" "k5 original (stays)" +put_nc_file "k5-doomed.txt" "k5 to trash and restore" +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/k5-doomed.txt" +# Take the trashed id of k5-doomed.txt +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_TRASH_BASE/") +TRASHED_HREF=$(extract_response_href_containing "$BODY" "k5-doomed") +TRASHED_ID=$(basename "$TRASHED_HREF") +[[ -n "$TRASHED_ID" && "$TRASHED_HREF" != "" ]] \ + || fail "K5: k5-doomed not in trash (no matching block)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MOVE \ + -H "Destination: $NC_FILES_BASE/k5-conflict.txt" \ + "$NC_TRASH_BASE/$TRASHED_ID") +case "$STATUS" in + 201|204) + pass "K5: restore-onto-existing → $STATUS (current behaviour pinned: collision NOT prevented at this layer)" + ;; + 412) + pass "K5: restore-onto-existing → 412 (current behaviour pinned: precondition-style refusal)" + ;; + 409) + pass "K5: restore-onto-existing → 409 (current behaviour pinned: name conflict)" + ;; + 500) + # Same shape as the G4/G5 bug — restore is a MOVE under + # the hood, and the handler doesn't catch the storage- + # layer "Already Exists" before it becomes an internal + # error. Pinned because that's the actual current + # behaviour, not because it's correct. + pass "K5: restore-onto-existing → 500 (KNOWN BUG: same root cause as G4/G5 — pinned)" + ;; + *) + fail "K5: unexpected status $STATUS — pin needs reviewing" + ;; +esac + +# ── Cleanup ────────────────────────────────────────────────────────────────── +echo " cleanup: empty trash + remove residual fixtures" +api_empty_trash || true +pass "cleanup done" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nc_propfind_baseline.sh b/tests/webdav/test_nc_propfind_baseline.sh new file mode 100755 index 00000000..c657db4a --- /dev/null +++ b/tests/webdav/test_nc_propfind_baseline.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +# ============================================================= +# OxiCloud — Baseline: NC WebDAV PROPFIND + OPTIONS +# ============================================================= +# Group D from BASELINE_TESTS_NC_WEBDAV.md (11 scenarios). +# +# This is the read surface NC client touches first on every +# sync cycle. The headline guard is D8 / D9 / D10: +# trailing-slash semantics on collection vs file hrefs in +# multistatus responses — past regression where collection +# hrefs were emitted without `/` aborted NC desktop parsing +# with `Invalid href "<…>" expected starting with +# ""`. +# +# Sequence: +# D1 OPTIONS on the user's root collection +# D2 PROPFIND Depth: 0 on home +# D3 PROPFIND Depth: 1 on empty home (just created admin, +# no fixtures yet — fixtures arrive at the D4 step) +# D4 Upload 2 files + create 1 subfolder, PROPFIND Depth: 1 +# D5 PROPFIND non-existent path → 404 +# D6 PROPFIND on a file (not a collection) +# D7 PROPFIND Depth: infinity on a 3-level tree +# D8 PROPFIND on a subdirectory at Depth: 0 (trailing slash +# guard on its own href) +# D9 PROPFIND subdirectory Depth: 1 with mixed content +# (trailing slash guard on every child) +# D10 PROPFIND home Depth: 1 with mixed content (trailing +# slash guard on home's own entry + every child) +# D11 PROPFIND with malformed XML body → 400 +# ============================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +source test.env +source common.sh +source lib/dav_helpers.sh + +echo +echo "=== NC WebDAV PROPFIND + OPTIONS (Group D baseline) ===" +echo + +oxicloud_login +mint_app_password +resolve_home_folder_id + +# Defensive: this test's D4/D10 assertions count entries at the home +# root, so a leftover from an earlier script (e.g. the +# move_copy_delete_trash failures that 500-leak fixture files +# pinned as KNOWN BUG) would poison the count. Wipe to clean state. +wipe_home_folder + +NC_FILES_BASE="$base_url/remote.php/dav/files/$username" + +# ── Fixture setup (via REST so we don't depend on WebDAV-write paths) ──────── + +PROPFIND_FIXTURE_DIR=$(mktemp -d) +trap 'rm -rf "$PROPFIND_FIXTURE_DIR"' EXIT + +echo "alpha contents" > "$PROPFIND_FIXTURE_DIR/alpha.txt" +echo "beta contents" > "$PROPFIND_FIXTURE_DIR/beta.txt" +echo "gamma contents" > "$PROPFIND_FIXTURE_DIR/gamma.txt" + +# Subdir "sub-d" for D8 / D9, with mixed children: +# sub-d/{file1.txt, file2.txt, deepest1/, deepest2/} +# Plus a nested file under deepest1/ for D7 (Depth: infinity). + +api_create_folder "sub-d" "$HOME_FOLDER_ID" +SUB_D_FOLDER_ID="$LAST_FOLDER_ID" + +api_create_folder "deepest1" "$SUB_D_FOLDER_ID" +DEEPEST1_FOLDER_ID="$LAST_FOLDER_ID" + +api_create_folder "deepest2" "$SUB_D_FOLDER_ID" +# DEEPEST2_FOLDER_ID not needed downstream — only its href + +api_upload_file "$PROPFIND_FIXTURE_DIR/alpha.txt" "$SUB_D_FOLDER_ID" +api_upload_file "$PROPFIND_FIXTURE_DIR/beta.txt" "$SUB_D_FOLDER_ID" + +# Deep file for D7 Depth: infinity. +api_upload_file "$PROPFIND_FIXTURE_DIR/gamma.txt" "$DEEPEST1_FOLDER_ID" +DEEP_FILE_ID="$LAST_FILE_ID" + +# Mixed-content top-level entry for D10: +# home / { alpha-home.txt, sub-d/, sub-d-extra/ } + +echo "home alpha" > "$PROPFIND_FIXTURE_DIR/alpha-home.txt" + +api_upload_file "$PROPFIND_FIXTURE_DIR/alpha-home.txt" "$HOME_FOLDER_ID" +HOME_ALPHA_FILE_ID="$LAST_FILE_ID" + +api_create_folder "sub-d-extra" "$HOME_FOLDER_ID" +SUB_EXTRA_FOLDER_ID="$LAST_FOLDER_ID" + +echo +echo "Fixtures ready: 1 home-level file, 2 home-level folders (sub-d, sub-d-extra)," +echo "sub-d holds 2 files + 2 sub-subfolders, deepest1 holds 1 file." +echo + +# ───────────────────────────────────────────────────────────── +# D1 — OPTIONS +# ───────────────────────────────────────────────────────────── +echo " D1: OPTIONS on root collection" +HEADERS=$(nc_curl -i -X OPTIONS "$NC_FILES_BASE/" | tr -d '\r') +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS") +[[ "$STATUS" == "200" ]] \ + || fail "D1: OPTIONS expected 200, got $STATUS" +grep -qi '^dav:.*1.*3' <<< "$HEADERS" \ + || fail "D1: OPTIONS missing 'DAV: 1, 3' header" +grep -qi '^allow:.*PROPFIND' <<< "$HEADERS" \ + || fail "D1: Allow header missing PROPFIND" +grep -qi '^allow:.*PUT' <<< "$HEADERS" \ + || fail "D1: Allow header missing PUT" +grep -qi '^allow:.*REPORT' <<< "$HEADERS" \ + || fail "D1: Allow header missing REPORT" +pass "D1: OPTIONS advertises DAV 1, 3 + Allow includes PROPFIND/PUT/REPORT" + +# ───────────────────────────────────────────────────────────── +# D2 — PROPFIND Depth: 0 home +# ───────────────────────────────────────────────────────────── +echo " D2: PROPFIND Depth: 0 on home root" +BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/") +N=$(count_responses "$BODY") +[[ "$N" == "1" ]] \ + || fail "D2: Depth:0 expected 1 response, got $N" +HOME_HREF=$(extract_href_for "$BODY" "/dav/files/$username/") +[[ -n "$HOME_HREF" ]] \ + || fail "D2: home href not found in body" +[[ "$HOME_HREF" == */ ]] \ + || fail "D2: home href does NOT end with '/' — got '$HOME_HREF'" +grep -q '' <<< "$BODY" \ + || fail "D2: home response missing " +grep -q '' <<< "$BODY" \ + || fail "D2: home response missing " +pass "D2: home Depth:0 — 1 response, href ends '/', collection + fileid present" + +# ───────────────────────────────────────────────────────────── +# D4 — PROPFIND Depth: 1 with mixed content +# +# We test D4 BEFORE D3 because we already set up fixtures. +# D3 (empty home) requires no fixtures, which is the natural +# state of a freshly-wiped storage but is broken by anything +# we did above. We re-create the empty-home invariant by +# moving the fixtures out of the way at D3-time. +# ───────────────────────────────────────────────────────────── +echo " D4: PROPFIND Depth: 1 on home (1 file + 2 folders ⇒ 4 responses)" +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/") +N=$(count_responses "$BODY") +[[ "$N" == "4" ]] \ + || fail "D4: Depth:1 expected 4 responses (collection + 1 file + 2 folders), got $N" +grep -q '' <<< "$BODY" \ + || fail "D4: at least one response should have " +assert_collection_hrefs_have_trailing_slash "$BODY" "D4" +pass "D4: 4 responses, trailing-slash semantics correct, content-length present" + +# ───────────────────────────────────────────────────────────── +# D10 — PROPFIND home Depth: 1 mixed content +# OWN entry + every child checked for trailing-slash. +# The body from D4 already meets D10's setup — re-assert +# on it with explicit OWN-entry focus. +# ───────────────────────────────────────────────────────────── +echo " D10: PROPFIND home Depth: 1 — explicit OWN-entry trailing slash check" +# OWN entry's href is the home root, must end '/' +HOME_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/") +[[ -n "$HOME_OWN_HREF" ]] && [[ "$HOME_OWN_HREF" == */ ]] \ + || fail "D10: OWN-entry href absent or missing trailing slash: '$HOME_OWN_HREF'" +# Sub-d subfolder href (collection) must end '/' +SUB_D_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d") +[[ -n "$SUB_D_HREF" ]] && [[ "$SUB_D_HREF" == */ ]] \ + || fail "D10: sub-d folder href missing trailing slash: '$SUB_D_HREF'" +# File href must NOT end '/' +HOME_ALPHA_HREF=$(extract_href_for "$BODY" "/dav/files/$username/alpha-home.txt") +[[ -n "$HOME_ALPHA_HREF" ]] && [[ "$HOME_ALPHA_HREF" != */ ]] \ + || fail "D10: alpha-home.txt href must NOT end '/': got '$HOME_ALPHA_HREF'" +pass "D10: OWN entry + folder + file all have correct trailing-slash semantics" + +# ───────────────────────────────────────────────────────────── +# D8 — PROPFIND on a subdirectory at Depth: 0 +# Its OWN href must end '/'. +# ───────────────────────────────────────────────────────────── +echo " D8: PROPFIND Depth: 0 on subdirectory /sub-d/" +BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/sub-d/") +N=$(count_responses "$BODY") +[[ "$N" == "1" ]] \ + || fail "D8: Depth:0 expected 1 response, got $N" +SUB_D_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d") +[[ -n "$SUB_D_OWN_HREF" ]] && [[ "$SUB_D_OWN_HREF" == */ ]] \ + || fail "D8: subdirectory OWN href missing trailing slash: '$SUB_D_OWN_HREF'" +grep -q '' <<< "$BODY" \ + || fail "D8: sub-d response missing " +pass "D8: subdir Depth:0 — OWN href ends '/' and resourcetype is collection" + +# ───────────────────────────────────────────────────────────── +# D9 — PROPFIND on subdirectory Depth: 1 with mixed content +# sub-d holds 2 files + 2 sub-subfolders ⇒ 5 responses, +# every collection href ends '/', every file href doesn't. +# ───────────────────────────────────────────────────────────── +echo " D9: PROPFIND Depth: 1 on /sub-d/ (mixed children ⇒ 5 responses)" +BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/sub-d/") +N=$(count_responses "$BODY") +[[ "$N" == "5" ]] \ + || fail "D9: Depth:1 on sub-d expected 5 responses, got $N" +assert_collection_hrefs_have_trailing_slash "$BODY" "D9" +# Spot-check the two specific children: deepest1/ is a collection; +# alpha.txt is a file. +DEEPEST1_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d/deepest1") +[[ -n "$DEEPEST1_HREF" ]] && [[ "$DEEPEST1_HREF" == */ ]] \ + || fail "D9: deepest1 folder href missing trailing slash: '$DEEPEST1_HREF'" +SUB_D_ALPHA_HREF=$(extract_href_for "$BODY" "/dav/files/$username/sub-d/alpha.txt") +[[ -n "$SUB_D_ALPHA_HREF" ]] && [[ "$SUB_D_ALPHA_HREF" != */ ]] \ + || fail "D9: alpha.txt href must NOT end '/': got '$SUB_D_ALPHA_HREF'" +pass "D9: 5 responses, trailing-slash semantics correct on every child" + +# ───────────────────────────────────────────────────────────── +# D6 — PROPFIND on a file (not collection) at Depth: 0 +# href must NOT end '/'. +# ───────────────────────────────────────────────────────────── +echo " D6: PROPFIND Depth: 0 on a file (not a collection)" +BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/alpha-home.txt") +N=$(count_responses "$BODY") +[[ "$N" == "1" ]] \ + || fail "D6: Depth:0 on file expected 1 response, got $N" +FILE_HREF=$(extract_href_for "$BODY" "/dav/files/$username/alpha-home.txt") +[[ -n "$FILE_HREF" ]] && [[ "$FILE_HREF" != */ ]] \ + || fail "D6: file href must NOT end '/': got '$FILE_HREF'" +grep -q '' <<< "$BODY" \ + || fail "D6: file response missing " +# The resourcetype on a file is empty `` or +# `` — NOT a . +grep -q '' <<< "$BODY" \ + && fail "D6: file response erroneously contains " +pass "D6: file Depth:0 — href no trailing slash, content-length present, no collection" + +# ───────────────────────────────────────────────────────────── +# D5 — PROPFIND non-existent path → 404 +# ───────────────────────────────────────────────────────────── +echo " D5: PROPFIND non-existent path → 404" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" \ + "$NC_FILES_BASE/this-path-does-not-exist-$(date +%s)") +[[ "$STATUS" == "404" ]] \ + || fail "D5: non-existent path expected 404, got $STATUS" +pass "D5: non-existent path returns 404" + +# ───────────────────────────────────────────────────────────── +# D7 — PROPFIND Depth: infinity behaviour +# +# OxiCloud's NC PROPFIND streaming handler treats `Depth: infinity` +# the same as `Depth: 1` (the branch is literally +# `if depth != "0" { … one level … }` — see +# `interfaces/nextcloud/webdav_handler.rs::build_nc_streaming_propfind`). +# No recursive descent. This is a deliberate implementation choice: +# many DAV servers either cap or 403 `Depth: infinity` because a +# full tree walk on a large account can be O(filesystem) work +# behind a single HTTP request (RFC 4918 §9.1 explicitly allows +# servers to refuse it with `propfind-finite-depth`). +# +# The test pins this: `Depth: infinity` on /sub-d/ returns the +# same 5 responses as `Depth: 1` (sub-d itself + 2 files + 2 +# sub-subfolders), with the nested gamma.txt NOT present. If +# OxiCloud later starts honouring infinity (full descent → 6 +# responses including gamma.txt) or refusing it (403), this +# assertion catches the change. +# +# Trailing-slash semantics still apply to whatever IS returned. +# ───────────────────────────────────────────────────────────── +echo " D7: PROPFIND Depth: infinity on /sub-d/ (pinned to current behaviour)" +BODY=$(nc_curl -X PROPFIND -H "Depth: infinity" "$NC_FILES_BASE/sub-d/") +N=$(count_responses "$BODY") +[[ "$N" == "5" ]] \ + || fail "D7: Depth:infinity on sub-d expected 5 responses (treated as Depth:1), got $N" +GAMMA_HREF=$(extract_href_for "$BODY" "/sub-d/deepest1/gamma.txt") +[[ -z "$GAMMA_HREF" ]] \ + || fail "D7: Depth:infinity unexpectedly returned the nested gamma.txt — server now doing recursive descent? Update this test." +assert_collection_hrefs_have_trailing_slash "$BODY" "D7" +pass "D7: Depth:infinity behaves as Depth:1 (5 responses, no nested descent)" + +# ───────────────────────────────────────────────────────────── +# D11 — Malformed PROPFIND body → 400 +# ───────────────────────────────────────────────────────────── +echo " D11: malformed PROPFIND XML body → 400" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X PROPFIND -H "Depth: 0" \ + -H "Content-Type: application/xml" \ + --data-binary '/dev/null 2>&1 \ + || fail "preflight: b3sum required for F8/F9 — install via 'brew install b3sum' or 'apt install b3sum'" + +# ── Helper: extract the first ETag value from a curl `-D -` dump ───────────── +header_value() { + grep -i "^$1:" <<< "$2" | awk '{print $2}' | tr -d '\r"' | head -n 1 +} + +# ── Helper: list home folder, find file by name, capture id + content_hash ─── +# +# Uses `/listing` (NOT `/contents`): `/contents` is deprecated AND +# its response shape was changed from `{files, folders}` to a flat +# array, so callers that try `.files[]` fail with "Cannot index +# array with string 'files'". The non-deprecated `/listing` +# endpoint still returns the `.files[] / .folders[]` shape we +# need here. Same endpoint `wipe_home_folder` + the API cleanup +# audit (`tests/api/storage_cleanup_check.sh`) use. +nc_lookup_via_rest() { + local name="$1" + local response + response=$(api_curl "$base_url/api/folders/$HOME_FOLDER_ID/listing") + LAST_FILE_ID=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .id' <<< "$response") + LAST_FILE_CONTENT_HASH=$(jq -r --arg n "$name" '.files[]? | select(.name == $n) | .content_hash' <<< "$response") + [[ -n "$LAST_FILE_ID" && "$LAST_FILE_ID" != "null" ]] \ + || fail "REST lookup for '$name' in home folder returned no id (response: $response)" +} + +# ───────────────────────────────────────────────────────────── +# F1 — PUT a new file → 201 + ETag + oc-etag +# ───────────────────────────────────────────────────────────── +echo " F1: PUT new file → 201" +SMALL_CONTENT="hello from group F" +SMALL_PATH="$PUT_FIXTURE_DIR/f1-small.txt" +printf '%s' "$SMALL_CONTENT" > "$SMALL_PATH" +SMALL_LEN=$(wc -c < "$SMALL_PATH" | tr -d ' ') + +HEADERS=$(nc_curl -D - -o /dev/null -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary "@$SMALL_PATH" \ + "$NC_FILES_BASE/f1-small.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "201" ]] \ + || fail "F1: PUT new expected 201, got $STATUS" +F1_ETAG=$(header_value "etag" "$HEADERS") +F1_OC_ETAG=$(header_value "oc-etag" "$HEADERS") +[[ -n "$F1_ETAG" ]] || fail "F1: response missing ETag header" +[[ -n "$F1_OC_ETAG" ]] || fail "F1: response missing oc-etag header" +[[ "$F1_ETAG" == "$F1_OC_ETAG" ]] \ + || fail "F1: ETag ($F1_ETAG) and oc-etag ($F1_OC_ETAG) should match" +# Pin "no oc-fileid header" current behaviour. +grep -qi '^oc-fileid:' <<< "$HEADERS" \ + && fail "F1: oc-fileid header is now present — pin needs updating" +pass "F1: PUT new → 201 + matching ETag/oc-etag, no oc-fileid header" + +# ───────────────────────────────────────────────────────────── +# F2 — GET retrieves the bytes we just PUT +# ───────────────────────────────────────────────────────────── +echo " F2: GET file just PUT" +ACTUAL=$(nc_curl "$NC_FILES_BASE/f1-small.txt") +[[ "$ACTUAL" == "$SMALL_CONTENT" ]] \ + || fail "F2: body mismatch — got '$ACTUAL', expected '$SMALL_CONTENT'" +pass "F2: GET returns exact bytes from F1's PUT" + +# ───────────────────────────────────────────────────────────── +# F3 — PUT overwrite same path → 204 + NEW ETag +# ───────────────────────────────────────────────────────────── +echo " F3: PUT overwrite → 204 + new ETag" +NEW_CONTENT="goodbye from group F" +NEW_PATH="$PUT_FIXTURE_DIR/f3-overwrite.txt" +printf '%s' "$NEW_CONTENT" > "$NEW_PATH" + +HEADERS=$(nc_curl -D - -o /dev/null -X PUT \ + -H "Content-Type: text/plain" \ + --data-binary "@$NEW_PATH" \ + "$NC_FILES_BASE/f1-small.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "204" ]] \ + || fail "F3: PUT overwrite expected 204, got $STATUS" +F3_ETAG=$(header_value "etag" "$HEADERS") +[[ -n "$F3_ETAG" ]] || fail "F3: overwrite response missing ETag" +[[ "$F3_ETAG" != "$F1_ETAG" ]] \ + || fail "F3: ETag must change on overwrite ($F1_ETAG → $F3_ETAG)" +pass "F3: PUT overwrite → 204 + new ETag (different from F1)" + +# ───────────────────────────────────────────────────────────── +# F4 — GET after overwrite returns new content (cache regression +# guard — see commit f4ce4092) +# ───────────────────────────────────────────────────────────── +echo " F4: GET after overwrite returns NEW content" +ACTUAL=$(nc_curl "$NC_FILES_BASE/f1-small.txt") +[[ "$ACTUAL" == "$NEW_CONTENT" ]] \ + || fail "F4: STALE content after overwrite — got '$ACTUAL', expected '$NEW_CONTENT' (regression of f4ce4092?)" +pass "F4: GET after overwrite serves the new bytes (no stale-cache)" + +# ───────────────────────────────────────────────────────────── +# F5 / F6 — Conditional PUT (pinned: currently no-op) +# ───────────────────────────────────────────────────────────── +echo " F5: PUT with If-None-Match: * on existing path (pinned current: 204, RFC-4918 would be 412)" +HEADERS=$(nc_curl -D - -o /dev/null -X PUT \ + -H "If-None-Match: *" -H "Content-Type: text/plain" \ + --data-binary 'F5-payload' \ + "$NC_FILES_BASE/f1-small.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \ + || fail "F5: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)" +pass "F5: PUT honours no conditional headers today — pinned" + +echo " F6: PUT with If-Match: \"wrong-etag\" (pinned current: succeeds, RFC-4918 would be 412)" +HEADERS=$(nc_curl -D - -o /dev/null -X PUT \ + -H 'If-Match: "deadbeef-never-matches"' -H "Content-Type: text/plain" \ + --data-binary 'F6-payload' \ + "$NC_FILES_BASE/f1-small.txt") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "204" || "$STATUS" == "201" ]] \ + || fail "F6: unexpected status $STATUS (expected 204 — current ignore-conditional behaviour)" +pass "F6: PUT honours no If-Match today — pinned" + +# ───────────────────────────────────────────────────────────── +# F7 — PUT a "large" file → succeeds, GET returns exact bytes +# +# Size is 3 MiB, deliberately just under `OXICLOUD_DIRECT_PUT_MAX_BYTES` +# (4 MiB in the test env — see `tests/common/server.env`). Files +# above that cap are expected to use the chunked-upload protocol, +# which is Group J territory. 3 MiB is still big enough to exercise +# the streaming-spool / hash-on-write path that F9 then validates +# end-to-end via the BLAKE3 round-trip. The BASELINE doc said "10 MB" +# but the test env constraint takes precedence. +# ───────────────────────────────────────────────────────────── +echo " F7: PUT 3 MiB random binary → 201/204 + GET returns same bytes" +LARGE_PATH="$PUT_FIXTURE_DIR/f7-large.bin" +dd if=/dev/urandom of="$LARGE_PATH" bs=1024 count=3072 status=none +LARGE_LEN=$(wc -c < "$LARGE_PATH" | tr -d ' ') +LARGE_LOCAL_HASH=$(b3sum --no-names "$LARGE_PATH" | awk '{print $1}') + +# Disable `Expect: 100-continue` — curl sends it for large bodies, +# and the resulting interim "HTTP/1.1 100 Continue" line would be +# the FIRST line in the `-D -` dump, making `awk 'NR==1'` pick up +# 100 instead of the final 201/204. The Expect handshake serves +# no functional purpose for the test. +HEADERS=$(nc_curl -D - -o /dev/null -X PUT \ + -H "Expect:" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$LARGE_PATH" \ + "$NC_FILES_BASE/f7-large.bin") +STATUS=$(awk 'NR==1{print $2}' <<< "$HEADERS" | tr -d '\r') +[[ "$STATUS" == "201" || "$STATUS" == "204" ]] \ + || fail "F7: PUT 3 MiB expected 201/204, got $STATUS" +DOWNLOADED="$PUT_FIXTURE_DIR/f7-large.downloaded" +nc_curl -o "$DOWNLOADED" "$NC_FILES_BASE/f7-large.bin" +cmp -s "$LARGE_PATH" "$DOWNLOADED" \ + || fail "F7: downloaded bytes differ from uploaded — streaming integrity broken" +pass "F7: 3 MiB streamed PUT round-trips byte-identically" + +# ───────────────────────────────────────────────────────────── +# F8 — BLAKE3 round-trip (small file) +# +# Uses a dedicated path (`f8-blake3-probe.txt`) that no other +# scenario in this script touches. F1-F6 all overwrite +# `f1-small.txt` repeatedly, so by the time F8 runs the server +# holds whatever F6's last PUT wrote (`F6-payload`), not what +# F3 wrote — comparing F3's local b3sum against the server +# would be a false mismatch. A fresh single-write fixture +# isolates the BLAKE3 round-trip from the F1-F6 sequence. +# +# Verifies the streaming hash-on-write path produced the +# canonical BLAKE3 the dedup/lifecycle layer expects. +# ───────────────────────────────────────────────────────────── +echo " F8: BLAKE3 round-trip (small file — local b3sum vs server content_hash)" +F8_PATH="$PUT_FIXTURE_DIR/f8-probe.txt" +printf 'f8 blake3 round-trip probe — single write, known bytes' > "$F8_PATH" +F8_LOCAL_HASH=$(b3sum --no-names "$F8_PATH" | awk '{print $1}') +nc_curl -X PUT -H "Content-Type: text/plain" \ + --data-binary "@$F8_PATH" \ + "$NC_FILES_BASE/f8-blake3-probe.txt" > /dev/null +nc_lookup_via_rest "f8-blake3-probe.txt" +[[ -n "$LAST_FILE_CONTENT_HASH" && "$LAST_FILE_CONTENT_HASH" != "null" ]] \ + || fail "F8: REST returned empty content_hash for f8-blake3-probe.txt" +[[ "$LAST_FILE_CONTENT_HASH" == "$F8_LOCAL_HASH" ]] \ + || fail "F8: BLAKE3 mismatch — server '$LAST_FILE_CONTENT_HASH' vs local '$F8_LOCAL_HASH'" +pass "F8: small-file content_hash matches local b3sum ($F8_LOCAL_HASH)" + +# ───────────────────────────────────────────────────────────── +# F9 — BLAKE3 round-trip (streamed file) +# +# Same check on the streaming hash-on-write path. The 3 MiB +# upload from F7 exercises the streaming spool / +# hasher.update / final blob promotion sequence — F8 only +# validates the small-buffer path. Size was 10 MB in the +# BASELINE doc; reduced to 3 MiB so it stays under the test +# env's direct-PUT cap (see F7 comment). +# ───────────────────────────────────────────────────────────── +echo " F9: BLAKE3 round-trip (3 MiB streamed file)" +nc_lookup_via_rest "f7-large.bin" +[[ -n "$LAST_FILE_CONTENT_HASH" && "$LAST_FILE_CONTENT_HASH" != "null" ]] \ + || fail "F9: REST returned empty content_hash for f7-large.bin" +[[ "$LAST_FILE_CONTENT_HASH" == "$LARGE_LOCAL_HASH" ]] \ + || fail "F9: BLAKE3 mismatch on 3 MiB — server '$LAST_FILE_CONTENT_HASH' vs local '$LARGE_LOCAL_HASH'" +pass "F9: 3 MiB streamed content_hash matches local b3sum" + +# ───────────────────────────────────────────────────────────── +# F10 — MKCOL creates a folder → 201 +# ───────────────────────────────────────────────────────────── +echo " F10: MKCOL new folder → 201" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$NC_FILES_BASE/f10-folder/") +[[ "$STATUS" == "201" ]] \ + || fail "F10: MKCOL new expected 201, got $STATUS" +# PROPFIND it to confirm +BODY=$(nc_curl -X PROPFIND -H "Depth: 0" "$NC_FILES_BASE/f10-folder/") +grep -q '' <<< "$BODY" \ + || fail "F10: PROPFIND of just-created folder lacks " +pass "F10: MKCOL creates folder, PROPFIND sees it as a collection" + +# ───────────────────────────────────────────────────────────── +# F11 — MKCOL with missing intermediate parent +# +# Pinned current behaviour: OxiCloud's MKCOL auto-creates +# missing intermediate parents (effectively `mkdir -p` +# semantics). Sending MKCOL on `/a/b/c/` where neither `a` nor +# `b` exists succeeds with 201 — both intermediates are +# silently created. +# +# Strict RFC 4918 §9.3.1 requires 409 Conflict here ("when the +# parent collection does not exist"). NC desktop tolerates +# either behaviour (it always MKCOLs ancestors one at a time +# during sync), so the auto-create behaviour is harmless in +# practice — but if you ever want strict mode, the fix lives +# in `interfaces/nextcloud/webdav_handler.rs::handle_mkcol`: +# look up the parent path before creating; 409 if missing. +# ───────────────────────────────────────────────────────────── +echo " F11: MKCOL with missing parent (pinned: auto-creates parents, RFC-4918 would 409)" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL \ + "$NC_FILES_BASE/f11-nonexistent-parent/inner/") +case "$STATUS" in + 201) + pass "F11: MKCOL auto-created intermediate parents (201) — pinned current behaviour" + ;; + 409) + fail "F11: server now returns 409 (RFC-4918 strict). Bug? Improvement? — review and update pin to strict assertion." + ;; + *) + fail "F11: unexpected status $STATUS" + ;; +esac +# Cleanup the auto-created parent so subsequent tests don't see it. +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f11-nonexistent-parent/" > /dev/null 2>&1 || true + +# ───────────────────────────────────────────────────────────── +# F12 — MKCOL on existing folder → 405 +# ───────────────────────────────────────────────────────────── +echo " F12: MKCOL on existing folder → 405" +STATUS=$(nc_curl -o /dev/null -w "%{http_code}" -X MKCOL "$NC_FILES_BASE/f10-folder/") +[[ "$STATUS" == "405" ]] \ + || fail "F12: existing-folder MKCOL expected 405, got $STATUS" +pass "F12: MKCOL on existing folder → 405" + +# ── Cleanup ────────────────────────────────────────────────────────────────── + +echo " cleanup: delete fixtures + empty trash" +# Use the NC DELETE (covered in group G) to round-trip through the +# same surface we're trying to baseline. +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f1-small.txt" || true +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f7-large.bin" || true +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f8-blake3-probe.txt" || true +nc_curl -o /dev/null -X DELETE "$NC_FILES_BASE/f10-folder/" || true +api_empty_trash || true +pass "cleanup done" + +# ── summary ─────────────────────────────────────────────────────────────────── + +echo +echo "Results: $PASS passed, $FAIL failed." +[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1 diff --git a/tests/webdav/test_nextcloud_chunked_upload_cap.sh b/tests/webdav/test_nextcloud_chunked_upload_cap.sh index 79c14b5a..681a4089 100755 --- a/tests/webdav/test_nextcloud_chunked_upload_cap.sh +++ b/tests/webdav/test_nextcloud_chunked_upload_cap.sh @@ -51,8 +51,8 @@ rest_delete() { curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorizati purge_from_trash() { local name="$1" local tid - tid=$(rest_get "/api/trash" \ - | jq -r --arg n "$name" 'first(.[] | select(.name == $n) | .id) // empty') + tid=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$name" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$tid" ]] && rest_delete "/api/trash/$tid" > /dev/null || true } diff --git a/tests/webdav/test_nextcloud_put_blake3.sh b/tests/webdav/test_nextcloud_put_blake3.sh index c5daf28c..78733814 100755 --- a/tests/webdav/test_nextcloud_put_blake3.sh +++ b/tests/webdav/test_nextcloud_put_blake3.sh @@ -49,8 +49,8 @@ rest_delete() { curl -s -o /dev/null -w "%{http_code}" -X DELETE -H "Authorizati purge_from_trash() { local name="$1" local tid - tid=$(rest_get "/api/trash" \ - | jq -r --arg n "$name" 'first(.[] | select(.name == $n) | .id) // empty') + tid=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$name" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') [[ -n "$tid" ]] && rest_delete "/api/trash/$tid" > /dev/null || true } diff --git a/tests/webdav/test_thumbnail_update.sh b/tests/webdav/test_thumbnail_update.sh index 5273507a..284f91bc 100755 --- a/tests/webdav/test_thumbnail_update.sh +++ b/tests/webdav/test_thumbnail_update.sh @@ -132,8 +132,8 @@ else fi echo " cleanup: checking trash for '$REMOTE'..." -STALE=$(rest_get "/api/trash" \ - | jq -r --arg n "$REMOTE" 'first(.[] | select(.name == $n) | .id) // empty') +STALE=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$REMOTE" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') if [[ -n "$STALE" ]]; then echo " cleanup: found trash item id=$STALE — purging..." ST=$(rest_delete "/api/trash/$STALE") @@ -221,8 +221,8 @@ echo " cleanup: WebDAV DELETE → $STATUS" [[ "$STATUS" == "204" ]] || fail "WebDAV DELETE expected 204, got $STATUS" pass "WebDAV DELETE → 204" -TRASH_ITEM=$(rest_get "/api/trash" \ - | jq -r --arg n "$REMOTE" '.[] | select(.name == $n) | .id // empty') +TRASH_ITEM=$(rest_get "/api/trash/resources" \ + | jq -r --arg n "$REMOTE" 'first(.items[] | select(.resource.name == $n) | .resource.id) // empty') if [[ -n "$TRASH_ITEM" ]]; then ST=$(rest_delete "/api/trash/$TRASH_ITEM") echo " cleanup: DELETE /api/trash/$TRASH_ITEM → $ST"