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
This commit is contained in:
Edouard Vanbelle
2026-07-14 14:15:43 +02:00
parent 5e95d6dccf
commit 54b5b3bf4f
7 changed files with 663 additions and 48 deletions
@@ -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<CalendarStorageAdapter>,
/// Concrete engine — same reasoning as `PersonalDriveLifecycleHook`:
/// `AuthorizationEngine` isn't dyn-compatible (native async-fn-in-
/// trait), so we hold the concrete `PgAclEngine`.
authorization: Arc<PgAclEngine>,
/// 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<CalendarStorageAdapter>,
authorization: Arc<PgAclEngine>,
) -> 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(())
}
}
+154
View File
@@ -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<AddressBookPgRepository>,
contact_storage: Arc<ContactStorageAdapter>,
/// 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<PgAclEngine>,
/// 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<AddressBookPgRepository>,
contact_storage: Arc<ContactStorageAdapter>,
authorization: Arc<PgAclEngine>,
) -> 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(())
}
}
+64 -7
View File
@@ -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<AddressBookPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()),
);
let contact_repo_for_hook: Arc<ContactPgRepository> = Arc::new(
crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()),
);
let group_repo_for_hook: Arc<ContactGroupPgRepository> = 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<CalendarPgRepository> = 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,
+13 -4
View File
@@ -60,9 +60,14 @@ HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 3 – Alice PROPFIND at Depth 1 lists her calendars.
# The response is a `<D:multistatus>` — each calendar surfaces
# as `<D:href>/caldav/<uuid>/</D:href>`. Regex-capture the
# UUID (first `/caldav/<uuid>/` in the body — the root href
# is `/caldav/` alone, no UUID, so it can't match).
# as `<D:href>/caldav/<uuid>/</D:href>`. 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/<uuid>/` 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"
# ─────────────────────────────────────────────────────────────
+219
View File
@@ -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
```
<?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]
# 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
```
<?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 "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
```
<?xml version="1.0" encoding="UTF-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop><D:displayname/><D:resourcetype/></D:prop>
</D:propfind>
```
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 `<displayname>Personal</displayname>` 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 `<multistatus>`
# 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 *
+43 -37
View File
@@ -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\""
+1
View File
@@ -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" \