Skip to content

Commit c8ea9a9

Browse files
ancheetahryanbas21
authored andcommitted
chore(davinci-client): refactor and improve polling
- validatePollingPrerequisites uses nodeSlice selectors, returns union instead of throwing - handleChallengePolling validates eagerly, uses Micro.map for interpretation - Removed private selectContinueServer and selectSelfLink functions - Added isInternalError type guard to store utils - Create a simpler pipeline at the shell refactor: refactor-polling
1 parent f6abd49 commit c8ea9a9

19 files changed

Lines changed: 1108 additions & 269 deletions

.changeset/lucky-parts-own.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@forgerock/sdk-request-middleware': minor
3+
'@forgerock/davinci-client': minor
4+
---
5+
6+
Support both challenge polling and continue polling in DaVinci

e2e/davinci-app/components/polling.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export default function pollingComponent(
2525
formEl.appendChild(button);
2626

2727
button.onclick = async () => {
28+
button.disabled = true;
29+
2830
const p = document.createElement('p');
2931
p.innerText = 'Polling...';
3032
formEl?.appendChild(p);
@@ -54,5 +56,7 @@ export default function pollingComponent(
5456
formEl?.appendChild(resultEl);
5557

5658
await submitForm();
59+
60+
button.disabled = false;
5761
};
5862
}

e2e/davinci-app/server-configs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,17 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
7777
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
7878
},
7979
},
80+
/**
81+
* AJ Polling
82+
*
83+
*/
84+
'ca0e8ba6-ad9f-4354-a778-d47fe8357ace': {
85+
clientId: 'ca0e8ba6-ad9f-4354-a778-d47fe8357ace',
86+
redirectUri: window.location.origin,
87+
scope: 'openid profile email revoke',
88+
serverConfig: {
89+
wellknown:
90+
'https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/.well-known/openid-configuration',
91+
},
92+
},
8093
};

