diff --git a/.env.example b/.env.example index cef10a0cf09569..67de7fbb90096b 100644 --- a/.env.example +++ b/.env.example @@ -145,9 +145,12 @@ GOOGLE_WEBHOOK_TOKEN= # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. GOOGLE_WEBHOOK_URL= +# Microsoft Graph Client ID (required for Outlook calendar cache token refresh) +MS_GRAPH_CLIENT_ID= +# Microsoft Graph Client Secret (required for Outlook calendar cache token refresh) +MS_GRAPH_CLIENT_SECRET= # Token to verify incoming webhooks from Microsoft Calendar MICROSOFT_WEBHOOK_TOKEN= - # Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL. MICROSOFT_WEBHOOK_URL= diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts index 61baa9cb91ab18..290a16524714fe 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/__tests__/route.test.ts @@ -160,7 +160,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); @@ -184,7 +184,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); @@ -208,7 +208,7 @@ describe("/api/webhooks/calendar-subscription/[provider]", () => { expect(response.status).toBe(200); const body = await response.json(); - expect(body.message).toBe("Webhook processed"); + expect(body).toEqual({}); expect(mockProcessWebhook).toHaveBeenCalledWith("google_calendar", request); }); }); diff --git a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts index a05053b70ab15c..4d1ff684529e59 100644 --- a/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts +++ b/apps/web/app/api/webhooks/calendar-subscription/[provider]/route.ts @@ -67,6 +67,13 @@ async function postHandler(request: NextRequest, ctx: { params: Promise calendarCacheEventService, }); + // only for office365 handshake validation + const url = new URL(request.url); + const validationToken = url.searchParams.get("validationToken"); + if (validationToken) { + return new NextResponse(validationToken, { status: 200, headers: { "Content-Type": "text/plain" } }); + } + // are features globally enabled const [isCacheEnabled, isSyncEnabled] = await Promise.all([ calendarSubscriptionService.isCacheEnabled(), @@ -79,7 +86,7 @@ async function postHandler(request: NextRequest, ctx: { params: Promise } await calendarSubscriptionService.processWebhook(providerFromParams, request); - return NextResponse.json({ message: "Webhook processed" }, { status: 200 }); + return NextResponse.json({}, { status: 200 }); } catch (error) { log.error("Error processing webhook", { error }); const message = error instanceof Error ? error.message : "Unknown error"; diff --git a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts index 1819d0806a7404..6f1c9965d89e8a 100644 --- a/packages/features/calendar-subscription/adapters/AdaptersFactory.ts +++ b/packages/features/calendar-subscription/adapters/AdaptersFactory.ts @@ -54,6 +54,7 @@ export class DefaultAdapterFactory implements AdapterFactory { * @returns */ getProviders(): CalendarSubscriptionProvider[] { + // TODO: add "office365_calendar" once the adapter is validated in production const providers: CalendarSubscriptionProvider[] = ["google_calendar"]; return providers; } diff --git a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts index 2338ad5d0b98c4..e048c40e22c010 100644 --- a/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts +++ b/packages/features/calendar-subscription/adapters/Office365CalendarSubscription.adapter.ts @@ -1,119 +1,101 @@ +import process from "node:process"; +import dayjs from "@calcom/dayjs"; import logger from "@calcom/lib/logger"; import type { SelectedCalendar } from "@calcom/prisma/client"; - import type { - CalendarSubscriptionEvent, - ICalendarSubscriptionPort, - CalendarSubscriptionResult, CalendarCredential, + CalendarSubscriptionEvent, CalendarSubscriptionEventItem, + CalendarSubscriptionResult, + ICalendarSubscriptionPort, } from "../lib/CalendarSubscriptionPort.interface"; +import { CalendarCacheEventService } from "../lib/cache/CalendarCacheEventService"; const log = logger.getSubLogger({ prefix: ["MicrosoftCalendarSubscriptionAdapter"] }); type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; type GraphClient = { accessToken: string }; -interface MicrosoftGraphEvent { +type MicrosoftGraphEvent = { id: string; iCalUId?: string; subject?: string; bodyPreview?: string; location?: { displayName?: string }; - start?: { dateTime: string; timeZone: string }; - end?: { dateTime: string; timeZone: string }; + start?: { dateTime?: string; date?: string; timeZone?: string }; + end?: { dateTime?: string; date?: string; timeZone?: string }; showAs?: "free" | "tentative" | "busy" | "oof" | "workingElsewhere" | "unknown"; isAllDay?: boolean; isCancelled?: boolean; type?: string; -} + recurringEventId?: string; + "@odata.etag"?: string; + createdDateTime?: string; + lastModifiedDateTime?: string; +}; -interface MicrosoftGraphEventsResponse { +type MicrosoftGraphEventsResponse = { "@odata.nextLink"?: string; "@odata.deltaLink"?: string; value: MicrosoftGraphEvent[]; -} - -interface MicrosoftGraphSubscriptionReq { - resource: string; - changeType: string; - notificationUrl: string; - expirationDateTime: string; - clientState?: string; -} - -interface MicrosoftGraphSubscriptionRes { - id: string; - resource: string; - expirationDateTime: string; -} - -type AdapterConfig = { - baseUrl?: string; - webhookToken?: string | null; - webhookUrl?: string | null; - subscriptionTtlMs?: number; }; -/** - * Office365 Calendar Subscription Adapter - * - * This adapter uses the Microsoft Graph API to create and manage calendar subscriptions - * @see https://docs.microsoft.com/en-us/graph/api/resources/subscription - */ +const BASE_URL = "https://graph.microsoft.com/v1.0"; +const SUBSCRIPTION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (max allowed for MS Graph) +const BUSY_STATES = ["busy", "tentative", "oof"]; + export class Office365CalendarSubscriptionAdapter implements ICalendarSubscriptionPort { - private readonly baseUrl: string; - private readonly webhookToken?: string | null; - private readonly webhookUrl?: string | null; - private readonly subscriptionTtlMs: number; - - constructor(cfg: AdapterConfig = {}) { - this.baseUrl = cfg.baseUrl ?? "https://graph.microsoft.com/v1.0"; - this.webhookToken = cfg.webhookToken ?? process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; - this.webhookUrl = cfg.webhookUrl ?? process.env.MICROSOFT_WEBHOOK_URL ?? null; - this.subscriptionTtlMs = cfg.subscriptionTtlMs ?? 3 * 24 * 60 * 60 * 1000; - } + private readonly baseUrl = BASE_URL; + private readonly subscriptionTtlMs = SUBSCRIPTION_TTL_MS; + private readonly webhookToken = process.env.MICROSOFT_WEBHOOK_TOKEN ?? null; + + private readonly webhookUrl = (() => { + const base = process.env.MICROSOFT_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL; + return base ? `${base}/api/webhooks/calendar-subscription/office365_calendar` : null; + })(); + private tokenCache = new Map(); async validate(request: Request): Promise { - // validate handshake - let validationToken: string | null = null; - if (request?.url) { - try { + try { + if (request?.url) { const urlObj = new URL(request.url); - validationToken = urlObj.searchParams.get("validationToken"); - } catch (e) { - log.warn("Invalid request URL", { url: request.url }); + const validationToken = urlObj.searchParams.get("validationToken"); + if (validationToken) return true; } - } - if (validationToken) return true; - - // validate notifications - const clientState = - request?.headers?.get("clientState") ?? - (typeof request?.body === "object" && request.body !== null && "clientState" in request.body - ? (request.body as { clientState?: string }).clientState - : undefined); - if (!this.webhookToken) { - log.warn("MICROSOFT_WEBHOOK_TOKEN missing"); - return false; - } - if (clientState !== this.webhookToken) { - log.warn("Invalid clientState"); + + const body = await request + .clone() + .json() + .catch(() => ({})); + const clientState = + request.headers.get("clientState") ?? body?.value?.[0]?.clientState ?? body?.clientState; + + if (!this.webhookToken) { + log.warn("MICROSOFT_WEBHOOK_TOKEN missing"); + return false; + } + + if (clientState !== this.webhookToken) { + log.warn("Invalid clientState"); + return false; + } + + return true; + } catch (err) { + log.error("Error validating Microsoft webhook", err); return false; } - return true; } async extractChannelId(request: Request): Promise { - let id: string | null = null; - if (request?.body && typeof request.body === "object" && "subscriptionId" in request.body) { - id = (request.body as { subscriptionId?: string }).subscriptionId ?? null; - } else if (request?.headers?.get("subscriptionId")) { - id = request.headers.get("subscriptionId"); - } - if (!id) { - log.warn("subscriptionId missing in webhook"); - } + const body = await request + .clone() + .json() + .catch(() => ({})); + const id = + body?.value?.[0]?.subscriptionId ?? body?.subscriptionId ?? request.headers.get("subscriptionId"); + + if (!id) log.warn("subscriptionId missing in webhook"); return id; } @@ -125,18 +107,19 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti throw new Error("Webhook config missing (MICROSOFT_WEBHOOK_URL/TOKEN)"); } - const expirationDateTime = new Date(Date.now() + this.subscriptionTtlMs).toISOString(); - - const body: MicrosoftGraphSubscriptionReq = { - resource: `me/calendars/${selectedCalendar.externalId}/events`, - changeType: "created,updated,deleted", - notificationUrl: this.webhookUrl, - expirationDateTime, - clientState: this.webhookToken, - }; - const client = await this.getGraphClient(credential); - const res = await this.request(client, "POST", "/subscriptions", body); + const res = await this.request<{ id: string; resource: string; expirationDateTime: string }>( + client, + "POST", + "/subscriptions", + { + resource: `me/calendars/${selectedCalendar.externalId}/events`, + changeType: "created,updated,deleted", + notificationUrl: this.webhookUrl, + expirationDateTime: new Date(Date.now() + this.subscriptionTtlMs).toISOString(), + clientState: this.webhookToken, + } + ); return { provider: "office365_calendar", @@ -160,26 +143,35 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti credential: CalendarCredential ): Promise { const client = await this.getGraphClient(credential); - - let deltaLink = selectedCalendar.syncToken ?? null; const items: MicrosoftGraphEvent[] = []; + let deltaLink = selectedCalendar.syncToken ?? null; if (deltaLink) { - const path = this.stripBase(deltaLink); - const r = await this.request(client, "GET", path); - items.push(...r.value); - deltaLink = r["@odata.deltaLink"] ?? deltaLink; + log.info("Fetching with deltaLink"); + const response = await this.request(client, "GET", deltaLink); + items.push(...response.value); + deltaLink = response["@odata.deltaLink"] ?? deltaLink; } else { - let next: string | null = `/me/calendars/${selectedCalendar.externalId}/events/delta`; + const now = dayjs().startOf("day"); + const monthsAhead = now.add(CalendarCacheEventService.MONTHS_AHEAD, "month").endOf("day"); + + const startDateTime = now.toISOString(); + const endDateTime = monthsAhead.toISOString(); + + let next: string | null = + `/me/calendars/${selectedCalendar.externalId}/calendarView/delta?startDateTime=${startDateTime}&endDateTime=${endDateTime}`; + + log.info("Initial fetch", { url: next }); + while (next) { - const r: MicrosoftGraphEventsResponse = await this.request( + const response: MicrosoftGraphEventsResponse = await this.request( client, "GET", next ); - items.push(...r.value); - deltaLink = r["@odata.deltaLink"] ?? deltaLink; - next = r["@odata.nextLink"] ? this.stripBase(r["@odata.nextLink"]) : null; + items.push(...response.value); + deltaLink = response["@odata.deltaLink"] ?? deltaLink; + next = response["@odata.nextLink"] ?? null; } } @@ -192,42 +184,92 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti private parseEvents(events: MicrosoftGraphEvent[]): CalendarSubscriptionEventItem[] { return events - .map((e) => { - const busy = e.showAs === "busy" || e.showAs === "tentative" || e.showAs === "oof"; - const start = e.start?.dateTime ? new Date(e.start.dateTime) : new Date(); - const end = e.end?.dateTime ? new Date(e.end.dateTime) : new Date(); - - return { - id: e.id, - iCalUID: e.iCalUId ?? null, - start, - end, - busy, - etag: null, - summary: e.subject ?? null, - description: e.bodyPreview ?? null, - location: e.location?.displayName ?? null, - kind: e.type ?? "microsoftgraph#event", - status: e.isCancelled ? "cancelled" : "confirmed", - isAllDay: e.isAllDay ?? false, - timeZone: e.start?.timeZone ?? null, - recurringEventId: null, - originalStartDate: null, - createdAt: null, - updatedAt: null, - }; - }) - .filter(({ id }) => !!id); + .filter((e) => e.id) + .map((e) => ({ + id: e.id, + iCalUID: e.iCalUId ?? null, + start: new Date(e.start?.dateTime ?? e.start?.date ?? Date.now()), + end: new Date(e.end?.dateTime ?? e.end?.date ?? Date.now()), + busy: e.showAs ? BUSY_STATES.includes(e.showAs) : true, + etag: e["@odata.etag"] ?? null, + summary: e.subject ?? null, + description: e.bodyPreview ?? null, + location: e.location?.displayName ?? null, + kind: e.type ?? "microsoft.graph.event", + status: e.isCancelled ? "cancelled" : "confirmed", + isAllDay: !!e.isAllDay, + timeZone: e.start?.timeZone ?? e.end?.timeZone ?? "UTC", + recurringEventId: e.recurringEventId ?? null, + originalStartDate: null, + createdAt: new Date(e.createdDateTime ?? Date.now()), + updatedAt: new Date(e.lastModifiedDateTime ?? Date.now()), + })); } private async getGraphClient(credential: CalendarCredential): Promise { - const accessToken = credential.delegatedTo?.serviceAccountKey?.private_key ?? (credential.key as string); - if (!accessToken) throw new Error("Missing Microsoft access token"); - return { accessToken }; + const key = credential.key as { + access_token?: string; + refresh_token?: string; + expiry_date?: number; + }; + + if (credential.delegatedTo?.serviceAccountKey?.private_key) { + return { accessToken: credential.delegatedTo.serviceAccountKey.private_key }; + } + + const credentialId = String(credential.id); + const cached = this.tokenCache.get(credentialId); + if (cached && Date.now() < cached.expiresAt) { + return { accessToken: cached.token }; + } + + const isExpired = key.expiry_date ? Date.now() >= key.expiry_date - 5 * 60 * 1000 : false; + + if (isExpired && key.refresh_token) { + log.info("Access token expired, refreshing...", { credentialId }); + const newToken = await this.refreshAccessToken(key.refresh_token); + + this.tokenCache.set(credentialId, { + token: newToken.access_token, + expiresAt: Date.now() + 55 * 60 * 1000, + }); + + return { accessToken: newToken.access_token }; + } + + if (!key.access_token) throw new Error("Missing Microsoft access token"); + return { accessToken: key.access_token }; } - private stripBase(urlOrPath: string): string { - return urlOrPath.startsWith("http") ? urlOrPath.replace(this.baseUrl, "") : urlOrPath; + private async refreshAccessToken(refreshToken: string): Promise<{ + access_token: string; + expires_in: number; + refresh_token: string; + }> { + const clientId = process.env.MS_GRAPH_CLIENT_ID; + const clientSecret = process.env.MS_GRAPH_CLIENT_SECRET; + if (!clientId || !clientSecret) throw new Error("Missing MS_GRAPH_CLIENT_ID or MS_GRAPH_CLIENT_SECRET"); + + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: params.toString(), + }); + + if (!response.ok) { + const error = await response.text(); + log.error("Failed to refresh token", { status: response.status, error }); + throw new Error("Failed to refresh Microsoft access token"); + } + + return await response.json(); } private async request( @@ -237,13 +279,15 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti data?: unknown ): Promise { const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`; - const headers: Record = { - Authorization: `Bearer ${client.accessToken}`, - "Content-Type": "application/json", + const init: RequestInit = { + method, + headers: { + Authorization: `Bearer ${client.accessToken}`, + "Content-Type": "application/json", + }, }; - const init: RequestInit = { method, headers }; - if (data && (method === "POST" || method === "PUT" || method === "PATCH")) { + if (data && ["POST", "PUT", "PATCH"].includes(method)) { init.body = JSON.stringify(data); } @@ -251,12 +295,10 @@ export class Office365CalendarSubscriptionAdapter implements ICalendarSubscripti if (!res.ok) { const text = await res.text().catch(() => ""); - log.error("Graph API error", { method, endpoint: this.stripBase(url), status: res.status, text }); + log.error("Graph API error", { method, url, status: res.status, text }); throw new Error(`Graph ${res.status} ${res.statusText}`); } - if (method === "DELETE" || res.status === 204) return {} as T; - - return (await res.json()) as T; + return method === "DELETE" || res.status === 204 ? ({} as T) : await res.json(); } } diff --git a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts index eec286e9be6462..e313cdd6206ee5 100644 --- a/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts +++ b/packages/features/calendar-subscription/adapters/__tests__/Office365CalendarSubscriptionAdapter.test.ts @@ -1,6 +1,6 @@ -import { describe, test, expect } from "vitest"; - import type { SelectedCalendar } from "@calcom/prisma/client"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { Office365CalendarSubscriptionAdapter } from "../Office365CalendarSubscription.adapter"; const _mockSelectedCalendar: SelectedCalendar = { id: "test-calendar-id", @@ -39,13 +39,369 @@ const _mockSelectedCalendar: SelectedCalendar = { const _mockCredential = { id: 1, + userId: 1, + delegationCredentialId: null, key: { access_token: "test-token" }, + type: "office365_calendar", + teamId: null, + appId: null, + invalid: false, user: { email: "test@example.com" }, delegatedTo: null, }; +// prevent actual logging during tests +vi.mock("@calcom/lib/logger", () => ({ + default: { + getSubLogger: () => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }), + }, +})); + describe("Office365CalendarSubscriptionAdapter", () => { - test("should be a placeholder test", () => { - expect(true).toBe(true); + const mockWebhookBaseUrl = "https://example.com"; + const mockWebhookToken = "test-webhook-token"; + const originalFetch = global.fetch; + + beforeEach(() => { + vi.stubEnv("MICROSOFT_WEBHOOK_URL", mockWebhookBaseUrl); + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", mockWebhookToken); + vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", "https://app.example.com"); + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + describe("validate", () => { + test("returns true with valid clientState", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { clientState: mockWebhookToken }, + }); + const res = await adapter.validate(req); + expect(res).toBe(true); + }); + + test("returns false if token does not match", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { clientState: "invalid" }, + }); + const res = await adapter.validate(req); + expect(res).toBe(false); + }); + + test("returns true for validationToken handshake", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com?validationToken=abc123"); + const res = await adapter.validate(req); + expect(res).toBe(true); + }); + + test("returns false if MICROSOFT_WEBHOOK_TOKEN is missing", async () => { + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", ""); + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com"); + const res = await adapter.validate(req); + expect(res).toBe(false); + }); + }); + + describe("extractChannelId", () => { + test("extracts subscriptionId from body", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + method: "POST", + body: JSON.stringify({ subscriptionId: "abc" }), + }); + const id = await adapter.extractChannelId(req); + expect(id).toBe("abc"); + }); + + test("extracts subscriptionId from headers", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com", { + headers: { subscriptionId: "hdr-123" }, + }); + const id = await adapter.extractChannelId(req); + expect(id).toBe("hdr-123"); + }); + + test("returns null if subscriptionId is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const req = new Request("https://example.com"); + const id = await adapter.extractChannelId(req); + expect(id).toBe(null); + }); + }); + + describe("subscribe", () => { + test("throws error if webhook config is missing", async () => { + vi.stubEnv("MICROSOFT_WEBHOOK_URL", ""); + vi.stubEnv("NEXT_PUBLIC_WEBAPP_URL", ""); + vi.stubEnv("MICROSOFT_WEBHOOK_TOKEN", ""); + const adapter = new Office365CalendarSubscriptionAdapter(); + await expect(adapter.subscribe(_mockSelectedCalendar, _mockCredential)).rejects.toThrow( + /Webhook config missing/ + ); + }); + + test("successfully subscribes to calendar events", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mockRes = { + id: "sub-1", + resource: "me/calendars/test@example.com/events", + expirationDateTime: new Date().toISOString(), + }; + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRes }); + + const res = await adapter.subscribe(_mockSelectedCalendar, _mockCredential); + expect(res.id).toBe("sub-1"); + expect(global.fetch).toHaveBeenCalled(); + }); + }); + + describe("unsubscribe", () => { + test("does nothing if channelResourceId is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn(); + await adapter.unsubscribe({ ..._mockSelectedCalendar, channelResourceId: null }, _mockCredential); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test("sends DELETE request correctly", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ ok: true }); + await adapter.unsubscribe(_mockSelectedCalendar, _mockCredential); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/subscriptions/"), + expect.objectContaining({ method: "DELETE" }) + ); + }); + }); + + describe("fetchEvents", () => { + test("uses deltaLink if syncToken exists", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mockRes = { + "@odata.deltaLink": "https://graph.microsoft.com/v1.0/me/calendars/.../events/delta?$token=abc", + value: [ + { + id: "1", + subject: "Event", + start: { dateTime: "2025-10-18T10:00:00Z" }, + end: { dateTime: "2025-10-18T11:00:00Z" }, + }, + ], + }; + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockRes }); + const res = await adapter.fetchEvents(_mockSelectedCalendar, _mockCredential); + expect(res.items.length).toBe(1); + expect(res.syncToken).toContain("$token=abc"); + }); + + test("performs full sync when no syncToken is present", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const mock1 = { "@odata.nextLink": "page2", value: [{ id: "a" }] }; + const mock2 = { "@odata.deltaLink": "done", value: [{ id: "b" }] }; + global.fetch = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => mock1 }) + .mockResolvedValueOnce({ ok: true, json: async () => mock2 }); + + const res = await adapter.fetchEvents({ ..._mockSelectedCalendar, syncToken: null }, _mockCredential); + expect(res.items.length).toBe(2); + expect(res.syncToken).toBe("done"); + }); + }); + + describe("getGraphClient", () => { + test("returns a valid accessToken", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const client = await adapter["getGraphClient"](_mockCredential); + expect(client.accessToken).toBe("test-token"); + }); + + test("throws error if access_token is missing", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const badCred = { ..._mockCredential, key: {} }; + await expect(adapter["getGraphClient"](badCred)).rejects.toThrow(/Missing Microsoft access token/); + }); + }); + + describe("request", () => { + test("performs successful API request", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "123" }) }); + const res = await adapter["request"]({ accessToken: "tok" }, "GET", "/me/events"); + expect(res).toEqual({ id: "123" }); + }); + + test("throws error when API call fails", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "err", + }); + await expect(adapter["request"]({ accessToken: "tok" }, "GET", "/me/events")).rejects.toThrow( + /Graph 400 Bad Request/ + ); + }); + }); + + describe("parseEvents", () => { + test("parses events correctly", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events = [ + { + id: "1", + subject: "Meeting", + bodyPreview: "desc", + start: { dateTime: "2025-10-18T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2025-10-18T11:00:00Z", timeZone: "UTC" }, + showAs: "busy", + }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].summary).toBe("Meeting"); + expect(parsed[0].busy).toBe(true); + }); + + test("handles events with different showAs values", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events = [ + { id: "1", showAs: "free" }, + { id: "2", showAs: "tentative" }, + { id: "3", showAs: "oof" }, + { id: "4", showAs: "workingElsewhere" }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].busy).toBe(false); + expect(parsed[1].busy).toBe(true); + expect(parsed[2].busy).toBe(true); + expect(parsed[3].busy).toBe(false); + }); + + test("handles cancelled events", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events = [ + { id: "1", isCancelled: true }, + { id: "2", isCancelled: false }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].status).toBe("cancelled"); + expect(parsed[1].status).toBe("confirmed"); + }); + + test("handles all-day events", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events = [ + { id: "1", isAllDay: true, start: { date: "2025-10-18" }, end: { date: "2025-10-19" } }, + ]; + const parsed = adapter["parseEvents"](events); + expect(parsed[0].isAllDay).toBe(true); + }); + + test("filters out events without id", () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const events = [{ id: "" }, { id: "valid" }]; + const parsed = adapter["parseEvents"](events); + expect(parsed.length).toBe(1); + expect(parsed[0].id).toBe("valid"); + }); + }); + + describe("getGraphClient - token refresh", () => { + test("uses cached token when available and not expired", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const credential = { ..._mockCredential, id: 999 }; + + await adapter["getGraphClient"](credential); + + const client = await adapter["getGraphClient"](credential); + expect(client.accessToken).toBe("test-token"); + }); + + test("refreshes token when expired", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", "test-client-id"); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-client-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + const expiredCred = { + ..._mockCredential, + key: { + access_token: "old-token", + refresh_token: "refresh-token", + expiry_date: Date.now() - 1000, // Expired + }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "new-token", + expires_in: 3600, + refresh_token: "new-refresh-token", + }), + }); + + const client = await adapter["getGraphClient"](expiredCred); + expect(client.accessToken).toBe("new-token"); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("login.microsoftonline.com"), + expect.any(Object) + ); + }); + + test("uses delegated credential service account key", async () => { + const adapter = new Office365CalendarSubscriptionAdapter(); + const delegatedCred = { + ..._mockCredential, + delegatedTo: { + serviceAccountKey: { + private_key: "service-account-key", + }, + }, + }; + + const client = await adapter["getGraphClient"](delegatedCred); + expect(client.accessToken).toBe("service-account-key"); + }); + }); + + describe("refreshAccessToken", () => { + test("throws error when MS_GRAPH_CLIENT_ID is missing", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", ""); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + await expect(adapter["refreshAccessToken"]("refresh-token")).rejects.toThrow( + /Missing MS_GRAPH_CLIENT_ID/ + ); + }); + + test("throws error when refresh fails", async () => { + vi.stubEnv("MS_GRAPH_CLIENT_ID", "test-client-id"); + vi.stubEnv("MS_GRAPH_CLIENT_SECRET", "test-client-secret"); + + const adapter = new Office365CalendarSubscriptionAdapter(); + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + text: async () => "invalid_grant", + }); + + await expect(adapter["refreshAccessToken"]("invalid-token")).rejects.toThrow( + /Failed to refresh Microsoft access token/ + ); + }); }); }); diff --git a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts index 84b500e0823b60..5507db7894123e 100644 --- a/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts +++ b/packages/features/calendar-subscription/lib/CalendarSubscriptionService.ts @@ -406,7 +406,13 @@ export class CalendarSubscriptionService { genericCalendarSuffixes: this.deps.adapterFactory.getGenericCalendarSuffixes(), }); log.debug("checkForNewSubscriptions", { count: rows.length }); - await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); + const results = await Promise.allSettled(rows.map(({ id }) => this.subscribe(id))); + + const errors = results.filter((r) => r.status === "rejected"); + const successes = results.filter((r) => r.status === "fulfilled"); + + log.info(`Subscriptions: ${successes.length} succeeded | ${errors.length} failed`); + errors.forEach((e) => log.error(e.reason)); } /**