595273277b
add a full coverage of Webdav and Nextcloud
purpose: prepare move to Drives and ensure no regression at all
test scenarios are in docs/plan/BASELINE_TESTS_NC_WEBDAV.md
current existing bugs identified via these tests:
┌──────────┬─────────┬────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Bug │ Surface │ Pin location │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ G4/G5/K5 │ NC │ AlreadyExists → 500 instead of 412 (handle_move + trashbin restore) │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ G9 │ NC │ Folder DELETE not row-recursive — orphan descendants stay live │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ M5/M7 │ Native │ resolve_path_for_user mismatch — PUT writes, GET reads via lenient lookup, MOVE/DELETE can't find │
│ │ │ via strict │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ M8 │ Native │ COPY discards destination filename — collides with source │
├──────────┼─────────┼────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ N2 │ Native │ LOCK creates the token, mutators don't check it — class-2 advertisement is aspirational │
└──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
440 lines
25 KiB
Markdown
440 lines
25 KiB
Markdown
# 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.*
|