Files
Oxicloud/docs/plan/BASELINE_TESTS_NC_WEBDAV.md
T
Edouard Vanbelle 595273277b test(e2e): webdav + nextcloud full e2e test coverage
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            │
      └──────────┴─────────┴────────────────────────────────────────────────────────────────────────────────────────────────────┘
2026-06-13 19:01:40 +02:00

440 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.*