From a2ad7757c3de33f2a8e13765d1e4a7952a5c028c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 6 Jul 2026 08:28:00 +0200 Subject: [PATCH] feat(calendar,addressbook): add tests for authz --- src/interfaces/api/handlers/caldav_handler.rs | 12 + tests/api/calendar.hurl | 250 ++++++++++++++ tests/api/contacts.hurl | 315 ++++++++++++++++++ tests/api/run.sh | 1 + 4 files changed, 578 insertions(+) create mode 100644 tests/api/calendar.hurl diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index d0468e08..82b51207 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -346,6 +346,18 @@ async fn handle_propfind( .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") .body(Body::from(response_body)) .unwrap()) + } else if first_is_uuid { + // Path segment IS a UUID but the calendar isn't + // accessible to the caller — could be another + // owner's calendar or genuinely missing. Return + // 404 (anti-enum, matches every other OxiCloud + // surface post-D7). The pre-Round-3 fall-through + // silently listed the caller's OWN calendars, + // which was misleading (the URL claimed one calendar, + // response returned unrelated ones) and violated + // the anti-enumeration contract audited in + // `docs/plan/authz_audit/caldav_carddav_wopi.md`. + Err(AppError::not_found("Calendar not found")) } else { // Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/) // List all calendars for this user diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl new file mode 100644 index 00000000..cb505d19 --- /dev/null +++ b/tests/api/calendar.hurl @@ -0,0 +1,250 @@ +# ============================================================= +# 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" + + +# ───────────────────────────────────────────────────────────── +# 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 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..4a199da2 100644 --- a/tests/api/contacts.hurl +++ b/tests/api/contacts.hurl @@ -276,3 +276,318 @@ 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 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/run.sh b/tests/api/run.sh index b90d182f..86c82567 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -161,6 +161,7 @@ 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/public_shares.hurl" \ "$API_DIR/permissions.hurl" \ "$API_DIR/grants.hurl" \