Merge pull request #452 from EdouardVanbelle/test/api-improve-test-coverage
chore(test): improve API test coverage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+312
@@ -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
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+396
@@ -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
@@ -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
|
||||
Executable
+191
@@ -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
|
||||
Executable
+214
@@ -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
|
||||
Executable
+195
@@ -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
@@ -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
|
||||
Executable
+316
@@ -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
|
||||
Executable
+324
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user