From e036e10004173db55e9e46bef22d6f0020b047bc Mon Sep 17 00:00:00 2001 From: Eduard Polonskyi <52964785+MrDeik@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:31:17 +0200 Subject: [PATCH] fix: sequence PWA and push onboarding --- app/api/dev-jmap/[...path]/route.ts | 144 +++++++++++++++++- .../push-notification-prompt.test.tsx | 14 +- .../__tests__/pwa-install-prompt.test.tsx | 69 +++++++++ components/push-notification-prompt.tsx | 8 +- components/pwa-install-prompt.tsx | 11 +- lib/__tests__/dev-jmap-mock.test.ts | 75 +++++++++ 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 components/__tests__/pwa-install-prompt.test.tsx diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 423fb0e8f..83897d9aa 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -12,6 +12,18 @@ import { NextRequest, NextResponse } from 'next/server'; const ACCOUNT_ID = 'dev-account-001'; const scheduledSubmissions: Array<{ id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled' }> = []; const emailCreationIds = new Map(); +const DEFAULT_PUSH_RELAY_ORIGIN = 'https://notifications.relay.bulwarkmail.org'; + +interface MockPushSubscription { + id: string; + deviceClientId: string; + expires: string | null; + types: string[] | null; + expectedVerificationCode: string; + verified: boolean; +} + +const pushSubscriptions: MockPushSubscription[] = []; // --------------------------------------------------------------------------- // Mailboxes @@ -1501,6 +1513,10 @@ function buildThreads() { type MethodArgs = Record; type MethodResult = [string, Record, string]; +type MethodHandler = ( + args: MethodArgs, + callId: string, +) => MethodResult | Promise; function handleCoreEcho(args: MethodArgs, callId: string): MethodResult { return ['Core/echo', args, callId]; @@ -1934,12 +1950,134 @@ function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult { return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; } +function handlePushSubscriptionGet(args: MethodArgs, callId: string): MethodResult { + const ids = Array.isArray(args.ids) ? args.ids as string[] : null; + const selected = ids + ? pushSubscriptions.filter((subscription) => ids.includes(subscription.id)) + : pushSubscriptions; + const foundIds = new Set(selected.map((subscription) => subscription.id)); + + return ['PushSubscription/get', { + state: nextState(), + list: selected.map(({ id, deviceClientId, expires, types }) => ({ + id, + deviceClientId, + expires, + types, + })), + notFound: ids?.filter((id) => !foundIds.has(id)) ?? [], + }, callId]; +} + +async function handlePushSubscriptionSet(args: MethodArgs, callId: string): Promise { + const created: Record = {}; + const notCreated: Record = {}; + const updated: Record = {}; + const notUpdated: Record = {}; + const destroyed: string[] = []; + const notDestroyed: Record = {}; + + const create = args.create as Record> | undefined; + if (create) { + for (const [creationId, data] of Object.entries(create)) { + try { + const url = new URL(String(data.url ?? '')); + const allowedOrigin = new URL( + process.env.NEXT_PUBLIC_PUSH_RELAY_URL || DEFAULT_PUSH_RELAY_ORIGIN, + ).origin; + if (url.origin !== allowedOrigin || !url.pathname.startsWith('/api/push/jmap/')) { + throw new Error('The dev mock only sends verification to the configured push relay'); + } + + const id = `push-${crypto.randomUUID()}`; + const verificationCode = crypto.randomUUID().replace(/-/g, ''); + const subscription: MockPushSubscription = { + id, + deviceClientId: String(data.deviceClientId ?? ''), + expires: typeof data.expires === 'string' ? data.expires : null, + types: Array.isArray(data.types) ? data.types as string[] : null, + expectedVerificationCode: verificationCode, + verified: false, + }; + + const verificationResponse = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + '@type': 'PushVerification', + pushSubscriptionId: id, + verificationCode, + }), + }); + if (!verificationResponse.ok) { + throw new Error(`Push relay rejected verification (${verificationResponse.status})`); + } + + pushSubscriptions.push(subscription); + created[creationId] = { + id, + expires: subscription.expires, + types: subscription.types, + }; + } catch (error) { + notCreated[creationId] = { + type: 'invalidProperties', + description: error instanceof Error ? error.message : 'Invalid push subscription', + }; + } + } + } + + const update = args.update as Record> | undefined; + if (update) { + for (const [id, patch] of Object.entries(update)) { + const subscription = pushSubscriptions.find((candidate) => candidate.id === id); + if (!subscription) { + notUpdated[id] = { type: 'notFound', description: 'Push subscription not found' }; + continue; + } + if ( + typeof patch.verificationCode === 'string' + && patch.verificationCode !== subscription.expectedVerificationCode + ) { + notUpdated[id] = { type: 'invalidProperties', description: 'Invalid verification code' }; + continue; + } + if (typeof patch.verificationCode === 'string') subscription.verified = true; + if (typeof patch.expires === 'string') subscription.expires = patch.expires; + if (Array.isArray(patch.types)) subscription.types = patch.types as string[]; + updated[id] = null; + } + } + + if (Array.isArray(args.destroy)) { + for (const id of args.destroy as string[]) { + const index = pushSubscriptions.findIndex((subscription) => subscription.id === id); + if (index === -1) { + notDestroyed[id] = { type: 'notFound' }; + continue; + } + pushSubscriptions.splice(index, 1); + destroyed.push(id); + } + } + + return ['PushSubscription/set', { + created, + notCreated, + updated, + notUpdated, + destroyed, + notDestroyed, + }, callId]; +} + // Catch-all for unknown methods function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult { return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId]; } -const METHOD_HANDLERS: Record MethodResult> = { +const METHOD_HANDLERS: Record = { 'Core/echo': handleCoreEcho, 'Mailbox/get': handleMailboxGet, 'Mailbox/set': handleMailboxSet, @@ -2009,6 +2147,8 @@ const METHOD_HANDLERS: Record Meth 'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], 'SieveScript/get': handleSieveScriptGet, 'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'PushSubscription/get': handlePushSubscriptionGet, + 'PushSubscription/set': handlePushSubscriptionSet, }; // --------------------------------------------------------------------------- @@ -2270,7 +2410,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ if (tooLarge) { responses.push(tooLarge); } else if (handler) { - const result = handler(args, callId); + const result = await handler(args, callId); responses.push(result); } else { responses.push(handleUnknown(method, args, callId)); diff --git a/components/__tests__/push-notification-prompt.test.tsx b/components/__tests__/push-notification-prompt.test.tsx index 4ff1758b8..86da69b04 100644 --- a/components/__tests__/push-notification-prompt.test.tsx +++ b/components/__tests__/push-notification-prompt.test.tsx @@ -152,7 +152,7 @@ describe("PushNotificationPrompt", () => { expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); }); - it("defers push onboarding when the PWA install prompt was shown", async () => { + it("defers push onboarding until the PWA install prompt is closed", async () => { render(); act(() => { window.dispatchEvent( @@ -165,5 +165,17 @@ describe("PushNotificationPrompt", () => { expect(mocks.isWebPushEnabled).not.toHaveBeenCalled(); expect(screen.queryByText("title")).not.toBeInTheDocument(); + + act(() => { + window.dispatchEvent( + new CustomEvent(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, { + detail: { visible: false }, + }), + ); + }); + await advancePromptDelay(); + + expect(mocks.isWebPushEnabled).toHaveBeenCalledWith("account-1"); + expect(screen.getByText("title")).toBeInTheDocument(); }); }); diff --git a/components/__tests__/pwa-install-prompt.test.tsx b/components/__tests__/pwa-install-prompt.test.tsx new file mode 100644 index 000000000..96d57f40d --- /dev/null +++ b/components/__tests__/pwa-install-prompt.test.tsx @@ -0,0 +1,69 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + PWA_INSTALL_PROMPT_VISIBILITY_EVENT, + PWAInstallPrompt, +} from "../pwa-install-prompt"; + +vi.mock("@/hooks/use-config", () => ({ + useConfig: () => ({ + appName: "Bulwark", + faviconUrl: "", + appLogoLightUrl: "", + appLogoDarkUrl: "", + }), +})); + +describe("PWAInstallPrompt", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("closes after the browser install prompt is dismissed", async () => { + const prompt = vi.fn().mockResolvedValue(undefined); + const event = new Event("beforeinstallprompt"); + Object.assign(event, { + prompt, + userChoice: Promise.resolve({ outcome: "dismissed" }), + }); + + render(); + act(() => window.dispatchEvent(event)); + expect(screen.getByText("title")).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText("install")); + await Promise.resolve(); + }); + + expect(prompt).toHaveBeenCalledOnce(); + expect(screen.queryByText("title")).not.toBeInTheDocument(); + }); + + it("announces that it is hidden after Not now", () => { + const visibility: boolean[] = []; + const listener = (event: Event) => { + visibility.push( + (event as CustomEvent<{ visible?: boolean }>).detail?.visible === true, + ); + }; + window.addEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener); + + try { + const event = new Event("beforeinstallprompt"); + Object.assign(event, { + prompt: vi.fn(), + userChoice: Promise.resolve({ outcome: "dismissed" }), + }); + + render(); + act(() => window.dispatchEvent(event)); + fireEvent.click(screen.getByText("not_now")); + + expect(visibility).toContain(true); + expect(visibility.at(-1)).toBe(false); + } finally { + window.removeEventListener(PWA_INSTALL_PROMPT_VISIBILITY_EVENT, listener); + } + }); +}); diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index 6f59e596e..4637e44fb 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -67,7 +67,7 @@ export function PushNotificationPrompt() { const [error, setError] = useState(null); const [sessionDismissedAccountId, setSessionDismissedAccountId] = useState(null); - const [pwaPromptWasShown, setPwaPromptWasShown] = useState(false); + const [pwaPromptVisible, setPwaPromptVisible] = useState(false); const accountId = client?.getAccountId() ?? null; const relayBaseUrl = (adminPushRelayUrl ?? "").trim() @@ -76,7 +76,7 @@ export function PushNotificationPrompt() { useEffect(() => { const handlePwaPromptVisibility = (event: Event) => { const visible = (event as CustomEvent<{ visible?: boolean }>).detail?.visible; - if (visible) setPwaPromptWasShown(true); + setPwaPromptVisible(visible === true); }; window.addEventListener( @@ -107,7 +107,7 @@ export function PushNotificationPrompt() { || !emailNotificationsEnabled || isExcludedPath(pathname) || sessionDismissedAccountId === accountId - || pwaPromptWasShown + || pwaPromptVisible || wasDismissed(accountId) || !isWebPushSupported() || Notification.permission === "denied" @@ -138,7 +138,7 @@ export function PushNotificationPrompt() { isDemoMode, pathname, policyLoaded, - pwaPromptWasShown, + pwaPromptVisible, sessionDismissedAccountId, ]); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 34c9fe488..058f641ae 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -50,12 +50,13 @@ export function PWAInstallPrompt() { if (!deferredPrompt) return; deferredPrompt.prompt(); - const { outcome } = await deferredPrompt.userChoice; + await deferredPrompt.userChoice; - if (outcome === "accepted") { - setDeferredPrompt(null); - setShowPrompt(false); - } + // A beforeinstallprompt event can only be consumed once. Close the card + // whether the browser prompt was accepted or dismissed so the next + // onboarding step can continue in the same session. + setDeferredPrompt(null); + setShowPrompt(false); }; const handleDismiss = () => { diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 0de022dd9..9623fee76 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -199,6 +199,81 @@ describe('dev-jmap mock server', () => { }); }); + describe('POST /api - PushSubscription lifecycle', () => { + it('creates, verifies, lists, and destroys a push subscription', async () => { + const relayFetch = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(null, { status: 202 }), + ); + + try { + const createReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { + create: { + new: { + deviceClientId: 'browser-device-1', + url: 'https://notifications.relay.bulwarkmail.org/api/push/jmap/browser-device-1', + types: ['Email'], + expires: '2030-01-01T00:00:00.000Z', + }, + }, + }, 'c0']], + }), + }); + const createRes = await POST(createReq, { params: Promise.resolve({ path: ['api'] }) }); + const createData = await createRes.json(); + const id = createData.methodResponses[0][1].created.new.id as string; + const verificationBody = JSON.parse( + String(relayFetch.mock.calls[0]?.[1]?.body), + ) as { verificationCode: string }; + + expect(id).toMatch(/^push-/); + expect(relayFetch).toHaveBeenCalledOnce(); + + const verifyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { + update: { [id]: { verificationCode: verificationBody.verificationCode } }, + }, 'v0']], + }), + }); + const verifyRes = await POST(verifyReq, { params: Promise.resolve({ path: ['api'] }) }); + const verifyData = await verifyRes.json(); + expect(verifyData.methodResponses[0][1].updated[id]).toBeNull(); + + const getReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/get', { ids: [id] }, 'g0']], + }), + }); + const getRes = await POST(getReq, { params: Promise.resolve({ path: ['api'] }) }); + const getData = await getRes.json(); + expect(getData.methodResponses[0][1].list).toEqual([ + expect.objectContaining({ id, deviceClientId: 'browser-device-1' }), + ]); + + const destroyReq = makeRequest('http://localhost:3000/api/dev-jmap/api', { + method: 'POST', + headers: { 'Content-Type': 'application/json', host: 'localhost:3000' }, + body: JSON.stringify({ + methodCalls: [['PushSubscription/set', { destroy: [id] }, 'd0']], + }), + }); + const destroyRes = await POST(destroyReq, { params: Promise.resolve({ path: ['api'] }) }); + const destroyData = await destroyRes.json(); + expect(destroyData.methodResponses[0][1].destroyed).toEqual([id]); + } finally { + relayFetch.mockRestore(); + } + }); + }); + describe('POST /api - back-references', () => { it('should resolve #ids from Email/query result', async () => { const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {