From 96d91cd74e247f574d1d21b5970934048e94c70a Mon Sep 17 00:00:00 2001 From: Juan Fernando Castro Date: Tue, 3 Nov 2026 10:06:15 -0500 Subject: [PATCH] fix(puter-js): push syncs skip sign-in and follow disable() across tabs (#4470) * fix(puter-js): background push syncs never prompt for sign-in The load-time sync and the resync after the browser replaces a subscription ask for the key and announce the subscription without opening Puter's sign-in; only enable() may. The confirmation and the removal never open it, and looking up the user for the opt-in uses a non-interactive /whoami. A rejected token during a background sync is not reported as a sync failure. * fix(puter-js): turning push off in one tab stops other tabs' syncs Tabs of a site take a shared Web Lock to subscribe and announce, and again to confirm, without holding it while the confirmation push is on its way. A load sync re-reads the opt-in inside the lock and stops when it is gone. disable() tells the other tabs first, so their syncs and any enable() in flight stop, then forgets the opt-ins and removes the subscription inside the same lock. Without Web Locks the re-reads and the message still apply. * fix(puter-js): wait for a push confirmation only once the lock is held A sync queued behind another tab's lock started its 30 second confirmation timer and listener before it got the lock, so a long wait failed the sync or left the timeout unhandled. The listener now starts inside the locked step, right before the subscription is announced. * test(puter-js): reach the push sync in the boot sign-in e2e The push page now has the user's opt-in and cached user from an earlier load, starts its sync before boot's own requests come back, and gets a rejected token from the push routes, so the spec covers the key request that must not open a sign-in. * test(puter-js): run the push boot test on full Chromium and skip it without push support The headless shell keeps Notification.permission at 'denied' after the permission is granted, so onPush() never synced there and the test saw no key request. The test now launches full Chromium itself, since a channel can't be set per test, and is skipped on WebKit and wherever the page has no PushManager or notification permission. * test(puter-js): keep the push boot test comment to one line --- .../src/modules/notifications/index.js | 56 ++- .../src/modules/notifications/lib/webPush.js | 142 +++++--- .../src/modules/notifications/webPush.test.js | 335 +++++++++++++++++- .../tests/e2e/fixtures/boot-reauth.html | 7 + .../tests/e2e/specs/bootReauth.spec.js | 98 +++++ 5 files changed, 578 insertions(+), 60 deletions(-) diff --git a/src/puter-js/src/modules/notifications/index.js b/src/puter-js/src/modules/notifications/index.js index b62c8cc51..edfd2afcb 100644 --- a/src/puter-js/src/modules/notifications/index.js +++ b/src/puter-js/src/modules/notifications/index.js @@ -5,6 +5,7 @@ import { cancel } from './cancel.js'; import { disable } from './disable.js'; import { enable } from './enable.js'; import { enablePush } from './enablePush.js'; +import { request } from './lib/api.js'; import { CLICKS_STORE, HANDLERS_STORE, @@ -48,6 +49,8 @@ export const CLICK_PARAM = 'puter.notification'; /** Where a site's tabs tell each other that a user's opt-in changed. */ const OPT_IN_CHANNEL = 'puter-push-opt-in'; +/** Held by a site's tabs while one changes this browser's subscription. */ +const PUSH_LOCK = 'puter-push'; /** As long as `push-sw.js` gives a handler. */ const HANDLER_TIMEOUT_MS = 5_000; @@ -60,6 +63,8 @@ const QUIET_SYNC_ERRORS = new Set([ 'push_revoked', 'push_unavailable', 'push_verification_canceled', + 'reauth_required', + 'token_auth_failed', 'too_many_requests', ]); @@ -73,6 +78,14 @@ const warnStorage = (error) => { ); }; +/** Runs `run` alone among the site's tabs, where the browser has Web Locks. */ +const withPushLock = (run) => { + const locks = globalThis.navigator?.locks; + return typeof locks?.request === 'function' + ? locks.request(PUSH_LOCK, () => run()) + : run(); +}; + /** Hidden, or in a window without focus. */ const pageAway = () => { const doc = globalThis.document; @@ -363,6 +376,7 @@ export class NotificationsModule extends EventListener { */ async _enableWebPush(worker) { this.#useWorker(worker); + this.#listenForOptInChanges(); /** @type {() => void} */ let pending = () => {}; const sent = new Promise((resolve) => { @@ -374,6 +388,8 @@ export class NotificationsModule extends EventListener { onPending: pending, signal, renewRemoved: true, + interactive: true, + withLock: withPushLock, }); try { // The GUI reads the registered device right after. @@ -409,12 +425,12 @@ export class NotificationsModule extends EventListener { * @internal */ async _disableWebPush(worker) { - this.#cancelVerification(); - try { - return await removeWebPush(this.puter, { worker }); - } finally { + this.#stopSyncs(); + if (this.puter.env === 'web') this.#optInChanged('off'); + return await withPushLock(async () => { if (this.puter.env === 'web') await this.#forgetOptIns(); - } + return await removeWebPush(this.puter, { worker }); + }); } /** @@ -460,8 +476,7 @@ export class NotificationsModule extends EventListener { */ async _forgetPush(authToken) { if (this.puter.env !== 'web') return; - this.#syncs.clear(); - this.#cancelVerification(); + this.#stopSyncs(); try { if (authToken && webPushSupported()) { await removeWebPush(this.puter, { @@ -510,8 +525,14 @@ export class NotificationsModule extends EventListener { const known = this.puter.whoami ?? (await this.puter.whoamiCache_) ?? null; if (typeof known?.uuid === 'string') return known.uuid; - const user = await this.puter.auth?.whoami?.(); - return typeof user?.uuid === 'string' ? user.uuid : null; + try { + const user = await request(this.puter, 'GET', '/whoami', null, { + interactiveReauth: false, + }); + return typeof user?.uuid === 'string' ? user.uuid : null; + } catch { + return null; + } } async #recordOptIn(on) { @@ -558,11 +579,11 @@ export class NotificationsModule extends EventListener { return on; } - /** Other tabs of the site re-read the opt-in this one just wrote. */ - #optInChanged() { + /** Other tabs re-read the opt-in; `off` also stops their syncs. */ + #optInChanged(kind = 'changed') { this.#listenForOptInChanges(); try { - this.#optInChannel?.postMessage('changed'); + this.#optInChannel?.postMessage(kind); } catch { // A closed channel; the other tabs read it on their next sync. } @@ -575,7 +596,10 @@ export class NotificationsModule extends EventListener { ) return; this.#optInChannel = new BroadcastChannel(OPT_IN_CHANNEL); - this.#optInChannel.onmessage = () => void this.#readOptIn(); + this.#optInChannel.onmessage = (event) => { + if (event.data === 'off') this.#stopSyncs(); + void this.#readOptIn(); + }; } async #syncIfOptedIn(worker) { @@ -584,6 +608,8 @@ export class NotificationsModule extends EventListener { syncWebPush(this.puter, { worker, signal: this.#stopVerifying.signal, + stillWanted: () => this.#readOptIn(), + withLock: withPushLock, }), worker, 'could not keep the push subscription current', @@ -625,9 +651,11 @@ export class NotificationsModule extends EventListener { await this.#recordOptIn(false); } - #cancelVerification() { + /** Ends this page's syncs in flight; the next `onPush` syncs again. */ + #stopSyncs() { this.#stopVerifying.abort(); this.#stopVerifying = new AbortController(); + this.#syncs.clear(); } /** diff --git a/src/puter-js/src/modules/notifications/lib/webPush.js b/src/puter-js/src/modules/notifications/lib/webPush.js index 11dddfff5..1f606fd56 100644 --- a/src/puter-js/src/modules/notifications/lib/webPush.js +++ b/src/puter-js/src/modules/notifications/lib/webPush.js @@ -220,11 +220,23 @@ const subscribeWith = async (registration, key) => { }; /** Tells the server about `subscription`; `active` or `pending`. */ -const announce = (puter, subscription, label) => - request(puter, 'POST', '/push/web/subscriptions', { - subscription: subscriptionBody(subscription), - ...(label ? { label } : {}), - }); +const announce = (puter, subscription, label, interactive) => + request( + puter, + 'POST', + '/push/web/subscriptions', + { + subscription: subscriptionBody(subscription), + ...(label ? { label } : {}), + }, + { interactiveReauth: interactive }, + ); + +const turnedOff = () => + new PuterJSError( + 'Push notifications were turned off before this browser was confirmed', + 'push_verification_canceled', + ); /** Whether `buffer` is the key Puter last subscribed this origin with. */ const earlierPuterKey = async (buffer) => { @@ -265,41 +277,44 @@ const currentSubscription = async (puter, registration, key) => { return null; }; -/** - * Announces `subscription` and, for one the server doesn't have yet, waits for - * the confirmation push and verifies it. - */ +/** Announces what `subscribe` gives, then confirms it if the server asks. */ const register = async ( puter, nav, - subscription, - { label, onPending, signal }, + subscribe, + { label, onPending, signal, interactive, whileWanted }, ) => { - // Listening first: the confirmation can arrive before the POST returns. - const challenge = nextChallenge(nav, signal); + /** @type {ReturnType | null} */ + let challenge = null; try { - const result = await announce(puter, subscription, label); + const { subscription, result } = await whileWanted(async () => { + const subscription = await subscribe(); + // Before the POST: the confirmation can arrive before it returns. + challenge = nextChallenge(nav, signal); + return { + subscription, + result: await announce(puter, subscription, label, interactive), + }; + }); if (result.status === 'active') return result.device; onPending?.(); const answer = await challenge.promise; - if (answer === null) { - throw new PuterJSError( - 'Push notifications were turned off before this browser was confirmed', - 'push_verification_canceled', - ); - } - const { device } = await request( - puter, - 'POST', - '/push/web/subscriptions/verify', - { - subscription: subscriptionBody(subscription), - challenge: answer, - }, + if (answer === null) throw turnedOff(); + const { device } = await whileWanted(() => + request( + puter, + 'POST', + '/push/web/subscriptions/verify', + { + subscription: subscriptionBody(subscription), + challenge: answer, + }, + { interactiveReauth: false }, + ), ); return device; } finally { - challenge.cancel(); + challenge?.cancel(); } }; @@ -316,12 +331,18 @@ const register = async ( * onPending?: () => void; * signal?: AbortSignal; * renewRemoved?: boolean; + * interactive?: boolean; + * stillWanted?: () => boolean | Promise; + * withLock?: (run: () => Promise) => Promise; * }} options * `onPending` runs once the server has the subscription and only its * confirmation is left. Aborting `signal` stops waiting for that * confirmation, with `push_verification_canceled`. `renewRemoved` replaces a * subscription the user removed in Puter instead of rejecting with - * `push_device_removed`. + * `push_device_removed`. `interactive` lets a rejected token open Puter's + * sign-in for the key and the subscription; the confirmation never does. + * Subscribing and confirming each run in `withLock` while `stillWanted` + * holds, else reject with `push_verification_canceled`. * @returns {Promise>} The server's device record. */ export async function syncWebPush( @@ -333,9 +354,18 @@ export async function syncWebPush( onPending, signal, renewRemoved = false, + interactive = false, + stillWanted = () => true, + withLock = (run) => run(), }, ) { - const { publicKey } = await request(puter, 'GET', '/push/web/key'); + const { publicKey } = await request( + puter, + 'GET', + '/push/web/key', + undefined, + { interactiveReauth: interactive }, + ); if (typeof publicKey !== 'string' || publicKey === '') { throw new PuterJSError( "Push notifications aren't available on this deployment", @@ -347,24 +377,50 @@ export async function syncWebPush( keepSiteWorker: puter.env !== 'gui', }); - let subscription = - (await currentSubscription(puter, registration, key)) ?? - (await subscribeWith(registration, key)); - await rememberPuterKey(publicKey); - - const options = { label, onPending, signal }; + /** @type {PushSubscription | null} */ + let subscription = null; + const options = { + label, + onPending, + signal, + interactive, + whileWanted: (run) => + withLock(async () => { + if (signal?.aborted || !(await stillWanted())) + throw turnedOff(); + return await run(); + }), + }; try { - return await register(puter, nav, subscription, options); + return await register( + puter, + nav, + async () => { + subscription = + (await currentSubscription(puter, registration, key)) ?? + (await subscribeWith(registration, key)); + await rememberPuterKey(publicKey); + return subscription; + }, + options, + ); } catch (error) { const renewable = RENEWABLE_SUBSCRIPTION_ERRORS.has(error?.code) || (renewRemoved && error?.code === 'push_device_removed'); - if (!renewable || signal?.aborted) throw error; + if (!renewable || !subscription || signal?.aborted) throw error; // A brand-new subscription the push service doesn't know yet, an // endpoint another account turned off here, or one the user removed. - await subscription.unsubscribe(); - subscription = await subscribeWith(registration, key); - return await register(puter, nav, subscription, options); + const stale = subscription; + return await register( + puter, + nav, + async () => { + await stale.unsubscribe(); + return await subscribeWith(registration, key); + }, + options, + ); } } @@ -408,7 +464,7 @@ export async function removeWebPush( 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken }, + { authToken, interactiveReauth: false }, ); } finally { await subscription.unsubscribe(); diff --git a/src/puter-js/src/modules/notifications/webPush.test.js b/src/puter-js/src/modules/notifications/webPush.test.js index deb8fce3a..47cdb780d 100644 --- a/src/puter-js/src/modules/notifications/webPush.test.js +++ b/src/puter-js/src/modules/notifications/webPush.test.js @@ -178,6 +178,7 @@ describe('syncWebPush', () => { keys: { p256dh: 'p', auth: 'a' }, }, }, + { interactiveReauth: false }, ); expect(store.get('handlers:puter:server-key')).toBe(KEY); }); @@ -545,6 +546,145 @@ describe('syncWebPush', () => { ), ).toHaveLength(2); }); + + /** A server that answers pending; `confirm()` hands back the challenge. */ + const pendingServer = (nav) => { + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd8' } }, + ); + return () => + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + }; + const reauthByRoute = () => + Object.fromEntries( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options?.interactiveReauth, + ]), + ); + + it.each([ + [false, undefined], + [true, true], + ])( + 'with interactive: %s, lets only the key and the announce open a sign-in', + async (interactive, given) => { + const nav = makeNav(); + const confirm = pendingServer(nav); + const onPending = vi.fn(); + const syncing = syncWebPush(puter, { + worker, + nav, + onPending, + interactive: given, + }); + await vi.waitFor(() => expect(onPending).toHaveBeenCalled()); + confirm(); + await syncing; + expect(reauthByRoute()).toEqual({ + '/push/web/key': interactive, + '/push/web/subscriptions': interactive, + '/push/web/subscriptions/verify': false, + }); + }, + ); + + it('subscribes and announces inside withLock, and not at all once push is no longer wanted', async () => { + const nav = makeNav(); + const order = []; + server((route) => { + order.push(route); + return { status: 'active', device: { uid: 'd1' } }; + }); + const withLock = async (run) => { + order.push('lock'); + try { + return await run(); + } finally { + order.push('unlock'); + } + }; + nav.pushManager.subscribe.mockImplementation(async () => { + order.push('subscribe'); + return makeSubscription(); + }); + await syncWebPush(puter, { worker, nav, withLock }); + expect(order).toEqual([ + 'lock', + 'subscribe', + '/push/web/subscriptions', + 'unlock', + ]); + + mockRequest.mockClear(); + nav.pushManager.subscribe.mockClear(); + await expect( + syncWebPush(puter, { worker, nav, stillWanted: () => false }), + ).rejects.toMatchObject({ code: 'push_verification_canceled' }); + expect(nav.pushManager.subscribe).not.toHaveBeenCalled(); + expect(Object.keys(reauthByRoute())).toEqual(['/push/web/key']); + }); + + it.each(['active', 'pending'])( + 'waits for the lock as long as it takes when the server answers %s', + async (status) => { + vi.useFakeTimers(); + const unhandled = []; + const note = (reason) => unhandled.push(reason); + process.on('unhandledRejection', note); + try { + const nav = makeNav(); + server(() => ({ status, device: { uid: 'd9' } })); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + const syncing = syncWebPush(puter, { + worker, + nav, + onPending: () => + nav.post({ + type: 'puter-push-challenge', + challenge: 'c.sig', + }), + withLock: async (run) => { + await held; + return await run(); + }, + }); + await vi.advanceTimersByTimeAsync(31_000); + release(); + await expect(syncing).resolves.toEqual({ uid: 'd9' }); + await vi.advanceTimersByTimeAsync(0); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', note); + } + }, + ); + + it('verifies only while push is still wanted when the confirmation arrives', async () => { + const nav = makeNav(); + const confirm = pendingServer(nav); + let wanted = true; + const syncing = syncWebPush(puter, { + worker, + nav, + onPending: () => { + wanted = false; + confirm(); + }, + stillWanted: () => wanted, + }); + await expect(syncing).rejects.toMatchObject({ + code: 'push_verification_canceled', + }); + expect(Object.keys(reauthByRoute())).not.toContain( + '/push/web/subscriptions/verify', + ); + }); }); describe('removeWebPush', () => { @@ -558,7 +698,7 @@ describe('removeWebPush', () => { 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: undefined }, + { authToken: undefined, interactiveReauth: false }, ); expect(subscription.unsubscribe).toHaveBeenCalled(); }); @@ -849,6 +989,8 @@ describe('onPush', () => { 'push_disabled', 'push_revoked', 'push_unavailable', + 'reauth_required', + 'token_auth_failed', 'too_many_requests', ])('keeps quiet about %s', async (code) => { store.set(OPT_IN, true); @@ -869,6 +1011,33 @@ describe('onPush', () => { error.mockRestore(); }); + it('never opens a sign-in, nor when the browser replaces the subscription', async () => { + store.set(OPT_IN, true); + const nav = makeNav({ existing: makeSubscription() }); + withBrowser(nav); + server(); + const { module } = makeModule(); + await module.onPush('chat', () => {}); + await vi.waitFor(() => + expect(mockRequest).toHaveBeenCalledTimes(2), + ); + nav.post({ type: 'puter-push-subscription-changed' }); + await vi.waitFor(() => + expect(mockRequest).toHaveBeenCalledTimes(4), + ); + expect( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options, + ]), + ).toEqual([ + ['/push/web/key', { interactiveReauth: false }], + ['/push/web/subscriptions', { interactiveReauth: false }], + ['/push/web/key', { interactiveReauth: false }], + ['/push/web/subscriptions', { interactiveReauth: false }], + ]); + }); + it('ends a subscription the user removed in Puter, quietly, and keeps pushes off until enable()', async () => { store.set(OPT_IN, true); const removed = makeSubscription(); @@ -1347,6 +1516,7 @@ describe('enable', () => { 'POST', '/push/web/subscriptions', expect.anything(), + { interactiveReauth: false }, ); }); @@ -1376,6 +1546,55 @@ describe('enable', () => { await expect(enabling).resolves.toBe(true); }); + it('lets the key and the announce open a sign-in, but not the confirmation', async () => { + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd2' } }, + ); + const { module } = makeModule(); + await module.enable(); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await vi.waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(3)); + expect( + mockRequest.mock.calls.map(([, , route, , options]) => [ + route, + options, + ]), + ).toEqual([ + ['/push/web/key', { interactiveReauth: true }], + ['/push/web/subscriptions', { interactiveReauth: true }], + ['/push/web/subscriptions/verify', { interactiveReauth: false }], + ]); + }); + + it('looks the user up without opening a sign-in when no user is cached', async () => { + withBrowser(makeNav({ existing: makeSubscription() })); + server((route) => + route === '/whoami' + ? { uuid: 'u-1' } + : { status: 'active', device: { uid: 'd1' } }, + ); + const auth = { whoami: vi.fn(async () => ({ uuid: 'u-1' })) }; + const { module } = makeModule({ + whoami: undefined, + whoamiCache_: Promise.resolve(null), + auth, + }); + await module.enable(); + expect(store.get(OPT_IN)).toBe(true); + expect(auth.whoami).not.toHaveBeenCalled(); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + 'GET', + '/whoami', + null, + { interactiveReauth: false }, + ); + }); + it('rejects user_gesture_required outside a click, without prompting', async () => { withBrowser(makeNav(), 'default'); Object.defineProperty(globalThis.navigator, 'userActivation', { @@ -1619,8 +1838,9 @@ describe('disable', () => { const { module } = makeModule(); const enabling = module.enable(); await vi.waitFor(() => expect(announce).toBeDefined()); - await module.disable(); + const disabling = module.disable(); announce(); + await expect(disabling).resolves.toBe(true); await expect(enabling).rejects.toMatchObject({ code: 'push_verification_canceled', }); @@ -1639,6 +1859,115 @@ describe('disable', () => { }); }); +describe.each([ + ['with Web Locks', false], + ['without Web Locks', true], +])('another tab turning push off, %s', (_label, noLocks) => { + beforeEach(() => { + if (noLocks) + Object.defineProperty(globalThis.navigator, 'locks', { + value: undefined, + configurable: true, + }); + }); + afterEach(() => { + if (noLocks) delete globalThis.navigator.locks; + }); + + /** Requests these tabs made; earlier tests' tabs still hear the channel. */ + const routes = (...tabs) => + mockRequest.mock.calls + .filter(([host]) => tabs.some((tab) => tab.puter === host)) + .map(([, method, route]) => `${method} ${route}`); + + it('stops a load sync before it announces, and the device stays removed', async () => { + store.set(OPT_IN, true); + withBrowser(makeNav({ existing: makeSubscription() })); + let releaseKey; + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') { + await new Promise((resolve) => { + releaseKey = resolve; + }); + return { publicKey: KEY }; + } + return method === 'DELETE' + ? { removed: true } + : { status: 'active', device: { uid: 'd1' } }; + }); + const onError = vi.fn(); + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError }); + await vi.waitFor(() => expect(releaseKey).toBeDefined()); + + const { module: other } = makeModule(); + await expect(other.disable()).resolves.toBe(true); + releaseKey(); + await flush(); + await flush(); + + expect(routes(loading, other)).toEqual([ + 'GET /push/web/key', + 'DELETE /push/web/subscriptions', + ]); + expect(onError).not.toHaveBeenCalled(); + expect(store.has(OPT_IN)).toBe(false); + }); + + it('stops a load sync waiting for its confirmation, without verifying', async () => { + store.set(OPT_IN, true); + const nav = makeNav(); + withBrowser(nav); + server((route) => + route === '/push/web/subscriptions' + ? { status: 'pending' } + : { device: { uid: 'd1' } }, + ); + const { module: loading } = makeModule(); + await loading.onPush('chat', () => {}, { onError: () => {} }); + await vi.waitFor(() => + expect(routes(loading)).toContain('POST /push/web/subscriptions'), + ); + + const { module: other } = makeModule(); + await other.disable(); + nav.post({ type: 'puter-push-challenge', challenge: 'c.sig' }); + await flush(); + await flush(); + + expect(routes(loading)).not.toContain( + 'POST /push/web/subscriptions/verify', + ); + }); + + it('makes an enable() in flight reject', async () => { + withBrowser(makeNav({ existing: makeSubscription() })); + let announce; + mockRequest.mockImplementation(async (_p, method, route) => { + if (route === '/push/web/key') return { publicKey: KEY }; + if (method === 'POST' && route === '/push/web/subscriptions') { + return await new Promise((resolve) => { + announce = () => resolve({ status: 'pending' }); + }); + } + return { removed: true }; + }); + const { module: enablingTab } = makeModule(); + const enabling = enablingTab.enable(); + await vi.waitFor(() => expect(announce).toBeDefined()); + + const { module: other } = makeModule(); + const disabling = other.disable(); + await flush(); + announce(); + await expect(enabling).rejects.toMatchObject({ + code: 'push_verification_canceled', + }); + await expect(disabling).resolves.toBe(true); + expect(store.has(OPT_IN)).toBe(false); + }); +}); + describe('sign-out', () => { it('removes the subscription with the leaving user`s token and forgets their push state', async () => { store.set(OPT_IN, true); @@ -1656,7 +1985,7 @@ describe('sign-out', () => { 'DELETE', '/push/web/subscriptions', { endpoint: subscription.endpoint }, - { authToken: 'leaving-token' }, + { authToken: 'leaving-token', interactiveReauth: false }, ); expect(subscription.unsubscribe).toHaveBeenCalled(); expect(store.has(OPT_IN)).toBe(false); diff --git a/src/puter-js/tests/e2e/fixtures/boot-reauth.html b/src/puter-js/tests/e2e/fixtures/boot-reauth.html index 91d752008..242d38a43 100644 --- a/src/puter-js/tests/e2e/fixtures/boot-reauth.html +++ b/src/puter-js/tests/e2e/fixtures/boot-reauth.html @@ -25,6 +25,13 @@ window.PUTER_API_ORIGIN = 'http://api.puter.localhost:4100'; +