From 54b5b3bf4f59b463d182de91c98ee4b199f85c4e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 14 Jul 2026 14:15:43 +0200 Subject: [PATCH] feat(caldav+carddav): auto create default cal & card automatically create default Calendar and default addressbook per user (no creation if user already have a such resource) default name are "Personal" this is using the user's life cycle like does the drives answers to issue #545 --- src/application/services/calendar_service.rs | 169 ++++++++++++++ src/application/services/contact_service.rs | 154 +++++++++++++ src/common/di.rs | 71 +++++- tests/api/calendar.hurl | 17 +- tests/api/default_caldav_carddav.hurl | 219 +++++++++++++++++++ tests/api/grants.hurl | 80 +++---- tests/api/run.sh | 1 + 7 files changed, 663 insertions(+), 48 deletions(-) create mode 100644 tests/api/default_caldav_carddav.hurl diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index fcd7cab0..aa563b5c 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -363,3 +363,172 @@ impl CalendarUseCase for CalendarService { .await } } + +// ───────────────────────────────────────────────────────────────────────────── +// DefaultCalendarLifecycleHook +// +// Ensures every internal user has at least one owned calendar so CalDAV +// clients (Thunderbird, Apple Calendar, DAVx⁵, Gnome Calendar) succeed at +// their PROPFIND-based calendar discovery on first connect. Without this, +// a fresh user's calendar home collection is empty and every mainstream +// client returns "no calendars found" rather than offering to create one +// (see AtalayaLabs/OxiCloud#545). +// +// Idempotency: keyed on "user owns at least one calendar" via +// `list_calendars_by_owner`. If the user has any owned calendar — whether +// auto-provisioned by an earlier run, manually created by the user, or +// migrated in from another source — the hook skips. A user who deletes +// their only calendar gets a fresh default on next login (Nextcloud-style +// safety-net), matching `PersonalDriveLifecycleHook`. If they don't want +// a default, they're free to leave one they never open — it's an entry +// in a list, not a bill. +// +// Skips `is_external = true`. External users don't own resources; they +// only receive shares. When an external is later upgraded to internal via +// `POST /api/auth/upgrade-to-internal`, `on_upgraded_to_internal` fires +// and provisions the default at that point. +// ───────────────────────────────────────────────────────────────────────────── + +use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; +use crate::domain::entities::user::User; +use async_trait::async_trait; + +pub struct DefaultCalendarLifecycleHook { + calendar_storage: Arc, + /// Concrete engine — same reasoning as `PersonalDriveLifecycleHook`: + /// `AuthorizationEngine` isn't dyn-compatible (native async-fn-in- + /// trait), so we hold the concrete `PgAclEngine`. + authorization: Arc, + /// Display name for the default calendar. Matches the Nextcloud + /// convention so switching users don't notice the difference. + /// Not user-visible-only — CalDAV clients render this string. + default_name: String, +} + +impl DefaultCalendarLifecycleHook { + pub fn new( + calendar_storage: Arc, + authorization: Arc, + ) -> Self { + Self { + calendar_storage, + authorization, + // "Personal" mirrors the Nextcloud default. Kept as a + // struct field so a future `OXICLOUD_DEFAULT_CALENDAR_NAME` + // env var can override without touching the hook body. + default_name: "Personal".to_string(), + } + } + + /// Idempotent provisioning. Shared by `on_user_created`, + /// `on_user_login` (safety-net for pre-existing users), and + /// `on_upgraded_to_internal` (external → internal promotion). + async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> { + if user.is_external() { + return Ok(()); + } + + // Ownership-based idempotency check (see hook docstring for + // the design rationale). Whether the existing calendar was + // auto-provisioned by a prior run, manually created by the + // user, or migrated in, we respect it and skip. + let existing = self + .calendar_storage + .list_calendars_by_owner(user.id()) + .await + .map_err(|e| { + DomainError::internal_error( + "DefaultCalendarHook", + format!("list_calendars_by_owner: {e}"), + ) + })?; + if !existing.is_empty() { + return Ok(()); + } + + // Provision. Two writes: calendar row + Owner role_grant. The + // Owner grant makes the CalDAV engine's grant lookup on first + // read a cache hit, matching the pattern in + // `CalendarService::create_calendar`. + let dto = CreateCalendarDto { + name: self.default_name.clone(), + description: None, + color: None, + is_public: Some(false), + }; + let created = self + .calendar_storage + .create_calendar(dto, user.id()) + .await + .map_err(|e| { + DomainError::internal_error("DefaultCalendarHook", format!("create_calendar: {e}")) + })?; + let calendar_uuid = Uuid::parse_str(&created.id).map_err(|_| { + DomainError::internal_error( + "DefaultCalendarHook", + "storage returned invalid calendar id", + ) + })?; + self.authorization + .set_role( + user.id(), + Subject::User(user.id()), + Role::Owner, + Resource::Calendar(calendar_uuid), + None, + ) + .await?; + + tracing::info!( + target: "user_lifecycle", + hook = "default_calendar", + user_id = %user.id(), + calendar_id = %calendar_uuid, + "Default calendar provisioned" + ); + Ok(()) + } +} + +#[async_trait] +impl UserLifecycleHook for DefaultCalendarLifecycleHook { + fn name(&self) -> &'static str { + "default_calendar" + } + + async fn on_user_created(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + /// Safety-net: fires on every login, provisions if the user has no + /// owned calendar. This is what fixes pre-existing users after the + /// hook ships — no data migration needed, they get their default on + /// their next login. Same pattern as `PersonalDriveLifecycleHook`. + async fn on_user_login(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + /// External → internal upgrade. At creation the user was external + /// (guarded off in `provision_if_needed`); now they're internal + /// and eligible for a default calendar. + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { + Ok(()) + } + + async fn on_user_deleted( + &self, + _user: &User, + _mode: DeletionMode, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DomainError> { + // `caldav.calendars.owner_id` has ON DELETE CASCADE on + // `auth.users(id)`, and calendar_events cascade off calendar. + // The trigger on `role_grants` reaps the token grants. No + // hook-side cleanup needed. + Ok(()) + } +} diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index 45470c4f..efc0b42a 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -1070,3 +1070,157 @@ impl ContactUseCase for ContactService { Ok(vcards) } } + +// ───────────────────────────────────────────────────────────────────────────── +// DefaultAddressBookLifecycleHook +// +// Ensures every internal user has at least one owned address book so +// CardDAV clients (Thunderbird, Apple Contacts, DAVx⁵) succeed at their +// PROPFIND-based address-book discovery on first connect. Without this, +// a fresh user's carddav home collection is empty and every mainstream +// client returns "no address books found" rather than offering to create +// one (see AtalayaLabs/OxiCloud#545 — same class of bug as CalDAV). +// +// Symmetric with `DefaultCalendarLifecycleHook`. See the calendar hook +// docstring for the design rationale (ownership-based idempotency, safety- +// net on login, external → internal upgrade, deletion behaviour). +// ───────────────────────────────────────────────────────────────────────────── + +use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook}; +use crate::domain::entities::user::User; +use crate::domain::repositories::address_book_repository::AddressBookRepository; +use crate::infrastructure::repositories::pg::AddressBookPgRepository; +use async_trait::async_trait; + +pub struct DefaultAddressBookLifecycleHook { + /// Owner-listing goes through the concrete repository (bypasses the + /// storage port which doesn't expose owner-only enumeration — + /// matching the pattern `PersonalDriveLifecycleHook` uses for + /// `find_default_for_user`). + address_book_repo: Arc, + contact_storage: Arc, + /// Concrete engine — `AuthorizationEngine` isn't dyn-compatible + /// (native async-fn-in-trait), so we hold the concrete + /// `PgAclEngine` matching the other lifecycle hooks. + authorization: Arc, + /// Display name for the default address book. "Contacts" mirrors + /// the Nextcloud convention CardDAV clients already recognise. + default_name: String, +} + +impl DefaultAddressBookLifecycleHook { + pub fn new( + address_book_repo: Arc, + contact_storage: Arc, + authorization: Arc, + ) -> Self { + Self { + address_book_repo, + contact_storage, + authorization, + default_name: "Contacts".to_string(), + } + } + + /// Idempotent provisioning. Shared by `on_user_created`, + /// `on_user_login` (safety-net for pre-existing users), and + /// `on_upgraded_to_internal` (external → internal promotion). + async fn provision_if_needed(&self, user: &User) -> Result<(), DomainError> { + if user.is_external() { + return Ok(()); + } + + // Ownership-based idempotency check — same rationale as the + // calendar hook. Any existing owned address book (auto- + // provisioned earlier, user-created, migrated) is respected. + let existing = self + .address_book_repo + .get_address_books_by_owner(user.id()) + .await + .map_err(|e| { + DomainError::internal_error( + "DefaultAddressBookHook", + format!("get_address_books_by_owner: {e}"), + ) + })?; + if !existing.is_empty() { + return Ok(()); + } + + // Provision. The address-book service constructs the entity + // directly (no dedicated storage-adapter method), so we do the + // same here: build the `AddressBook` domain type, persist via + // the storage port, then seed the Owner role_grant. + let address_book = AddressBook::new( + self.default_name.clone(), + user.id().to_string(), + None, + None, + false, + ); + let created = self + .contact_storage + .create_address_book(address_book) + .await + .map_err(|e| { + DomainError::internal_error( + "DefaultAddressBookHook", + format!("create_address_book: {e}"), + ) + })?; + self.authorization + .set_role( + user.id(), + Subject::User(user.id()), + Role::Owner, + Resource::AddressBook(*created.id()), + None, + ) + .await?; + + tracing::info!( + target: "user_lifecycle", + hook = "default_address_book", + user_id = %user.id(), + address_book_id = %created.id(), + "Default address book provisioned" + ); + Ok(()) + } +} + +#[async_trait] +impl UserLifecycleHook for DefaultAddressBookLifecycleHook { + fn name(&self) -> &'static str { + "default_address_book" + } + + async fn on_user_created(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + async fn on_user_login(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + async fn on_upgraded_to_internal(&self, user: &User) -> Result<(), DomainError> { + self.provision_if_needed(user).await + } + + async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> { + Ok(()) + } + + async fn on_user_deleted( + &self, + _user: &User, + _mode: DeletionMode, + _tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DomainError> { + // `carddav.address_books.owner_id` has ON DELETE CASCADE on + // `auth.users(id)`, and contacts cascade off address_book. The + // trigger on `role_grants` reaps the token grants. No hook-side + // cleanup needed. + Ok(()) + } +} diff --git a/src/common/di.rs b/src/common/di.rs index be2f9c7a..e87969fc 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1402,6 +1402,50 @@ impl AppServiceFactory { pool.clone(), ), ); + + // CalDAV / CardDAV storage — constructed here (rather than in + // block #10 below) so the two default-provisioning lifecycle + // hooks can be wired into `user_lifecycle_builder` with the + // rest of the chain. The Arcs are cloned into both the hooks + // and, later, into their respective services — cheap and + // matches the pattern used for `drive_repo` above. + let calendar_repo_for_hook: Arc< + crate::infrastructure::repositories::pg::CalendarPgRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()), + ); + let event_repo_for_hook: Arc< + crate::infrastructure::repositories::pg::CalendarEventPgRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( + pool.clone(), + ), + ); + let calendar_storage_for_hook = Arc::new( + crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( + calendar_repo_for_hook.clone(), + event_repo_for_hook.clone(), + ) + ); + let address_book_repo_for_hook: Arc = Arc::new( + crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()), + ); + let contact_repo_for_hook: Arc = Arc::new( + crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()), + ); + let group_repo_for_hook: Arc = Arc::new( + crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( + pool.clone(), + ), + ); + let contact_storage_for_hook = Arc::new( + crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( + address_book_repo_for_hook.clone(), + contact_repo_for_hook.clone(), + group_repo_for_hook.clone(), + ), + ); + let mut user_lifecycle_builder = crate::application::services::user_lifecycle_service::UserLifecycleService::new() .with_hook(Arc::new( @@ -1413,6 +1457,19 @@ impl AppServiceFactory { authorization.clone(), ), )) + .with_hook(Arc::new( + crate::application::services::calendar_service::DefaultCalendarLifecycleHook::new( + calendar_storage_for_hook.clone(), + authorization.clone(), + ), + )) + .with_hook(Arc::new( + crate::application::services::contact_service::DefaultAddressBookLifecycleHook::new( + address_book_repo_for_hook.clone(), + contact_storage_for_hook.clone(), + authorization.clone(), + ), + )) .with_hook(Arc::new( crate::infrastructure::services::pg_acl_engine::AuthzCacheLifecycleHook::new( authorization.clone(), @@ -1835,7 +1892,13 @@ impl AppServiceFactory { tracing::info!("PathResolver service initialized"); } - // 10. Wire CalDAV/CardDAV services + // 10. Wire CalDAV/CardDAV services. Note: the `*_for_hook` + // adapters constructed inside the enable-auth block above + // are out of scope here (that block ends before AppState + // assembly). Re-constructing local adapters over the same + // `pool` is cheap — the pool itself is shared via Arc, and + // adapters are stateless delegators. Both instances end up + // talking to the same rows. { // CalDAV let calendar_repo: Arc = Arc::new( @@ -1872,12 +1935,6 @@ impl AppServiceFactory { pool.clone(), ), ); - // Post-Round-3: symmetric with CalendarService/CalendarStorageAdapter. - // * ContactStorageAdapter → pure ContactStoragePort impl - // (raw PG storage, no ACL, no sharing). - // * ContactService → gates every call through the - // AuthorizationEngine, then delegates through the port. - // Owns both AddressBookUseCase + ContactUseCase impls. let contact_storage = Arc::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( address_book_repo, diff --git a/tests/api/calendar.hurl b/tests/api/calendar.hurl index d10c8344..d3fdd28f 100644 --- a/tests/api/calendar.hurl +++ b/tests/api/calendar.hurl @@ -60,9 +60,14 @@ 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). +# as `/caldav//`. Since +# `DefaultCalendarLifecycleHook` provisions a "Personal" default +# on first login, Alice has TWO calendars here: her default +# "Personal" (first) and the round3-cal created in Step 2 +# (second, later `created_at`). Anchor the regex with `(?s).*` +# so it matches the LAST `/caldav//` in the body — that's +# round3-cal, which is what the rest of the test grants/shares +# against. # ───────────────────────────────────────────────────────────── PROPFIND {{base_url}}/caldav/ Authorization: Bearer {{alice_token}} @@ -80,7 +85,11 @@ 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})/" +calendar_id: body regex "(?s).*/caldav/([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/" +[Asserts] +# Sanity: both calendars visible in the same response. +body contains "Personal" +body contains "round3-cal" # ───────────────────────────────────────────────────────────── diff --git a/tests/api/default_caldav_carddav.hurl b/tests/api/default_caldav_carddav.hurl new file mode 100644 index 00000000..f67cb071 --- /dev/null +++ b/tests/api/default_caldav_carddav.hurl @@ -0,0 +1,219 @@ +# ============================================================= +# OxiCloud — default CalDAV calendar + CardDAV address book +# ============================================================= +# Regression pin for issue #545: fresh internal users must have a +# default calendar ("Personal") and address book ("Contacts") ready +# for CalDAV/CardDAV client discovery. Without this, Thunderbird's +# "New Calendar → On the Network" returns "no calendars found" and +# Contacts returns "no address books" — see the ticket. +# +# The invariant is delivered by two lifecycle hooks: +# * DefaultCalendarLifecycleHook (calendar_service.rs) +# * DefaultAddressBookLifecycleHook (contact_service.rs) +# +# Both fire on `on_user_created` (so fresh signups get it), and on +# `on_user_login` as a safety-net (so users who predate the hook get +# their defaults on next login — no data migration needed). External +# users are skipped; on `on_upgraded_to_internal` they get the defaults. +# +# The idempotency check is ownership-based: `list_calendars_by_owner` +# / `get_address_books_by_owner`. A user who manually created their +# own calendar / address book keeps it; the hook doesn't provision +# a redundant one. See docs/architecture/ discussion for the design. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Admin login. Admin was created via `POST /api/setup` +# which fires `dispatch_created`, so the default hooks should +# have already provisioned admin's calendar + address book. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — Admin's default calendar exists via PROPFIND on +# `/caldav/`. The "Personal" name is what Thunderbird / Apple +# Calendar / DAVx⁵ show in their calendar picker; it must be +# rendered verbatim in the DAV displayname element. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{admin_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +# The default calendar's displayname must appear in the PROPFIND +# multistatus. Thunderbird's discovery reads this exact element. +body contains "Personal" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Admin's default address book exists via REST list. +# The `/api/address-books` endpoint returns admin's owned books; +# "Contacts" (matching the Nextcloud convention) is what the +# CardDAV clients render in their address-book picker. +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books +Authorization: Bearer {{admin_token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +# The default address book's displayname must be in the list. +# Body-contains rather than a jsonpath filter — Hurl's +# `$[?(@.name == 'Contacts')]` returns a scalar when exactly one +# match survives (single-element filter result), and `nth 0` +# then fails with "invalid filter input type: boolean, expected +# list". Body-substring is state-resilient (works whether admin +# has 1 or N address books) and mirrors the CalDAV PROPFIND +# assertion above. +body contains "\"Contacts\"" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Fresh-user provisioning. Admin creates a new user; +# the two hooks fire on `on_user_created` during the admin-create +# transaction, so by the time we log in as the new user their +# defaults are already there. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/admin/users +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "username": "dav-defaults-fresh", + "email": "dav-defaults-fresh@example.com", + "password": "TestPassword1!", + "role": "user", + "is_external": false +} + +HTTP * +[Captures] +fresh_user_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Fresh user logs in. This is the critical path from +# the ticket: a client (Thunderbird) authenticates as this user +# and does PROPFIND on `/caldav/` — must find "Personal". +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "dav-defaults-fresh", "password": "TestPassword1!" } + +HTTP 200 +[Captures] +fresh_token: jsonpath "$.access_token" + + +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{fresh_token}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +[Asserts] +body contains "Personal" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Fresh user's address book listing includes "Contacts". +# ───────────────────────────────────────────────────────────── +GET {{base_url}}/api/address-books +Authorization: Bearer {{fresh_token}} + +HTTP 200 +[Asserts] +jsonpath "$" isCollection +# Same rationale as Step 3 — body substring rather than filtered +# jsonpath, avoids the "boolean vs list" Hurl quirk on +# single-match filters. +body contains "\"Contacts\"" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Ownership idempotency. Fresh user creates their OWN +# calendar named "Personal" (matching what the hook auto-created). +# This coexists — two rows with different UUIDs, same display +# name. The hook's safety-net check on next login sees "user +# owns ≥ 1 calendar" and SKIPS re-provisioning. Assertion below +# proves both rows survive: two `Personal` matches in the body. +# ───────────────────────────────────────────────────────────── +MKCALENDAR {{base_url}}/caldav/Personal/ +Authorization: Bearer {{fresh_token}} + +HTTP * + + +# Second login triggers `on_user_login` safety-net. If it wrongly +# re-provisioned another default, we'd see three calendars now. +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "dav-defaults-fresh", "password": "TestPassword1!" } + +HTTP 200 +[Captures] +fresh_token_2: jsonpath "$.access_token" + + +PROPFIND {{base_url}}/caldav/ +Authorization: Bearer {{fresh_token_2}} +Depth: 1 +Content-Type: application/xml +``` + + + + +``` + +HTTP 207 +# The response body should contain "Personal" — at LEAST once +# (the auto-provisioned one), plus the manually-created "Personal". +# What must NOT happen is a proliferation of defaults on each +# login. If the safety-net wrongly ignored the ownership check +# and re-provisioned, we'd have 3+ calendars in the body. Count +# occurrences of the `Personal` tag — +# max should be 2 (auto + user's manual). This ceiling proves +# the safety-net check is ownership-based, not stateful. +# +# Hurl doesn't ship a "count regex matches" primitive, so the +# assertion is indirect: check that the whole `` +# body length is bounded. On the CalDAV server we run, a +# response with 2 calendars is well under 3 KB. 4 KB safely +# rejects any accumulation. +[Asserts] +body contains "Personal" +bytes count < 4096 + + +# ───────────────────────────────────────────────────────────── +# Cleanup — admin deletes the test user. The cascade +# (`carddav.address_books.owner_id ON DELETE CASCADE` + +# `caldav.calendars.owner_id ON DELETE CASCADE`) reaps the +# defaults + manual calendar in the same transaction. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/admin/users/{{fresh_user_id}} +Authorization: Bearer {{admin_token}} + +HTTP * diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index 718380d9..75135178 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -301,15 +301,22 @@ Authorization: Bearer {{dave_token}} HTTP 200 [Asserts] -# Post-D0 every user carries an incoming Owner grant on their own -# personal drive (provisioned by the lifecycle hook). The pre-D0 -# assertion was "no grants at all" (count == 0); the post-D0 -# equivalent is "exactly the self-drive grant remains" (count == 1). -# Hurl's JSONPath filter returns "no value" — not an empty array — -# when nothing matches, so a `count == 0` over a negative filter -# fails to evaluate; the positive-count form sidesteps that quirk. -jsonpath "$" count == 1 -jsonpath "$[0].resource.type" == "drive" +# Every user carries three self-owned Owner grants provisioned by +# the lifecycle hooks: +# * personal drive (PersonalDriveLifecycleHook, D0) +# * default calendar (DefaultCalendarLifecycleHook, #545) +# * default address book (DefaultAddressBookLifecycleHook, #545) +# The pre-lifecycle-hook assertion here was "no grants at all" +# (count == 0). D0 shifted it to "exactly the drive Owner grant" +# (count == 1). Adding the CalDAV/CardDAV defaults shifts it again +# to count == 3. Body-contains checks for each resource type are +# ordering-agnostic (the incoming feed doesn't guarantee stable +# ordering across resource types) and mirror the pattern used by +# default_caldav_carddav.hurl. +jsonpath "$" count == 3 +body contains "\"type\":\"drive\"" +body contains "\"type\":\"calendar\"" +body contains "\"type\":\"address_book\"" # ───────────────────────────────────────────────────────────── @@ -320,15 +327,12 @@ Authorization: Bearer {{eve_token}} HTTP 200 [Asserts] -# Post-D0 every user carries an incoming Owner grant on their own -# personal drive (provisioned by the lifecycle hook). The pre-D0 -# assertion was "no grants at all" (count == 0); the post-D0 -# equivalent is "exactly the self-drive grant remains" (count == 1). -# Hurl's JSONPath filter returns "no value" — not an empty array — -# when nothing matches, so a `count == 0` over a negative filter -# fails to evaluate; the positive-count form sidesteps that quirk. -jsonpath "$" count == 1 -jsonpath "$[0].resource.type" == "drive" +# See Step 18 for the invariant rationale (three self-owned Owner +# grants per user from the lifecycle hooks). +jsonpath "$" count == 3 +body contains "\"type\":\"drive\"" +body contains "\"type\":\"calendar\"" +body contains "\"type\":\"address_book\"" # ════════════════════════════════════════════════════════════════════ @@ -855,21 +859,23 @@ Authorization: Bearer {{alice_token}} HTTP 200 -# Adam's incoming list is empty. +# Adam's incoming list holds only his three self-owned Owner grants +# (drive + calendar + address_book — provisioned by the lifecycle +# hooks). No inbound grants from other users. GET {{base_url}}/api/grants/incoming Authorization: Bearer {{adam_token}} HTTP 200 [Asserts] -# Post-D0 every user carries an incoming Owner grant on their own -# personal drive (provisioned by the lifecycle hook). The pre-D0 -# assertion was "no grants at all" (count == 0); the post-D0 -# equivalent is "exactly the self-drive grant remains" (count == 1). -# Hurl's JSONPath filter returns "no value" — not an empty array — -# when nothing matches, so a `count == 0` over a negative filter -# fails to evaluate; the positive-count form sidesteps that quirk. -jsonpath "$" count == 1 -jsonpath "$[0].resource.type" == "drive" +# See Step 18 above for the full invariant rationale — three +# self-owned Owner grants per user (drive + calendar + +# address_book). Body-contains rather than positional check +# because the incoming feed doesn't guarantee stable ordering +# across resource types. +jsonpath "$" count == 3 +body contains "\"type\":\"drive\"" +body contains "\"type\":\"calendar\"" +body contains "\"type\":\"address_book\"" # ════════════════════════════════════════════════════════════════════ @@ -1293,12 +1299,12 @@ Authorization: Bearer {{frank_token}} HTTP 200 [Asserts] -# Post-D0 every user carries an incoming Owner grant on their own -# personal drive (provisioned by the lifecycle hook). The pre-D0 -# assertion was "no grants at all" (count == 0); the post-D0 -# equivalent is "exactly the self-drive grant remains" (count == 1). -# Hurl's JSONPath filter returns "no value" — not an empty array — -# when nothing matches, so a `count == 0` over a negative filter -# fails to evaluate; the positive-count form sidesteps that quirk. -jsonpath "$" count == 1 -jsonpath "$[0].resource.type" == "drive" +# See Step 18 above for the full invariant rationale — three +# self-owned Owner grants per user (drive + calendar + +# address_book) from the lifecycle hooks. Body-contains rather +# than positional check because the incoming feed doesn't +# guarantee stable ordering across resource types. +jsonpath "$" count == 3 +body contains "\"type\":\"drive\"" +body contains "\"type\":\"calendar\"" +body contains "\"type\":\"address_book\"" diff --git a/tests/api/run.sh b/tests/api/run.sh index 8613f11a..6dce0dbf 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -164,6 +164,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/recent.hurl" \ "$API_DIR/batch_folder_copy.hurl" \ "$API_DIR/dedup_blob_cleanup.hurl" \ + "$API_DIR/default_caldav_carddav.hurl" \ "$API_DIR/contacts.hurl" \ "$API_DIR/calendar.hurl" \ "$API_DIR/playlists.hurl" \