Skip to content

Commit 82bfda6

Browse files
committed
fix(journey-client): create JourneyLoginFailure step, handle Login Failure case
1 parent eedcca7 commit 82bfda6

12 files changed

Lines changed: 324 additions & 48 deletions

File tree

.changeset/whole-mangos-find.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@forgerock/journey-client': patch
3+
---
4+
5+
Return `JourneyLoginFailure` by hitting the previously-unreached `LoginFailure` branch when `start()`/`next()` receives a failure payload with a login failure `code`

e2e/journey-app/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,11 @@ if (searchParams.get('middleware') === 'true') {
206206
renderComplete();
207207
} else if (step?.type === 'LoginFailure') {
208208
console.error('Journey failed');
209-
renderForm();
210209
renderError();
210+
const errorHtml = errorEl.innerHTML;
211+
step = await journeyClient.start({ journey: journeyName });
212+
renderForm();
213+
errorEl.innerHTML = errorHtml;
211214
} else {
212215
console.error('Unknown node status', step);
213216
}

lefthook.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ pre-commit:
33
nx-sync:
44
run: pnpm nx sync
55
nx-check:
6-
run: pnpm nx affected -t typecheck lint build api-report --tui=false
6+
run: env -u GIT_DIR NX_NO_CLOUD=true pnpm nx affected -t typecheck lint build api-report --tui=false
77
stage_fixed: true
88
format:
99
run: pnpm nx format:write

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;

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
2424
import { Step } from '@forgerock/sdk-types';
2525
import { StepDetail } from '@forgerock/sdk-types';
2626
import { StepType } from '@forgerock/sdk-types';
27+
import { WellknownResponse } from '@forgerock/sdk-types';
2728

2829
export { ActionTypes }
2930

@@ -494,6 +495,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
494495
setValidateOnly(value: boolean): void;
495496
}
496497

498+
export { WellknownResponse }
499+
497500
// (No @packageDocumentation comment for this package)
498501

499502
```

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
2323
import { Step } from '@forgerock/sdk-types';
2424
import { StepDetail } from '@forgerock/sdk-types';
2525
import { StepType } from '@forgerock/sdk-types';
26+
import { WellknownResponse } from '@forgerock/sdk-types';
2627

2728
export { ActionTypes }
2829

@@ -481,6 +482,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
481482
setValidateOnly(value: boolean): void;
482483
}
483484

485+
export { WellknownResponse }
486+
484487
// (No @packageDocumentation comment for this package)
485488

486489
```

packages/journey-client/src/lib/client.store.test.ts

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
1+
// @vitest-environment node
12
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
34
*
45
* This software may be modified and distributed under the terms
56
* of the MIT license. See the LICENSE file for details.
67
*/
78

8-
import { callbackType } from '@forgerock/sdk-types';
99
import { afterEach, describe, expect, test, vi } from 'vitest';
1010

11-
import type { GenericError, Step, WellknownResponse } from '@forgerock/sdk-types';
12-
1311
import { journey } from './client.store.js';
1412
import { createJourneyStep } from './step.utils.js';
13+
14+
import { callbackType, type GenericError, type Step, type WellknownResponse } from '../index.js';
15+
1516
import { JourneyClientConfig } from './config.types.js';
1617

