import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CMType, COUNTERPARTY_ASSIST_MESSAGES, ErrorIdentifierCode, HMType, } from '../types'; import { seal } from '../utils/encryption'; import { MARGIN_MS } from '../utils/tokenRefresh'; import { getRefreshResult, Notabene } from '../v21'; import { jwt } from './helpers/jwt'; const realSetTimeout = setTimeout; const LIFETIME = 10 * 60_000; // Each call returns a token that expires LIFETIME after the call (n seconds apart so tokens differ). const tokenSequence = () => { let n = 0; return vi.fn(async () => jwt({ delegate: true, exp: Math.floor(Date.now() / 1000) + LIFETIME / 1000 + n++, }), ); }; const makeComponent = ( fetchToken: () => Promise, fetchCounterpartyAssistToken?: () => Promise<{ token: string; jti: string }>, ) => new Notabene({ nodeUrl: 'https://node.example.com', uxUrl: 'https://connect.example.com', fetchToken, fetchCounterpartyAssistToken, }).createWithdrawalAssist({}); type C = ReturnType; describe('v2.1 Notabene fetchToken', () => { let container: HTMLDivElement; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); document.body.innerHTML = ''; }); // Connects a real MessageChannel and collects what the host posts to the component. const connect = (iframe: HTMLIFrameElement) => { const channel = new MessageChannel(); const received: any[] = []; channel.port1.onmessage = (e) => received.push(e.data); window.dispatchEvent( new MessageEvent('message', { source: iframe.contentWindow, ports: [channel.port2], }), ); return { channel, received }; }; // MessageChannel delivery is not timer based, so give it real time. const flushPort = async () => { await vi.advanceTimersByTimeAsync(0); await new Promise((r) => realSetTimeout(r, 20)); }; it('embed puts the fetched token in the iframe hash', async () => { const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); component.embed(container); const iframe = container.querySelector('iframe')!; expect(iframe.getAttribute('src')).toBeNull(); await vi.advanceTimersByTimeAsync(0); const src = new URL(iframe.src); const hash = new URLSearchParams(src.hash.slice(1)); expect(hash.get('authToken')).toBe(await fetchToken.mock.results[0].value); expect(hash.get('value')).toBe('{}'); expect(hash.get('embedded')).toBe('true'); expect(hash.has('canMintCounterpartyAssistToken')).toBe(false); expect(src.searchParams.get('nodeUrl')).toBe('https://node.example.com'); }); it('embed adds only canMintCounterpartyAssistToken to the iframe URL when the host can mint one', async () => { const srcOf = async (component: C) => { const parent = document.createElement('div'); document.body.appendChild(parent); component.embed(parent); await vi.advanceTimersByTimeAsync(0); const src = new URL(parent.querySelector('iframe')!.src); const fragment = Object.fromEntries( new URLSearchParams(src.hash.slice(1)), ); src.hash = ''; return { url: src.toString(), fragment }; }; const plain = await srcOf(makeComponent(tokenSequence())); const flagged = await srcOf( makeComponent(tokenSequence(), async () => ({ token: 'cpa', jti: 'j' })), ); expect(flagged).toEqual({ url: plain.url, fragment: { ...plain.fragment, canMintCounterpartyAssistToken: 'true' }, }); }); it('posts a token message to the port on refresh', async () => { const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); component.embed(container); const { received } = connect(container.querySelector('iframe')!); await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(LIFETIME - MARGIN_MS); await flushPort(); expect(received).toEqual([ { type: HMType.TOKEN, token: await fetchToken.mock.results[1].value }, ]); }); it('answers a counterparty-assist token request with the token and jti', async () => { const cpaToken = jwt({ delegate: true, exp: 1, cpa: true }); const component = makeComponent(tokenSequence(), async () => ({ token: cpaToken, jti: 'jti-1', })); component.embed(container); const { channel, received } = connect(container.querySelector('iframe')!); await vi.advanceTimersByTimeAsync(0); channel.port1.postMessage({ type: COUNTERPARTY_ASSIST_MESSAGES.REQUEST_TOKEN, reqid: 'r1', }); await flushPort(); expect(received).toContainEqual({ type: COUNTERPARTY_ASSIST_MESSAGES.TOKEN, reqid: 'r1', token: cpaToken, jti: 'jti-1', }); }); it('answers a counterparty-assist token request with an error when no callback is configured', async () => { const component = makeComponent(tokenSequence()); component.embed(container); const { channel, received } = connect(container.querySelector('iframe')!); await vi.advanceTimersByTimeAsync(0); vi.spyOn(console, 'error').mockImplementation(() => {}); channel.port1.postMessage({ type: COUNTERPARTY_ASSIST_MESSAGES.REQUEST_TOKEN, reqid: 'r1', }); await flushPort(); expect(received).toContainEqual({ type: COUNTERPARTY_ASSIST_MESSAGES.TOKEN, reqid: 'r1', error: 'fetchCounterpartyAssistToken is not configured', }); }); it('rejects a counterparty-assist token identical to the fetchToken token', async () => { const sameToken = jwt({ delegate: true, exp: 1 }); const component = makeComponent( async () => sameToken, async () => ({ token: sameToken, jti: 'jti-1' }), ); component.embed(container); const { channel, received } = connect(container.querySelector('iframe')!); await vi.advanceTimersByTimeAsync(0); vi.spyOn(console, 'error').mockImplementation(() => {}); channel.port1.postMessage({ type: COUNTERPARTY_ASSIST_MESSAGES.REQUEST_TOKEN, reqid: 'r1', }); await flushPort(); expect(received).toContainEqual({ type: COUNTERPARTY_ASSIST_MESSAGES.TOKEN, reqid: 'r1', error: expect.stringContaining('different token'), }); }); it('replays a refreshed token when the port connects after it', async () => { const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); component.embed(container); await vi.advanceTimersByTimeAsync(LIFETIME - MARGIN_MS); const { received } = connect(container.querySelector('iframe')!); await flushPort(); expect(received).toEqual([ { type: HMType.TOKEN, token: await fetchToken.mock.results[1].value }, ]); }); describe('stops refreshing on teardown', () => { it.each([ ['removeEmbed', (c: C) => c.removeEmbed()], ['closeModal', (c: C) => c.closeModal()], [ 'COMPLETE', async (_c: C, port: MessagePort) => { port.postMessage({ type: CMType.COMPLETE, response: {} }); await flushPort(); }, ], [ 'CANCEL', async (_c: C, port: MessagePort) => { port.postMessage({ type: CMType.CANCEL }); await flushPort(); }, ], ])('%s', async (name, teardown) => { const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); if (name === 'closeModal') { // The modal cleanup is not under test here, so ignore completion(). HTMLDialogElement.prototype.showModal ??= () => {}; HTMLDialogElement.prototype.close ??= () => {}; component.openModal().catch(() => {}); } else { component.embed(container); } const iframe = document.querySelector('iframe')!; const port1 = connect(iframe).channel.port1; await vi.advanceTimersByTimeAsync(0); expect(fetchToken).toHaveBeenCalledTimes(1); await teardown(component, port1); await vi.advanceTimersByTimeAsync(2 * LIFETIME); expect(fetchToken).toHaveBeenCalledTimes(1); }); it('host removing the iframe stops refreshing after the next fetch', async () => { const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); component.embed(container); await vi.advanceTimersByTimeAsync(0); container.remove(); await vi.advanceTimersByTimeAsync(3 * LIFETIME); expect(fetchToken).toHaveBeenCalledTimes(2); }); }); it('emits TOKEN_INVALID to error listeners when fetchToken rejects', async () => { const component = makeComponent(() => Promise.reject(new Error('backend down')), ); const onError = vi.fn(); component.on(CMType.ERROR, onError); component.embed(container); await vi.advanceTimersByTimeAsync(0); expect(onError).toHaveBeenCalledWith({ type: CMType.ERROR, identifier: ErrorIdentifierCode.TOKEN_INVALID, message: 'backend down', }); }); it('rejects a token that is not delegated', async () => { const component = makeComponent(async () => jwt({ delegate: false })); const onError = vi.fn(); component.on(CMType.ERROR, onError); component.embed(container); await vi.advanceTimersByTimeAsync(0); expect(onError).toHaveBeenCalledWith({ type: CMType.ERROR, identifier: ErrorIdentifierCode.TOKEN_INVALID, message: expect.stringMatching(/delegated token/), }); expect(container.querySelector('iframe')!.getAttribute('src')).toBeNull(); }); it('openModal closes the modal when the first fetch fails', async () => { HTMLDialogElement.prototype.showModal ??= () => {}; HTMLDialogElement.prototype.close ??= () => {}; const component = makeComponent(() => Promise.reject(new Error('backend down')), ); void component.openModal().catch(() => {}); expect(document.querySelector('dialog')).not.toBeNull(); await vi.advanceTimersByTimeAsync(0); expect(document.querySelector('dialog')).toBeNull(); }); describe('popup', () => { const stubPopup = () => { const popup = { location: { href: '' }, closed: false, close: vi.fn() }; const open = vi.spyOn(window, 'open').mockReturnValue(popup as any); return { popup, open }; }; it('popup opens about:blank then navigates with the token', async () => { const fetchToken = tokenSequence(); const { popup, open } = stubPopup(); const component = makeComponent(fetchToken); void component.popup().catch(() => {}); expect(open).toHaveBeenCalledWith( 'about:blank', '_blank', 'popup=true,width=600,height=600', ); expect(popup.location.href).toBe(''); await vi.advanceTimersByTimeAsync(0); const hash = new URLSearchParams( new URL(popup.location.href).hash.slice(1), ); expect(hash.get('authToken')).toBe( await fetchToken.mock.results[0].value, ); }); it('popup stops refreshing once the window is closed', async () => { const fetchToken = tokenSequence(); const { popup } = stubPopup(); const component = makeComponent(fetchToken); void component.popup().catch(() => {}); await vi.advanceTimersByTimeAsync(0); popup.closed = true; await vi.advanceTimersByTimeAsync(2 * LIFETIME); expect(fetchToken).toHaveBeenCalledTimes(2); }); it('popup stops refreshing when window.open is blocked', async () => { vi.spyOn(window, 'open').mockReturnValue(null); const fetchToken = tokenSequence(); const component = makeComponent(fetchToken); void component.popup().catch(() => {}); await vi.advanceTimersByTimeAsync(2 * LIFETIME); expect(fetchToken).toHaveBeenCalledTimes(1); }); it('popup closes the blank window when the first fetch fails', async () => { const { popup } = stubPopup(); const component = makeComponent(() => Promise.reject(new Error('backend down')), ); void component.popup().catch(() => {}); await vi.advanceTimersByTimeAsync(0); expect(popup.close).toHaveBeenCalled(); }); }); it('open throws when fetchToken is configured', () => { const component = makeComponent(tokenSequence()); expect(() => component.open()).toThrow(/not supported/); }); }); describe('v2.1 getRefreshResult', () => { afterEach(() => { vi.restoreAllMocks(); }); it('leaves the in-progress transaction out of an active link', async () => { const { key, ciphertext } = await seal({ tx: { amountDecimal: '10' } }); const metadata = { participants: [], nodeUrl: 'https://node.example.com' }; vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( JSON.stringify({ id: 'c1', metadata, status: 'active', sealed: [ciphertext], version: 1, }), ), ); const result = await getRefreshResult({ url: 'https://connections.test/c1', key, id: 'c1', }); expect(result).toEqual({ id: 'c1', metadata, status: 'active' }); }); });