diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..289c8a5276 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 2321431a0a..05f38634dc 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts new file mode 100644 index 0000000000..688e9e481a --- /dev/null +++ b/packages/sdk-effects/iframe-manager/src/lib/iframe-manager.effects.test.ts @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { iFrameManager } from './iframe-manager.effects.js'; + +/** + * Patches an iframe's contentWindow.location.href to simulate navigation, + * then fires a 'load' event so the onLoadHandler runs. + */ +function simulateIframeLoad(iframe: HTMLIFrameElement, href: string): void { + Object.defineProperty(iframe, 'contentWindow', { + value: { location: { href } }, + writable: true, + configurable: true, + }); + iframe.dispatchEvent(new Event('load')); +} + +describe('iFrameManager', () => { + describe('getParamsByRedirect – input validation', () => { + it('throws synchronously when successParams is empty', () => { + const manager = iFrameManager(); + expect(() => + manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 1000, + successParams: [], + errorParams: ['error'], + }), + ).toThrow('successParams and errorParams must be provided'); + }); + + it('throws synchronously when errorParams is empty', () => { + const manager = iFrameManager(); + expect(() => + manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 1000, + successParams: ['code'], + errorParams: [], + }), + ).toThrow('successParams and errorParams must be provided'); + }); + + it('throws synchronously when successParams or errorParams is undefined', () => { + const manager = iFrameManager(); + expect(() => + manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 1000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + successParams: undefined as any, + errorParams: ['error'], + }), + ).toThrow('successParams and errorParams must be provided'); + }); + }); + + describe('getParamsByRedirect – iframe lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it('creates a hidden iframe with display:none and appends it to document.body', () => { + const manager = iFrameManager(); + manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe'); + expect(iframe).not.toBeNull(); + expect(iframe?.style.display).toBe('none'); + }); + + it('sets iframe.src to the provided URL', () => { + const manager = iFrameManager(); + manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + expect(iframe.src).toBe('https://example.com/start'); + }); + + it('rejects with timeout error when iframe never resolves', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 3000, + successParams: ['code'], + errorParams: ['error'], + }); + + vi.advanceTimersByTime(3000); + + await expect(promise).rejects.toEqual({ + type: 'internal_error', + message: 'iframe timed out', + }); + }); + + it('removes the iframe from the DOM after timeout', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com', + timeout: 3000, + successParams: ['code'], + errorParams: ['error'], + }); + + vi.advanceTimersByTime(3000); + await promise.catch(vi.fn()); + + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('resolves with all query params when any successParam key is present in the redirect URL', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123&state=xyz'); + + const result = await promise; + expect(result).toEqual({ code: 'abc123', state: 'xyz' }); + }); + + it('removes the iframe from the DOM after resolving with success params', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123'); + + await promise; + expect(document.querySelector('iframe')).toBeNull(); + }); + + it('resolves (not rejects) with all query params when an errorParam key is present', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error', 'error_description'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + simulateIframeLoad( + iframe, + 'https://app.example.com/callback?error=access_denied&error_description=User+cancelled', + ); + + const result = await promise; + expect(result).toEqual({ + error: 'access_denied', + error_description: 'User cancelled', + }); + }); + + it('ignores the initial about:blank load event and keeps waiting', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + + simulateIframeLoad(iframe, 'about:blank'); + vi.advanceTimersByTime(100); + + simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123'); + + const result = await promise; + expect(result).toEqual({ code: 'abc123' }); + }); + + it('waits through intermediate redirects before resolving', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + + simulateIframeLoad(iframe, 'https://example.com/authorize'); + simulateIframeLoad(iframe, 'https://app.example.com/callback?code=final'); + + const result = await promise; + expect(result).toEqual({ code: 'final' }); + }); + + it('rejects with internal_error when contentWindow access throws (cross-origin)', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + + Object.defineProperty(iframe, 'contentWindow', { + get() { + throw new DOMException('Blocked a frame with origin', 'SecurityError'); + }, + configurable: true, + }); + + iframe.dispatchEvent(new Event('load')); + + await expect(promise).rejects.toEqual({ + type: 'internal_error', + message: 'unexpected failure', + }); + }); + + it('removes the iframe from the DOM after cross-origin rejection', async () => { + const manager = iFrameManager(); + const promise = manager.getParamsByRedirect({ + url: 'https://example.com/start', + timeout: 5000, + successParams: ['code'], + errorParams: ['error'], + }); + + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + + Object.defineProperty(iframe, 'contentWindow', { + get() { + throw new DOMException('Blocked a frame with origin', 'SecurityError'); + }, + configurable: true, + }); + + iframe.dispatchEvent(new Event('load')); + await promise.catch(vi.fn()); + + expect(document.querySelector('iframe')).toBeNull(); + }); + }); +}); diff --git a/packages/sdk-effects/iframe-manager/vite.config.ts b/packages/sdk-effects/iframe-manager/vite.config.ts index d66e3b2f06..60d96e1926 100644 Binary files a/packages/sdk-effects/iframe-manager/vite.config.ts and b/packages/sdk-effects/iframe-manager/vite.config.ts differ