diff --git a/.gitignore b/.gitignore index 69dc400..b61556c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,11 @@ docker/auth/*.local # npm / pnpm node_modules .pnpm-store +# npm cache lands here because the frontend Dockerfile sets +# NPM_CONFIG_CACHE=/home/frontend/.npm (so npm can write a cache without +# needing /.npm/_logs at the root). When the host bind-mounts +# ./src/frontend onto /home/frontend, the cache ends up on the host. +.npm/ # Mails src/backend/core/templates/mail/ diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile index af9ae03..76fc0a1 100644 --- a/src/frontend/Dockerfile +++ b/src/frontend/Dockerfile @@ -23,6 +23,23 @@ COPY ./apps/calendars ./apps/calendars WORKDIR /home/frontend +# Make the image and any anonymous volumes derived from it writable by +# any host user whose group ID is `node` (1000). Compose runs the +# container as `${DOCKER_USER:-1000}` which resolves to the host +# uid:gid — on macOS that's typically 501:20 / 501:1000, on Linux often +# 1000:1000. Owning everything by `node:node` plus `g+w` lets npm +# rename/rewrite packages from either case, so `make install-front` +# Just Works without a root-owned anonymous-volume fight. +# +# Also pre-create the npm cache + log directories under HOME so npm +# doesn't try to write to `/.npm/_logs` (which it can't, as a non-root +# user with no $HOME). +RUN mkdir -p /home/frontend/.npm \ + && chown -R node:node /home/frontend \ + && chmod -R g+w /home/frontend +ENV HOME=/home/frontend +ENV NPM_CONFIG_CACHE=/home/frontend/.npm + ### ---- Front-end builder image ---- FROM frontend-deps AS calendars diff --git a/src/frontend/apps/calendars/package.json b/src/frontend/apps/calendars/package.json index 4f5637f..0171bff 100644 --- a/src/frontend/apps/calendars/package.json +++ b/src/frontend/apps/calendars/package.json @@ -20,30 +20,20 @@ "@gouvfr-lasuite/cunningham-react": "4.2.0", "@gouvfr-lasuite/ui-kit": "0.19.10", "@tanstack/react-query": "5.90.21", - "@tanstack/react-table": "8.21.3", - "@viselect/react": "3.9.0", "clsx": "2.1.1", "date-fns": "4.1.0", "i18next": "25.8.14", "i18next-browser-languagedetector": "8.2.1", - "ical.js": "2.2.1", "next": "16.1.6", - "next-i18next": "15.4.3", - "pretty-bytes": "7.1.0", "react": "19.2.4", "react-dom": "19.2.4", - "react-dropzone": "15.0.0", - "react-hook-form": "7.71.2", "react-i18next": "16.5.6", "react-toastify": "11.0.5", "sass": "1.97.3", - "ts-ics": "2.4.3", - "tsdav": "2.1.8" + "ts-ics": "2.4.3" }, "devDependencies": { "@gouvfr-lasuite/cunningham-tokens": "3.1.0", - "@tanstack/eslint-plugin-query": "5.91.4", - "@tanstack/react-query-devtools": "5.91.3", "@types/jest": "30.0.0", "@types/node": "24.12.0", "@types/react": "19.2.14", @@ -51,6 +41,7 @@ "eslint": "9.30.1", "eslint-config-next": "16.1.6", "jest": "30.2.0", + "jest-environment-jsdom": "30.2.0", "ts-jest": "29.4.6", "typescript": "5.9.3" } diff --git a/src/frontend/apps/calendars/src/features/api/fetchApi.ts b/src/frontend/apps/calendars/src/features/api/fetchApi.ts index dc52272..6985c93 100644 --- a/src/frontend/apps/calendars/src/features/api/fetchApi.ts +++ b/src/frontend/apps/calendars/src/features/api/fetchApi.ts @@ -20,12 +20,30 @@ export const SESSION_STORAGE_REDIRECT_AFTER_LOGIN_URL = /** * Redirect to the login page, saving the current URL for post-login redirect. * Called on any 401 response to handle expired sessions. + * + * Idempotent — if several concurrent requests all return 401 (e.g. multiple + * React Query refetches on window focus), only the first call actually + * persists the URL and triggers navigation; later calls are no-ops so the + * stored `redirect_after_login_url` doesn't get overwritten by whatever + * the URL happens to be mid-navigation. */ +let redirectInFlight = false; export function redirectToLogin() { - sessionStorage.setItem( - SESSION_STORAGE_REDIRECT_AFTER_LOGIN_URL, - window.location.href, - ); + if (redirectInFlight) return; + redirectInFlight = true; + // Persisting the current URL is best-effort: Safari Private Browsing, + // a full storage quota, or a `SecurityError` from a sandboxed context + // can all throw here. Treat that as "we just won't restore the URL + // after login" — never let it suppress the actual navigation, which + // is what unsticks the session. + try { + sessionStorage.setItem( + SESSION_STORAGE_REDIRECT_AFTER_LOGIN_URL, + window.location.href, + ); + } catch { + // intentionally swallow — the redirect itself is what matters. + } window.location.replace(new URL("authenticate/", baseApiUrl()).href); } diff --git a/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx b/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx index 42cca54..5ac2745 100644 --- a/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx +++ b/src/frontend/apps/calendars/src/features/calendar/contexts/CalendarContext.tsx @@ -11,7 +11,7 @@ import { import { useTranslation } from "react-i18next"; import { CalDavService } from "../services/dav/CalDavService"; import { EventCalendarAdapter } from "../services/dav/EventCalendarAdapter"; -import { caldavServerUrl, headers, fetchOptions } from "../utils/DavClient"; +import { caldavServerUrl } from "../utils/DavClient"; import type { CalDavCalendar, CalDavCalendarCreate, @@ -415,8 +415,6 @@ export const CalendarContextProvider = ({ try { const result = await caldavService.connect({ serverUrl: caldavServerUrl, - headers, - fetchOptions, userEmail, }); if (isMounted && result.success) { diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts index c15ea1c..1b1525f 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/CalDavService.ts @@ -16,17 +16,7 @@ import { type IcsDateObject, type IcsEvent, } from 'ts-ics' -import { - createAccount, - fetchCalendarObjects as davFetchCalendarObjects, - createCalendarObject as davCreateCalendarObject, - updateCalendarObject as davUpdateCalendarObject, - deleteCalendarObject as davDeleteCalendarObject, - makeCalendar as davMakeCalendar, - DAVNamespaceShort, - type DAVCalendarObject, - davRequest, -} from 'tsdav' +import { davRequest } from '@/features/calendar/utils/DavClient' import type { CalDavCredentials, CalDavAccount, @@ -40,16 +30,9 @@ import type { EventFilter, CalDavShareInvite, CalDavShareResponse, - CalDavSharee, - CalDavInvitation, CalDavResponse, - SyncReport, - SyncOptions, FreeBusyRequest, FreeBusyResponse, - CalendarAcl, - CalDavPrincipal, - SchedulingRequest, SchedulingResponse, CalDavAttendee, } from './types/caldav-service' @@ -58,20 +41,17 @@ import { buildProppatchXml, buildShareRequestXml, buildUnshareRequestXml, - buildInviteReplyXml, - buildSyncCollectionXml, - buildPrincipalSearchXml, - executeDavRequest, + buildMkCalendarXml, + buildCalendarQueryXml, escapeXml, CALENDAR_PROPS, - propfindLs, + NS, parseCalendarComponents, - parseSharePrivilege, parseInviteSharees, parseInviteOrganizerEmail, parseCalendarOrder, getCalendarUrlFromEventUrl, - withErrorHandling, + asResult, type ShareeXmlParams, type CalendarProps, } from './caldav-helpers' @@ -88,10 +68,10 @@ export class CalDavService { // ============================================================================ async connect(credentials: CalDavCredentials): Promise> { - return withErrorHandling(async () => { - // Fast path: skip tsdav discovery (.well-known redirect + two PROPFINDs) - // when we know the user's email. SabreDAV exposes principals and - // calendar homes at fixed paths (see src/caldav/server.php). + return asResult(async () => { + // SabreDAV exposes principals and calendar homes at fixed paths + // derived from the user's email (see src/caldav/server.php), so we + // can build the account locally without round-tripping discovery. // // The path segment encoding must match what SabreDAV emits in its // PROPFIND hrefs — otherwise the hardcoded homeUrl won't be a @@ -100,49 +80,25 @@ export class CalDavService { // URLUtil::encodePath leaves RFC 3986 sub-delims and `@` literal, // whereas encodeURIComponent escapes `@` (→ %40) and `+` (→ %2B), // so we put those two back. - if (credentials.userEmail) { - const serverUrl = credentials.serverUrl.endsWith('/') - ? credentials.serverUrl - : `${credentials.serverUrl}/` - const encodedEmail = encodeURIComponent(credentials.userEmail) - .replace(/%40/g, '@') - .replace(/%2B/g, '+') - this._account = { - serverUrl, - rootUrl: serverUrl, - principalUrl: `${serverUrl}principals/users/${encodedEmail}/`, - homeUrl: `${serverUrl}calendars/users/${encodedEmail}/`, - headers: credentials.headers, - fetchOptions: credentials.fetchOptions, - } - return this._account - } - - const account = await createAccount({ - account: { - serverUrl: credentials.serverUrl, - accountType: 'caldav', - }, - headers: credentials.headers, - fetchOptions: credentials.fetchOptions, - }) - - if (!account.homeUrl) { - throw new Error( - 'CalDAV discovery failed: calendar home URL not found. ' + - 'The server may not have a calendar provisioned for this user.' - ) - } - + // + // Defensive runtime check — TypeScript marks `userEmail` as required, + // but callers could pass `undefined` at runtime (JSON, dynamic, etc). + // Without this, `encodeURIComponent(undefined)` yields the literal + // string `"undefined"` and silently produces a bogus homeUrl. + if (!credentials.userEmail) { + throw new Error('CalDAV connect requires a userEmail') + } + const serverUrl = credentials.serverUrl.endsWith('/') + ? credentials.serverUrl + : `${credentials.serverUrl}/` + const encodedEmail = encodeURIComponent(credentials.userEmail) + .replace(/%40/g, '@') + .replace(/%2B/g, '+') this._account = { - serverUrl: credentials.serverUrl, - rootUrl: account.rootUrl, - principalUrl: account.principalUrl, - homeUrl: account.homeUrl, - headers: credentials.headers, - fetchOptions: credentials.fetchOptions, + serverUrl, + principalUrl: `${serverUrl}principals/users/${encodedEmail}/`, + homeUrl: `${serverUrl}calendars/users/${encodedEmail}/`, } - return this._account }, 'Failed to connect') } @@ -167,23 +123,25 @@ export class CalDavService { return { success: false, error: 'Calendar home URL not available' } } - return withErrorHandling(async () => { - const responses = await propfindLs({ + return asResult(async () => { + const result = await davRequest({ url: this._account!.homeUrl!, + method: 'PROPFIND', props: CALENDAR_PROPS, depth: '1', - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, }) + if (!result.success || !result.responses) { + throw new Error(result.error ?? 'Failed to fetch calendars') + } - const calendars: CalDavCalendar[] = responses + const calendars: CalDavCalendar[] = result.responses .filter((r) => Object.keys( (r.props?.resourcetype ?? {}) as Record, ).includes('calendar'), ) .map((rs) => this.parseCalendarPropfindResponse( - new URL(rs.href ?? '', this._account!.rootUrl ?? '').href, + new URL(rs.href ?? '', this._account!.serverUrl).href, rs.props, )) @@ -253,24 +211,24 @@ export class CalDavService { resourcetype: props?.resourcetype ? Object.keys(props.resourcetype as Record) : undefined, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, } } async fetchCalendar(calendarUrl: string): Promise> { - return withErrorHandling(async () => { - const response = await propfindLs({ + return asResult(async () => { + const result = await davRequest({ url: calendarUrl, + method: 'PROPFIND', props: CALENDAR_PROPS, depth: '0', - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, }) + if (!result.success || !result.responses) { + throw new Error(result.error ?? `Calendar not found: ${result.status}`) + } - const rs = response[0] - if (!rs.ok) { - throw new Error(`Calendar not found: ${rs.status}`) + const rs = result.responses[0] + if (!rs?.ok) { + throw new Error(`Calendar not found: ${rs?.status}`) } const calendar = this.parseCalendarPropfindResponse(calendarUrl, rs.props) @@ -285,41 +243,26 @@ export class CalDavService { return { success: false, error: 'Not connected or home URL not available' } } - return withErrorHandling(async () => { + return asResult(async () => { const calendarUrl = `${this._account!.homeUrl}${crypto.randomUUID()}/` - // Build props for makeCalendar - const props: Record = { - displayname: params.displayName, - } - - if (params.description) { - props[`${DAVNamespaceShort.CALDAV}:calendar-description`] = params.description - } - - if (params.color) { - props[`${DAVNamespaceShort.CALDAV_APPLE}:calendar-color`] = params.color - } - - if (params.timezone) { - props[`${DAVNamespaceShort.CALDAV}:calendar-timezone`] = params.timezone - } + const body = buildMkCalendarXml({ + displayName: params.displayName, + description: params.description, + color: params.color, + timezone: params.timezone, + }) - // Use tsdav's makeCalendar - const responses = await davMakeCalendar({ + const result = await davRequest({ url: calendarUrl, - props, - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, + method: 'MKCALENDAR', + body, }) - // Check response - const response = responses[0] - if (response && !response.ok && response.status && response.status >= 400) { - throw new Error(`Failed to create calendar: ${response.status}`) + if (!result.success) { + throw new Error(result.error ?? `Failed to create calendar: ${result.status}`) } - // Fetch the created calendar to get all properties const calendarResult = await this.fetchCalendar(calendarUrl) if (!calendarResult.success || !calendarResult.data) { throw new Error(calendarResult.error || 'Failed to fetch created calendar') @@ -349,12 +292,10 @@ export class CalDavService { } const body = buildProppatchXml(proppatchParams) - const result = await executeDavRequest({ + const result = await davRequest({ url: calendarUrl, method: 'PROPPATCH', body, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, }) if (!result.success) { @@ -365,29 +306,23 @@ export class CalDavService { } async deleteCalendar(calendarUrl: string): Promise { - const result = await executeDavRequest({ + const result = await davRequest({ url: calendarUrl, method: 'DELETE', - body: '', - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, }) if (result.success) { this._calendars.delete(calendarUrl) + return { success: true } } - return result + return { success: false, error: result.error, status: result.status } } getCalendar(calendarUrl: string): CalDavCalendar | undefined { return this._calendars.get(calendarUrl) } - getCalendars(): CalDavCalendar[] { - return Array.from(this._calendars.values()) - } - // ============================================================================ // Event CRUD Operations // ============================================================================ @@ -398,7 +333,7 @@ export class CalDavService { return { success: false, error: 'Calendar not found in cache. Fetch calendars first.' } } - return withErrorHandling(async () => { + return asResult(async () => { const timeRange = filter?.timeRange ? { start: @@ -412,24 +347,33 @@ export class CalDavService { } : undefined - const davObjects = await davFetchCalendarObjects({ - calendar: { - url: calendar.url, - ctag: calendar.ctag, - syncToken: calendar.syncToken, - }, + const body = buildCalendarQueryXml({ timeRange, expand: filter?.expand ?? false, - headers: calendar.headers, - fetchOptions: calendar.fetchOptions, }) - const events: CalDavEvent[] = davObjects.map((obj) => ({ - url: obj.url, - etag: obj.etag, - calendarUrl, - data: convertIcsCalendar(undefined, obj.data), - })) + const result = await davRequest({ + url: calendar.url, + method: 'REPORT', + body, + depth: '1', + }) + if (!result.success || !result.responses) { + throw new Error(result.error ?? 'Failed to fetch events') + } + + const events: CalDavEvent[] = result.responses + .filter((r) => r.ok && r.props?.calendarData) + .map((r) => { + const url = new URL(r.href ?? '', this._account!.serverUrl).href + const ics = r.props?.calendarData as string + return { + url, + etag: r.props?.getetag as string | undefined, + calendarUrl, + data: convertIcsCalendar(undefined, ics), + } + }) events.forEach((evt) => this._events.set(evt.url, evt)) return events @@ -498,22 +442,21 @@ export class CalDavService { exdateToAdd: Date, etag?: string ): Promise> { - return withErrorHandling(async () => { + return asResult(async () => { // Fetch the raw ICS file - const fetchResponse = await fetch(eventUrl, { + const fetchResult = await davRequest({ + url: eventUrl, method: 'GET', - headers: { - Accept: 'text/calendar', - ...this._account?.headers, - }, - ...this._account?.fetchOptions, + headers: { Accept: 'text/calendar' }, }) - if (!fetchResponse.ok) { - throw new Error(`Failed to fetch event: ${fetchResponse.status}`) + if (!fetchResult.success || fetchResult.body === undefined) { + throw new Error( + fetchResult.error ?? `Failed to fetch event: ${fetchResult.status}`, + ) } - const icsText = await fetchResponse.text() + const icsText = fetchResult.body // Parse ICS into structured object const calendar = convertIcsCalendar(undefined, icsText) @@ -563,22 +506,21 @@ export class CalDavService { const updatedIcsText = generateIcsCalendar(calendar) // PUT the updated event back - const updateResponse = await fetch(eventUrl, { + const updateResult = await davRequest({ + url: eventUrl, method: 'PUT', - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - ...(etag ? { 'If-Match': etag } : {}), - ...this._account?.headers, - }, body: updatedIcsText, - ...this._account?.fetchOptions, + contentType: 'text/calendar; charset=utf-8', + headers: etag ? { 'If-Match': etag } : undefined, }) - if (!updateResponse.ok) { - throw new Error(`Failed to update event: ${updateResponse.status}`) + if (!updateResult.success) { + throw new Error( + updateResult.error ?? `Failed to update event: ${updateResult.status}`, + ) } - const newEtag = updateResponse.headers.get('ETag') || undefined + const newEtag = updateResult.responseHeaders?.get('ETag') || undefined return { etag: newEtag } }, 'Failed to add EXDATE to event') @@ -597,22 +539,21 @@ export class CalDavService { uid: string, etag?: string, ): Promise> { - return withErrorHandling(async () => { + return asResult(async () => { // Fetch the raw ICS - const fetchResponse = await fetch(eventUrl, { + const fetchResult = await davRequest({ + url: eventUrl, method: 'GET', - headers: { - Accept: 'text/calendar', - ...this._account?.headers, - }, - ...this._account?.fetchOptions, + headers: { Accept: 'text/calendar' }, }) - if (!fetchResponse.ok) { - throw new Error(`Failed to fetch event: ${fetchResponse.status}`) + if (!fetchResult.success || fetchResult.body === undefined) { + throw new Error( + fetchResult.error ?? `Failed to fetch event: ${fetchResult.status}`, + ) } - const icsText = await fetchResponse.text() + const icsText = fetchResult.body const calendar = convertIcsCalendar(undefined, icsText) const sourceEvent = calendar.events?.find( @@ -673,22 +614,21 @@ export class CalDavService { const updatedIcsText = generateIcsCalendar(calendar) // PUT the updated ICS - const updateResponse = await fetch(eventUrl, { + const updateResult = await davRequest({ + url: eventUrl, method: 'PUT', - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - ...(etag ? { 'If-Match': etag } : {}), - ...this._account?.headers, - }, body: updatedIcsText, - ...this._account?.fetchOptions, + contentType: 'text/calendar; charset=utf-8', + headers: etag ? { 'If-Match': etag } : undefined, }) - if (!updateResponse.ok) { - throw new Error(`Failed to update event: ${updateResponse.status}`) + if (!updateResult.success) { + throw new Error( + updateResult.error ?? `Failed to update event: ${updateResult.status}`, + ) } - const newEtag = updateResponse.headers.get('ETag') || undefined + const newEtag = updateResult.responseHeaders?.get('ETag') || undefined return { etag: newEtag } }, 'Failed to delete override instance') } @@ -705,22 +645,21 @@ export class CalDavService { uid: string, etag?: string, ): Promise> { - return withErrorHandling(async () => { + return asResult(async () => { // Fetch the raw ICS - const fetchResponse = await fetch(eventUrl, { + const fetchResult = await davRequest({ + url: eventUrl, method: 'GET', - headers: { - Accept: 'text/calendar', - ...this._account?.headers, - }, - ...this._account?.fetchOptions, + headers: { Accept: 'text/calendar' }, }) - if (!fetchResponse.ok) { - throw new Error(`Failed to fetch event: ${fetchResponse.status}`) + if (!fetchResult.success || fetchResult.body === undefined) { + throw new Error( + fetchResult.error ?? `Failed to fetch event: ${fetchResult.status}`, + ) } - const icsText = await fetchResponse.text() + const icsText = fetchResult.body const calendar = convertIcsCalendar(undefined, icsText) const sourceEvent = calendar.events?.find( @@ -771,22 +710,21 @@ export class CalDavService { const updatedIcsText = generateIcsCalendar(calendar) // PUT the updated ICS - const updateResponse = await fetch(eventUrl, { + const updateResult = await davRequest({ + url: eventUrl, method: 'PUT', - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - ...(etag ? { 'If-Match': etag } : {}), - ...this._account?.headers, - }, body: updatedIcsText, - ...this._account?.fetchOptions, + contentType: 'text/calendar; charset=utf-8', + headers: etag ? { 'If-Match': etag } : undefined, }) - if (!updateResponse.ok) { - throw new Error(`Failed to update event: ${updateResponse.status}`) + if (!updateResult.success) { + throw new Error( + updateResult.error ?? `Failed to update event: ${updateResult.status}`, + ) } - const newEtag = updateResponse.headers.get('ETag') || undefined + const newEtag = updateResult.responseHeaders?.get('ETag') || undefined return { etag: newEtag } }, 'Failed to truncate recurring series') } @@ -804,22 +742,21 @@ export class CalDavService { occurrenceDate: Date, etag?: string, ): Promise> { - return withErrorHandling(async () => { + return asResult(async () => { // Fetch the raw ICS - const fetchResponse = await fetch(eventUrl, { + const fetchResult = await davRequest({ + url: eventUrl, method: 'GET', - headers: { - Accept: 'text/calendar', - ...this._account?.headers, - }, - ...this._account?.fetchOptions, + headers: { Accept: 'text/calendar' }, }) - if (!fetchResponse.ok) { - throw new Error(`Failed to fetch event: ${fetchResponse.status}`) + if (!fetchResult.success || fetchResult.body === undefined) { + throw new Error( + fetchResult.error ?? `Failed to fetch event: ${fetchResult.status}`, + ) } - const icsText = await fetchResponse.text() + const icsText = fetchResult.body const calendar = convertIcsCalendar(undefined, icsText) const sourceEvent = calendar.events?.find( (e) => e.uid === overrideEvent.uid && !e.recurrenceId, @@ -898,49 +835,46 @@ export class CalDavService { const updatedIcsText = generateIcsCalendar(calendar) // PUT the updated ICS - const updateResponse = await fetch(eventUrl, { + const updateResult = await davRequest({ + url: eventUrl, method: 'PUT', - headers: { - 'Content-Type': 'text/calendar; charset=utf-8', - ...(etag ? { 'If-Match': etag } : {}), - ...this._account?.headers, - }, body: updatedIcsText, - ...this._account?.fetchOptions, + contentType: 'text/calendar; charset=utf-8', + headers: etag ? { 'If-Match': etag } : undefined, }) - if (!updateResponse.ok) { - throw new Error(`Failed to update event: ${updateResponse.status}`) + if (!updateResult.success) { + throw new Error( + updateResult.error ?? `Failed to update event: ${updateResult.status}`, + ) } - const newEtag = updateResponse.headers.get('ETag') || undefined + const newEtag = updateResult.responseHeaders?.get('ETag') || undefined return { etag: newEtag } }, 'Failed to create override instance') } async fetchEvent(eventUrl: string): Promise> { - return withErrorHandling(async () => { - const fetchResponse = await fetch(eventUrl, { + return asResult(async () => { + const result = await davRequest({ + url: eventUrl, method: 'GET', - headers: { - Accept: 'text/calendar', - ...this._account?.headers, - }, - ...this._account?.fetchOptions, + headers: { Accept: 'text/calendar' }, }) - if (!fetchResponse.ok) { - throw new Error(`Event not found: ${fetchResponse.status}`) + if (!result.success || result.body === undefined) { + throw new Error( + result.error ?? `Event not found: ${result.status}`, + ) } - const icsData = await fetchResponse.text() const calendarUrl = getCalendarUrlFromEventUrl(eventUrl) const event: CalDavEvent = { url: eventUrl, - etag: fetchResponse.headers.get('etag') ?? undefined, + etag: result.responseHeaders?.get('etag') ?? undefined, calendarUrl, - data: convertIcsCalendar(undefined, icsData), + data: convertIcsCalendar(undefined, result.body), } this._events.set(event.url, event) @@ -954,7 +888,7 @@ export class CalDavService { return { success: false, error: 'Calendar not found' } } - return withErrorHandling(async () => { + return asResult(async () => { const event = { ...params.event } if (!event.uid) { event.uid = crypto.randomUUID() @@ -969,26 +903,22 @@ export class CalDavService { this.validateTimezones(icsCalendar) const iCalString = generateIcsCalendar(icsCalendar) - const response = await davCreateCalendarObject({ - calendar: { - url: calendar.url, - ctag: calendar.ctag, - syncToken: calendar.syncToken, - }, - iCalString, - filename: `${event.uid}.ics`, - headers: calendar.headers, - fetchOptions: calendar.fetchOptions, + const eventUrl = `${params.calendarUrl}${event.uid}.ics` + const response = await davRequest({ + url: eventUrl, + method: 'PUT', + body: iCalString, + contentType: 'text/calendar; charset=utf-8', + headers: { 'If-None-Match': '*' }, }) - if (!response.ok) { - throw new Error(`Failed to create event: ${response.status}`) + if (!response.success) { + throw new Error(response.error ?? `Failed to create event: ${response.status}`) } - const eventUrl = `${params.calendarUrl}${event.uid}.ics` const createdEvent: CalDavEvent = { url: eventUrl, - etag: response.headers.get('etag') ?? undefined, + etag: response.responseHeaders?.get('etag') ?? undefined, calendarUrl: params.calendarUrl, data: icsCalendar, } @@ -1007,7 +937,7 @@ export class CalDavService { return { success: false, error: 'Calendar not found' } } - return withErrorHandling(async () => { + return asResult(async () => { const icsCalendar: IcsCalendar = cachedEvent?.data ?? { prodId: '-//CalDavService//NONSGML v1.0//EN', version: '2.0', @@ -1085,25 +1015,23 @@ export class CalDavService { this.validateTimezones(icsCalendar) const iCalString = generateIcsCalendar(icsCalendar) - const davObject: DAVCalendarObject = { - url: params.eventUrl, - etag: params.etag ?? cachedEvent?.etag, - data: iCalString, - } + const ifMatchEtag = params.etag ?? cachedEvent?.etag - const response = await davUpdateCalendarObject({ - calendarObject: davObject, - headers: calendar.headers, - fetchOptions: calendar.fetchOptions, + const response = await davRequest({ + url: params.eventUrl, + method: 'PUT', + body: iCalString, + contentType: 'text/calendar; charset=utf-8', + headers: ifMatchEtag ? { 'If-Match': ifMatchEtag } : undefined, }) - if (!response.ok) { - throw new Error(`Failed to update event: ${response.status}`) + if (!response.success) { + throw new Error(response.error ?? `Failed to update event: ${response.status}`) } const updatedEvent: CalDavEvent = { url: params.eventUrl, - etag: response.headers.get('etag') ?? undefined, + etag: response.responseHeaders?.get('etag') ?? undefined, calendarUrl, data: icsCalendar, } @@ -1135,11 +1063,8 @@ export class CalDavService { } const cachedSource = this._events.get(params.sourceEventUrl) - const sourceCalendarUrl = - cachedSource?.calendarUrl ?? getCalendarUrlFromEventUrl(params.sourceEventUrl) - const sourceCalendar = this._calendars.get(sourceCalendarUrl) - return withErrorHandling(async () => { + return asResult(async () => { // Strip trailing slashes before splitting so a stray collection-shaped // URL (ends with `/`) doesn't yield an empty filename that we'd then // concatenate onto the target — producing the target collection URL @@ -1154,35 +1079,21 @@ export class CalDavService { const newEventUrl = `${params.targetCalendarUrl}${filename}` const sourceEtag = params.sourceEtag ?? cachedSource?.etag - // The source calendar may not be in the cache (stale state, refresh - // race, or a source URL whose calendar key doesn't normalize to a - // cached entry). Fall back to the target calendar's auth/fetch - // settings so credentials are always sent — both calendars belong - // to the same CalDAV account, so the headers are interchangeable. - const fetchOptions = { - ...targetCalendar.fetchOptions, - ...sourceCalendar?.fetchOptions, - } - const authHeaders = { - ...targetCalendar.headers, - ...sourceCalendar?.headers, - } - const response = await fetch(params.sourceEventUrl, { - ...fetchOptions, + const response = await davRequest({ + url: params.sourceEventUrl, method: 'MOVE', headers: { - ...authHeaders, Destination: newEventUrl, Overwrite: 'F', ...(sourceEtag ? { 'If-Match': sourceEtag } : {}), }, }) - if (!response.ok) { - throw new Error(`Failed to move event: ${response.status}`) + if (!response.success) { + throw new Error(response.error ?? `Failed to move event: ${response.status}`) } - const newEtag = response.headers.get('etag') ?? undefined + const newEtag = response.responseHeaders?.get('etag') ?? undefined if (cachedSource) { this._events.delete(params.sourceEventUrl) @@ -1200,29 +1111,18 @@ export class CalDavService { async deleteEvent(eventUrl: string, etag?: string): Promise { const cachedEvent = this._events.get(eventUrl) - const calendarUrl = cachedEvent?.calendarUrl ?? getCalendarUrlFromEventUrl(eventUrl) - const calendar = this._calendars.get(calendarUrl) - return withErrorHandling(async () => { + return asResult(async () => { const resolvedEtag = etag ?? cachedEvent?.etag - const davObject: DAVCalendarObject = { + const response = await davRequest({ url: eventUrl, - etag: resolvedEtag, - data: cachedEvent?.data ? generateIcsCalendar(cachedEvent.data) : '', - } - - const response = await davDeleteCalendarObject({ - calendarObject: davObject, - headers: { - ...(resolvedEtag ? { 'If-Match': resolvedEtag } : {}), - ...calendar?.headers, - }, - fetchOptions: calendar?.fetchOptions, + method: 'DELETE', + headers: resolvedEtag ? { 'If-Match': resolvedEtag } : undefined, }) - if (!response.ok && response.status !== 204) { - throw new Error(`Failed to delete event: ${response.status}`) + if (!response.success) { + throw new Error(response.error ?? `Failed to delete event: ${response.status}`) } this._events.delete(eventUrl) @@ -1230,14 +1130,6 @@ export class CalDavService { }, 'Failed to delete event') } - getEvent(eventUrl: string): CalDavEvent | undefined { - return this._events.get(eventUrl) - } - - getEventsForCalendar(calendarUrl: string): CalDavEvent[] { - return Array.from(this._events.values()).filter((e) => e.calendarUrl === calendarUrl) - } - // ============================================================================ // Calendar Sharing (CalDAV Sharing Extension) // ============================================================================ @@ -1256,12 +1148,10 @@ export class CalDavService { const body = buildShareRequestXml(shareeParams) - const result = await executeDavRequest({ + const result = await davRequest({ url: params.calendarUrl, method: 'POST', body, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, }) if (!result.success) { @@ -1280,192 +1170,21 @@ export class CalDavService { async unshareCalendar(calendarUrl: string, shareeHref: string): Promise { const body = buildUnshareRequestXml(shareeHref) - return executeDavRequest({ + const result = await davRequest({ url: calendarUrl, method: 'POST', body, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, }) - } - - async getShareInvitations(): Promise> { - if (!this._account?.homeUrl) { - return { success: false, error: 'Not connected' } - } - - return withErrorHandling(async () => { - const response = await propfindLs({ - url: this._account!.homeUrl!, - props: { [`${DAVNamespaceShort.CALENDAR_SERVER}:notification-URL`]: {} }, - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, - depth: '0', - }) - - const notificationUrl = response[0]?.props?.['notification-URL']?.href - if (!notificationUrl) { - return [] - } - - const notificationsResponse = await propfindLs({ - url: notificationUrl, - props: { [`${DAVNamespaceShort.CALENDAR_SERVER}:notification`]: {} }, - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, - depth: '1', - }) - - const invitations: CalDavInvitation[] = [] - for (const item of notificationsResponse) { - const notification = item.props?.notification - if (notification?.['invite-notification']) { - const invite = notification['invite-notification'] - invitations.push({ - uid: invite.uid || crypto.randomUUID(), - calendarUrl: invite['hosturl']?.href || '', - ownerHref: invite['organizer']?.href || '', - ownerDisplayName: invite['organizer']?.['common-name'], - summary: invite.summary, - privilege: parseSharePrivilege(invite['access']), - status: 'pending', - }) - } - } - - return invitations - }, 'Failed to get invitations') - } - - async acceptShareInvitation( - invitationUid: string, - inReplyTo: string - ): Promise> { - return this.respondToShareInvitation(invitationUid, inReplyTo, true) - } - async declineShareInvitation(invitationUid: string, inReplyTo: string): Promise { - const result = await this.respondToShareInvitation(invitationUid, inReplyTo, false) - return { success: result.success, error: result.error, status: result.status } - } - - private async respondToShareInvitation( - _invitationUid: string, - inReplyTo: string, - accept: boolean - ): Promise> { - if (!this._account?.homeUrl) { - return { success: false, error: 'Not connected' } - } - - const body = buildInviteReplyXml(inReplyTo, accept) - - const result = await executeDavRequest({ - url: this._account.homeUrl, - method: 'POST', - body, - headers: this._account.headers, - fetchOptions: this._account.fetchOptions, - }) - - if (!result.success) { - return { success: false, error: result.error, status: result.status } - } - - if (accept) { - await this.fetchCalendars() - } - - return { success: true } - } - - /** - * Re-fetch the calendar and return its sharees. ``CS:invite`` is now - * part of the standard calendar PROPFIND, so this is a thin wrapper - * over ``fetchCalendar`` — kept as a public method to preserve the - * existing API surface and to give callers a single round-trip way - * to refresh the share list after an invite/update/delete. - */ - async getCalendarSharees(calendarUrl: string): Promise> { - const result = await this.fetchCalendar(calendarUrl) - if (!result.success || !result.data) { - return { success: false, error: result.error } - } - return { success: true, data: result.data.sharees ?? [] } + return result.success + ? { success: true } + : { success: false, error: result.error, status: result.status } } // ============================================================================ // Scheduling (iTIP - RFC 5546) // ============================================================================ - async sendSchedulingRequest(request: SchedulingRequest): Promise> { - if (!this._account) { - return { success: false, error: 'Not connected' } - } - - return withErrorHandling(async () => { - const event = { ...request.event } - event.organizer = { - email: request.organizer.email, - name: request.organizer.name, - } - event.attendees = request.attendees.map((att) => ({ - email: att.email, - name: att.name, - role: att.role, - partstat: att.partstat ?? 'NEEDS-ACTION', - rsvp: att.rsvp, - cutype: att.cutype, - })) - - const icsCalendar: IcsCalendar = { - prodId: '-//CalDavService//NONSGML v1.0//EN', - version: '2.0', - method: request.method, - events: [event], - } - - this.validateTimezones(icsCalendar) - const iCalString = generateIcsCalendar(icsCalendar) - - const outboxUrl = await this.findSchedulingOutbox() - if (!outboxUrl) { - throw new Error('Scheduling outbox not found') - } - - // Construct full URL - outboxUrl from PROPFIND is an absolute path (e.g. /caldav/calendars/...) - // so we only need to prepend the origin, not the full serverUrl (which already has /caldav/) - const fullOutboxUrl = outboxUrl.startsWith('http') - ? outboxUrl - : `${new URL(this._account!.serverUrl).origin}${outboxUrl}` - - // Use fetch directly to avoid davRequest URL construction issues in dev mode - // Note: fetchOptions is spread first so its headers don't override our Content-Type - const response = await fetch(fullOutboxUrl, { - ...this._account!.fetchOptions, - method: 'POST', - headers: { - ...this._account!.headers, - 'Content-Type': 'text/calendar; charset=utf-8; method=' + request.method, - }, - body: iCalString, - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`Failed to send scheduling request: ${response.status} - ${errorText}`) - } - - return { - success: true, - responses: request.attendees.map((att) => ({ - recipient: att.email, - status: 'delivered' as const, - })), - } - }, 'Failed to send scheduling request') - } - async respondToMeeting( eventUrl: string, event: IcsEvent, @@ -1523,7 +1242,7 @@ export class CalDavService { return { success: false, error: 'Not connected' } } - return withErrorHandling(async () => { + return asResult(async () => { const outboxUrl = await this.findSchedulingOutbox() if (!outboxUrl) { throw new Error('Scheduling outbox not found') @@ -1537,23 +1256,20 @@ export class CalDavService { ? outboxUrl : `${new URL(this._account!.serverUrl).origin}${outboxUrl}` - // Note: fetchOptions is spread first so its headers don't override our Content-Type - const response = await fetch(fullOutboxUrl, { - ...this._account!.fetchOptions, + const response = await davRequest({ + url: fullOutboxUrl, method: 'POST', - headers: { - ...this._account!.headers, - 'Content-Type': 'text/calendar; charset=utf-8', - }, body: fbRequest, + contentType: 'text/calendar; charset=utf-8', }) - if (!response.ok) { - throw new Error(`Failed to query free/busy: ${response.status}`) + if (!response.success || response.body === undefined) { + throw new Error( + response.error ?? `Failed to query free/busy: ${response.status}`, + ) } - const xmlText = await response.text() - return parseScheduleFreeBusyResponse(xmlText) + return parseScheduleFreeBusyResponse(response.body) }, 'Failed to query free/busy') } @@ -1571,18 +1287,20 @@ export class CalDavService { return { success: false, error: 'Not connected' } } - return withErrorHandling(async () => { - const response = await propfindLs({ + return asResult(async () => { + const result = await davRequest({ url: this._account!.homeUrl!, + method: 'PROPFIND', props: { - [`${DAVNamespaceShort.CALDAV}:calendar-availability`]: {}, + [`${NS.CALDAV}:calendar-availability`]: {}, }, - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, depth: '0', }) + if (!result.success || !result.responses) { + throw new Error(result.error ?? 'Failed to get availability') + } - return response[0]?.props?.['calendarAvailability'] ?? null + return result.responses[0]?.props?.['calendarAvailability'] ?? null }, 'Failed to get availability') } @@ -1607,147 +1325,14 @@ export class CalDavService { ` - return executeDavRequest({ + const result = await davRequest({ url: this._account.homeUrl, method: 'PROPPATCH', body, - headers: this._account.headers, - fetchOptions: this._account.fetchOptions, }) - } - - // ============================================================================ - // Sync Operations - // ============================================================================ - - async syncCalendar(calendarUrl: string, options?: SyncOptions): Promise> { - const calendar = this._calendars.get(calendarUrl) - if (!calendar) { - return { success: false, error: 'Calendar not found' } - } - - return withErrorHandling(async () => { - const syncToken = options?.syncToken ?? calendar.syncToken ?? '' - const body = buildSyncCollectionXml({ syncToken, syncLevel: options?.syncLevel }) - - const responses = await davRequest({ - url: calendarUrl, - init: { - method: 'REPORT', - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - Depth: '1', - ...calendar.headers, - }, - body, - }, - fetchOptions: calendar.fetchOptions, - }) - - const response = responses[0] - if (!response?.ok) { - throw new Error(`Failed to sync calendar: ${response?.status}`) - } - - const newSyncToken = (response.props?.['sync-token'] as string) ?? '' - - return { - syncToken: newSyncToken, - changed: [], - deleted: [], - } - }, 'Failed to sync calendar') - } - - // ============================================================================ - // ACL Operations - // ============================================================================ - - async getCalendarAcl(calendarUrl: string): Promise> { - return withErrorHandling(async () => { - const response = await propfindLs({ - url: calendarUrl, - props: { - [`${DAVNamespaceShort.DAV}:acl`]: {}, - [`${DAVNamespaceShort.DAV}:owner`]: {}, - }, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, - depth: '0', - }) - - const rs = response[0] - if (!rs.ok) { - throw new Error(`Failed to get ACL: ${rs.status}`) - } - - return { - calendarUrl, - entries: [], - ownerHref: rs.props?.owner?.href, - } - }, 'Failed to get ACL') - } - - // ============================================================================ - // Principal Operations - // ============================================================================ - - async getPrincipal(principalUrl?: string): Promise> { - const url = principalUrl ?? this._account?.principalUrl - if (!url) { - return { success: false, error: 'Principal URL not available' } - } - - return withErrorHandling(async () => { - const response = await propfindLs({ - url, - props: { - [`${DAVNamespaceShort.DAV}:displayname`]: {}, - [`${DAVNamespaceShort.CALDAV}:calendar-home-set`]: {}, - [`${DAVNamespaceShort.CARDDAV}:addressbook-home-set`]: {}, - [`${DAVNamespaceShort.CALENDAR_SERVER}:email-address-set`]: {}, - }, - headers: this._account?.headers, - fetchOptions: this._account?.fetchOptions, - depth: '0', - }) - - const rs = response[0] - if (!rs.ok) { - throw new Error(`Failed to get principal: ${rs.status}`) - } - - return { - url, - displayName: rs.props?.displayname?._cdata ?? rs.props?.displayname, - email: rs.props?.['email-address-set']?.['email-address'], - calendarHomeSet: rs.props?.['calendar-home-set']?.href, - addressBookHomeSet: rs.props?.['addressbook-home-set']?.href, - } - }, 'Failed to get principal') - } - - async searchPrincipals(query: string): Promise> { - if (!this._account?.principalUrl) { - return { success: false, error: 'Not connected' } - } - - const body = buildPrincipalSearchXml(query) - - const result = await executeDavRequest({ - url: this._account.principalUrl, - method: 'REPORT', - body, - headers: { Depth: '0', ...this._account.headers }, - fetchOptions: this._account.fetchOptions, - }) - - if (!result.success) { - return { success: false, error: result.error, status: result.status } - } - - return { success: true, data: [] } + return result.success + ? { success: true } + : { success: false, error: result.error, status: result.status } } // ============================================================================ @@ -1790,16 +1375,15 @@ export class CalDavService { if (!this._account?.principalUrl) return null try { - const response = await propfindLs({ + const result = await davRequest({ url: this._account.principalUrl, - props: { [`${DAVNamespaceShort.CALDAV}:schedule-outbox-URL`]: {} }, - headers: this._account.headers, - fetchOptions: this._account.fetchOptions, + method: 'PROPFIND', + props: { [`${NS.CALDAV}:schedule-outbox-URL`]: {} }, depth: '0', }) // Note: tsdav converts XML property names to camelCase - return response[0]?.props?.['scheduleOutboxURL']?.href ?? null + return result.responses?.[0]?.props?.['scheduleOutboxURL']?.href ?? null } catch { return null } @@ -1820,20 +1404,24 @@ export class CalDavService { return { success: false, error: 'Not connected or principal URL not found' } } - return withErrorHandling(async () => { - const response = await propfindLs({ + return asResult(async () => { + const result = await davRequest({ url: this._account!.principalUrl!, + method: 'PROPFIND', props: { - [`${DAVNamespaceShort.CALDAV}:schedule-outbox-URL`]: {}, - [`${DAVNamespaceShort.CALDAV}:schedule-inbox-URL`]: {}, - [`${DAVNamespaceShort.CALDAV}:calendar-user-address-set`]: {}, + [`${NS.CALDAV}:schedule-outbox-URL`]: {}, + [`${NS.CALDAV}:schedule-inbox-URL`]: {}, + [`${NS.CALDAV}:calendar-user-address-set`]: {}, }, - headers: this._account!.headers, - fetchOptions: this._account!.fetchOptions, depth: '0', }) + if (!result.success || !result.responses) { + throw new Error( + result.error ?? 'Failed to get scheduling capabilities', + ) + } - const props = response[0]?.props ?? {} + const props = result.responses[0]?.props ?? {} // Note: tsdav converts XML property names to camelCase // schedule-outbox-URL becomes scheduleOutboxURL @@ -1861,7 +1449,7 @@ export class CalDavService { scheduleOutboxUrl, scheduleInboxUrl, calendarUserAddressSet, - rawResponse: response, + rawResponse: result.responses, } }, 'Failed to get scheduling capabilities') } diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts deleted file mode 100644 index b57e74a..0000000 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/VCardComponent.ts +++ /dev/null @@ -1,43 +0,0 @@ -import ICAL from 'ical.js' - -export class VCardComponent { - - public component: ICAL.Component - - public constructor(component: ICAL.Component) { - if (component) this.component = component - else this.component = new ICAL.Component('vcard') - - } - - get version() { return this._getProp('version') as string } - set version(value: string) { this._setProp('version', value) } - - get uid() { return this._getProp('uid') as string } - set uid(value: string) { this._setProp('uid', value) } - - get email() { return this._getProp('email') as (string | null) } - set email(value: string | null) { this._setProp('email', value) } - - get name() { - return this.version.startsWith('2') - ? (this._getProp('n') as string[]).filter(n => !!n).reverse().join(' ') - : this._getProp('fn') as string - } - set name(value: string) { - if (this.version.startsWith('2')) { - const [name, family] = value.split(' ', 1) - this._setProp('n', [family ?? '', name, '', '', '']) - } else { - this._setProp('fn', value) - } - } - - private _setProp(name: string, value: unknown) { - this.component.updatePropertyWithValue(name, value) - } - - private _getProp(name: string): unknown { - return this.component.getFirstPropertyValue(name) - } -} diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.connect.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.connect.test.ts new file mode 100644 index 0000000..8b54cde --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.connect.test.ts @@ -0,0 +1,105 @@ +/** + * CalDavService.connect — URL derivation regression test. + * + * connect() builds principal/home URLs by URI-encoding the user's email and + * substituting it into a fixed SabreDAV path template. The encoding has to + * match what SabreDAV emits in its PROPFIND hrefs *exactly* — otherwise the + * hardcoded homeUrl won't be a string-prefix of the calendar URLs we later + * receive, and the owned-vs-shared bucket logic in CalendarContext breaks. + * + * SabreDAV's `URLUtil::encodePath` leaves RFC 3986 sub-delims and `@` literal. + * `encodeURIComponent` escapes `@` (→ `%40`) and `+` (→ `%2B`), so connect() + * has to put those two back. This test pins that behaviour. + */ +import { CalDavService } from '../CalDavService' + +describe('CalDavService.connect — URL derivation', () => { + it('derives principalUrl and homeUrl from a plain email', async () => { + const svc = new CalDavService() + const result = await svc.connect({ + serverUrl: 'http://srv/caldav/', + userEmail: 'user1@example.local', + }) + expect(result.success).toBe(true) + expect(result.data?.principalUrl).toBe( + 'http://srv/caldav/principals/users/user1@example.local/', + ) + expect(result.data?.homeUrl).toBe( + 'http://srv/caldav/calendars/users/user1@example.local/', + ) + }) + + it('appends a trailing slash to a serverUrl that lacks one', async () => { + const svc = new CalDavService() + const result = await svc.connect({ + serverUrl: 'http://srv/caldav', // no trailing slash + userEmail: 'user1@example.local', + }) + expect(result.success).toBe(true) + expect(result.data?.serverUrl).toBe('http://srv/caldav/') + expect(result.data?.homeUrl).toBe( + 'http://srv/caldav/calendars/users/user1@example.local/', + ) + }) + + it('preserves @ literally (does not percent-encode it)', async () => { + const svc = new CalDavService() + const result = await svc.connect({ + serverUrl: 'http://srv/caldav/', + userEmail: 'firstname.lastname@example.com', + }) + // SabreDAV's URLUtil::encodePath leaves @ alone, so the homeUrl in + // our cache must match the hrefs we'll receive from PROPFIND. If this + // regressed to %40, the owned/shared bucket split in CalendarContext + // would treat every calendar as "shared" (URL prefix mismatch). + expect(result.data?.homeUrl).toContain('@example.com/') + expect(result.data?.homeUrl).not.toContain('%40') + }) + + it('preserves + literally for emails like user+tag@example.com', async () => { + const svc = new CalDavService() + const result = await svc.connect({ + serverUrl: 'http://srv/caldav/', + userEmail: 'sub+plus@example.com', + }) + expect(result.data?.homeUrl).toContain('sub+plus@example.com/') + expect(result.data?.homeUrl).not.toContain('%2B') + }) + + it('encodes characters that DO need escaping (e.g. spaces, slashes)', async () => { + const svc = new CalDavService() + const result = await svc.connect({ + serverUrl: 'http://srv/caldav/', + // Pathological email — not a real-world one, but encoding any character + // that breaks URL parsing should still happen. + userEmail: 'has space@example.com', + }) + expect(result.success).toBe(true) + expect(result.data?.homeUrl).toContain('has%20space@example.com/') + }) + + it('rejects connect() without a userEmail', async () => { + const svc = new CalDavService() + // Cast away the type guard to exercise the runtime check; this also + // documents that we no longer have the legacy discovery fallback. + const result = await svc.connect({ + serverUrl: 'http://srv/caldav/', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + expect(result.success).toBe(false) + expect(result.error).toMatch(/userEmail/) + }) + + it('makes the account observable via getAccount() after connect succeeds', async () => { + const svc = new CalDavService() + expect(svc.isConnected()).toBe(false) + await svc.connect({ + serverUrl: 'http://srv/caldav/', + userEmail: 'user1@example.local', + }) + expect(svc.isConnected()).toBe(true) + expect(svc.getAccount()?.homeUrl).toBe( + 'http://srv/caldav/calendars/users/user1@example.local/', + ) + }) +}) diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.moveEvent.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.moveEvent.test.ts index 6550f9c..b44e4c6 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.moveEvent.test.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/CalDavService.moveEvent.test.ts @@ -1,22 +1,18 @@ /** - * CalDavService.moveEvent — auth-fallback regression test. + * CalDavService.moveEvent — request-shape regression test. * - * The MOVE request is built with raw fetch (tsdav doesn't expose - * a high-level move helper), so it has to assemble auth headers and - * fetch options itself. If the source calendar isn't in the cache - * (refresh race, stale state, source URL whose calendar key doesn't - * normalize to a cached entry), the original spread-of-undefined - * implementation silently dropped Authorization and credentials, - * causing opaque 401s. moveEvent must fall back to the target - * calendar's headers/options — both belong to the same CalDAV - * account, so they're interchangeable. + * MOVE is built via raw fetch (tsdav doesn't expose a high-level move). + * The unified `davRequest` carries session-cookie auth via + * `credentials: 'include'` and the shared `X-LS-Client: web` header, so + * moveEvent no longer needs to splice per-calendar `Authorization` + * headers — those used to be the source of an opaque 401 when the + * source calendar entry wasn't cached. This test pins the request + * shape so a future refactor can't silently regress it. */ import { CalDavService } from '../CalDavService' type CalendarStubInit = { url: string - headers?: Record - fetchOptions?: RequestInit } function injectCalendar(svc: CalDavService, init: CalendarStubInit) { @@ -27,14 +23,12 @@ function injectCalendar(svc: CalDavService, init: CalendarStubInit) { const calendars: Map = (svc as any)._calendars calendars.set(init.url, { url: init.url, - headers: init.headers, - fetchOptions: init.fetchOptions, // The remaining fields are unused by moveEvent but required by // CalDavCalendar — leave as undefined. }) } -describe('CalDavService.moveEvent — auth fallback', () => { +describe('CalDavService.moveEvent — request shape', () => { const originalFetch = globalThis.fetch let fetchMock: jest.Mock @@ -43,6 +37,7 @@ describe('CalDavService.moveEvent — auth fallback', () => { ok: true, status: 201, headers: new Headers({ etag: '"new-etag"' }), + text: async () => '', }) globalThis.fetch = fetchMock as unknown as typeof fetch }) @@ -51,18 +46,15 @@ describe('CalDavService.moveEvent — auth fallback', () => { globalThis.fetch = originalFetch }) - it('falls back to target calendar headers when source is not cached', async () => { + it('issues MOVE with correct Destination/If-Match and includes credentials', async () => { const svc = new CalDavService() const targetCalendarUrl = 'http://srv/cal/B/' const sourceEventUrl = 'http://srv/cal/A/event-uid.ics' - // Only the target calendar is cached. The source calendar entry is - // missing — this is the failure mode we're testing. - injectCalendar(svc, { - url: targetCalendarUrl, - headers: { Authorization: 'Bearer target-token' }, - fetchOptions: { credentials: 'include' as RequestCredentials }, - }) + // Even when the source calendar isn't cached, the request must go + // out cleanly — auth is provided by `credentials: 'include'`, not + // by per-calendar header splicing. + injectCalendar(svc, { url: targetCalendarUrl }) const result = await svc.moveEvent({ sourceEventUrl, @@ -76,33 +68,23 @@ describe('CalDavService.moveEvent — auth fallback', () => { const [calledUrl, init] = fetchMock.mock.calls[0] expect(calledUrl).toBe(sourceEventUrl) expect(init.method).toBe('MOVE') - - // Auth must come through despite the missing source calendar entry. expect(init.headers).toMatchObject({ - Authorization: 'Bearer target-token', Destination: 'http://srv/cal/B/event-uid.ics', Overwrite: 'F', 'If-Match': '"src-etag"', + 'X-LS-Client': 'web', }) expect(init.credentials).toBe('include') }) - it('uses source calendar headers when both source and target are cached, target only as fallback', async () => { + it('omits If-Match when no sourceEtag is provided', async () => { const svc = new CalDavService() const sourceCalendarUrl = 'http://srv/cal/A/' const targetCalendarUrl = 'http://srv/cal/B/' const sourceEventUrl = `${sourceCalendarUrl}event-uid.ics` - injectCalendar(svc, { - url: targetCalendarUrl, - headers: { Authorization: 'Bearer target-token' }, - fetchOptions: { credentials: 'include' as RequestCredentials }, - }) - injectCalendar(svc, { - url: sourceCalendarUrl, - headers: { Authorization: 'Bearer source-token' }, - fetchOptions: { credentials: 'include' as RequestCredentials }, - }) + injectCalendar(svc, { url: targetCalendarUrl }) + injectCalendar(svc, { url: sourceCalendarUrl }) const result = await svc.moveEvent({ sourceEventUrl, @@ -111,8 +93,6 @@ describe('CalDavService.moveEvent — auth fallback', () => { expect(result.success).toBe(true) const [, init] = fetchMock.mock.calls[0] - // Source headers win when both are present (per spread order: - // ...target, ...source). Target only fills gaps. - expect(init.headers.Authorization).toBe('Bearer source-token') + expect(init.headers['If-Match']).toBeUndefined() }) }) diff --git a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts index d342fac..45fea6e 100644 --- a/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts +++ b/src/frontend/apps/calendars/src/features/calendar/services/dav/__tests__/caldav-helpers.test.ts @@ -1,29 +1,25 @@ /** - * Tests for CalDAV Helper functions + * @jest-environment jsdom + * + * Tests for CalDAV Helper functions. Uses jsdom because the imported + * `parseDavErrorMessage` (from `DavClient`) parses via native `DOMParser`. */ import { escapeXml, XML_NS, xmlProp, - xmlPropOptional, buildCalendarPropsXml, buildMkCalendarXml, buildProppatchXml, - sharePrivilegeToXml, + buildCalendarQueryXml, parseSharePrivilege, - buildShareeSetXml, buildShareRequestXml, buildUnshareRequestXml, - buildInviteReplyXml, - buildSyncCollectionXml, - buildPrincipalSearchXml, parseCalendarComponents, parseCalendarOrder, - parseDavErrorMessage, - parseShareStatus, getCalendarUrlFromEventUrl, } from '../caldav-helpers' -import type { SharePrivilege } from '../types/caldav-service' +import { parseDavErrorMessage } from '@/features/calendar/utils/DavClient' describe('caldav-helpers', () => { // ============================================================================ @@ -81,17 +77,6 @@ describe('caldav-helpers', () => { }) }) - describe('xmlPropOptional', () => { - it('returns element when value is defined', () => { - expect(xmlPropOptional('D', 'displayname', 'Test')).toBe( - 'Test' - ) - }) - - it('returns empty string when value is undefined', () => { - expect(xmlPropOptional('D', 'displayname', undefined)).toBe('') - }) - }) }) // ============================================================================ @@ -141,6 +126,43 @@ describe('caldav-helpers', () => { expect(result).toContain('New Calendar') expect(result).toContain('') }) + + it('emits calendar-timezone when timezone is provided', () => { + // Calendar-timezone carries the VTIMEZONE block that SabreDAV + // uses to render floating events for this calendar. + const result = buildMkCalendarXml({ + displayName: 'TZ Calendar', + timezone: 'BEGIN:VTIMEZONE\nTZID:Europe/Paris\nEND:VTIMEZONE', + }) + expect(result).toContain('') + expect(result).toContain('TZID:Europe/Paris') + }) + + it('emits calendar-color when color is provided', () => { + const result = buildMkCalendarXml({ + displayName: 'C', + color: '#ff0000', + }) + expect(result).toContain('#ff0000') + }) + + it('escapes displayName to prevent XML/XSS injection', () => { + // Verified end-to-end in the browser: a calendar named + // `` renders as + // plain text in the sidebar (React auto-escape) AND is stored by + // SabreDAV with the angle brackets entity-encoded. The key + // invariant: every `<` and `>` is escaped, so no opening tag of + // any element survives in the body. + const evil = `">` + const result = buildMkCalendarXml({ displayName: evil }) + expect(result).toContain( + '<script>alert('xss')</script>"><img src=x onerror=alert(2)>', + ) + // No raw `