diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 51ac2ffc..e2559997 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -26,4 +26,17 @@ ignore = [ # instant unmaintained — transitive via azure_core 0.21.0 (latest available). # No direct security impact; no upgrade path exists. "RUSTSEC-2024-0384", + + # quick-xml 0.31.0 — transitive via azure_core 0.21.0 (unofficial SDK, + # now archived). Our direct dep is already on 0.41.0; the 0.31 copy is + # only reachable through the azure_storage_blobs chain, which parses + # XML responses received from Azure Storage over TLS. Neither CVE is + # exploitable without attacker-controlled XML, so the vector requires + # MitM of the TLS channel to Azure (or a compromised storage + # endpoint). Real fix is migrating to the official azure_core 1.0 / + # azure_storage_blob 1.0 SDK — tracked separately. + # RUSTSEC-2026-0195: unbounded ns-declaration allocation → mem-DoS + # RUSTSEC-2026-0194: quadratic dup-attribute check → CPU-DoS + "RUSTSEC-2026-0195", + "RUSTSEC-2026-0194", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2edd9ec4..ed076ede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,14 +171,32 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + # Pinned to match wasm/oxicloud-plugin-hello/rust-toolchain.toml. + # Reproducibility of the committed .wasm fixtures depends on both + # sides using the same rustc — even a patch bump shifts codegen. + # Bump both together. + - uses: dtolnay/rust-toolchain@1.96.1 with: targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - name: Rebuild committed wasm fixtures run: bash scripts/build-plugin-hello.sh - - name: Fail if fixtures are stale (rebuild + commit them) - run: git diff --exit-code tests/fixtures/plugins/ + # NOTE: no `git diff --exit-code` staleness check. + # + # Cross-host wasm builds (contributor aarch64-macOS vs CI + # x86_64-linux, same rustc 1.96.1, same `--remap-path-prefix`, + # same `CARGO_INCREMENTAL=0`, same profile) still produce + # byte-different .wasm — plain `cargo build` doesn't guarantee + # bit-reproducible cross-host wasm output. The proper fixes + # (containerised builds, or dropping the committed fixtures and + # rebuilding from source everywhere) are deferred; the frontend + # wasm crate (`wasm/oxicloud-hash`) will hit the same wall when + # we add a similar check for it, so we'll tackle both together. + # For now: CI rebuilds the fixtures fresh above and uses those + # for the plugin runtime tests below. The versions committed at + # HEAD are a convenience for local dev without the wasm32 + # toolchain — they may drift from what CI produces, which is + # fine as long as the runtime tests pass. - name: Run plugin runtime tests # Quote: the trailing `::` confuses GitHub's YAML parser (mapping # values not allowed) and aborts the whole workflow at load time. @@ -351,6 +369,15 @@ jobs: env: BUILD_TARGET: release + # WebDAV URL-scheme variant: `OXICLOUD_WEBDAV_DRIVE_PATH=""` + # (drive listing at `/webdav/`, no `@drive` sigil). Runs a + # separately-configured server on its own port so the default + # WebDAV suite above stays on the `"@drive"` back-compat config. + - name: Run WebDAV drive-root variant tests + run: bash tests/webdav-drive-root/run.sh + env: + BUILD_TARGET: release + # OIDC integration: drives the SPA's SSO flow end-to-end against # the fake IdP (auto-approve login + consent, real PKCE/JWT # round-trip) and asserts the d1bbe8ba contract — OIDC callback diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 29882904..1bd6e185 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Docker Hub Release +name: "Docker Hub & GHCR Release" on: push: @@ -15,6 +15,7 @@ on: env: REGISTRY_IMAGE: diocrafts/oxicloud + GHCR_REGISTRY_IMAGE: ghcr.io/atalayalabs/oxicloud jobs: # Run tests before publishing @@ -59,6 +60,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 360 needs: test + permissions: + contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 @@ -93,6 +97,13 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and Push Multi-Arch Image uses: docker/build-push-action@v6 with: @@ -102,6 +113,8 @@ jobs: tags: | ${{ env.REGISTRY_IMAGE }}:${{ env.VERSION }} ${{ env.REGISTRY_IMAGE }}:latest + ${{ env.GHCR_REGISTRY_IMAGE }}:${{ env.VERSION }} + ${{ env.GHCR_REGISTRY_IMAGE }}:latest cache-from: type=gha cache-to: type=gha,mode=max # GitHub Actions env piped through so build.rs stamps diff --git a/Cargo.lock b/Cargo.lock index a5f9438c..ed556920 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,9 +1772,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -4212,7 +4212,7 @@ dependencies = [ [[package]] name = "oxicloud" -version = "0.8.0" +version = "0.8.2" dependencies = [ "accept-language", "aes-gcm", @@ -4267,7 +4267,7 @@ dependencies = [ "ort", "pdf-extract", "percent-encoding", - "quick-xml 0.39.4", + "quick-xml 0.41.0", "rand_core 0.6.4", "rayon", "reqwest", @@ -4743,9 +4743,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] diff --git a/Cargo.toml b/Cargo.toml index ac29094c..e512b4db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxicloud" -version = "0.8.0" +version = "0.8.2" edition = "2024" default-run = "oxicloud" @@ -34,7 +34,7 @@ sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } argon2 = "0.5.3" rand_core = { version = "0.6", features = ["std", "getrandom"] } -quick-xml = "0.39.4" +quick-xml = "0.41.0" dotenvy = "0.15.7" moka = { version = "0.12.15", features = ["future", "sync"] } http-range-header = "0.4" @@ -209,7 +209,6 @@ required-features = ["bench"] lto = "thin" codegen-units = 1 opt-level = 3 -panic = "abort" strip = true [profile.dev] diff --git a/docs/config/env.md b/docs/config/env.md index 316bd0a1..cabc50eb 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -70,6 +70,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata | | `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` | | `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. | +| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive//…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav//…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. | ## Storage Backend diff --git a/docs/plan/drive.md b/docs/plan/drive.md index 3845d5ff..cf01db69 100644 --- a/docs/plan/drive.md +++ b/docs/plan/drive.md @@ -761,15 +761,24 @@ accommodates them without schema migration) #### Native WebDAV (`/webdav/...`) -| URL | Resolves to | -|---|---| -| `/webdav/` | Caller's default personal drive root + `` (back-compat with today's behaviour) | -| `/webdav/@drive//` | Specific drive root + `` | +**SHIPPED 2026-07-06.** Config-driven via env +`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` (`FeaturesConfig::webdav_drive_listing_prefix`; +default `"@drive"`, sanitized by trimming leading/trailing `/`). +Three deployment shapes: -Today's `/webdav/` handler implicitly looks up the caller's -home folder and prepends it. Post-drives, the same handler looks up -the caller's personal drive and resolves paths inside it. **Zero -breakage** for existing native WebDAV clients. +| `WEBDAV_DRIVE_LISTING_PREFIX` | URL | Resolves to | +|---|---|---| +| `@drive` (default) | `/webdav/…` | caller's default personal drive (back-compat) | +| `@drive` | `/webdav/@drive/` | drive listing | +| `@drive` | `/webdav/@drive//…` | specific drive | +| `""` (empty) | `/webdav/` | drive listing | +| `""` | `/webdav//…` | specific drive | +| any other | same shape as `@drive`, segment substituted | | + +`` is a drive UUID **or** the drive's display name (matched +against `storage.folders.name` of the drive root). Only drives the +caller has Read on via `role_grants` resolve; unknown selector and +permission denial both return 404 (anti-enumeration). **Why the `@drive` sigil and NOT `/webdav/drives//...`** (earlier draft) or top-level `/drives//...` (also @@ -777,32 +786,47 @@ considered): `@` is the established structural-routing sigil (GitHub `@user/repo`, npm `@scope/pkg`, LDAP `@domain`) — it reads as "this is not user content, this is a routing token." Realistic collision risk drops to near-zero: nobody creates a -top-level folder named exactly `@drive` by accident, and the -defensive layer collapses to a single one-liner in MKCOL / PUT / -REST create paths that refuses that literal name at any drive -root. Compared to top-level `/drives//...`, the `@drive` -shape keeps **one URL root for everything WebDAV** — single -`` block in reverse-proxy configs, single mental model -for sysadmins, single dispatcher in `webdav_routes()`. +top-level folder named exactly `@drive` by accident. Keeps **one +URL root for everything WebDAV** — single `` block in +reverse-proxy configs, single mental model for sysadmins, single +dispatcher in `webdav_routes()`. Making the segment +config-tunable per deployment lets operators pick a different +sigil (`drives`) or drop it entirely (`""` = drive-listing at +root) without a code change. -**Implementation notes:** -- Route parser accepts both `/webdav/@drive//...` and the - URL-encoded form `/webdav/%40drive//...` — WebDAV clients - percent-encode `@` inconsistently. -- One-liner guard in upload paths refuses creation of a folder - literally named `@drive` at any drive root (case-sensitive). -- `webdav_href()` (today at `webdav_handler.rs:94`) becomes - drive-context-aware: responses for a request under - `/webdav/@drive//...` must reference back to - `/webdav/@drive//...`, otherwise the client follows the - `` and lands on the back-compat surface (wrong drive). +**Implementation:** `resolve_webdav_scope` in +`src/interfaces/api/handlers/webdav_handler.rs`. Selector accepts +UUIDs and display names; UUID form is tried first. Legacy +tolerance in the default-drive branch: bookmarks that already +carried the drive-root name as their first segment +(`/webdav/Personal/foo` under a Personal-default user) are +passed through instead of double-prepended. -The `drives` path segment is **reserved**: a folder literally named -`drives` cannot exist at the top level of any drive. Migration -pre-check refuses to start if existing data violates this — operator -must rename before upgrading. (Conservative estimate: zero existing -folders are named exactly `drives`. The migration script reports any -collisions for manual fix-up.) +**Hurl coverage:** +- `tests/api/webdav_drive_root.hurl` — default `@drive` config +- `tests/webdav-drive-root/drive_root_empty_config.hurl` — empty + config (separately-configured server; runs under + `tests/webdav-drive-root/run.sh`, wired into `just api-test` + and CI's `api-test` job) + +**Href construction — verified drive-aware:** `webdav_href()` +prints `/webdav/`, but the `` input is `client_path` +extracted from `req.uri()` (the URL segment after `/webdav/`), not +the scope-resolved db_path. So a request to +`/webdav/@drive//folder/` renders children as +`/webdav/@drive//folder//` — the `@drive//` +prefix is preserved on every hop. `client_path` is threaded into +`base_href` at `handle_propfind` and passed through +`build_streaming_propfind_response` unchanged. + +**Deferred (not blocking):** +- One-liner guard refusing folder creation named literally + `@drive` at drive root (defensive against future collisions — + today an unknown `@drive` folder at drive root is unreachable + via WebDAV under the default config, so it's low priority). +- Cross-drive MOVE / COPY currently 403 — same-drive only. + Cross-drive copy has REST-side support; WebDAV MOVE/COPY + could route through it once permission mapping is designed. #### NextCloud-compat WebDAV (`/remote.php/dav/...`) @@ -1324,13 +1348,12 @@ mutation site updates `updated_by`. the filesystem rather than browsing a single folder: Photos, Music library, Favorites, Recent items, Search, Trash. With drives landing, each of these needs an explicit scope decision. The -table below locks the choices; the rationale is **noise risk by -file type**, not a uniform rule. +table below locks the choices. | Section | Scope | Capability flag (per-drive policy) | Why | |---|---|---|---| -| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Shared drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for shared drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). | -| **Music** — library view (future) + playlists | Cross-drive (all accessible drives) | `policies.forbid_music_index = true` to opt a drive out | Audio files in shared drives are almost always intentional content (band collaboration, family music, podcast archive). Defaulting cross-drive matches user intent. Owner opts a drive out for the rare case it shouldn't be indexed. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. | +| **Photos** (`/api/photos`) | Default Personal Drive only | `policies.include_in_photo_index = true` to opt a non-default drive in | Non-default drives often carry images that aren't "photos" (screenshots, scans, charts-as-PNGs). Defaulting cross-drive pollutes the personal timeline. Opt-in for non-default drives where the owner explicitly wants them indexed (e.g. "Family Photos" shared drive). | +| **Music** — library view (future) + playlists | Default Personal Drive only | `policies.include_in_music_index = true` to opt a non-default drive in | Symmetric with Photos: audio files in a work drive or a random shared folder shouldn't silently bleed into the personal music library. Owner opts a non-default drive in (e.g. "Family Music", "Band Collaboration") when the drive genuinely is a music library. The Music section today is *only* playlists; a `/api/music/tracks` library view added later inherits this scope. | | **Music playlists** (`audio.playlists`) | User-scoped, cross-drive curation | n/a | Playlists are a curation tool. `owner_id` stays on `auth.users(id)`; tracks reference files via `playlist_items.file_id` and may live in any drive the user has access to. At list time, `list_playlist_tracks` filters out tracks in drives the caller can no longer reach (see §11's defense-in-depth pattern). | | **Favorites** (`/api/favorites/resources`) | Cross-drive (all accessible drives) | n/a | Personal organisation tool. Star a PDF from the work drive AND a photo from Personal — the whole point is cross-drive curation. ReBAC visibility check at list time drops rows the user can no longer reach. | | **Recent items** (`/api/recent/*`) | Cross-drive (all accessible drives) | n/a | Personal history. Same shape as Favorites — you touched files across drives; the timeline reflects that. ReBAC visibility check at list time. | @@ -1340,33 +1363,151 @@ file type**, not a uniform rule. #### Capability flag mechanism Both `policies.include_in_photo_index` and -`policies.forbid_music_index` live under the same JSONB +`policies.include_in_music_index` live under the same JSONB `policies` column on `storage.drives` (see §8) — no new schema. -The default values reflect the table above: omitted = "off" for -photos (so non-default drives don't show photos unless the owner -opts in), omitted = "off" for music (so all accessible drives -*are* indexed unless the owner opts out). +Both flags follow the same shape: **omitted = off**. The query +predicate then reduces to a single positive rule for every +drive: -The owner-only UI in the drive settings panel toggles these -flags. The query layer reads them at request time; flipping -either flag is instant — no reindex required because the filter -applies in the query Must-clause, the index itself is unchanged. +```sql +WHERE fi.drive_id IN ( + SELECT d.id FROM storage.drives d + JOIN storage.role_grants rg + ON rg.resource_type='drive' AND rg.resource_id=d.id + WHERE rg.subject_id IN (caller's effective subjects) + AND (d.policies->>'include_in_photo_index')::boolean = true +) +``` -#### The Photos/Music asymmetry — defensible, not a smell +No `default_for_user` OR-branch, no per-kind carve-out. -Photos defaulting to "default-drive only" while Music defaults to -"cross-drive" is the one case where two similar surfaces have -different defaults. The justification is the noise-risk argument -above: image content in shared drives is heterogeneous (often -not "photos" in the gallery sense), audio content in shared -drives is usually intentional. The capability flags let owners -fix either case, but the defaults match what the typical user -will want without configuration. +**Default personal drive gets both flags set to `true` on +creation.** The `PersonalDriveLifecycleHook` (§3) that creates +the default personal drive on user provisioning populates +`policies` with `{"include_in_photo_index": true, +"include_in_music_index": true}`. Existing default personal +drives get the same two flags via a one-shot backfill migration +alongside the flag introduction. Net effect: every user's +default personal drive is in scope from moment one, no user +configuration required for the common case, but the SQL is +kind-agnostic. -If a uniform rule is ever preferred, the cheapest move is to -flip Photos to cross-drive with `forbid_photo_index` as the -opt-out (mirroring Music). That can land later without a schema -change — just a behaviour change. +**Non-default drives** (secondary personals, shared drives) are +created with the flags omitted, so they stay out of scope until +the owner explicitly opts in via the admin "Manage policies" +modal. + +Flipping either flag on any drive is instant — the query reads +`policies` at request time; the index itself is unchanged. +Toggle-off on a default personal drive is *possible* (admins +own the drive-policy mutation surface — see §8) but shows a +confirm dialog in the UI ("this will empty the user's Photos +timeline" / "…their Music library"), since it's an unusual +action. + +#### Why symmetric (both opt-in) instead of asymmetric + +An earlier version of this section had Music default to +cross-drive (`forbid_music_index` as an opt-out), on the +argument that audio in shared drives is "almost always +intentional content." That asymmetry created two problems: + +1. **Mixed-form flag naming** — one `include_in_*` and one + `forbid_*` with opposite meanings, hard to reason about in the + admin UI and the query layer. +2. **The "shared audio is always intentional" claim doesn't + hold under scrutiny** — a work drive with a few voicemail + MP3s or a project drive with a stray podcast recording + shouldn't bleed into the personal music library any more than + a work drive with screenshots should bleed into Photos. + +Symmetric opt-in (`include_in_*_index` for both) fixes both. +The "Family Music" case still works — the owner flips the flag +once on drive creation, same one-time gesture as "Family Photos" +under the pre-existing photo policy. The default-personal case +(90%+ of users) needs no configuration for either surface. + +#### Face indexing — per-drive clustering, scope follows Photos + +Face indexing is bound to the same scope as `/api/photos` — the +two surfaces show the same content set, so the face data behind +that content lives in the same scope. + +Two layers to keep distinct: + +**Storage layer — per blob.** Face fingerprints are keyed on +`blob_hash` (BLAKE3), FK to `storage.blobs.hash`. Fingerprints +are deterministic from content bytes, and OxiCloud dedups +content via blob hash — so a photo uploaded into N drives (or N +times by N users) produces *one* fingerprint set, computed once, +reused forever. Cascade-deletes when the blob is GC'd (ref_count +→ 0). No `user_id`, `created_by`, `file_id`, `drive_id`, or +group key on the fingerprint row: identity is the content. + +**Clustering layer — per drive.** Cluster computation runs +*within* a drive: take every fingerprint reachable via a file in +that drive (`storage.files.drive_id = X` JOIN +`face_fingerprints` ON `blob_hash`), cluster them, emit clusters +scoped to drive X. The query repeats per drive the caller can +see (default personal + drives where +`policies.include_in_photo_index = true` AND the caller has +Read). Same-person fingerprints from different drives land in +**separate** clusters by default — even when both drives reach +the exact same blob, because clustering is keyed on drive, not +on fingerprint identity. + +**Why per-drive clustering:** + +The drive is already the data boundary post-D6 — quota, sharing, +trash, AuthZ all pivot on `drive_id`. The face library is part +of the drive's content, not a cross-drive aggregate. Two +properties fall out cleanly: + +- **Family-drive UX works.** Alice and Bob both members of + "Family" with `include_in_photo_index=true`. Alice uploads + Christmas photos; Bob uploads birthday photos. Grandma is in + both. Both see the *same* Grandma cluster in Family — one + merged cluster derived from fingerprints across both uploads. + Labels on the Family cluster are drive-scoped (anyone with + Photos access to Family sees them). +- **Personal-drive isolation is preserved.** Each user's + personal drive is access-isolated by definition (nobody else + has Read on it). So a personal-drive cluster is visible only + to the drive's owner. The privacy guarantee falls out of + drive-access scoping — no separate user-id key needed. + +**Cross-drive clusters don't auto-merge.** Bob labelling +"Grandma" in his Personal-drive cluster does NOT propagate to +Family's Grandma cluster. Two separate visual clusters by +default — even if the embedding similarity would otherwise +match them. Rationale: auto-propagating private labels into a +shared drive would silently expose personal classifications. +Future UX can offer explicit per-cluster merging ("these two +clusters are the same person") — user-driven, never silent. + +**Shared-drive opt-in is the consent surface.** Enabling +`include_in_photo_index` on a drive is the owner saying "the +photos in this drive are part of the drive's photo library, +including the face data they contain." Doesn't add a new +sharing surface — surfaces what was already visible (anyone +with Read on a photo can see who's in it). + +**Implementation:** + +- `face_fingerprints(blob_hash, embedding, …)` — FK to + `storage.blobs.hash`, no `user_id` / `file_id` / `drive_id` + column. Cascade-delete via the blob ref-count → 0 GC path. +- Cluster query: `SELECT … FROM storage.files f JOIN + face_fingerprints fp ON fp.blob_hash = f.blob_hash WHERE + f.drive_id = $1 AND NOT f.is_trashed` for each drive in the + caller's Photos-scope set. +- Pre-D7 the legacy `(user_id, blob_hash)` query in + `face_indexing_service.rs::lookup_user` stays in place; D7 + drops `user_id` from the column set in lockstep with the + global user_id retirement, leaving the fingerprint row keyed + on `blob_hash` alone. Both the `include_in_photo_index` policy + AND D7's user_id drop must land before face indexing can move + to the per-drive clustering model. #### Verification sketch diff --git a/example.env b/example.env index c500b869..b814ae7d 100644 --- a/example.env +++ b/example.env @@ -85,6 +85,30 @@ OXICLOUD_SERVER_HOST=127.0.0.1 # to an admin token. Default: false. #OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=false +# Native WebDAV URL segment that returns the drive listing. Sanitized +# by trimming leading/trailing `/` so `/@drive/`, `@drive`, and +# `@drive/` are equivalent. Three deployment modes: +# +# * Default `@drive` — back-compat with pre-multi-drive clients. +# /webdav/… → caller's default personal drive +# /webdav/@drive/ → drive listing (per-drive virtual +# folders) +# /webdav/@drive//… → specific drive by UUID or its +# display name +# +# * Empty `""` — no default-drive shortcut; `/webdav/` IS the +# drive listing. Clients must always name the drive. +# /webdav/ → drive listing +# /webdav//… → specific drive +# +# * Any other string (e.g. `drives`) — same shape as `@drive` but +# with your chosen segment substituted. +# +# Selector `` is a drive UUID or the drive's display name. Only +# drives the caller has Read on via role_grants resolve; unknown +# selector and permission denial both return 404 (anti-enumeration). +#OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=@drive + # How often (milliseconds) the background job drains storage.tree_etag_dirty # and bumps folder tree ETags (default: 500). Write paths only enqueue bump # requests — this is the upper bound on how stale an ancestor folder's ETag diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 00000000..4ff903a0 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,11 @@ +# AGENTS.md — Frontend + +Complements the repo-root `/AGENTS.md`. Not shipped (adapter-static +copies only `frontend/static/`). + +## localStorage keys + +Prefix `oxi-`, kebab-case separators. Example: `oxi-view-mode`. +Enforced by `$lib/utils/localStoragePrefs::wipeAppKeys()` which sweeps +every `oxi-*` key on user-account switches — any other prefix leaks the +previous user's state into the new one. diff --git a/frontend/src/app.html b/frontend/src/app.html index 505b1b20..700f9219 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,14 +3,30 @@ - + + + + + + %sveltekit.head% +
+ +

{t('drive.policies', 'Policies')}

+ +
+

+ {t( + 'drive.policies_help', + "Rules an OxiCloud admin has set for this drive. Only admins can change them; you're seeing the current state." + )} +

+ +
+ {#if canDelete} - - - - - +
Redirecting, please wait...
- + + \ No newline at end of file diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl new file mode 100644 index 00000000..d10c8344 --- /dev/null +++ b/tests/api/calendar.hurl @@ -0,0 +1,282 @@ +# ============================================================= +# OxiCloud – CalDAV + Round-3 AuthZ end-to-end scenario +# ============================================================= +# Verifies the full CalDAV surface post-Round-3: +# +# * MKCALENDAR / PROPFIND / DELETE against `/caldav/*` all +# route through `CalendarService`, which enforces +# `authz.require` on every method. +# * Cross-user access uses the 404 anti-enum shape (was 403 +# in the bespoke `check_calendar_access` era). +# * Sharing goes through the generic `POST /api/grants` with +# `resource.type = "calendar"` — a first-class ReBAC +# resource variant added in Round 3 Phase 1. +# * A shared calendar shows up in the recipient's PROPFIND +# listing while the grant is live and disappears again +# after revoke. +# +# The `calendar_id` is server-assigned at MKCALENDAR time and +# surfaces in the PROPFIND response as `/caldav//`. We +# extract it with a regex on the response body — the fresh CI +# database (`tests/webdav/run.sh` spawns a private Postgres) +# guarantees admin has zero pre-existing calendars, so the +# first-match regex is unambiguous. +# +# CalDAV auth is JWT via the same middleware the REST API uses +# (`/caldav/*` and `/carddav/*` are both wrapped in +# `auth_middleware + require_internal_user_layer` in main.rs). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Alice (admin) logs in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – MKCALENDAR: create a fresh calendar for the test. +# Empty body → the CalDAV handler derives the display name +# from the last path segment ("round3-cal" here). The response +# is 201 with an empty body — CalDAV convention. The +# server-assigned UUID is captured in Step 3 via PROPFIND. +# ───────────────────────────────────────────────────────────── +MKCALENDAR {{base_url}}/caldav/round3-cal/ +Authorization: Bearer {{alice_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Alice PROPFIND at Depth 1 lists her calendars. +# The response is a `` — each calendar surfaces +# as `/caldav//`. Regex-capture the +# UUID (first `/caldav//` in the body — the root href +# is `/caldav/` alone, no UUID, so it can't match). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{alice_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + + + + +``` + +HTTP 207 +[Captures] +calendar_id: body regex "/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Provision Bob. Idempotent: `HTTP *` accepts 201 +# on the first run and 409 on subsequent ones. Login is the +# actual precondition. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "caldav_bob", + "password": "CaldavBobPassword1!", + "email": "caldav_bob@example.com", + "role": "user" +} + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "caldav_bob", + "password": "CaldavBobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" +bob_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Cross-user PROPFIND. Bob has no grant on Alice's +# calendar; his listing does NOT include the calendar's UUID. +# (Bob's OWN response body will list his lifecycle-provisioned +# calendars — none of them collide with Alice's UUID.) +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Cross-user direct PROPFIND on Alice's calendar +# → 404. `authz.require(Read)` denies with `NotFound` for +# anti-enumeration parity with files/folders/drives. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/{{calendar_id}}/ +Authorization: Bearer {{bob_token}} +Depth: 0 +Content-Type: application/xml +``` + + + + +``` + +HTTP * +[Asserts] +status >= 400 +status < 500 + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Alice shares the calendar with Bob as Viewer via +# the generic ReBAC grant endpoint. `resource.type = "calendar"` +# is a first-class variant post-Round-3. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "calendar", "id": "{{calendar_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +share_grant_id: jsonpath "$.grants[0].id" +[Asserts] +jsonpath "$.grants[0].role" == "viewer" +jsonpath "$.grants[0].resource.type" == "calendar" +jsonpath "$.grants[0].resource.id" == "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Bob PROPFIND now includes Alice's calendar. The +# `list_my_calendars` service method reads +# `authz.list_incoming_grants(user)` and unions across +# owned + shared, replacing the pre-Round-3 owner-only query. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 8b – Unified list-on-resource: Alice queries +# `GET /api/grants?resource_type=calendar&resource_id=…`. The +# handler requires `Share` on the resource (Alice's Owner grant +# satisfies it) and returns the raw `role_grants` rows including +# the Owner self-grant. Confirms `ResourceTypeDto::Calendar` is +# admitted at the query-string boundary. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants?resource_type=calendar&resource_id={{calendar_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].subject.id" contains "{{bob_user_id}}" +jsonpath "$[*].subject.id" contains "{{alice_user_id}}" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer" +jsonpath "$[?(@.subject.id == '{{alice_user_id}}')].role" == "owner" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "calendar" + + +# ───────────────────────────────────────────────────────────── +# Step 8c – Viewer Bob is denied on the unified list endpoint — +# `Share` is required, Viewer's bundle excludes it → 404 +# anti-enum shape (same treatment as any other resource type). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants?resource_type=calendar&resource_id={{calendar_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Alice revokes the grant. `DELETE /api/grants/{id}` +# maps to a single `role_grants` row delete. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/grants/{{share_grant_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Bob PROPFIND no longer includes Alice's calendar. +# The role_grants row is gone, so `list_incoming_grants` won't +# surface it and `list_my_calendars` collapses back to Bob's +# own. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{calendar_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Cleanup: Alice deletes the calendar. The service +# runs `authz.require(Delete)` (owner passes via the seeded +# Owner grant), then `revoke_all_for_resource` wipes any +# remaining grants on the calendar in case a share slipped +# through. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/caldav/{{calendar_id}}/ +Authorization: Bearer {{alice_token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 diff --git a/tests/api/contacts.hurl b/tests/api/contacts.hurl index 2570668d..075a8e14 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -24,6 +24,7 @@ Content-Type: application/json HTTP 200 [Captures] token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" [Asserts] jsonpath "$.access_token" isString jsonpath "$.token_type" == "Bearer" @@ -276,3 +277,344 @@ Authorization: Bearer {{token}} HTTP 200 [Asserts] jsonpath "$" isCollection + + +# ═════════════════════════════════════════════════════════════ +# Round 3 — CardDAV/AddressBook AuthZ regression +# ═════════════════════════════════════════════════════════════ +# Post-Round-3, address-book access + sharing routes through +# `AuthorizationEngine` and `storage.role_grants`. The dedicated +# `carddav.address_book_shares` table stopped being consulted; +# the generic `POST /api/grants` endpoint accepts +# `resource.type = "address_book"` as a first-class ReBAC +# resource. +# +# Coverage: +# 15. Fresh book owned by admin (Alice). +# 16. Non-member user (Bob) doesn't see the book. +# 17. Bob's direct GET on the book → 404 (anti-enum, was 403 +# pre-Round-3). +# 18. Alice shares with Bob as Viewer via `POST /api/grants`. +# 19. Bob's listing includes the book with is_readonly=true. +# 20. Viewer role's bundle has no Create — Bob's contact +# write → 404 (anti-enum). +# 21. Alice revokes via `DELETE /api/grants/{id}`. +# 22. Bob no longer sees the book. +# 23. Cleanup. +# ============================================================= + + +# Step 15 — Alice creates a fresh book for the share regression. +POST {{base_url}}/api/address-books +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "Round3 Share Book", + "description": "Book for the multi-user share regression", + "is_public": false +} + +HTTP 201 +[Captures] +share_book_id: jsonpath "$.id" + + +# Step 16 — Provision Bob. Idempotent: accept 201 on first run, +# 409 on subsequent runs; login is the actual precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "username": "carddav_bob", + "password": "CarddavBobPassword1!", + "email": "carddav_bob@example.com", + "role": "user" +} + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "carddav_bob", + "password": "CarddavBobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" +bob_user_id: jsonpath "$.user.id" + + +# Step 17 — Bob's book listing does NOT include Alice's book. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{share_book_id}} + + +# Step 18a — Direct GET on Alice's book: 404 (anti-enum). +GET {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# Step 18b — Contact-write into Alice's book: 404. Bob has no +# grant, so authz.require(Create) rejects with NotFound. +# Body is minimal on purpose — the endpoint's wire DTO +# (`CreateContactRequest`) marks every collection field +# `#[serde(default)]`, so `full_name` alone deserialises +# fine and lets the request reach the authz gate. Any +# body-side 422 here would mask the AuthZ regression the +# step is meant to verify. +POST {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "full_name": "Sneaky Insert" +} + +HTTP 404 + + +# Step 19 — Alice shares the book with Bob as Viewer via the +# generic ReBAC grant endpoint. `resource.type = "address_book"` +# is a first-class variant post-Round-3. +POST {{base_url}}/api/grants +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "address_book", "id": "{{share_book_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +share_grant_id: jsonpath "$.grants[0].id" +[Asserts] +jsonpath "$.grants[0].role" == "viewer" +jsonpath "$.grants[0].resource.type" == "address_book" +jsonpath "$.grants[0].resource.id" == "{{share_book_id}}" + + +# Step 20 — Bob's listing now includes the book, marked readonly +# because he's not the owner. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id == '{{share_book_id}}')].is_readonly" == true + + +# Step 21 — Viewer bundle has no Create permission — Bob's +# contact write still 404s. Same minimal-body reasoning as +# Step 18b: keep the request valid at the wire layer so any +# rejection has to come from the AuthZ engine. +POST {{base_url}}/api/address-books/{{share_book_id}}/contacts +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ + "full_name": "Viewer Cannot Write" +} + +HTTP 404 + + +# Step 21b — Unified list-on-resource: Alice queries +# `GET /api/grants?resource_type=address_book&resource_id=…`. +# `Share` is required (Alice's Owner grant satisfies it) and the +# response includes the Owner self-grant that the per-domain +# UI hides. Confirms `ResourceTypeDto::AddressBook` is admitted +# at the query-string boundary. +GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}} +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].subject.id" contains "{{bob_user_id}}" +jsonpath "$[*].subject.id" contains "{{admin_user_id}}" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer" +jsonpath "$[?(@.subject.id == '{{admin_user_id}}')].role" == "owner" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "address_book" + + +# Step 21c — Viewer Bob is denied on the unified list endpoint — +# `Share` isn't in the Viewer bundle → 404 anti-enum shape. +GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# Step 22 — Alice revokes the grant. +DELETE {{base_url}}/api/grants/{{share_grant_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# Step 23 — Bob's listing no longer includes the book. +GET {{base_url}}/api/address-books +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains {{share_book_id}} + + +# Step 24 — Cleanup: Alice deletes the book. +DELETE {{base_url}}/api/address-books/{{share_book_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# ═════════════════════════════════════════════════════════════ +# Round 3 — CardDAV protocol coverage +# ═════════════════════════════════════════════════════════════ +# Verifies the CardDAV surface end-to-end: +# +# * MKCOL creates an address book via the CardDAV protocol +# (`ContactService::create_address_book` seeds an Owner +# role_grant on the caller so the engine's cache warms). +# * PROPFIND lists it in the caller's address-book home. +# * A non-member's PROPFIND doesn't include the book. +# * `POST /api/grants` with `resource.type = "address_book"` +# grants Read to the non-member. +# * The recipient's PROPFIND now includes the book. +# * Revoke → book vanishes. +# * DELETE cleans up. +# +# Book UUID is server-assigned at MKCOL time and appears in the +# PROPFIND multistatus as `/carddav//`. +# Regex-capture is unambiguous only if admin has zero +# pre-existing CardDAV books — true on the CI DB (fresh from +# `tests/webdav/run.sh`'s private Postgres), false in a +# populated dev DB. +# ============================================================= + + +# Step 25 — Alice creates a fresh book via CardDAV MKCOL. +# Empty body — `handle_mkcol` derives the display name from the +# path's last segment. +MKCOL {{base_url}}/carddav/round3-carddav-book/ +Authorization: Bearer {{token}} + +HTTP 201 + + +# Step 26 — Alice PROPFIND at Depth 1 lists her books. Capture +# the server-assigned UUID with a regex on the `` value. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{token}} +Depth: 1 +Content-Type: application/xml +``` + + + + + + + +``` + +HTTP 207 +[Captures] +carddav_book_id: body regex "/carddav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" + + +# Step 27 — Bob PROPFIND: the book UUID is NOT in his response. +# (Bob's lifecycle-provisioned books, if any, get their own +# UUIDs — no collision.) +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{carddav_book_id}}" + + +# Step 28 — Alice shares the book with Bob as Viewer via the +# generic ReBAC grant endpoint (same wire format as the +# calendar test, only the resource type differs). +POST {{base_url}}/api/grants +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "address_book", "id": "{{carddav_book_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +carddav_grant_id: jsonpath "$.grants[0].id" + + +# Step 29 — Bob PROPFIND now includes the shared book. The +# CardDAV handler routes through the same +# `list_user_address_books` as the REST API, so the shared +# book flows in via the role_grants union. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body contains "{{carddav_book_id}}" + + +# Step 30 — Alice revokes the grant. +DELETE {{base_url}}/api/grants/{{carddav_grant_id}} +Authorization: Bearer {{token}} + +HTTP 204 + + +# Step 31 — Bob PROPFIND no longer includes the book. +PROPFIND {{base_url}}/carddav/ +Authorization: Bearer {{bob_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body not contains "{{carddav_book_id}}" + + +# Step 32 — Cleanup: Alice deletes the book via CardDAV DELETE. +DELETE {{base_url}}/carddav/{{carddav_book_id}}/ +Authorization: Bearer {{token}} + +HTTP * +[Asserts] +status >= 200 +status < 300 diff --git a/tests/api/drive_policies.hurl b/tests/api/drive_policies.hurl index 4691905f..98f388eb 100644 --- a/tests/api/drive_policies.hurl +++ b/tests/api/drive_policies.hurl @@ -471,6 +471,67 @@ Content-Type: application/json HTTP 201 +# ───────────────────────────────────────────────────────────── +# Step 10c — partial-merge regression guard. +# +# The `PATCH /api/drives/{id}/policies` handler documents that +# omitting a field means "leave it alone", not "set it to false". +# Prior implementation round-tripped the wire body through the +# typed `DrivePolicies` struct (which has `#[serde(default)]`, so +# every omitted field defaults to `false`) and then serialised the +# whole struct into the JSONB `||` merge — silently clobbering +# every unmentioned flag back to `false`. This step exercises +# multi-flag interaction so that regression can't creep back: +# +# 1. Set `forbid_sharing = true`, assert the bag. +# 2. In a SEPARATE PATCH, set only `forbid_public_links = true`. +# 3. Assert `forbid_sharing` STILL reads `true` in the response +# — proving the merge honoured "leave omitted keys alone". +# +# Reset both back to false at the end so the shared-drive steps +# below start from a clean state. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": true +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == true +jsonpath "$.forbid_public_links" == false + +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_public_links": true +} + +HTTP 200 +[Asserts] +# The load-bearing assertion — `forbid_sharing` must NOT have been +# clobbered by the omitted-key regression. +jsonpath "$.forbid_sharing" == true +jsonpath "$.forbid_public_links" == true + +# Reset both. +PATCH {{base_url}}/api/drives/{{personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "forbid_sharing": false, + "forbid_public_links": false +} + +HTTP 200 +[Asserts] +jsonpath "$.forbid_sharing" == false +jsonpath "$.forbid_public_links" == false + + # ───────────────────────────────────────────────────────────── # Step 11 — `forbid_external_sharing` on a SHARED drive, via # `POST /api/drives/{id}/members`. @@ -818,6 +879,113 @@ Content-Type: application/json HTTP 200 +# ───────────────────────────────────────────────────────────── +# Step 11d — `include_in_photo_index` scope opt-in (§15). +# +# Default personal drives are seeded with the flag = true by the +# `PersonalDriveLifecycleHook` + backfill migration +# (20260901000000_default_personal_photo_music_flags.sql). Non- +# default drives (shared, secondary personals) start opted-out +# and only surface in `/api/photos` after an admin flips the +# flag on via PATCH. +# +# Coverage: +# a. Upload a PNG into dp_owner's default Personal drive → +# surfaces in `/api/photos` (default-personal auto-opted in). +# b. Upload a PNG into the shared drive → does NOT surface +# (flag omitted). +# c. Admin flips `include_in_photo_index=true` on the shared +# drive → the shared-drive PNG surfaces in `/api/photos`. +# +# `/api/photos` returns a flat array of PhotoDto — each carries +# the file's `id`. Assertions use `jsonpath "$[*].id" contains +# "…"` to sidestep the single-match filter quirks +# (feedback_hurl_jsonpath_filter_empty). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{personal_root_id}} +file: file,fixtures/blue-image.png; image/png + +HTTP 201 +[Captures] +personal_photo_id: jsonpath "$.id" + + +# Baseline — personal-drive photo is visible in the timeline. +GET {{base_url}}/api/photos +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains "{{personal_photo_id}}" + + +# Upload a PNG into the SHARED drive's root. dp_owner is Owner +# on the shared drive from earlier steps, so Create passes. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{owner_token}} +[MultipartFormData] +folder_id: {{shared_root_id}} +file: file,fixtures/red-image.png; image/png + +HTTP 201 +[Captures] +shared_photo_id: jsonpath "$.id" + + +# Shared drive is NOT opted-in yet — the shared photo must be +# absent from `/api/photos`. The personal photo stays visible. +GET {{base_url}}/api/photos +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" not contains "{{shared_photo_id}}" +jsonpath "$[*].id" contains "{{personal_photo_id}}" + + +# Flip `include_in_photo_index=true` on the shared drive. +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "include_in_photo_index": true +} + +HTTP 200 +[Asserts] +jsonpath "$.include_in_photo_index" == true + + +# Shared-drive photo now surfaces in `/api/photos`. Personal +# photo remains visible — no regression on the always-in-scope +# default drive. +GET {{base_url}}/api/photos +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains "{{shared_photo_id}}" +jsonpath "$[*].id" contains "{{personal_photo_id}}" + + +# Cleanup — both photos so the shared-drive delete below finds +# an empty drive. The personal-drive photo cascade-deletes with +# dp_owner in Step 12; we still remove it here so the delete +# path is exercised explicitly (deletes don't affect the flag). +DELETE {{base_url}}/api/files/{{shared_photo_id}} +Authorization: Bearer {{owner_token}} + +HTTP 204 + +DELETE {{base_url}}/api/files/{{personal_photo_id}} +Authorization: Bearer {{owner_token}} + +HTTP 204 + + # Cleanup the shared drive: empty (no content was added) → delete # via DELETE /api/drives/{id}. dp_owner is Owner so the call # carries Manage; the per-drive empty-before-delete guard passes diff --git a/tests/api/drive_quota.hurl b/tests/api/drive_quota.hurl index ec7a5540..b624d748 100644 --- a/tests/api/drive_quota.hurl +++ b/tests/api/drive_quota.hurl @@ -302,6 +302,97 @@ jsonpath "$.blobs_deleted" exists jsonpath "$.bytes_freed" exists +# ───────────────────────────────────────────────────────────── +# Step 11 — Pre-flight quota gate on MOVE and COPY. +# +# Silent gap before 2026-07-06: +# `move_file_with_perms` / `move_folder_with_perms` +# / `copy_file_with_perms` / `copy_folder_tree_with_perms` +# never called `check_drive_quota` on the destination. +# A user could bypass a tight drive's cap by uploading +# to their unlimited personal drive first and MOVE-ing +# (or COPY-ing) into the tight drive afterwards. +# +# Fix landed in the service layer, so both REST + WebDAV + +# NC WebDAV surfaces got the check for free. This step +# locks in the 507 shape on the REST path: +# +# a) MOVE a 5 MiB file from unlimited → tight → 507. +# b) COPY a 5 MiB file from unlimited → tight → 507. +# c) Sanity — same MOVE targeted at unlimited still 200. +# ───────────────────────────────────────────────────────────── + +# Capture the 5 MiB file id currently living in the unlimited drive +# (uploaded at Step 7). We'll try to relocate it into the 100-byte +# tight drive. +GET {{base_url}}/api/files?folder_id={{unlimited_root_id}} +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Captures] +big_file_id: jsonpath "$[0].id" + + +# 11a — MOVE 5 MiB file into the tight (100-byte quota) drive. +# Refused at the service pre-check: 5_242_880 + 32 > 100. +PUT {{base_url}}/api/files/{{big_file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{tight_root_id}}" +} + +HTTP 507 + + +# 11b — COPY same file into tight drive. Same refusal shape as MOVE +# — COPY creates a NEW file row that counts against +# `drives.used_bytes` even when blob dedup means no new bytes +# hit the store. Batch endpoint lives under `/api/batch/…`, +# not `/api/files/…`. +POST {{base_url}}/api/batch/files/copy +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "file_ids": ["{{big_file_id}}"], + "target_folder_id": "{{tight_root_id}}" +} + +# Batch envelope: 200 all-ok, 206 partial, 400 all-failed. Our +# single-item batch has one quota-refused item → 400 with the +# failure in the `.failed[]` array (per `BatchOperationResponse`). +HTTP 400 +[Asserts] +jsonpath "$.stats.failed" == 1 +jsonpath "$.stats.successful" == 0 +jsonpath "$.failed[0].id" == "{{big_file_id}}" +jsonpath "$.failed[0].error" exists + + +# 11c — Sanity: the file MOVE isn't universally broken. Targeting +# the unlimited drive's own root succeeds (it's already +# there, but MOVE is idempotent for same-parent — service +# returns 200 without re-doing storage work). +PUT {{base_url}}/api/files/{{big_file_id}}/move +Authorization: Bearer {{owner_token}} +Content-Type: application/json +{ + "folder_id": "{{unlimited_root_id}}" +} + +HTTP 200 + + +# `used_bytes` on the tight drive is unchanged — the two refused +# operations above never wrote anything. +GET {{base_url}}/api/drives +Authorization: Bearer {{owner_token}} + +HTTP 200 +[Asserts] +jsonpath "$[?(@.id=='{{tight_drive_id}}')].used_bytes" == 32 + + # No cleanup tail here — `tests/api/storage_cleanup_check.sh` enumerates # every drive via `GET /api/admin/drives` and drains+deletes any that # isn't admin's default. This keeps individual Hurl tests focused on diff --git a/tests/api/drives_membership.hurl b/tests/api/drives_membership.hurl index e34bc1e5..cc450d3a 100644 --- a/tests/api/drives_membership.hurl +++ b/tests/api/drives_membership.hurl @@ -511,6 +511,26 @@ HTTP 200 jsonpath "$[*].id" contains {{team_drive_id}} +# ───────────────────────────────────────────────────────────── +# Step 21b — Upload gate by role (post-Drive AuthZ audit Round 2). +# Bob is Editor on team_drive; `POST /api/files/upload` +# targeting team_root_folder_id should succeed. This is +# the REST-side counterpart of the WebDAV/NC PUT chain +# hardened by `update_file_streaming_with_perms`. If +# this fails, the whole role-bundle → Permission::Create +# wiring is broken. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +bob_editor_upload_id: jsonpath "$.id" + + # ───────────────────────────────────────────────────────────── # Step 22 — Higher role wins: Bob now ALSO gets a Viewer direct # grant (would lower his bundle). The collapsed caller_role @@ -537,6 +557,50 @@ HTTP 200 jsonpath "$[*].id" contains {{team_drive_id}} +# ───────────────────────────────────────────────────────────── +# Step 22b — Viewer CANNOT upload into a shared drive. +# Post-Drive AuthZ audit Round 2: the create branch of +# `update_file_streaming_with_perms` requires +# `Permission::Create` on the parent folder — bundled +# with `owner`/`editor`/`contributor` role_grants only, +# NOT with `viewer`. `POST /api/files/upload` shares the +# same `save_file_with_blob` gate, so a Viewer probe +# must land 404 (anti-enum: same shape as no-such-folder) +# + `authz.denied` audit line. Also verify the batch / +# overwrite paths refuse — the whole chain from +# drive-membership to file write is exercised here. +# ───────────────────────────────────────────────────────────── + +# 22b.i — Fresh file: 404. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + + +# 22b.ii — Overwrite attempt on the Editor-era upload: still 404. +# `save_file_with_blob` catches the duplicate name at the +# `Create`-permission check before the upsert races (which +# would otherwise 409). The audit shape stays 404. +POST {{base_url}}/api/files/upload +Authorization: Bearer {{bob_token}} +[MultipartFormData] +folder_id: {{team_root_folder_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 404 + + +# 22b.iii — Alice's Editor-era file is untouched. +GET {{base_url}}/api/files/{{bob_editor_upload_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 + + # ============================================================= # Per-role mutation matrix — what every role can / can't do # ============================================================= @@ -845,15 +909,22 @@ HTTP 409 # 30c — Clear the lingering content (the Editor-created folder from -# Step 27). Delete via the regular folder endpoint so the row -# lands in trash, not the live tree; `is_empty` excludes -# trashed rows so a populated trash bin is allowed. +# Step 27 and the Editor-era file from Step 21b). Delete via +# the regular endpoints so rows land in trash, not the live +# tree; `is_empty` excludes trashed rows so a populated trash +# bin is allowed. DELETE {{base_url}}/api/folders/{{editor_created_folder_id}} Authorization: Bearer {{alice_token}} HTTP 204 +DELETE {{base_url}}/api/files/{{bob_editor_upload_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + # 30d — Owner on an empty drive → 204. DELETE {{base_url}}/api/drives/{{team_drive_id}} Authorization: Bearer {{alice_token}} diff --git a/tests/api/favorites.hurl b/tests/api/favorites.hurl index 6c8d8d5f..d8609ab8 100644 --- a/tests/api/favorites.hurl +++ b/tests/api/favorites.hurl @@ -159,3 +159,77 @@ Authorization: Bearer {{token}} HTTP 200 [Asserts] jsonpath "$.items" count == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cross-tenant regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before this fix, `POST /api/favorites/…` +# accepted any UUID and enrolled it; the listing endpoint +# then JOINed back to storage.files/folders and returned +# name/mime/size/drive_id for anything the caller had +# managed to add — an information oracle over the whole +# tenant. Now the write path calls `authz.require(Read, …)` +# per item; a caller with no grant gets 404 (anti-enum) +# + `authz.denied` audit line. See +# `docs/plan/authz_audit/rest_storage.md`. +# ───────────────────────────────────────────────────────────── + +# Create a second, unprivileged user. Idempotent: `HTTP *` accepts +# either 201 (first run) or 409 (subsequent runs). The login below +# is the actual precondition — if it succeeds we know the user +# exists with the expected password. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ "username": "fav_mallory", "password": "FavMalloryPassword1!", "email": "fav_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "fav_mallory", "password": "FavMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 12a — Single-add on admin's file: 404 (anti-enum shape). +POST {{base_url}}/api/favorites/file/{{file_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 12b — Single-add on admin's folder: 404. +POST {{base_url}}/api/favorites/folder/{{test1_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 12c — Batch: must fail wholesale on the first denial. A partial +# success would still leak "which items are valid" — the same +# oracle we're closing. +POST {{base_url}}/api/favorites/batch +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "items": [ + { "item_id": "{{file_id}}", "item_type": "file" }, + { "item_id": "{{test1_id}}", "item_type": "folder" } + ] +} + +HTTP 404 + + +# Step 12d — Mallory's favorites list is EMPTY — no partial success +# slipped through. +GET {{base_url}}/api/favorites/resources +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 0 diff --git a/tests/api/playlists.hurl b/tests/api/playlists.hurl new file mode 100644 index 00000000..fd8a8283 --- /dev/null +++ b/tests/api/playlists.hurl @@ -0,0 +1,408 @@ +# ============================================================= +# OxiCloud – Music (playlist) + Round-3 AuthZ end-to-end scenario +# ============================================================= +# Verifies the full playlist REST surface post-Round-3: +# +# * `POST /api/playlists` seeds an Owner grant on +# `Resource::Playlist(uuid)` so the caller can see it via the +# unified engine (list, get) on the very next request. +# * `GET /api/playlists` returns the union of owned + shared +# playlists via `authz.list_incoming_grants`; the pre-Round-3 +# owner-only + separate shared query pair is gone. +# * Cross-user reads (`GET /api/playlists/{id}`) return the 404 +# anti-enum shape (was 403 in the bespoke +# `user_has_access` era). +# * Sharing works through BOTH surfaces post-migration: +# - Generic `POST /api/grants` with `resource.type = "playlist"` +# (first-class ReBAC variant added in this PR) +# - Legacy `POST /api/playlists/{id}/share` (bool `can_write`) +# still routes through the same `role_grants` table via +# `authz.set_role`, so both flows converge on the unified +# engine. +# * `GET /api/playlists/{id}/shares` reads `list_grants_on_resource` +# and hides the Owner self-grant. +# * Revoke through either surface drops the playlist from the +# recipient's listing. +# * Viewer role blocks writes: `Update`/`Delete`/`Share` all 404 for +# a Viewer, matching the anti-enum shape. +# +# The `playlist_id` is captured from the POST response body. Fresh CI +# database via `tests/api/run.sh`, so admin has no prior playlists — +# the JSONPath capture from `GET /api/playlists` is unambiguous. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 – Alice (admin) logs in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "{{username}}", + "password": "{{password}}" +} + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 – Alice creates a playlist. The response body carries the +# server-assigned UUID and `owner_id == alice_user_id`. The service +# also seeds an Owner role_grant on `Resource::Playlist(uuid)` — +# proven by Step 4 which lists playlists via +# `authz.list_incoming_grants` and expects this one to surface. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/playlists +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "name": "round3-playlist", + "description": "Music AuthZ migration coverage" +} + +HTTP 201 +[Captures] +playlist_id: jsonpath "$.id" +[Asserts] +jsonpath "$.name" == "round3-playlist" +jsonpath "$.owner_id" == "{{alice_user_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 3 – Alice GETs the playlist she just created. This is the +# fast-path validation of the Owner grant seeded at create time: +# without it, `authz.require(Read)` would return NotFound and this +# would 404. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 4 – Alice lists playlists — hers appears exactly once. +# The service reads `list_incoming_grants(Alice)` and filters to +# `Resource::Playlist`, so this exercises the same code path as +# CalDAV's `list_my_calendars`. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 5 – Provision Bob. Idempotent: `HTTP *` accepts 201 first +# run, 409 subsequent runs. Login is the real precondition. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "music_bob", + "password": "MusicBobPassword1!", + "email": "music_bob@example.com", + "role": "user" +} + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ + "username": "music_bob", + "password": "MusicBobPassword1!" +} + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" +bob_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 6 – Cross-user GET on Alice's playlist → 404. Before Round 3 +# this was the bespoke `user_has_access` denial which returned 403; +# post-migration `authz.require(Read)` denies with `NotFound` for +# anti-enumeration parity with files/folders/drives. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 7 – Bob's playlist listing does NOT include Alice's. The +# `list_incoming_grants(Bob)` call sees no grant on that playlist, +# so nothing surfaces. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$..id" not contains "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 8 – Alice shares the playlist with Bob as Viewer via the +# generic ReBAC grant endpoint. `resource.type = "playlist"` is a +# first-class variant added by this PR; before Round 3, this +# request would 400 (Unsupported resource type). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "playlist", "id": "{{playlist_id}}" }, + "role": "viewer" +} + +HTTP 201 +[Captures] +share_grant_id: jsonpath "$.grants[0].id" +[Asserts] +jsonpath "$.grants[0].role" == "viewer" +jsonpath "$.grants[0].resource.type" == "playlist" +jsonpath "$.grants[0].resource.id" == "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 9 – Bob GET now succeeds. `authz.require(Read)` sees the +# Viewer role_grant row and grants access. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 10 – Bob's listing now surfaces Alice's playlist — proving +# the owned + shared union in `list_playlists`. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists?include_shared=true +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].id" contains "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 11 – Bob cannot rename the playlist. Viewer's bundle is +# Read-only (no Update), so `require_playlist_perm(Update)` denies +# with the 404 anti-enum shape. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "name": "hijacked" } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 – Bob cannot delete the playlist. Viewer's bundle +# excludes Delete → 404. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 13 – Bob cannot re-share the playlist. Viewer's bundle +# excludes Share → 404 on the legacy /share endpoint (which now +# routes through `authz.require(Share)`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/playlists/{{playlist_id}}/share +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "user_id": "{{alice_user_id}}", "can_write": true } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 14 – Alice lists shares via the legacy endpoint. The +# service reads `list_grants_on_resource` and drops the Owner +# self-grant, so exactly one row surfaces: Bob as Viewer +# (can_write=false). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}}/shares +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].user_id" contains "{{bob_user_id}}" +jsonpath "$[?(@.user_id == '{{bob_user_id}}')].can_write" == false +jsonpath "$[*].user_id" not contains "{{alice_user_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 14b – Same query, unified endpoint. `GET /api/grants? +# resource_type=playlist&resource_id=…` requires `Share` on the +# resource (same gate as the legacy /shares endpoint) and returns +# the raw `role_grants` rows — including the Owner self-grant that +# the legacy DTO hides. Confirms `ResourceTypeDto::Playlist` is +# admitted at the wire boundary and that both surfaces read the +# same underlying data. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}} +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].subject.id" contains "{{bob_user_id}}" +jsonpath "$[*].subject.id" contains "{{alice_user_id}}" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].role" == "viewer" +jsonpath "$[?(@.subject.id == '{{alice_user_id}}')].role" == "owner" +jsonpath "$[?(@.subject.id == '{{bob_user_id}}')].resource.type" == "playlist" + + +# ───────────────────────────────────────────────────────────── +# Step 14c – Bob (Viewer only) is denied on the unified list +# endpoint: `Share` is required, Viewer's bundle excludes it → +# 404 anti-enum shape. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/grants?resource_type=playlist&resource_id={{playlist_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 15 – Alice revokes the ReBAC grant. `DELETE /api/grants/{id}` +# deletes the single `role_grants` row keyed by grant_id. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/grants/{{share_grant_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 16 – Bob's GET goes back to 404, and his listing drops the +# playlist. The `role_grants` row is gone → `list_incoming_grants` +# doesn't surface it, `require(Read)` denies. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +GET {{base_url}}/api/playlists?include_shared=true +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Asserts] +jsonpath "$..id" not contains "{{playlist_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 17 – Alice re-shares Bob as Editor via the LEGACY endpoint. +# `can_write=true` maps to `Role::Editor` inside +# `music_service::share_playlist` — proving the legacy surface +# and `/api/grants` now converge on the same `role_grants` table. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/playlists/{{playlist_id}}/share +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ "user_id": "{{bob_user_id}}", "can_write": true } + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 18 – Editor CAN update (Editor's bundle includes Update). +# Confirms the can_write=true → Editor mapping actually takes +# effect at the engine level. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "description": "renamed by editor bob" } + +HTTP 200 +[Asserts] +jsonpath "$.description" == "renamed by editor bob" + + +# ───────────────────────────────────────────────────────────── +# Step 19 – Editor still cannot Share (Share stays Owner-only). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/playlists/{{playlist_id}}/share +Authorization: Bearer {{bob_token}} +Content-Type: application/json +{ "user_id": "{{alice_user_id}}", "can_write": false } + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 20 – `/shares` now reports Bob as Editor (can_write=true). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}}/shares +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$[*].user_id" contains "{{bob_user_id}}" +jsonpath "$[?(@.user_id == '{{bob_user_id}}')].can_write" == true + + +# ───────────────────────────────────────────────────────────── +# Step 21 – Alice removes the legacy-endpoint share. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/playlists/{{playlist_id}}/share/{{bob_user_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 22 – Post-remove listing is empty (Owner self-grant is +# still hidden). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}}/shares +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$..user_id" not contains "{{bob_user_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step 23 – Cleanup: Alice deletes the playlist. The service +# runs `authz.require(Delete)` (owner passes via the seeded Owner +# grant), then `revoke_all_for_resource` wipes any stray grants. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 24 – GET returns 404 after delete (nothing to enum). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/playlists/{{playlist_id}} +Authorization: Bearer {{alice_token}} + +HTTP 404 diff --git a/tests/api/public_shares.hurl b/tests/api/public_shares.hurl index 81b02edc..641f2073 100644 --- a/tests/api/public_shares.hurl +++ b/tests/api/public_shares.hurl @@ -274,6 +274,85 @@ status >= 400 status < 500 +# ───────────────────────────────────────────────────────────── +# 14b — Viewer-laundering regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before the fix, `POST /api/shares` checked +# only "does the item exist" — any authenticated user who +# could name the UUID could mint a public Viewer link, +# laundering read access into a permanent anonymous URL +# that survived their own grant revocation. Now the +# service calls `authz.require(Share, resource)` before +# minting the token; a caller without `Share` +# (Viewer/Commenter/Contributor/no-grant-at-all) gets 404 +# (anti-enum) + `authz.denied` audit line. See +# `docs/plan/authz_audit/admin_membership.md`. +# +# We test the strongest form: an unrelated user with no +# grant at all. The intermediate case (Viewer with Read +# but not Share) is covered by the same code path — Share +# is bundled only with owner/editor role_grants. +# ───────────────────────────────────────────────────────────── + +# Create/lookup the attacker. Idempotent: `HTTP *` accepts either +# 201 (first run) or 409 (subsequent runs). Login below is the real +# precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "username": "sh_mallory", "password": "ShMalloryPassword1!", "email": "sh_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "sh_mallory", "password": "ShMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 14b.i — Mallory tries to mint a public share on admin's +# folder: 404 (anti-enum). No token appears in the +# response body. +POST {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "item_id": "{{share_folder_id}}", + "item_name": "public-share-test", + "item_type": "folder" +} + +HTTP 404 + + +# Step 14b.ii — Same attempt on admin's file: 404. +POST {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} +Content-Type: application/json +{ + "item_id": "{{shared_file_id}}", + "item_name": "hello.txt", + "item_type": "file" +} + +HTTP 404 + + +# Step 14b.iii — Mallory has no shares — no partial success slipped +# through. (`GET /api/shares` returns only shares the +# caller created; response is paginated.) +GET {{base_url}}/api/shares +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" isCollection +jsonpath "$.items" count == 0 + + # ───────────────────────────────────────────────────────────── # 15 — Teardown: revoke the password share + the direct # file-share, then delete the folder. diff --git a/tests/api/recent.hurl b/tests/api/recent.hurl index 4f423908..f290aba4 100644 --- a/tests/api/recent.hurl +++ b/tests/api/recent.hurl @@ -187,3 +187,67 @@ DELETE {{base_url}}/api/recent/clear Authorization: Bearer {{token}} HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Cross-tenant regression (post-Drive AuthZ audit, +# Round 1 HIGH). Before this fix, `POST /api/recent/…` +# accepted any UUID and the listing endpoint JOINed back +# to storage.files/folders (name/mime/size/drive_id) — a +# metadata oracle over the whole tenant. Now the write +# path calls `authz.require(Read, …)`; unauthorised +# callers get 404 (anti-enum) + `authz.denied` audit line. +# See `docs/plan/authz_audit/rest_storage.md`. +# ───────────────────────────────────────────────────────────── + +# Re-discover a folder id so the attacker has TWO targets to probe +# (file + folder). Same test1 folder as favorites.hurl. +GET {{base_url}}/api/folders/{{home_folder_id}}/resources?resource_types=folder +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +test1_id: jsonpath "$.items[0].resource.id" + + +# Create/lookup the attacker. Idempotent: `HTTP *` accepts either +# 201 (first run) or 409 (subsequent runs). Login below is the real +# precondition. +POST {{base_url}}/api/admin/users +Authorization: Bearer {{token}} +Content-Type: application/json +{ "username": "rec_mallory", "password": "RecMalloryPassword1!", "email": "rec_mallory@example.com", "role": "user" } + +HTTP * + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "rec_mallory", "password": "RecMalloryPassword1!" } + +HTTP 200 +[Captures] +mallory_token: jsonpath "$.access_token" + + +# Step 10a — Record admin's file into mallory's recent: 404. +POST {{base_url}}/api/recent/file/{{file_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 10b — Same for admin's folder: 404. +POST {{base_url}}/api/recent/folder/{{test1_id}} +Authorization: Bearer {{mallory_token}} + +HTTP 404 + + +# Step 10c — Mallory's recent list stays empty. +GET {{base_url}}/api/recent/resources +Authorization: Bearer {{mallory_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 0 diff --git a/tests/api/run.sh b/tests/api/run.sh index 57a4b8bc..e5ba25be 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -38,17 +38,34 @@ wait_for_http() { SERVER_PID="" +WOPI_MOCK_PID="" + cleanup() { if [[ -n "$SERVER_PID" ]]; then log "Stopping OxiCloud server (pid $SERVER_PID)..." kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi + if [[ -n "$WOPI_MOCK_PID" ]]; then + log "Stopping WOPI mock discovery (pid $WOPI_MOCK_PID)..." + kill "$WOPI_MOCK_PID" 2>/dev/null || true + wait "$WOPI_MOCK_PID" 2>/dev/null || true + fi bash "$COMMON/stop-db.sh" } trap cleanup EXIT +# ── 0. WOPI mock discovery ──────────────────────────────────────────────────── +# Serves the static discovery.xml `OXICLOUD_WOPI_DISCOVERY_URL` +# points at (server.env pins port 9100). Started BEFORE OxiCloud so +# the server's cache-fill on first WOPI request finds it. The mock +# is stdlib-only Python (no deps) — see the file header for what it +# returns and why it's cheap. +log "Starting WOPI mock discovery on port 9100..." +node "$COMMON/wopi_mock_discovery.js" > /tmp/wopi-mock-discovery.log 2>&1 & +WOPI_MOCK_PID=$! + # ── 1. Start postgres ───────────────────────────────────────────────────────── bash "$COMMON/spawn-db.sh" @@ -144,6 +161,8 @@ 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/calendar.hurl" \ + "$API_DIR/playlists.hurl" \ "$API_DIR/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \ @@ -170,7 +189,10 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/webdav_dead_properties.hurl" \ "$API_DIR/nc_webdav_dead_properties.hurl" \ "$API_DIR/webdav_protected_properties.hurl" \ - "$API_DIR/webdav_nested_move_cascade.hurl" + "$API_DIR/webdav_drive_root.hurl" \ + "$API_DIR/webdav_permissions.hurl" \ + "$API_DIR/webdav_nested_move_cascade.hurl" \ + "$API_DIR/wopi_authz.hurl" #bash "$API_DIR/dedup_bulk_upload.sh" diff --git a/tests/api/storage_cleanup_check.sh b/tests/api/storage_cleanup_check.sh index 1943b6aa..5bed925f 100755 --- a/tests/api/storage_cleanup_check.sh +++ b/tests/api/storage_cleanup_check.sh @@ -150,9 +150,14 @@ while IFS= read -r drive_id; do while IFS= read -r file_id; do [[ -z "$file_id" ]] && continue - curl -sf -X DELETE -H "$AUTH" "$base_url/api/files/$file_id" >/dev/null + HTTP_STATUS=$(curl -s -H "$AUTH" -o /tmp/del.json -w '%{http_code}' \ + -X DELETE "$base_url/api/files/$file_id") + if [[ "$HTTP_STATUS" != "204" ]]; then + log "FILE DELETE FAILED: file=$file_id drive=$drive_id ($DRIVE_NAME) status=$HTTP_STATUS body=$(cat /tmp/del.json)" + fi done < <(echo "$CONTENTS" | jq -r '.items[] | select(.resource_type == "file") | .resource.id') + # Empty the drive's per-drive trash so D3b's "drive must be empty" # guard passes on the delete. `/api/trash/drive/{id}` is the # Owner-only per-drive empty (admin is Owner now via the grant diff --git a/tests/api/webdav_drive_root.hurl b/tests/api/webdav_drive_root.hurl new file mode 100644 index 00000000..a3e220c2 --- /dev/null +++ b/tests/api/webdav_drive_root.hurl @@ -0,0 +1,229 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme +# ============================================================= +# Exercises the native WebDAV URL scheme documented in +# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`: +# +# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`): +# * `/webdav/` → default drive's contents +# * `/webdav/@drive/` → drive listing (per-drive +# virtual folders) +# * `/webdav/@drive//…` → explicit drive by UUID +# * `/webdav/@drive//…` → explicit drive by name +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as +# an immediate child of the default drive. This is the +# user-visible bug fix: pre-refactor, `/webdav/` returned a +# drive listing instead of the default drive's contents. +# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as +# a virtual child (at least the caller's default is present). +# 6. PROPFIND `/webdav/@drive//` — descends into the +# selected drive by UUID; magic folder appears here too. +# 7. PROPFIND `/webdav/@drive//` — same via display name. +# 8. Cleanup: DELETE the magic folder via REST. +# +# The magic folder name embeds a run-scoped marker so parallel +# `hurl --jobs N` runs don't step on each other and repeat runs +# against a shared DB don't collide. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# `GET /api/drives` returns rows in a stable order: +# the caller's default personal drive first, then by +# display name. See `DriveRepository::list_readable_by`. +# `default_for_user` on the DTO is present-only for +# default rows (`Option` with `skip_serializing_if`), +# so `$[0]` — combined with the stable order — is the +# default drive for a fresh admin account. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# A default personal drive has exactly one root folder +# (the drive-root itself). We need its id to create the +# magic folder as its child. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# The name is deterministic-yet-unique so PROPFIND +# assertions below can find it by exact string match, +# and parallel test runs can't collide. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). The default +# deployment maps this to the caller's DEFAULT drive +# contents, so Depth: 1 must include the magic folder. +# +# Pre-refactor this returned a drive listing instead — +# the exact regression that broke back-compat with +# pre-multi-drive WebDAV clients. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit +# drive picker — Depth: 1 returns one virtual child +# per drive the caller has Read on. The default drive +# must appear (by its display name). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav/@drive//`. The explicit +# by-UUID selector — descends INTO the chosen drive. +# Depth: 1 lists that drive's top-level children — +# the magic folder must be one of them. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — PROPFIND on `/webdav/@drive//`. The explicit +# by-name selector — same result as the UUID form. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root). +# The drive-listing target has no writable parent +# folder — 405 Method Not Allowed. This guard prevents +# a client from silently succeeding at "creating a +# drive by MKCOL" (the drive-create surface is +# `POST /api/drives`, not WebDAV). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/@drive/`. +# `` gets interpreted as a drive selector; +# no drive with that name/UUID exists → 404. Sits +# adjacent to Step 9 so any future maintainer touching +# the pseudo-root rejection sees BOTH shapes at once +# (bare listing = 405, unknown selector = 404). +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav/@drive//x.txt`. +# Same rejection shape as MKCOL — trying to write a +# file into a non-existent drive. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL +# segment immediately after `@drive/` is ALWAYS a +# drive selector — never a filename. A caller that +# bookmarks a file URL under `@drive` with a name +# that doesn't match any drive must get 404, not +# silently create a file at the drive-listing level. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/test.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST so +# subsequent test runs / other hurl files don't see +# our marker. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/api/webdav_permissions.hurl b/tests/api/webdav_permissions.hurl new file mode 100644 index 00000000..48707af3 --- /dev/null +++ b/tests/api/webdav_permissions.hurl @@ -0,0 +1,301 @@ +# ============================================================= +# OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy +# ============================================================= +# End-to-end coverage for the two WebDAV authz axes exposed by the +# `@drive` URL scheme: +# +# 1. Per-role gates through the drive-scope resolver: a Viewer on a +# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An +# Editor can. AuthZ denials return `NotFound` (anti-enum), so +# a probing caller can't tell a genuinely-missing folder from +# one they simply lack Create on. +# +# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the +# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move` +# in `src/domain/entities/drive.rs`) — even a fully-authorised +# Editor can't move content OUT of a drive whose owner has +# forbidden cross-drive movement. Rejection is 405 +# (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`). +# +# Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config — +# runs alongside the other tests in `tests/api/run.sh`. Uses the +# `@drive/` selector so the paths don't collide with any +# drive-name-collision oddities. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (bootstrapped by `setup.hurl`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a fresh user "webdav_bob" via the admin +# endpoint, log him in. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "webdav_bob", + "password": "WebdavBobPassword1!", + "email": "webdav_bob@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "webdav_bob", "password": "WebdavBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# Capture Bob's default personal drive id — used by the cross-drive +# MOVE scenario. Bob is not a member of any shared drive yet, so his +# `/api/drives` listing has exactly one entry (his own default). +GET {{base_url}}/api/drives +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_personal_drive_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Admin creates a shared drive owned by admin. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "webdav-perm-shared", + "owner": { "type": "user", "id": "{{admin_user_id}}" } +} + +HTTP 201 +[Captures] +shared_drive_id: jsonpath "$.id" +shared_root_id: jsonpath "$.root_folder_id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Grant Bob VIEWER on the shared drive via /api/grants. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "drive", "id": "{{shared_drive_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root. +# Depth 0 to keep the assertion minimal; a 207 with the +# drive's own href suffices as "Bob has Read". +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/ +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive. +# `authz.require(Create, Folder)` denial returns +# `DomainError::not_found` (anti-enum), which maps to 404. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Bob (VIEWER) CANNOT PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +viewer should not upload +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Admin creates a probe folder in the shared drive so +# the Editor-can-rename step below has a real target. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{admin_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder. +# MOVE requires Update on the source, which Viewer +# doesn't have. Same anti-enum 404 shape. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Promote Bob from VIEWER to EDITOR. +# `PATCH /api/drives/{id}/members/{subject-type}/{id}` +# mutates the role in-place. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}} +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "role": "editor" } + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Bob (EDITOR) CAN MKCOL a new folder. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder +Authorization: Bearer {{bob_token}} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Bob (EDITOR) CAN PUT a file. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +editor uploaded content +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 14 — Bob puts a file in his OWN personal drive as the +# source for the cross-drive MOVE test below. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Content-Type: text/plain +``` +cross-drive probe payload +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's +# PERSONAL drive. The policy sits on the SOURCE drive +# per `DrivePolicies::refuse_cross_drive_move`; only +# OxiCloud-admin can PATCH policies. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": true } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == true + + +# ───────────────────────────────────────────────────────────── +# Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his +# PERSONAL drive to the SHARED drive. Blocked at the +# service layer by the policy — `OperationNotSupported` +# maps to 405 Method Not Allowed. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 17 — Admin flips the policy OFF. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ "forbid_cross_drive_move": false } + +HTTP 200 +[Asserts] +jsonpath "$.forbid_cross_drive_move" == false + + +# ───────────────────────────────────────────────────────────── +# Step 18 — Bob retries the same MOVE. Now the policy is off, +# Bob has Update on source (his own personal drive) + +# Create on dest parent (Editor on shared drive), so +# the move succeeds. 201 on rename/move to a new URL, +# per `handle_move`'s existing convention. +# ───────────────────────────────────────────────────────────── +MOVE {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 19 — Verify the destination now exists and the source +# is gone. Both PROPFINDs use Bob's token to also +# re-confirm the AuthZ gates on the destination side. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 207 + + +PROPFIND {{base_url}}/webdav/xdrive-probe.txt +Authorization: Bearer {{bob_token}} +Depth: 0 + +HTTP 404 diff --git a/tests/api/wopi_authz.hurl b/tests/api/wopi_authz.hurl new file mode 100644 index 00000000..144e0df7 --- /dev/null +++ b/tests/api/wopi_authz.hurl @@ -0,0 +1,377 @@ +# ============================================================= +# OxiCloud — WOPI authorization at token redemption +# ============================================================= +# Regression coverage for the WOPI verb-handler bypass documented in +# memory note `wopi-authz-bypass`. Two bugs closed: +# +# 1. Verb handlers (check_file_info, get_file, put_file, +# file_operations, host_page) previously did NOT call +# `AuthorizationEngine::require` at redemption. A grant +# revoked between mint-time and request-time silently kept +# working until the token TTL expired. +# +# 2. The mint helper decided `can_write` from the client's +# `requested_action` string (`!= "view"` → write). A Viewer +# clicking "Edit in Collabora" received a write-capable +# token because the string was "edit". +# +# The fix wires `authz.require` on every verb and derives +# `can_write` from the caller's actual Update permission. This +# suite hits both paths through the real HTTP surface. +# +# Note on infra: +# * `OXICLOUD_WOPI_ENABLED=true` in tests/common/server.env +# * `OXICLOUD_WOPI_SECRET` pinned so the tokens the server mints +# round-trip verify-able through the suite +# * WOPI discovery served by `tests/common/wopi_mock_discovery.py` +# started by run.sh — mock URL points at a black-hole editor +# so we only assert on OxiCloud's own responses +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login as admin (owner) and capture home folder id +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +alice_token: jsonpath "$.access_token" +alice_user_id: jsonpath "$.user.id" + + +GET {{base_url}}/api/folders +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_home_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Create a Bob user (Viewer under test) via admin API +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "username": "wopi-bob", + "password": "WopiBobPassword1!", + "email": "wopi-bob@example.com", + "role": "user" +} + +HTTP 201 +[Captures] +bob_user_id: jsonpath "$.id" + + +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "wopi-bob", "password": "WopiBobPassword1!" } + +HTTP 200 +[Captures] +bob_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Alice uploads a plain-text file the WOPI verbs will +# target. `text/plain` is in the mock discovery XML so +# `/api/wopi/editor-url` resolves to a real (black-hole) +# editor URL — the endpoint returns 200 with an +# access_token we can then poke at the verbs. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/files/upload +Authorization: Bearer {{alice_token}} +[MultipartFormData] +folder_id: {{alice_home_id}} +file: file,fixtures/hello.txt; text/plain + +HTTP 201 +[Captures] +file_id: jsonpath "$.id" +[Asserts] +jsonpath "$.mime_type" == "text/plain" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Alice mints an editor-URL for her own file with +# `action=edit`. Owner has Update → can_write=true. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_edit_token: jsonpath "$.access_token" +[Asserts] +jsonpath "$.access_token" isString +jsonpath "$.editor_url" contains "edit" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — CheckFileInfo with the owner's edit token. Verb +# re-checks Read → allowed. `user_can_write=true` +# reflects real Update. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_edit_token}} + +HTTP 200 +[Asserts] +jsonpath "$.UserId" == "{{alice_user_id}}" +jsonpath "$.UserCanWrite" == true +jsonpath "$.SupportsUpdate" == true + + +# ───────────────────────────────────────────────────────────── +# Step 6 — GetFile with the owner's edit token. Verb re-checks +# Read → 200 with body. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}} + +HTTP 200 +[Asserts] +body contains "Hello" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PutFile with the owner's edit token. Verb re-checks +# Update → 200. The owner overwrites her own file. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_edit_token}} +Content-Type: application/octet-stream +``` +owner overwrite via WOPI PutFile +``` + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Alice explicitly requests view mode. Even the owner +# gets `can_write=false` — the token respects the +# client's downgrade so Collabora can open a doc +# "read-only for co-browsing". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=view +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Captures] +alice_view_token: jsonpath "$.access_token" + + +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{alice_view_token}} + +HTTP 200 +[Asserts] +# Owner explicitly requested view — supports_update flips off. +jsonpath "$.UserCanWrite" == false +jsonpath "$.SupportsUpdate" == false + + +# View token trying to write → 401 (token's can_write bit says no +# before the authz.require ever runs). +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{alice_view_token}} +Content-Type: application/octet-stream +``` +owner trying to write with view token +``` + +HTTP 401 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — SECURITY: Bob has NO grant on Alice's file. Requests +# an edit-URL. The mint helper's Read gate fires → 404 +# (anti-enum). This is the pre-fix behaviour holding +# — mint-time Read was already enforced via +# get_file_with_perms. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Alice grants Bob the Viewer role on the file. +# Capture the grant id off the POST response so Step +# 13's revoke doesn't need to LIST + filter (the LIST +# endpoint returns a bare JSON array, not +# `.grants[?...]`, and Hurl's single-match filter +# capture behaviour is quirky — see memory note +# `feedback_hurl_jsonpath_filter_empty`). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "viewer" +} + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — SECURITY: Bob (Viewer) requests an EDIT token. Fix +# #12: mint helper derives `can_write` from real +# Update permission, not from the requested_action +# string. Bob has Read but not Update → token is +# minted with `can_write=false` even though he asked +# for "edit". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_forged_edit_token: jsonpath "$.access_token" + + +# CheckFileInfo with Bob's "edit" token shows UserCanWrite=false +# because the token's can_write bit was scrubbed at mint. Prior +# to the fix this was `true` — a Viewer editing Alice's file. +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_forged_edit_token}} + +HTTP 200 +[Asserts] +jsonpath "$.UserId" == "{{bob_user_id}}" +jsonpath "$.UserCanWrite" == false +jsonpath "$.SupportsUpdate" == false + + +# Bob attempting PutFile with his "edit" token → 401. The +# token's own can_write=false is the outer gate; even if the +# token had somehow been forged with can_write=true, the +# redemption-time authz.require(Update) would return 404. +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}} +Content-Type: application/octet-stream +``` +Bob trying to write as Viewer +``` + +HTTP 401 + + +# Bob CAN read (his Read grant is real). +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_forged_edit_token}} + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — SECURITY: promote Bob to Editor. Now he legitimately +# holds Update, so an edit token becomes truly write- +# capable. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/grants +Authorization: Bearer {{alice_token}} +Content-Type: application/json +{ + "subject": { "type": "user", "id": "{{bob_user_id}}" }, + "resource": { "type": "file", "id": "{{file_id}}" }, + "role": "editor" +} + +# The engine's `ON CONFLICT UPDATE` collapses one role row per +# (subject, resource), so this Editor grant REPLACES the Viewer +# grant from Step 10 rather than stacking. Bob now holds +# Editor alone; revoking it in Step 13 leaves him with no +# grants at all. +HTTP 201 +[Captures] +bob_grant_id: jsonpath "$.grants[0].id" + + +GET {{base_url}}/api/wopi/editor-url?file_id={{file_id}}&action=edit +Authorization: Bearer {{bob_token}} + +HTTP 200 +[Captures] +bob_real_edit_token: jsonpath "$.access_token" + + +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}} + +HTTP 200 +[Asserts] +# Bob is a real Editor now → can_write flips to true. +jsonpath "$.UserCanWrite" == true +jsonpath "$.SupportsUpdate" == true + + +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} +Content-Type: application/octet-stream +``` +Bob as Editor legitimately writes +``` + +HTTP 200 + + +# ───────────────────────────────────────────────────────────── +# Step 13 — SECURITY: revoke Bob's grant AFTER his edit token was +# minted. The token stays cryptographically valid until +# TTL, but every subsequent verb call must hit the +# authorization engine and reject. +# +# This is the CORE bug the memory note describes: prior +# to the fix Bob's PutFile still succeeded here because +# the verb handlers trusted the token in isolation. +# +# The Editor grant from Step 12 REPLACED the Viewer +# grant from Step 10 (engine's ON CONFLICT UPDATE — +# one role row per subject/resource). So revoking the +# Editor grant leaves Bob with no grants at all; every +# verb — Read AND Update — must refuse. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/grants/{{bob_grant_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 + + +# CheckFileInfo — no Read → 404. Prior to the fix the verb +# handler trusted the token and returned 200 with the file's +# metadata. +GET {{base_url}}/wopi/files/{{file_id}}?access_token={{bob_real_edit_token}} + +HTTP 404 + + +# GetFile — no Read → 404. Prior to the fix Bob could still +# download the file content until the token TTL expired. +GET {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} + +HTTP 404 + + +# PutFile — no Update → 404 (verb-side require_wopi_perm), OR +# 401 if the token's own `!claims.can_write` gate happened to +# fire first. The important assertion is "not 200" — a revoked +# grant must never let the caller through. +POST {{base_url}}/wopi/files/{{file_id}}/contents?access_token={{bob_real_edit_token}} +Content-Type: application/octet-stream +``` +Bob post-revoke tries to write +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Cleanup — delete the test file so subsequent Hurl files don't +# see it. Bob user stays; other tests may reuse the `wopi-bob` +# username, but the grants that made this test meaningful are +# gone. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/files/{{file_id}} +Authorization: Bearer {{alice_token}} + +HTTP 204 diff --git a/tests/common/init-test-schema.sh b/tests/common/init-test-schema.sh index fb36972d..7eceb93f 100644 --- a/tests/common/init-test-schema.sh +++ b/tests/common/init-test-schema.sh @@ -75,9 +75,11 @@ BEGIN VALUES ('personal', admin_id, NULL) RETURNING id INTO drive_id; + -- Post-D7: `storage.folders.user_id` dropped. Ownership lives on the + -- drive-Owner role_grant below; provenance in `created_by`/`updated_by`. INSERT INTO storage.folders - (name, parent_id, user_id, drive_id, created_by, updated_by) - VALUES ('Personal', NULL, admin_id, drive_id, admin_id, admin_id) + (name, parent_id, drive_id, created_by, updated_by) + VALUES ('Personal', NULL, drive_id, admin_id, admin_id) RETURNING id INTO folder_id; UPDATE storage.drives SET root_folder_id = folder_id WHERE id = drive_id; diff --git a/tests/common/server-webdav-drive-root.env b/tests/common/server-webdav-drive-root.env new file mode 100644 index 00000000..3fafb75b --- /dev/null +++ b/tests/common/server-webdav-drive-root.env @@ -0,0 +1,85 @@ +# Shared test-server environment variables. +# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node). +# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here — +# each test suite sets those to avoid port/directory conflicts. + +DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test +OXICLOUD_STATIC_PATH=./static +OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars +OXICLOUD_ENABLE_AUTH=true +OXICLOUD_ENABLE_TRASH=true +OXICLOUD_ENABLE_SEARCH=true +OXICLOUD_ENABLE_FILE_SHARING=true +OXICLOUD_ENABLE_MUSIC=true +OXICLOUD_EXPOSE_SYSTEM_USERS=true +OXICLOUD_WOPI_ENABLED=true +# Fixed secret so the Hurl WOPI test can hand-craft valid access +# tokens with a known signing key. Prod deployments MUST override +# this to a random per-deployment value. +OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod +# Discovery URL points at a black hole — VERB endpoints don't need +# discovery, and the WOPI Hurl suite deliberately does NOT touch +# `/api/wopi/editor-url` (the only path that would fetch it), so +# an unreachable URL keeps startup fast and hermetic. +OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml +OXICLOUD_WOPI_TOKEN_TTL_SECS=3600 +OXICLOUD_OIDC_ENABLED=false + +OXICLOUD_NEXTCLOUD_ENABLED=true + +# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`, +# `/api/admin/internal/trigger-gc`). Off by default in production; +# the Hurl suite needs them to assert post-delete quota convergence +# without waiting out the 600 s reconciliation tick. +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +RUST_LOG="warn,audit=info,sqlx::migrate=info" +#RUST_LOG="warn,audit=info,oxicloud::quota=debug" +#RUST_LOG=debug +#RUST_LOG=info + +# Per-chunk upload cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass +# under the cap, while the cap test sends a 5 MiB fixture to trigger 413. +OXICLOUD_CHUNK_MAX_BYTES=4194304 + +# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl. +# 4 MiB: same threshold as the chunked cap so the existing 5 MiB +# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one +# generated file. All existing direct-PUT tests +# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB, +# _nextcloud_put_blake3 = 32 B) stay safely under this cap. +OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304 + +# grow up limits for tests +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 +# invitation body and follow the magic-link URL. The `SMTP_FROM` value +# is required so the mock can build a valid Message; host/port are +# irrelevant in mock mode but kept set for completeness. +OXICLOUD_SMTP_MOCK=true +OXICLOUD_SMTP_HOST=localhost +OXICLOUD_SMTP_PORT=25 +OXICLOUD_SMTP_FROM='OxiCloud Tests ' +OXICLOUD_SMTP_TLS=none +OXICLOUD_ALLOW_EXTERNAL_USERS=true + +# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can +# exercise the cap behaviour with a small, deterministic request count. +# Production defaults are 50 / 5 / 200 respectively (see example.env). +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 + +OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true + +# /webdav/ will points directly to list of drives +OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="" diff --git a/tests/common/server.env b/tests/common/server.env index 8cc4f118..6c27ee39 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -13,7 +13,17 @@ OXICLOUD_ENABLE_SEARCH=true OXICLOUD_ENABLE_FILE_SHARING=true OXICLOUD_ENABLE_MUSIC=true OXICLOUD_EXPOSE_SYSTEM_USERS=true -OXICLOUD_WOPI_ENABLED=false +OXICLOUD_WOPI_ENABLED=true +# Fixed secret so the Hurl WOPI test can hand-craft valid access +# tokens with a known signing key. Prod deployments MUST override +# this to a random per-deployment value. +OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod +# Discovery URL points at a black hole — VERB endpoints don't need +# discovery, and the WOPI Hurl suite deliberately does NOT touch +# `/api/wopi/editor-url` (the only path that would fetch it), so +# an unreachable URL keeps startup fast and hermetic. +OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml +OXICLOUD_WOPI_TOKEN_TTL_SECS=3600 OXICLOUD_OIDC_ENABLED=false OXICLOUD_NEXTCLOUD_ENABLED=true diff --git a/tests/common/wipe-storage.sh b/tests/common/wipe-storage.sh index 5ac8d1c5..4d0aff19 100644 --- a/tests/common/wipe-storage.sh +++ b/tests/common/wipe-storage.sh @@ -34,9 +34,10 @@ wipe_storage() { fi # Sanity check: must end in tests//storage where is - # lowercase alphanumeric. Stops `rm -rf` from ever running against - # an unexpected expansion of a callerʼs path. - if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then + # lowercase alphanumeric (hyphens allowed so multi-word runner names + # like `webdav-drive-root` pass). Stops `rm -rf` from ever running + # against an unexpected expansion of a caller's path. + if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then echo "[wipe_storage] ERROR: '$path' does not match .../tests//storage — refusing to wipe" >&2 return 1 fi diff --git a/tests/common/wopi_mock_discovery.js b/tests/common/wopi_mock_discovery.js new file mode 100644 index 00000000..88005321 --- /dev/null +++ b/tests/common/wopi_mock_discovery.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +// Minimal mock WOPI discovery server for the Hurl WOPI suite. +// +// Serves a valid RFC-shaped discovery XML on `GET /discovery.xml` so +// `OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:/discovery.xml` +// resolves to a real editor URL when `/api/wopi/editor-url` fetches it. +// +// The `urlsrc` we hand back points at a black-hole host so no real +// editor process needs to be running — the Hurl suite only asserts on +// OxiCloud's own responses (token contents, HTTP status codes, +// headers). The mock exists purely to let `get_editor_url` succeed +// end-to-end so we can exercise the mint-time authz path (Viewer- +// clicks-Edit gets a read-only token). +// +// Node stdlib only — matches the tooling used by tests/oidc/fake_idp +// (both are stdlib-free apart from `node-oidc-provider` on that side). +// No package.json, no npm install, no extra dependency for the api +// test suite. Started + reaped by `tests/api/run.sh`. Port comes from +// `WOPI_MOCK_PORT` env var (default 9100). + +'use strict'; + +const http = require('http'); + +const DISCOVERY_XML = ` + + + + + + + + + + + + + + + +`; + +const port = Number(process.env.WOPI_MOCK_PORT || 9100); + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/discovery.xml') { + res.writeHead(200, { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Length': Buffer.byteLength(DISCOVERY_XML), + }); + res.end(DISCOVERY_XML); + return; + } + res.writeHead(404); + res.end(); +}); + +// SIGTERM from `kill` in run.sh cleanup — exit quietly so the test +// runner's tail-of-log stays clean. +for (const sig of ['SIGTERM', 'SIGINT']) { + process.on(sig, () => server.close(() => process.exit(0))); +} + +server.listen(port, '127.0.0.1', () => { + console.log(`wopi-mock-discovery listening on 127.0.0.1:${port}`); +}); diff --git a/tests/fixtures/plugins/hello.wasm b/tests/fixtures/plugins/hello.wasm index f1518195..f9d10e2c 100755 Binary files a/tests/fixtures/plugins/hello.wasm and b/tests/fixtures/plugins/hello.wasm differ diff --git a/tests/fixtures/plugins/net.wasm b/tests/fixtures/plugins/net.wasm index d7100fa7..99ebc896 100755 Binary files a/tests/fixtures/plugins/net.wasm and b/tests/fixtures/plugins/net.wasm differ diff --git a/tests/fixtures/plugins/omit_login.wasm b/tests/fixtures/plugins/omit_login.wasm index 686e1812..ba4e3c1d 100755 Binary files a/tests/fixtures/plugins/omit_login.wasm and b/tests/fixtures/plugins/omit_login.wasm differ diff --git a/tests/fixtures/plugins/panic.wasm b/tests/fixtures/plugins/panic.wasm index 18f47360..d5b7c77e 100755 Binary files a/tests/fixtures/plugins/panic.wasm and b/tests/fixtures/plugins/panic.wasm differ diff --git a/tests/fixtures/plugins/sleep.wasm b/tests/fixtures/plugins/sleep.wasm index a6527a35..1eeea87e 100755 Binary files a/tests/fixtures/plugins/sleep.wasm and b/tests/fixtures/plugins/sleep.wasm differ diff --git a/tests/fixtures/plugins/wrong_abi.wasm b/tests/fixtures/plugins/wrong_abi.wasm index f3108bc3..99053578 100755 Binary files a/tests/fixtures/plugins/wrong_abi.wasm and b/tests/fixtures/plugins/wrong_abi.wasm differ diff --git a/tests/webdav-drive-root/drive_root_empty_config.hurl b/tests/webdav-drive-root/drive_root_empty_config.hurl new file mode 100644 index 00000000..5102e012 --- /dev/null +++ b/tests/webdav-drive-root/drive_root_empty_config.hurl @@ -0,0 +1,186 @@ +# ============================================================= +# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant +# ============================================================= +# Companion to `webdav_drive_root.hurl`. That file exercises the +# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this +# one exercises the empty-string config where `/webdav/` IS the +# drive listing and there's no default-drive shortcut. +# +# Server env for this test: `tests/common/server-webdav-drive-root.env` +# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that +# config is active — it is NOT part of the standard `run.sh` +# invocation (which starts the default-config server). +# +# Coverage: +# 1. Login, capture JWT +# 2. Resolve caller's default drive (id + display name) +# 3. Create a magic folder under the home root via REST +# 4. PROPFIND `/webdav/` — drive listing (default drive +# appears as a virtual child under its display name). +# 5. PROPFIND `/webdav//` — descend into a drive by +# UUID. Magic folder appears. +# 6. PROPFIND `/webdav//` — descend into a drive by +# display name. Magic folder appears. +# 7. `/webdav/@drive/` returns 404 in this mode — the sigil +# has no reserved meaning when `webdav_drive_listing_prefix=""`. +# A drive genuinely named `@drive` would resolve here; the +# 404 comes from "no such drive," not the sigil. +# 8. Cleanup: DELETE the magic folder via REST. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Resolve caller's default drive (id + display name). +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/drives +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +default_drive_id: jsonpath "$[0].id" +default_drive_name: jsonpath "$[0].name" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Resolve the caller's home root folder id. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/folders +Authorization: Bearer {{token}} + +HTTP 200 +[Captures] +home_folder_id: jsonpath "$[0].id" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Create a magic folder under the home root via REST. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/folders +Authorization: Bearer {{token}} +Content-Type: application/json +{ + "name": "hurl-drive-root-empty-magic-marker", + "parent_id": "{{home_folder_id}}" +} + +HTTP 201 +[Captures] +magic_folder_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPFIND on `/webdav/` (bare root). With +# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive +# listing — the default drive appears as a virtual +# child under its display name. The magic folder does +# NOT appear here (it lives one level deeper). +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists +# Magic folder is one level deeper — must NOT show up at root. +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists + + +# ───────────────────────────────────────────────────────────── +# Step 6 — PROPFIND on `/webdav//`. Descends into the +# default drive; magic folder is a top-level child. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_id}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPFIND on `/webdav//`. Same descent via +# display name. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/{{default_drive_name}}/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 207 +[Asserts] +xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists + + +# ───────────────────────────────────────────────────────────── +# Step 8 — `/webdav/@drive/` has no reserved meaning in the +# empty-config mode. `@drive` is treated as a plain +# drive selector; no drive by that name → 404. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/@drive/ +Authorization: Bearer {{token}} +Depth: 1 + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root). +# In the empty-config mode `/webdav/` IS the drive +# listing — there's no writable parent, so 405 +# Method Not Allowed. This guard prevents a client +# from creating something at "root" that shadows a +# drive name. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/ +Authorization: Bearer {{token}} + +HTTP 405 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Reject MKCOL at `/webdav/`. The first +# URL segment is the drive selector in this config; +# an unknown selector yields 404. A client cannot +# "create a drive" via MKCOL — the drive-create +# surface is `POST /api/drives`. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/hurl-not-a-real-drive +Authorization: Bearer {{token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Reject PUT at `/webdav//x.txt`. Same +# rejection shape as MKCOL. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +probe +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 12 — Cleanup: DELETE the magic folder via REST. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/folders/{{magic_folder_id}} +Authorization: Bearer {{token}} + +HTTP 204 diff --git a/tests/webdav-drive-root/run.sh b/tests/webdav-drive-root/run.sh new file mode 100755 index 00000000..fc8d4631 --- /dev/null +++ b/tests/webdav-drive-root/run.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# WebDAV drive-root URL-scheme variant runner. +# +# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the +# WebDAV `@drive` path segment is disabled and `/webdav/` IS the +# drive listing. `tests/api/webdav_drive_root.hurl` covers the +# default `"@drive"` config in the main API run; this runner +# starts a separately-configured server to cover the empty-string +# case, mirroring the OIDC runner's shape. +# +# Usage (from repo root): +# bash tests/webdav-drive-root/run.sh +# +# Prerequisites: docker, cargo, hurl ≥ 4.0 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMMON="$REPO_ROOT/tests/common" +TEST_DIR="$REPO_ROOT/tests/webdav-drive-root" + +# shellcheck source=test.env +source "$TEST_DIR/test.env" + +SERVER_PORT="${base_url##*:}" + +log() { echo "[webdav-drive-root] $*"; } +die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; } + +wait_for_http() { + local url="$1" timeout="${2:-60}" + local deadline=$(( $(date +%s) + timeout )) + until curl -sf "$url" >/dev/null 2>&1; do + [[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url" + sleep 1 + done +} + +# ── Teardown (always runs on exit) ──────────────────────────────────────────── + +SERVER_PID="" + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping OxiCloud server (pid $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + bash "$COMMON/stop-db.sh" +} + +trap cleanup EXIT + +# ── 1. Start postgres ───────────────────────────────────────────────────────── + +bash "$COMMON/spawn-db.sh" + +# ── 2. Load the drive-root-variant server env + port ────────────────────────── + +set -a +# shellcheck source=../common/server-webdav-drive-root.env +source "$COMMON/server-webdav-drive-root.env" +OXICLOUD_SERVER_PORT=$SERVER_PORT +OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage" +set +a + +# shellcheck source=../common/wipe-storage.sh +source "$COMMON/wipe-storage.sh" +wipe_storage "$OXICLOUD_STORAGE_PATH" + +# ── 3. Start OxiCloud server with the drive-root-variant config ─────────────── + +BUILD_TARGET="${BUILD_TARGET:-debug}" +OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud" + +if [[ ! -x "$OXICLOUD_BIN" ]]; then + log "Building OxiCloud server ($BUILD_TARGET)..." + 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 + +log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..." +"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" & +SERVER_PID=$! +log "Waiting for server at $base_url..." +wait_for_http "$base_url/ready" 120 +log "Server is ready." + +# ── 4. Run Hurl tests ───────────────────────────────────────────────────────── +# +# `setup.hurl` from the shared api/ suite bootstraps the initial admin +# account via `POST /api/setup` — the endpoint locks after the first +# admin exists, so it's a one-shot idempotency-by-server-state seed. +# We reuse the file rather than duplicating the setup body so credential +# / schema changes in the api tests automatically flow here. + +log "Running Hurl tests..." +hurl --variables-file "$TEST_DIR/test.env" \ + --file-root "$REPO_ROOT/tests" \ + --test --jobs 1 \ + "$REPO_ROOT/tests/api/setup.hurl" \ + "$TEST_DIR/drive_root_empty_config.hurl" + +log "webdav-drive-root tests passed." diff --git a/tests/webdav-drive-root/test.env b/tests/webdav-drive-root/test.env new file mode 100644 index 00000000..5da9de60 --- /dev/null +++ b/tests/webdav-drive-root/test.env @@ -0,0 +1,9 @@ +# Test credentials for the WebDAV drive-root variant runner — NOT real secrets. +# Runs on a separate port from tests/api and tests/webdav so a +# `just api-test` chain doesn't collide when the previous runner's +# teardown is still in progress. +base_url=http://localhost:8089 +username=admin +email=admin@example.com +# gitguardian:ignore +password=TestPassword1! diff --git a/tests/webdav/test_nc_move_copy_delete_trash.sh b/tests/webdav/test_nc_move_copy_delete_trash.sh index 4a26930c..3425754c 100755 --- a/tests/webdav/test_nc_move_copy_delete_trash.sh +++ b/tests/webdav/test_nc_move_copy_delete_trash.sh @@ -323,7 +323,20 @@ grep -q 'g8-doomed' <<< "$BODY" \ || fail "K1: g8-doomed.txt not in trashbin PROPFIND" grep -q '' <<< "$BODY" \ || fail "K1: trashbin response missing " -pass "K1: trashbin shows g8-doomed.txt with original-location" + +# Post-D3 (secondary/shared drive support): the `original-location` +# value is drive-relative — the emitter strips the drive-root segment +# from the internal `storage.folders.path` (`"Personal/g8-doomed.txt"` +# for a file at the default drive root) so NC clients see +# `"g8-doomed.txt"` regardless of what the drive's root is named. +# Regression guard: the pre-D3 code hardcoded `strip_prefix("Personal/")` +# — a bug that would silently break secondary drives. Assert the +# stripped shape (no leading `Personal/`, no leading `/`, no drive +# segment). +grep -q 'g8-doomed\.txt' <<< "$BODY" \ + || fail "K1: original-location not drive-relative (expected 'g8-doomed.txt', got: $(grep -o '[^<]*' <<< "$BODY"))" + +pass "K1: trashbin shows g8-doomed.txt with drive-relative original-location" # Extract the trashed item id (last segment of the href). # Trashbin hrefs are `/remote.php/dav/trashbin/{user}/trash/{uuid}` diff --git a/wasm/oxicloud-plugin-hello/src/lib.rs b/wasm/oxicloud-plugin-hello/src/lib.rs index cb6b5bce..37b9fa7f 100644 --- a/wasm/oxicloud-plugin-hello/src/lib.rs +++ b/wasm/oxicloud-plugin-hello/src/lib.rs @@ -37,8 +37,12 @@ pub fn abi_version() -> FnResult { } /// Handler for the `file.uploaded` event. +/// +/// `#[plugin_fn]` rewrites the fn signature, so an outer `#[allow]` doesn't +/// reach the inner scope where `input` is bound — hence the `_` prefix on the +/// parameter. The well-behaved tail rebinds it as `input` locally. #[plugin_fn] -pub fn on_file_uploaded(input: String) -> FnResult { +pub fn on_file_uploaded(_input: String) -> FnResult { // --- misbehaving variants (compiled in only under their feature) --------- #[cfg(feature = "panic")] panic!("intentional panic: exercises host failure isolation"); @@ -53,27 +57,33 @@ pub fn on_file_uploaded(input: String) -> FnResult { } } - #[cfg(feature = "net")] + // The well-behaved tail is unreachable under the diverging variants above; + // gate it so the compiler doesn't flag input/tail as unused/dead. + #[cfg(not(any(feature = "panic", feature = "sleep")))] { - // Attempt an outbound HTTP call. The host grants no `allowed_hosts`, so - // Extism denies this before any socket is opened (offline-deterministic) - // and the error propagates out of the handler. - let req = HttpRequest::new("https://example.com/"); - let _ = http::request::<()>(&req, None)?; - } + let input = _input; - // --- well-behaved path --------------------------------------------------- - let ev: serde_json::Value = serde_json::from_str(&input)?; - let path = ev["payload"]["path"].as_str().unwrap_or(""); - let size = ev["payload"]["size"].as_u64().unwrap_or(0); + #[cfg(feature = "net")] + { + // Attempt an outbound HTTP call. The host grants no `allowed_hosts`, + // so Extism denies this before any socket is opened + // (offline-deterministic) and the error propagates out. + let req = HttpRequest::new("https://example.com/"); + let _ = http::request::<()>(&req, None)?; + } - unsafe { - log( - "info".to_string(), - format!("hello plugin saw upload: {path} ({size} bytes)"), - )?; + let ev: serde_json::Value = serde_json::from_str(&input)?; + let path = ev["payload"]["path"].as_str().unwrap_or(""); + let size = ev["payload"]["size"].as_u64().unwrap_or(0); + + unsafe { + log( + "info".to_string(), + format!("hello plugin saw upload: {path} ({size} bytes)"), + )?; + } + Ok(serde_json::json!({ "ok": true }).to_string()) } - Ok(serde_json::json!({ "ok": true }).to_string()) } /// Handler for the `user.login` event. Dropped by the `omit_login` variant so