Skip to content

Commit 61b4a8d

Browse files
committed
test(iframe-manager): add unit tests with jsdom
1 parent eedcca7 commit 61b4a8d

4 files changed

Lines changed: 289 additions & 14 deletions

File tree

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,11 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
267267
resume: (input: {
268268
continueToken: string;
269269
}) => Promise<InternalErrorResponse | NodeStates>;
270-
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode>;
270+
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode>;
271271
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
272272
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
273-
poll: (collector: PollingCollector) => Poller;
273+
pollStatus: (collector: PollingCollector) => Poller;
274274
getClient: () => {
275-
status: "start";
276-
} | {
277275
action: string;
278276
collectors: Collectors[];
279277
description?: string;
@@ -287,6 +285,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
287285
status: "error";
288286
} | {
289287
status: "failure";
288+
} | {
289+
status: "start";
290290
} | {
291291
authorization?: {
292292
code?: string;
@@ -297,7 +297,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
297297
getCollectors: () => Collectors[];
298298
getError: () => DaVinciError | null;
299299
getErrorCollectors: () => CollectorErrors[];
300-
getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode;
300+
getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode;
301301
getServer: () => {
302302
_links?: Links;
303303
id?: string;
@@ -306,8 +306,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
306306
href?: string;
307307
eventName?: string;
308308
status: "continue";
309-
} | {
310-
status: "start";
311309
} | {
312310
_links?: Links;
313311
eventName?: string;
@@ -323,6 +321,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
323321
interactionId?: string;
324322
interactionToken?: string;
325323
status: "failure";
324+
} | {
325+
status: "start";
326326
} | {
327327
_links?: Links;
328328
eventName?: string;

packages/davinci-client/api-report/davinci-client.types.api.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -267,13 +267,11 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
267267
resume: (input: {
268268
continueToken: string;
269269
}) => Promise<InternalErrorResponse | NodeStates>;
270-
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode>;
270+
start: <QueryParams extends OutgoingQueryParams = OutgoingQueryParams>(options?: StartOptions<QueryParams> | undefined) => Promise<ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode>;
271271
update: <T extends SingleValueCollectors | MultiSelectCollector | ObjectValueCollectors | AutoCollectors>(collector: T) => Updater<T>;
272272
validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator;
273-
poll: (collector: PollingCollector) => Poller;
273+
pollStatus: (collector: PollingCollector) => Poller;
274274
getClient: () => {
275-
status: "start";
276-
} | {
277275
action: string;
278276
collectors: Collectors[];
279277
description?: string;
@@ -287,6 +285,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
287285
status: "error";
288286
} | {
289287
status: "failure";
288+
} | {
289+
status: "start";
290290
} | {
291291
authorization?: {
292292
code?: string;
@@ -297,7 +297,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
297297
getCollectors: () => Collectors[];
298298
getError: () => DaVinciError | null;
299299
getErrorCollectors: () => CollectorErrors[];
300-
getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode;
300+
getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode;
301301
getServer: () => {
302302
_links?: Links;
303303
id?: string;
@@ -306,8 +306,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
306306
href?: string;
307307
eventName?: string;
308308
status: "continue";
309-
} | {
310-
status: "start";
311309
} | {
312310
_links?: Links;
313311
eventName?: string;
@@ -323,6 +321,8 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
323321
interactionId?: string;
324322
interactionToken?: string;
325323
status: "failure";
324+
} | {
325+
status: "start";
326326
} | {
327327
_links?: Links;
328328
eventName?: string;
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9+
import { iFrameManager } from './iframe-manager.effects.js';
10+
11+
/**
12+
* Patches an iframe's contentWindow.location.href to simulate navigation,
13+
* then fires a 'load' event so the onLoadHandler runs.
14+
*/
15+
function simulateIframeLoad(iframe: HTMLIFrameElement, href: string): void {
16+
Object.defineProperty(iframe, 'contentWindow', {
17+
value: { location: { href } },
18+
writable: true,
19+
configurable: true,
20+
});
21+
iframe.dispatchEvent(new Event('load'));
22+
}
23+
24+
describe('iFrameManager', () => {
25+
describe('getParamsByRedirect – input validation', () => {
26+
it('throws synchronously when successParams is empty', () => {
27+
const manager = iFrameManager();
28+
expect(() =>
29+
manager.getParamsByRedirect({
30+
url: 'https://example.com',
31+
timeout: 1000,
32+
successParams: [],
33+
errorParams: ['error'],
34+
}),
35+
).toThrow('successParams and errorParams must be provided');
36+
});
37+
38+
it('throws synchronously when errorParams is empty', () => {
39+
const manager = iFrameManager();
40+
expect(() =>
41+
manager.getParamsByRedirect({
42+
url: 'https://example.com',
43+
timeout: 1000,
44+
successParams: ['code'],
45+
errorParams: [],
46+
}),
47+
).toThrow('successParams and errorParams must be provided');
48+
});
49+
50+
it('throws synchronously when successParams or errorParams is undefined', () => {
51+
const manager = iFrameManager();
52+
expect(() =>
53+
manager.getParamsByRedirect({
54+
url: 'https://example.com',
55+
timeout: 1000,
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
successParams: undefined as any,
58+
errorParams: ['error'],
59+
}),
60+
).toThrow('successParams and errorParams must be provided');
61+
});
62+
});
63+
64+
describe('getParamsByRedirect – iframe lifecycle', () => {
65+
beforeEach(() => {
66+
vi.useFakeTimers();
67+
});
68+
69+
afterEach(() => {
70+
vi.useRealTimers();
71+
document.body.replaceChildren();
72+
});
73+
74+
it('creates a hidden iframe with display:none and appends it to document.body', () => {
75+
const manager = iFrameManager();
76+
manager.getParamsByRedirect({
77+
url: 'https://example.com',
78+
timeout: 5000,
79+
successParams: ['code'],
80+
errorParams: ['error'],
81+
});
82+
83+
const iframe = document.querySelector('iframe');
84+
expect(iframe).not.toBeNull();
85+
expect(iframe?.style.display).toBe('none');
86+
});
87+
88+
it('sets iframe.src to the provided URL', () => {
89+
const manager = iFrameManager();
90+
manager.getParamsByRedirect({
91+
url: 'https://example.com/start',
92+
timeout: 5000,
93+
successParams: ['code'],
94+
errorParams: ['error'],
95+
});
96+
97+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
98+
expect(iframe.src).toBe('https://example.com/start');
99+
});
100+
101+
it('rejects with timeout error when iframe never resolves', async () => {
102+
const manager = iFrameManager();
103+
const promise = manager.getParamsByRedirect({
104+
url: 'https://example.com',
105+
timeout: 3000,
106+
successParams: ['code'],
107+
errorParams: ['error'],
108+
});
109+
110+
vi.advanceTimersByTime(3000);
111+
112+
await expect(promise).rejects.toEqual({
113+
type: 'internal_error',
114+
message: 'iframe timed out',
115+
});
116+
});
117+
118+
it('removes the iframe from the DOM after timeout', async () => {
119+
const manager = iFrameManager();
120+
const promise = manager.getParamsByRedirect({
121+
url: 'https://example.com',
122+
timeout: 3000,
123+
successParams: ['code'],
124+
errorParams: ['error'],
125+
});
126+
127+
vi.advanceTimersByTime(3000);
128+
await promise.catch(vi.fn());
129+
130+
expect(document.querySelector('iframe')).toBeNull();
131+
});
132+
133+
it('resolves with all query params when any successParam key is present in the redirect URL', async () => {
134+
const manager = iFrameManager();
135+
const promise = manager.getParamsByRedirect({
136+
url: 'https://example.com/start',
137+
timeout: 5000,
138+
successParams: ['code'],
139+
errorParams: ['error'],
140+
});
141+
142+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
143+
simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123&state=xyz');
144+
145+
const result = await promise;
146+
expect(result).toEqual({ code: 'abc123', state: 'xyz' });
147+
});
148+
149+
it('removes the iframe from the DOM after resolving with success params', async () => {
150+
const manager = iFrameManager();
151+
const promise = manager.getParamsByRedirect({
152+
url: 'https://example.com/start',
153+
timeout: 5000,
154+
successParams: ['code'],
155+
errorParams: ['error'],
156+
});
157+
158+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
159+
simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123');
160+
161+
await promise;
162+
expect(document.querySelector('iframe')).toBeNull();
163+
});
164+
165+
it('resolves (not rejects) with all query params when an errorParam key is present', async () => {
166+
const manager = iFrameManager();
167+
const promise = manager.getParamsByRedirect({
168+
url: 'https://example.com/start',
169+
timeout: 5000,
170+
successParams: ['code'],
171+
errorParams: ['error', 'error_description'],
172+
});
173+
174+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
175+
simulateIframeLoad(
176+
iframe,
177+
'https://app.example.com/callback?error=access_denied&error_description=User+cancelled',
178+
);
179+
180+
const result = await promise;
181+
expect(result).toEqual({
182+
error: 'access_denied',
183+
error_description: 'User cancelled',
184+
});
185+
});
186+
187+
it('ignores the initial about:blank load event and keeps waiting', async () => {
188+
const manager = iFrameManager();
189+
const promise = manager.getParamsByRedirect({
190+
url: 'https://example.com/start',
191+
timeout: 5000,
192+
successParams: ['code'],
193+
errorParams: ['error'],
194+
});
195+
196+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
197+
198+
simulateIframeLoad(iframe, 'about:blank');
199+
vi.advanceTimersByTime(100);
200+
201+
simulateIframeLoad(iframe, 'https://app.example.com/callback?code=abc123');
202+
203+
const result = await promise;
204+
expect(result).toEqual({ code: 'abc123' });
205+
});
206+
207+
it('waits through intermediate redirects before resolving', async () => {
208+
const manager = iFrameManager();
209+
const promise = manager.getParamsByRedirect({
210+
url: 'https://example.com/start',
211+
timeout: 5000,
212+
successParams: ['code'],
213+
errorParams: ['error'],
214+
});
215+
216+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
217+
218+
simulateIframeLoad(iframe, 'https://example.com/authorize');
219+
simulateIframeLoad(iframe, 'https://app.example.com/callback?code=final');
220+
221+
const result = await promise;
222+
expect(result).toEqual({ code: 'final' });
223+
});
224+
225+
it('rejects with internal_error when contentWindow access throws (cross-origin)', async () => {
226+
const manager = iFrameManager();
227+
const promise = manager.getParamsByRedirect({
228+
url: 'https://example.com/start',
229+
timeout: 5000,
230+
successParams: ['code'],
231+
errorParams: ['error'],
232+
});
233+
234+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
235+
236+
Object.defineProperty(iframe, 'contentWindow', {
237+
get() {
238+
throw new DOMException('Blocked a frame with origin', 'SecurityError');
239+
},
240+
configurable: true,
241+
});
242+
243+
iframe.dispatchEvent(new Event('load'));
244+
245+
await expect(promise).rejects.toEqual({
246+
type: 'internal_error',
247+
message: 'unexpected failure',
248+
});
249+
});
250+
251+
it('removes the iframe from the DOM after cross-origin rejection', async () => {
252+
const manager = iFrameManager();
253+
const promise = manager.getParamsByRedirect({
254+
url: 'https://example.com/start',
255+
timeout: 5000,
256+
successParams: ['code'],
257+
errorParams: ['error'],
258+
});
259+
260+
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
261+
262+
Object.defineProperty(iframe, 'contentWindow', {
263+
get() {
264+
throw new DOMException('Blocked a frame with origin', 'SecurityError');
265+
},
266+
configurable: true,
267+
});
268+
269+
iframe.dispatchEvent(new Event('load'));
270+
await promise.catch(vi.fn());
271+
272+
expect(document.querySelector('iframe')).toBeNull();
273+
});
274+
});
275+
});
2 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)