Merge pull request #452 from EdouardVanbelle/test/api-improve-test-coverage

chore(test): improve API test coverage
This commit is contained in:
Dionisio Pozo
2026-06-15 14:56:22 +02:00
committed by GitHub
36 changed files with 5093 additions and 43 deletions
+8 -1
View File
@@ -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
+439
View File
@@ -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, `<oc:fileid>` /
`<d:getetag>` 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://<host>/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: "<host>", loginName: "admin", appPassword: "<token>" }`. 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:<app_pw>` | 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:<app_pw>` | 200; full profile including `groups`, `lastLogin`, `backend`, `quota` |
| C4 | `GET /ocs/v1.php/cloud/users/bob?format=json` Basic Auth `admin:<app_pw>` (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 "<requested-url>"`.
| 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 `<d:response>`; href is `/remote.php/dav/files/admin/` (trailing `/`); has `<d:resourcetype><d:collection/></d:resourcetype>` and `<oc:fileid>` |
| D3 | `PROPFIND /remote.php/dav/files/admin/` `Depth: 1` on **empty home** | 207; exactly 1 `<d:response>` (collection only) |
| D4 | Upload 2 files (`a.txt`, `b.txt`) + create subfolder `sub/` via MKCOL → `PROPFIND Depth: 1` on home | 207; 4 `<d:response>` entries; files have `<d:getcontentlength>` matching their byte count; folder href ends `/`; all 4 have `<oc:fileid>` |
| D5 | `PROPFIND /remote.php/dav/files/admin/nonexistent` `Depth: 0` | 404 |
| D6 | `PROPFIND` on a **file** `Depth: 0` | 207; 1 `<d:response>`; href does NOT end `/`; has `<d:getcontentlength>`; `<d:resourcetype>` is empty (not `<d:collection/>`) |
| 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 `<d:response>` 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 `<d:response>`, pair its `<d:href>` against its
`<d:resourcetype>`. Assertion: if `<d:collection/>` 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: "<etag>"` 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: "<wrong-etag>"` | 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 `<d:collection/>` 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://<host>/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 `<oc:favorite>1</oc:favorite>` | 207 multistatus; status row says `HTTP/1.1 200 OK` for `oc:favorite` |
| H2 | After H1, `REPORT /remote.php/dav/files/admin/` with `<oc:filter-files>` body filtering on `<oc:favorite>1</oc:favorite>` | 207; multistatus contains the file from H1 |
| H3 | `PROPPATCH` `<oc:favorite>0</oc:favorite>` 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 `<d:response>`) |
| I2 | `REPORT` favorites filter with 3 favorited files | 207; exactly 3 responses; each has favorited file href + correct trailing-slash semantics |
| I3 | `REPORT` `<d:searchrequest>` 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 `<d:nresults>2</d:nresults>` 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 `<d:getcontentlength>` 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 `<nc:trashbin-original-location>` with original path |
| K2 | `MOVE /remote.php/dav/trashbin/admin/trash/<trashed_id>` `Destination: /remote.php/dav/files/admin/restored.txt` | 201; restored at destination |
| K3 | Delete a file → `DELETE /remote.php/dav/trashbin/admin/trash/<trashed_id>` (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=<id>&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 `<d:lockinfo>` with `<d:owner>test</d:owner>`, header `Timeout: Second-60` | 200; response body has `<d:locktoken><d:href>opaquelocktoken:…</d:href>`; `Lock-Token` header set |
| N2 | `PUT /webdav/locked.txt` from a different lock context (no `If: (<token>)`) | 423 Locked |
| N3 | `UNLOCK /webdav/locked.txt` with header `Lock-Token: <opaquelocktoken:…>` | 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~<drive_uuid>/…`. Responses'
hrefs must echo the composite form `admin~<drive_uuid>`.
- **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.*
+57 -2
View File
@@ -120,8 +120,63 @@ fn make_socket(addr: &SocketAddr, reuse_port: bool) -> std::io::Result<Socket> {
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Load .env file if present (for local development)
dotenvy::dotenv().ok();
// Minimal CLI:
// --version Print version + branch + commit hash and exit.
// --config <path> 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<String> = 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 <path>]\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()
+186
View File
@@ -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
+134
View File
@@ -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
+1 -1
View File
@@ -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
+166
View File
@@ -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
+107
View File
@@ -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
+282
View File
@@ -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
+109
View File
@@ -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
+74
View File
@@ -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: <url>, 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/<token>`), 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
+160
View File
@@ -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
+54
View File
@@ -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"
+71
View File
@@ -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"
+261
View File
@@ -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
+50 -12
View File
@@ -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"
+162
View File
@@ -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
+45 -1
View File
@@ -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/<session_id>/`; NC sessions land under
# `.uploads/nextcloud/<user>/<session_id>/`.
#
# 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."
+6 -3
View File
@@ -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
+47
View File
@@ -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/<name>/storage` (lowercase alphanumeric
# `<name>`). 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/<x>/storage suffix
# $HOME — same
# tests/api — missing /storage
# /tests/storage — missing the <name> 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/<name>/storage where <name> 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/<name>/storage — refusing to wipe" >&2
return 1
fi
echo "[wipe_storage] Wipe $path to ensure clean startup"
rm -rf "$path"
mkdir -p "$path"
}
+312
View File
@@ -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 `<d:response>` children in a
# multistatus body
# extract_href_for BODY SUBPATH
# — extract the first `<d:href>` value whose
# path ends with SUBPATH
# assert_collection_hrefs_have_trailing_slash BODY
# — for every `<d:response>` in BODY that
# contains `<d:collection/>`, asserts that
# the `<d:href>` 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 `<d:response>` (or `<D:response>`) children in a multistatus
# body. Case-insensitive on the namespace prefix because OxiCloud's
# two DAV surfaces use different cases: the NC handler emits
# `<d:response>` (lowercase prefix), while the native `/webdav/`
# handler emits `<D:response>` (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 `<d:href>` 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>[^<]+</[dD]:href>' <<< "$body" \
| sed -E 's|^<[dD]:href>([^<]+)</[dD]:href>$|\1|' \
| { grep -F "$suffix" || true; } \
| head -n 1
}
# Extract the `<d:href>` from a `<d:response>` block whose body
# contains the given substring anywhere (e.g. an
# `<nc:trashbin-filename>` 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="</[dD]:response>" }
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 <d:response> in a
# multistatus body. The function walks the body once and, per
# response, asserts:
#
# - if <d:resourcetype> contains <d:collection/>, the matching
# <d:href> MUST end with '/'
# - otherwise the <d:href> 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 </d:response> or </D:response>
# (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>" }
/<[dD]:response>/ {
href = ""
is_coll = 0
if (match($0, /<[dD]:href>[^<]+<\/[dD]:href>/)) {
m = substr($0, RSTART, RLENGTH)
# Strip the surrounding <d:href>/</d:href> 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)"
}
+13 -3
View File
@@ -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..."
+2 -2
View File
@@ -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
}
+6 -6
View File
@@ -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"
+4 -4
View File
@@ -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
+396
View File
@@ -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: <token> 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/<name>`) 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='<?xml version="1.0" encoding="utf-8"?>
<d:lockinfo xmlns:d="DAV:">
<d:lockscope><d:exclusive/></d:lockscope>
<d:locktype><d:write/></d:locktype>
<d:owner>baseline-test-owner</d:owner>
</d:lockinfo>'
# ─────────────────────────────────────────────────────────────
# 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: (<token>)` 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:(<token>) — 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
+250
View File
@@ -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 <d:response> 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
+191
View File
@@ -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
+214
View File
@@ -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='<?xml version="1.0" encoding="utf-8"?>
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<oc:filter-rules>
<oc:favorite>1</oc:favorite>
</oc:filter-rules>
</oc:filter-files>'
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 <d:response> entries"
# ─────────────────────────────────────────────────────────────
# H1 — PROPPATCH oc:favorite=1 on foo.txt → 207
# ─────────────────────────────────────────────────────────────
PROPPATCH_FAV1='<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set>
</d:propertyupdate>'
PROPPATCH_FAV0='<?xml version="1.0" encoding="utf-8"?>
<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:set><d:prop><oc:favorite>0</oc:favorite></d:prop></d:set>
</d:propertyupdate>'
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='<?xml version="1.0" encoding="utf-8"?>
<d:searchrequest xmlns:d="DAV:">
<d:basicsearch>
<d:select><d:prop><d:displayname/></d:prop></d:select>
<d:from><d:scope><d:href>/remote.php/dav/files/'"$username"'/</d:href></d:scope></d:from>
<d:where>
<d:like>
<d:prop><d:displayname/></d:prop>
<d:literal>%foo%</d:literal>
</d:like>
</d:where>
</d:basicsearch>
</d:searchrequest>'
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 <nresults>2</nresults> caps at 2"
SEARCH_BODY_LIMITED='<?xml version="1.0" encoding="utf-8"?>
<d:searchrequest xmlns:d="DAV:">
<d:basicsearch>
<d:select><d:prop><d:displayname/></d:prop></d:select>
<d:from><d:scope><d:href>/remote.php/dav/files/'"$username"'/</d:href></d:scope></d:from>
<d:where>
<d:like>
<d:prop><d:displayname/></d:prop>
<d:literal>%foo%</d:literal>
</d:like>
</d:where>
<d:nresults>2</d:nresults>
</d:basicsearch>
</d:searchrequest>'
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: <nresults>2</nresults> correctly caps the response count"
# ── summary ───────────────────────────────────────────────────────────────────
echo
echo "Results: $PASS passed, $FAIL failed."
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
+195
View File
@@ -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: <current-etag> → 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
+443
View File
@@ -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 '<nc:trashbin-original-location>' <<< "$BODY" \
|| fail "K1: trashbin response missing <nc:trashbin-original-location>"
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
# `<d:response>` block by `<nc:trashbin-filename>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 <d:response> 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 <d:response> 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
+316
View File
@@ -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
# "<requested-url>"`.
#
# 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 '<d:collection/>' <<< "$BODY" \
|| fail "D2: home response missing <d:collection/>"
grep -q '<oc:fileid>' <<< "$BODY" \
|| fail "D2: home response missing <oc:fileid>"
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 '<d:getcontentlength>' <<< "$BODY" \
|| fail "D4: at least one response should have <d:getcontentlength>"
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 '<d:collection/>' <<< "$BODY" \
|| fail "D8: sub-d response missing <d:collection/>"
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 '<d:getcontentlength>' <<< "$BODY" \
|| fail "D6: file response missing <d:getcontentlength>"
# The resourcetype on a file is empty `<d:resourcetype/>` or
# `<d:resourcetype></d:resourcetype>` — NOT a <d:collection/>.
grep -q '<d:collection/>' <<< "$BODY" \
&& fail "D6: file response erroneously contains <d:collection/>"
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 '<d:propfind xmlns:d="DAV:"><d:prop><d:displayname' \
"$NC_FILES_BASE/")
[[ "$STATUS" == "400" ]] \
|| fail "D11: malformed PROPFIND body expected 400, got $STATUS"
pass "D11: malformed XML rejected with 400"
# ─────────────────────────────────────────────────────────────
# D3 — Empty-home Depth: 1
# Move fixtures to trash + empty trash, then verify only
# the OWN-collection response comes back.
# ─────────────────────────────────────────────────────────────
echo " D3: PROPFIND Depth: 1 on EMPTY home (after fixture cleanup)"
api_delete_folder "$SUB_D_FOLDER_ID"
api_delete_folder "$SUB_EXTRA_FOLDER_ID"
api_delete_file "$HOME_ALPHA_FILE_ID"
api_empty_trash
BODY=$(nc_curl -X PROPFIND -H "Depth: 1" "$NC_FILES_BASE/")
N=$(count_responses "$BODY")
[[ "$N" == "1" ]] \
|| fail "D3: empty home Depth:1 expected 1 response (collection only), got $N"
HOME_OWN_HREF=$(extract_href_for "$BODY" "/dav/files/$username/")
[[ -n "$HOME_OWN_HREF" ]] && [[ "$HOME_OWN_HREF" == */ ]] \
|| fail "D3: empty-home OWN href missing trailing slash: '$HOME_OWN_HREF'"
pass "D3: empty home Depth:1 — only the collection itself"
# ── summary ───────────────────────────────────────────────────────────────────
echo
echo "Results: $PASS passed, $FAIL failed."
[[ "$FAIL" -eq 0 ]] && echo "All tests passed." || exit 1
+324
View File
@@ -0,0 +1,324 @@
#!/usr/bin/env bash
# =============================================================
# OxiCloud — Baseline: NC WebDAV PUT / MKCOL + BLAKE3 round-trip
# =============================================================
# Group F from BASELINE_TESTS_NC_WEBDAV.md.
#
# Headline assertions:
# F8 / F9 — local b3sum of the uploaded bytes equals the
# server's FileDto.content_hash retrieved via the
# REST API. Proves the hash-on-write streaming path
# actually produces the canonical BLAKE3 — the
# value every downstream dedup / lifecycle hook
# keys on. This is the load-bearing check that
# would have caught the `etag` vs `content_hash`
# confusion (the 0135930d regression).
#
# Pinned behaviour notes (not bugs, but worth catching if any
# change in either direction):
# F5 / F6 — NC PUT does NOT process If-None-Match / If-Match
# today. Both succeed with 201/204 instead of the
# strict-RFC-4918 412. If a later commit adds
# conditional support, the assertions here will
# trip and you can update them deliberately.
# F1 — NC PUT returns ETag + oc-etag headers but NO
# oc-fileid header (the file id is discoverable
# via PROPFIND or REST). Pinned as "absent".
# =============================================================
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 PUT / MKCOL + BLAKE3 (Group F 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"
PUT_FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$PUT_FIXTURE_DIR"' EXIT
# ── Pre-flight: confirm `b3sum` is available ─────────────────────────────────
command -v b3sum >/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 '<d:collection/>' <<< "$BODY" \
|| fail "F10: PROPFIND of just-created folder lacks <d:collection/>"
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
@@ -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
}
+2 -2
View File
@@ -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
}
+4 -4
View File
@@ -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"