Files
Oxicloud/tests/api/contacts.hurl
T
2026-08-21 23:56:25 +02:00

733 lines
26 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================
# OxiCloud – Contacts API end-to-end scenario
# =============================================================
# Run:
# hurl --variables-file tests/api/hurl.vars --test tests/api/contacts.hurl
#
# Variables required (see hurl.vars):
# base_url – e.g. http://localhost:8087
# username – OxiCloud username
# password – OxiCloud password
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 – Login and capture the JWT token
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{
"username": "{{username}}",
"password": "{{password}}"
}
HTTP 200
[Captures]
token: jsonpath "$.access_token"
admin_user_id: jsonpath "$.user.full.user.id"
[Asserts]
jsonpath "$.access_token" isString
jsonpath "$.token_type" == "Bearer"
# ─────────────────────────────────────────────────────────────
# Step 2 – List address books (at least the system book)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
# System address book must always be present
jsonpath "$[?(@.id == 'system')].is_system" == true
jsonpath "$[?(@.id == 'system')].is_readonly" == true
# ─────────────────────────────────────────────────────────────
# Step 3 – Create a personal address book
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/address-books
Authorization: Bearer {{token}}
Content-Type: application/json
{
"name": "Personal",
"description": "Personal address book created by Hurl tests",
"is_public": false
}
HTTP 201
[Captures]
book_id: jsonpath "$.id"
[Asserts]
jsonpath "$.id" isString
jsonpath "$.name" == "Personal"
jsonpath "$.description" == "Personal address book created by Hurl tests"
jsonpath "$.is_public" == false
jsonpath "$.is_system" == false
jsonpath "$.is_readonly" == false
# ─────────────────────────────────────────────────────────────
# Step 4 – List contacts in the new book – must be empty
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" isCollection
jsonpath "$" count == 0
# ─────────────────────────────────────────────────────────────
# Step 5 – Create contact John Doe
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"email": [
{
"email": "john.doe@example.com",
"type": "work",
"is_primary": true
}
],
"phone": [
{
"number": "+1-555-0100",
"type": "mobile",
"is_primary": true
}
]
}
HTTP 201
[Captures]
contact_id: jsonpath "$.id"
[Asserts]
jsonpath "$.id" isString
jsonpath "$.address_book_id" == {{book_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.full_name" == "John Doe"
jsonpath "$.email" count == 1
jsonpath "$.email[0].email" == "john.doe@example.com"
jsonpath "$.email[0].type" == "work"
jsonpath "$.email[0].is_primary" == true
jsonpath "$.phone" count == 1
jsonpath "$.phone[0].number" == "+1-555-0100"
jsonpath "$.phone[0].type" == "mobile"
jsonpath "$.phone[0].is_primary" == true
# ─────────────────────────────────────────────────────────────
# Step 6 – List contacts – exactly 1 result, must be John Doe
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[*].id" contains {{contact_id}}
jsonpath "$[0].first_name" == "John"
jsonpath "$[0].last_name" == "Doe"
jsonpath "$[0].full_name" == "John Doe"
# ─────────────────────────────────────────────────────────────
# Step 7 – Get John Doe by id and verify all fields
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
HTTP 200
[Captures]
etag: header "ETag"
[Asserts]
header "ETag" exists
jsonpath "$.id" == {{contact_id}}
jsonpath "$.address_book_id" == {{book_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.full_name" == "John Doe"
jsonpath "$.email[0].email" == "john.doe@example.com"
jsonpath "$.email[0].type" == "work"
jsonpath "$.email[0].is_primary" == true
jsonpath "$.phone[0].number" == "+1-555-0100"
jsonpath "$.phone[0].type" == "mobile"
# ─────────────────────────────────────────────────────────────
# Step 8 – Update John Doe: add nickname, notes, and organisation
# Uses If-Match for optimistic concurrency
# Captures the refreshed ETag into etag_updated so that
# the original etag remains available as a stale value.
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
Content-Type: application/json
If-Match: {{etag}}
{
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"nickname": "JD",
"organization": "ACME Corp",
"notes": "Updated via Hurl test",
"email": [
{
"email": "john.doe@example.com",
"type": "work",
"is_primary": true
}
],
"phone": [
{
"number": "+1-555-0100",
"type": "mobile",
"is_primary": true
}
]
}
HTTP 200
[Captures]
etag_updated: header "ETag"
[Asserts]
header "ETag" exists
jsonpath "$.id" == {{contact_id}}
jsonpath "$.first_name" == "John"
jsonpath "$.last_name" == "Doe"
jsonpath "$.nickname" == "JD"
jsonpath "$.organization" == "ACME Corp"
jsonpath "$.notes" == "Updated via Hurl test"
# ─────────────────────────────────────────────────────────────
# Step 9 – Stale ETag rejection: update with the pre-step-8 ETag
# The contact was already modified so this must return 412
# ─────────────────────────────────────────────────────────────
PUT {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
Content-Type: application/json
If-Match: {{etag}}
{
"first_name": "John",
"last_name": "Doe",
"full_name": "John Doe",
"nickname": "should-not-be-saved"
}
HTTP 412
# ─────────────────────────────────────────────────────────────
# Step 10 – Delete John Doe (uses refreshed ETag from step 8)
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
Authorization: Bearer {{token}}
If-Match: {{etag_updated}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 11 – Address book must be empty again
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/{{book_id}}/contacts
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$" count == 0
# ─────────────────────────────────────────────────────────────
# Step 12 – Delete the personal address book
# ─────────────────────────────────────────────────────────────
DELETE {{base_url}}/api/address-books/{{book_id}}
Authorization: Bearer {{token}}
HTTP 204
# ─────────────────────────────────────────────────────────────
# Step 13 – Verify the address book is gone from the list
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$[*].id" not contains {{book_id}}
# ─────────────────────────────────────────────────────────────
# Step 14 – List the system address book (OxiCloud users)
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/address-books/system/contacts
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.full.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 has Read
# on the address book (viewer role) so graduated denial returns
# 403, not 404 (see [[project_authz_require_graduated_denial]]).
# 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 403
# 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. Bob has Read → graduated
# denial returns 403 (see [[project_authz_require_graduated_denial]]).
GET {{base_url}}/api/grants?resource_type=address_book&resource_id={{share_book_id}}
Authorization: Bearer {{bob_token}}
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 21d–21g — Regression pin for AuthZ audit #13 (2026-07-12).
#
# `ContactService::delete_contact` used to `authz.require(Update)`
# on the address book instead of `Delete`. Editor role bundle
# (Read + Comment + Create + Update) satisfies Update → any
# Editor grantee on a shared address book could delete individual
# contacts. Fix: swap the required Permission on delete_contact
# + delete_group to `Delete`. Sibling `CalendarService::delete_event`
# was the ground-truth pattern.
#
# The pin promotes Bob to Editor (so his bundle includes Update
# but NOT Delete — exactly the pre-fix bypass condition), seeds a
# canary contact as Alice, has Bob attempt DELETE, then confirms
# Alice still sees the contact. Pre-fix would 204; post-fix 403.
# ─────────────────────────────────────────────────────────────
# 21d — Promote Bob from Viewer to Editor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "editor"
}
HTTP 200
# 21e — Alice seeds a canary contact in the shared book.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{token}}
Content-Type: application/json
{
"full_name": "audit-13 delete-permission canary"
}
HTTP 201
[Captures]
audit13_contact_id: jsonpath "$.id"
# 21f — Bob (Editor) DELETE the canary → 403. Editor has Read
# so graduated denial fires with `visibility=visible`. Pre-fix
# this returned 204 because `require(Update)` succeeded on the
# Editor bundle.
DELETE {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{bob_token}}
HTTP 403
# 21g — Alice re-fetches to confirm the canary is still there
# (Bob's DELETE really was refused, not just responded to).
GET {{base_url}}/api/address-books/{{share_book_id}}/contacts/{{audit13_contact_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{audit13_contact_id}}"
# ─────────────────────────────────────────────────────────────
# Step 21h–21i — Regression pin for AuthZ audit #19 (2026-07-12).
#
# `ContactService::create_contact` + `create_contact_from_vcard`
# + `create_group` used to `authz.require(Update)` on the address
# book, which the Contributor bundle (Read + Create) does NOT
# satisfy — so Contributor grantees were blocked from adding
# contacts via REST or CardDAV PUT despite holding the intended
# Create permission. Not a bypass, an over-restrictive gate.
# Fix: `Permission::Create`. Sibling `#13` above closed the
# mirror bug on the delete verbs.
#
# The pin demotes Bob from Editor (Step 21d) to Contributor —
# Contributor is the minimal role that MUST succeed post-fix and
# FAILED pre-fix. Bob then POSTs a contact via REST; pre-fix this
# 403'd, post-fix returns 201.
# ─────────────────────────────────────────────────────────────
# 21h — Demote Bob from Editor to Contributor.
PUT {{base_url}}/api/grants/role
Authorization: Bearer {{token}}
Content-Type: application/json
{
"subject": { "type": "user", "id": "{{bob_user_id}}" },
"resource": { "type": "address_book", "id": "{{share_book_id}}" },
"role": "contributor"
}
HTTP 200
# 21i — Bob (Contributor) creates a contact → 201. Pre-fix, the
# service required Update which Contributor's bundle doesn't hold,
# so this 403'd and the CardDAV surface was equally blocked.
POST {{base_url}}/api/address-books/{{share_book_id}}/contacts
Authorization: Bearer {{bob_token}}
Content-Type: application/json
{
"full_name": "audit-19 contributor-can-create canary"
}
HTTP 201
[Captures]
audit19_contact_id: jsonpath "$.id"
# 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 `<D:href>/carddav/<uuid>/</D:href>`.
# 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 `<D:href>` value.
PROPFIND {{base_url}}/carddav/
Authorization: Bearer {{token}}
Depth: 1
Content-Type: application/xml
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:displayname/>
<D:resourcetype/>
</D:prop>
</D:propfind>
```
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
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
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
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
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
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
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