Skip to content

Commit f66b1fd

Browse files
committed
fix(journey-client): create JourneyLoginFailure step and handle LoginFailure case
1 parent eedcca7 commit f66b1fd

4 files changed

Lines changed: 110 additions & 22 deletions

File tree

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 `code`.

e2e/journey-app/main.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ if (searchParams.get('middleware') === 'true') {
206206
renderComplete();
207207
} else if (step?.type === 'LoginFailure') {
208208
console.error('Journey failed');
209-
renderForm();
210209
renderError();
211210
} else {
212211
console.error('Unknown node status', step);

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

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// @vitest-environment node
12
/*
23
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
34
*
@@ -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_401WithCodeInBody_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_401WithCodeInBody_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,

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

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import type { GenericError } from '@forgerock/sdk-types';
1717
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
1818
import type { Step } from '@forgerock/sdk-types';
19+
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
1920

2021
import { createJourneyStore } from './client.store.utils.js';
2122
import { configSlice } from './config.slice.js';
@@ -155,32 +156,48 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
155156

156157
const self: JourneyClient = {
157158
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;
159+
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
160+
if (data) {
161+
return createJourneyObject(data);
162+
}
163+
164+
const errorData = (error as FetchBaseQueryError | undefined)?.data;
165+
const errorStep = errorData as Step | undefined;
166+
if (errorStep?.code !== undefined) {
167+
return createJourneyObject(errorStep);
166168
}
167-
return createJourneyObject(data);
169+
170+
const genericError: GenericError = {
171+
error: 'no_response_data',
172+
message: 'No data received from server when starting journey',
173+
type: 'unknown_error',
174+
};
175+
return genericError;
168176
},
169177

170178
/**
171179
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
172180
*/
173181
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;
182+
const { data, error } = await store.dispatch(
183+
journeyApi.endpoints.next.initiate({ step, options }),
184+
);
185+
if (data) {
186+
return createJourneyObject(data);
187+
}
188+
189+
const errorData = (error as FetchBaseQueryError | undefined)?.data;
190+
const errorStep = errorData as Step | undefined;
191+
if (errorStep?.code !== undefined) {
192+
return createJourneyObject(errorStep);
182193
}
183-
return createJourneyObject(data);
194+
195+
const genericError: GenericError = {
196+
error: 'no_response_data',
197+
message: 'No data received from server when submitting step',
198+
type: 'unknown_error',
199+
};
200+
return genericError;
184201
},
185202

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

0 commit comments

Comments
 (0)