1718
/**
@@ -75,7 +76,7 @@ function getUrlFromInput(input: RequestInfo | URL): string {
7576
/**
7677
* Helper to setup mock fetch for wellknown + journey responses
7778
*/
78-
function setupMockFetch(journeyResponse: Step | null = null) {
79+
function setupMockFetch(journeyResponse: Step | null = null, authenticateStatus = 200) {
7980
mockFetch.mockImplementation((input: RequestInfo | URL) => {
8081
const url = getUrlFromInput(input);
8182

@@ -85,8 +86,13 @@ function setupMockFetch(journeyResponse: Step | null = null) {
8586
}
8687

8788
// Journey authenticate endpoint
88-
if (journeyResponse && url.includes('/authenticate')) {
89-
return Promise.resolve(new Response(JSON.stringify(journeyResponse)));
89+
if (url.includes('/authenticate')) {
90+
if (journeyResponse === null) {
91+
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
92+
}
93+
return Promise.resolve(
94+
new Response(JSON.stringify(journeyResponse), { status: authenticateStatus }),
95+
);
9096
}
9197

9298
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
@@ -152,6 +158,30 @@ describe('journey-client', () => {
152158
}
153159
});
154160

161+
test('start_401WithStepPayload_ReturnsLoginFailure', async () => {
162+
const failurePayload: Step = {
163+
code: 401,
164+
message: 'Access Denied',
165+
reason: 'Unauthorized',
166+
detail: { failureUrl: 'https://example.com/failure' },
167+
};
168+
setupMockFetch(failurePayload, 401);
169+
170+
const client = await journey({ config: mockConfig });
171+
const result = await client.start();
172+
173+
expect(result).toBeDefined();
174+
expect(isGenericError(result)).toBe(false);
175+
expect(result).toHaveProperty('type', 'LoginFailure');
176+
177+
if (!isGenericError(result) && result.type === 'LoginFailure') {
178+
expect(result.payload).toEqual(failurePayload);
179+
expect(result.getCode()).toBe(401);
180+
expect(result.getMessage()).toBe('Access Denied');
181+
expect(result.getReason()).toBe('Unauthorized');
182+
}
183+
});
184+
155185
test('next_WellknownConfig_SendsStepAndReturnsNext', async () => {
156186
const initialStep = createJourneyStep({
157187
authId: 'test-auth-id',
@@ -192,6 +222,34 @@ describe('journey-client', () => {
192222
}
193223
});
194224

225+
test('next_401WithStepPayload_ReturnsLoginFailure', async () => {
226+
const initialStep = createJourneyStep({
227+
authId: 'test-auth-id',
228+
callbacks: [],
229+
});
230+
const failurePayload: Step = {
231+
code: 401,
232+
message: 'Access Denied',
233+
reason: 'Unauthorized',
234+
detail: { failureUrl: 'https://example.com/failure' },
235+
};
236+
setupMockFetch(failurePayload, 401);
237+
238+
const client = await journey({ config: mockConfig });
239+
const result = await client.next(initialStep, {});
240+
241+
expect(result).toBeDefined();
242+
expect(isGenericError(result)).toBe(false);
243+
expect(result).toHaveProperty('type', 'LoginFailure');
244+
245+
if (!isGenericError(result) && result.type === 'LoginFailure') {
246+
expect(result.payload).toEqual(failurePayload);
247+
expect(result.getCode()).toBe(401);
248+
expect(result.getMessage()).toBe('Access Denied');
249+
expect(result.getReason()).toBe('Unauthorized');
250+
}
251+
});
252+
195253
test('redirect_WellknownConfig_StoresStepAndCallsLocationAssign', async () => {
196254
const mockStepPayload: Step = {
197255
callbacks: [
@@ -204,6 +262,15 @@ describe('journey-client', () => {
204262
};
205263
const step = createJourneyStep(mockStepPayload);
206264
const assignMock = vi.fn();
265+
// Node test environment doesn't provide `window`, so create a minimal shim
266+
// with a real `location` getter so we can keep using vi.spyOn(..., 'get').
267+
(globalThis as unknown as { window?: unknown }).window = {};
268+
Object.defineProperty(window, 'location', {
269+
configurable: true,
270+
get: () => ({
271+
assign: vi.fn(),
272+
}),
273+
});
207274
const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({
208275
...window.location,
209276
assign: assignMock,
@@ -367,7 +434,7 @@ describe('journey-client', () => {
367434

368435
expect(isGenericError(result)).toBe(true);
369436
if (isGenericError(result)) {
370-
expect(result.error).toBe('no_response_data');
437+
expect(result.error).toBe('request_failed');
371438
expect(result.type).toBe('unknown_error');
372439
}
373440
});

packages/journey-client/src/lib/client.store.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
33
*
44
* This software may be modified and distributed under the terms
55
* of the MIT license. See the LICENSE file for details.
@@ -21,7 +21,7 @@ import { createJourneyStore } from './client.store.utils.js';
2121
import { configSlice } from './config.slice.js';
2222
import { journeyApi } from './journey.api.js';
2323
import { createStorage } from '@forgerock/storage';
24-
import { createJourneyObject } from './journey.utils.js';
24+
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
2525
import { wellknownApi } from './wellknown.api.js';
2626

2727
import type { JourneyStep } from './step.utils.js';
@@ -155,32 +155,28 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
155155

156156
const self: JourneyClient = {
157157
start: async (options?: StartParam) => {
158-
const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159-
if (!data) {
160-
const error: GenericError = {
161-
error: 'no_response_data',
162-
message: 'No data received from server when starting journey',
163-
type: 'unknown_error',
164-
};
165-
return error;
158+
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159+
const result = handleJourneyResponse(data, error);
160+
if ('error' in result) {
161+
return result;
166162
}
167-
return createJourneyObject(data);
163+
164+
return createJourneyObject(result);
168165
},
169166

170167
/**
171168
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
172169
*/
173170
next: async (step: JourneyStep, options?: NextOptions) => {
174-
const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
175-
if (!data) {
176-
const error: GenericError = {
177-
error: 'no_response_data',
178-
message: 'No data received from server when submitting step',
179-
type: 'unknown_error',
180-
};
181-
return error;
171+
const { data, error } = await store.dispatch(
172+
journeyApi.endpoints.next.initiate({ step, options }),
173+
);
174+
const result = handleJourneyResponse(data, error);
175+
if ('error' in result) {
176+
return result;
182177
}
183-
return createJourneyObject(data);
178+
179+
return createJourneyObject(result);
184180
},
185181

186182
// TODO: Remove the actual redirect from this method and just return the URL to the caller

0 commit comments

Comments
 (0)