packages/davinci-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"immer": "catalog:"
3535
},
3636
"devDependencies": {
37+
"@effect/vitest": "catalog:effect",
3738
"vitest": "catalog:vitest"
3839
},
3940
"publishConfig": {
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/*
2+
* Copyright (c) 2026 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 { Micro } from 'effect';
9+
import { describe, expect, vi } from 'vitest';
10+
import { it } from '@effect/vitest';
11+
12+
import {
13+
buildChallengeEndpoint,
14+
isChallengeStillPending,
15+
interpretChallengeResponse,
16+
} from './client.store.effects.js';
17+
import { getPollingModeµ } from './client.store.utils.js';
18+
import type { PollDispatchResult } from './client.store.effects.js';
19+
import type { PollingCollector } from './collector.types.js';
20+
21+
const mockLog = {
22+
debug: vi.fn(),
23+
error: vi.fn(),
24+
warn: vi.fn(),
25+
info: vi.fn(),
26+
} as any;
27+
28+
// ---------------------------------------------------------------------------
29+
// buildChallengeEndpoint
30+
// ---------------------------------------------------------------------------
31+
32+
describe('buildChallengeEndpoint', () => {
33+
it('returns a constructed URL string for a valid self link', () => {
34+
const selfHref =
35+
'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/orchestrate';
36+
const challenge = 'abc123';
37+
38+
const result = buildChallengeEndpoint(selfHref, challenge);
39+
40+
expect(result).toBe(
41+
'https://auth.pingone.ca/3b2b0d54-99f9-4c28-b57e-d4e66e8e72c2/davinci/user/credentials/challenge/abc123/status',
42+
);
43+
});
44+
45+
it('returns InternalErrorResponse when envId is missing from URL path', () => {
46+
// pathname is just '/' → split gives ['', ''] → envId is empty string → falsy
47+
const selfHref = 'https://auth.pingone.ca/';
48+
const challenge = 'abc123';
49+
50+
const result = buildChallengeEndpoint(selfHref, challenge);
51+
52+
expect(typeof result).toBe('object');
53+
expect((result as any).type).toBe('internal_error');
54+
});
55+
56+
it('returns InternalErrorResponse for a completely invalid URL', () => {
57+
const result = buildChallengeEndpoint('not-a-url', 'abc123');
58+
59+
expect(typeof result).toBe('object');
60+
expect((result as any).type).toBe('internal_error');
61+
});
62+
});
63+
64+
// ---------------------------------------------------------------------------
65+
// isChallengeStillPending
66+
// ---------------------------------------------------------------------------
67+
68+
describe('isChallengeStillPending', () => {
69+
it('returns false when response has an error', () => {
70+
const response: PollDispatchResult = { error: { status: 400, data: {} } };
71+
expect(isChallengeStillPending(response)).toBe(false);
72+
});
73+
74+
it('returns false when isChallengeComplete is true', () => {
75+
const response: PollDispatchResult = {
76+
data: { isChallengeComplete: true, status: 'approved' },
77+
};
78+
expect(isChallengeStillPending(response)).toBe(false);
79+
});
80+
81+
it('returns true when challenge is still pending', () => {
82+
const response: PollDispatchResult = { data: { isChallengeComplete: false } };
83+
expect(isChallengeStillPending(response)).toBe(true);
84+
});
85+
86+
it('returns true when data has no isChallengeComplete field', () => {
87+
const response: PollDispatchResult = { data: { someOtherField: 'value' } };
88+
expect(isChallengeStillPending(response)).toBe(true);
89+
});
90+
});
91+
92+
// ---------------------------------------------------------------------------
93+
// interpretChallengeResponse
94+
// ---------------------------------------------------------------------------
95+
96+
describe('interpretChallengeResponse', () => {
97+
it("returns 'expired' for a 400 error with serviceName 'challengeExpired'", () => {
98+
const response: PollDispatchResult = {
99+
error: { status: 400, data: { serviceName: 'challengeExpired' } },
100+
};
101+
102+
const result = interpretChallengeResponse(response, mockLog);
103+
104+
expect(result).toBe('expired');
105+
});
106+
107+
it("returns 'error' for other HTTP errors (status 500)", () => {
108+
const response: PollDispatchResult = {
109+
error: { status: 500, data: { message: 'Server Error' } },
110+
};
111+
112+
const result = interpretChallengeResponse(response, mockLog);
113+
114+
expect(result).toBe('error');
115+
});
116+
117+
it('returns InternalErrorResponse for a SerializedError (has message, no status)', () => {
118+
const response: PollDispatchResult = {
119+
error: { name: 'SerializedError', message: 'Network failure' },
120+
};
121+
122+
const result = interpretChallengeResponse(response, mockLog);
123+
124+
expect(typeof result).toBe('object');
125+
expect((result as any).type).toBe('internal_error');
126+
expect((result as any).error.message).toBe('Network failure');
127+
});
128+
129+
it("returns 'error' for non-object data", () => {
130+
const response: PollDispatchResult = { data: 'just a string' };
131+
132+
const result = interpretChallengeResponse(response, mockLog);
133+
134+
expect(result).toBe('error');
135+
});
136+
137+
it('returns the status from a completed challenge', () => {
138+
const response: PollDispatchResult = {
139+
data: { isChallengeComplete: true, status: 'approved' },
140+
};
141+
142+
const result = interpretChallengeResponse(response, mockLog);
143+
144+
expect(result).toBe('approved');
145+
});
146+
147+
it("returns 'error' when challenge is complete but status is missing", () => {
148+
const response: PollDispatchResult = {
149+
data: { isChallengeComplete: true },
150+
};
151+
152+
const result = interpretChallengeResponse(response, mockLog);
153+
154+
expect(result).toBe('error');
155+
});
156+
157+
it("returns 'timedOut' for an incomplete challenge when the schedule is exhausted", () => {
158+
const response: PollDispatchResult = {
159+
data: { isChallengeComplete: false },
160+
};
161+
162+
const result = interpretChallengeResponse(response, mockLog);
163+
164+
expect(result).toBe('timedOut');
165+
});
166+
});
167+
168+
// ---------------------------------------------------------------------------
169+
// getPollingModeµ
170+
// ---------------------------------------------------------------------------
171+
172+
describe('getPollingModeµ', () => {
173+
const basePollingCollector: PollingCollector = {
174+
category: 'SingleValueAutoCollector',
175+
error: null,
176+
type: 'PollingCollector',
177+
id: 'polling-0',
178+
name: 'polling',
179+
input: { key: 'polling', value: '', type: 'POLLING' },
180+
output: {
181+
key: 'polling',
182+
type: 'POLLING',
183+
config: { pollInterval: 2000, pollRetries: 5, retriesRemaining: 5 },
184+
},
185+
};
186+
187+
it.effect('succeeds with challenge mode when challenge and pollChallengeStatus are set', () =>
188+
Micro.gen(function* () {
189+
const collector: PollingCollector = {
190+
...basePollingCollector,
191+
output: {
192+
...basePollingCollector.output,
193+
config: {
194+
pollInterval: 2000,
195+
pollRetries: 5,
196+
pollChallengeStatus: true,
197+
challenge: 'test-challenge',
198+
},
199+
},
200+
};
201+
202+
const result = yield* getPollingModeµ(collector);
203+
204+
expect(result).toStrictEqual({ _tag: 'challenge', challenge: 'test-challenge' });
205+
}),
206+
);
207+
208+
it.effect('succeeds with continue mode when no challenge is present', () =>
209+
Micro.gen(function* () {
210+
const result = yield* getPollingModeµ(basePollingCollector);
211+
212+
expect(result).toStrictEqual({
213+
_tag: 'continue',
214+
retriesRemaining: 5,
215+
pollInterval: 2000,
216+
});
217+
}),
218+
);
219+
220+
it.effect('succeeds with unknown mode for ambiguous configuration', () =>
221+
Micro.gen(function* () {
222+
const collector: PollingCollector = {
223+
...basePollingCollector,
224+
output: {
225+
...basePollingCollector.output,
226+
config: {
227+
pollInterval: 2000,
228+
pollRetries: 5,
229+
challenge: 'test-challenge',
230+
// pollChallengeStatus is absent — ambiguous
231+
},
232+
},
233+
};
234+
235+
const result = yield* getPollingModeµ(collector);
236+
237+
expect(result).toStrictEqual({ _tag: 'unknown' });
238+
}),
239+
);
240+
241+
it.effect('fails when collector type is not PollingCollector', () =>
242+
Micro.gen(function* () {
243+
const badCollector = { ...basePollingCollector, type: 'TextCollector' } as any;
244+
245+
const result = yield* Micro.exit(getPollingModeµ(badCollector));
246+
247+
expect(result).toStrictEqual(
248+
Micro.exitFail({
249+
error: {
250+
message: 'Collector provided to poll is not a PollingCollector',
251+
type: 'argument_error',
252+
},
253+
type: 'internal_error',
254+
}),
255+
);
256+
}),
257+
);
258+
259+
it.effect('fails when retriesRemaining is undefined in continue mode', () =>
260+
Micro.gen(function* () {
261+
const collector: PollingCollector = {
262+
...basePollingCollector,
263+
output: {
264+
...basePollingCollector.output,
265+
config: { pollInterval: 2000, pollRetries: 5 },
266+
},
267+
};
268+
269+
const result = yield* Micro.exit(getPollingModeµ(collector));
270+
271+
expect(result).toStrictEqual(
272+
Micro.exitFail({
273+
error: {
274+
message: 'No retries found on PollingCollector',
275+
type: 'argument_error',
276+
},
277+
type: 'internal_error',
278+
}),
279+
);
280+
}),
281+
);
282+
});

0 commit comments

Comments
 (0)