Skip to content

Commit 091667c

Browse files
committed
fix(journey-client): create JourneyLoginFailure step, handle Login Failure case
1 parent d947ac2 commit 091667c

9 files changed

Lines changed: 298 additions & 31 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
}

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

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

2930
export { ActionTypes }
3031

@@ -499,6 +500,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
499500
setValidateOnly(value: boolean): void;
500501
}
501502

503+
export { WellknownResponse }
504+
502505
// (No @packageDocumentation comment for this package)
503506

504507
```

packages/journey-client/api-report/journey-client.types.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

@@ -486,6 +487,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
486487
setValidateOnly(value: boolean): void;
487488
}
488489

490+
export { WellknownResponse }
491+
489492
// (No @packageDocumentation comment for this package)
490493

491494
```

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

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
// @vitest-environment node
22
/*
3-
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
* Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved.
44
*
55
* This software may be modified and distributed under the terms
66
* of the MIT license. See the LICENSE file for details.
77
*/
88

9-
import { callbackType } from '@forgerock/sdk-types';
109
import { afterEach, describe, expect, test, vi } from 'vitest';
1110

12-
import type { GenericError, Step, WellknownResponse } from '@forgerock/sdk-types';
13-
1411
import { journey } from './client.store.js';
1512
import { createJourneyStep } from './step.utils.js';
13+
14+
import { callbackType, type GenericError, type Step, type WellknownResponse } from '../index.js';
15+
1616
import { JourneyClientConfig } from './config.types.js';
1717

1818
/**
@@ -76,7 +76,7 @@ function getUrlFromInput(input: RequestInfo | URL): string {
7676
/**
7777
* Helper to setup mock fetch for wellknown + journey responses
7878
*/
79-
function setupMockFetch(journeyResponse: Step | null = null) {
79+
function setupMockFetch(journeyResponse: Step | null = null, authenticateStatus = 200) {
8080
mockFetch.mockImplementation((input: RequestInfo | URL) => {
8181
const url = getUrlFromInput(input);
8282

@@ -86,8 +86,13 @@ function setupMockFetch(journeyResponse: Step | null = null) {
8686
}
8787

8888
// Journey authenticate endpoint
89-
if (journeyResponse && url.includes('/authenticate')) {
90-
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+
);
9196
}
9297

9398
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
@@ -154,6 +159,30 @@ describe('journey-client', () => {
154159
}
155160
});
156161

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

226+
test('next_401WithStepPayload_ReturnsLoginFailure', async () => {
227+
const initialStep = createJourneyStep({
228+
authId: 'test-auth-id',
229+
callbacks: [],
230+
});
231+
const failurePayload: Step = {
232+
code: 401,
233+
message: 'Access Denied',
234+
reason: 'Unauthorized',
235+
detail: { failureUrl: 'https://example.com/failure' },
236+
};
237+
setupMockFetch(failurePayload, 401);
238+
239+
const client = await journey({ config: mockConfig });
240+
const result = await client.next(initialStep, {});
241+
242+
expect(result).toBeDefined();
243+
expect(isGenericError(result)).toBe(false);
244+
expect(result).toHaveProperty('type', 'LoginFailure');
245+
246+
if (!isGenericError(result) && result.type === 'LoginFailure') {
247+
expect(result.payload).toEqual(failurePayload);
248+
expect(result.getCode()).toBe(401);
249+
expect(result.getMessage()).toBe('Access Denied');
250+
expect(result.getReason()).toBe('Unauthorized');
251+
}
252+
});
253+
197254
test('redirect_WellknownConfig_StoresStepAndCallsLocationAssign', async () => {
198255
const mockStepPayload: Step = {
199256
callbacks: [
@@ -366,7 +423,7 @@ describe('journey-client', () => {
366423

367424
expect(isGenericError(result)).toBe(true);
368425
if (isGenericError(result)) {
369-
expect(result.error).toBe('no_response_data');
426+
expect(result.error).toBe('request_failed');
370427
expect(result.type).toBe('unknown_error');
371428
}
372429
});

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';
@@ -158,32 +158,28 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
158158
subscribe: store.subscribe,
159159

160160
start: async (options?: StartParam) => {
161-
const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
162-
if (!data) {
163-
const error: GenericError = {
164-
error: 'no_response_data',
165-
message: 'No data received from server when starting journey',
166-
type: 'unknown_error',
167-
};
168-
return error;
161+
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
162+
const result = handleJourneyResponse(data, error);
163+
if ('error' in result) {
164+
return result;
169165
}
170-
return createJourneyObject(data);
166+
167+
return createJourneyObject(result);
171168
},
172169

173170
/**
174171
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
175172
*/
176173
next: async (step: JourneyStep, options?: NextOptions) => {
177-
const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
178-
if (!data) {
179-
const error: GenericError = {
180-
error: 'no_response_data',
181-
message: 'No data received from server when submitting step',
182-
type: 'unknown_error',
183-
};
184-
return error;
174+
const { data, error } = await store.dispatch(
175+
journeyApi.endpoints.next.initiate({ step, options }),
176+
);
177+
const result = handleJourneyResponse(data, error);
178+
if ('error' in result) {
179+
return result;
185180
}
186-
return createJourneyObject(data);
181+
182+
return createJourneyObject(result);
187183
},
188184

189185
// TODO: Remove the actual redirect from this method and just return the URL to the caller
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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 { describe, expect, it } from 'vitest';
9+
10+
import { StepType } from '../types.js';
11+
import { type Step } from '../index.js';
12+
13+
import { createJourneyObject, handleJourneyResponse } from './journey.utils.js';
14+
import type { JourneyLoginFailure } from './login-failure.utils.js';
15+
16+
describe('createJourneyObject', () => {
17+
it('returns Step when provided a step with authId', () => {
18+
const stepPayload: Step = {
19+
authId: 'test-auth-id',
20+
callbacks: [],
21+
};
22+
23+
const result = createJourneyObject(stepPayload);
24+
25+
expect(result).not.toHaveProperty('error');
26+
expect(result).toHaveProperty('type', StepType.Step);
27+
expect(result).toHaveProperty('payload');
28+
expect((result as { payload: Step }).payload).toEqual(stepPayload);
29+
});
30+
31+
it('returns LoginSuccess when provided a step with successUrl', () => {
32+
const successPayload: Step = {
33+
successUrl: 'https://example.com/success',
34+
realm: 'root',
35+
tokenId: 'token-123',
36+
};
37+
38+
const result = createJourneyObject(successPayload);
39+
40+
expect(result).not.toHaveProperty('error');
41+
expect(result).toHaveProperty('type', StepType.LoginSuccess);
42+
expect(result).toHaveProperty('payload', successPayload);
43+
});
44+
45+
it('returns LoginFailure when provided a step without authId or successUrl', () => {
46+
const failurePayload: Step = {
47+
code: 401,
48+
message: 'Access Denied',
49+
reason: 'Unauthorized',
50+
detail: { failureUrl: 'https://example.com/failure' },
51+
};
52+
53+
const result = createJourneyObject(failurePayload);
54+
55+
expect(result).not.toHaveProperty('error');
56+
expect(result).toHaveProperty('type', StepType.LoginFailure);
57+
expect(result).toHaveProperty('payload', failurePayload);
58+
59+
const failure = result as JourneyLoginFailure;
60+
expect(failure.getCode()).toBe(401);
61+
expect(failure.getMessage()).toBe('Access Denied');
62+
expect(failure.getReason()).toBe('Unauthorized');
63+
});
64+
});
65+
66+
describe('handleJourneyResponse', () => {
67+
it('returns Step data when FetchBaseQueryError has numeric status and object body', () => {
68+
const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' };
69+
const error = { status: 401, data: body };
70+
71+
const result = handleJourneyResponse(undefined, error);
72+
73+
expect(result).toBe(body);
74+
});
75+
76+
it('returns GenericError when FetchBaseQueryError has numeric status but non-object body', () => {
77+
const error = { status: 500, data: 'Internal Server Error' };
78+
79+
const result = handleJourneyResponse(undefined, error);
80+
81+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
82+
});
83+
84+
it('returns GenericError for FETCH_ERROR', () => {
85+
const error = { status: 'FETCH_ERROR' as const, error: 'Network error' };
86+
87+
const result = handleJourneyResponse(undefined, error);
88+
89+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
90+
expect((result as { message: string }).message).toContain('Network error');
91+
});
92+
93+
it('returns GenericError for PARSING_ERROR', () => {
94+
const error = {
95+
status: 'PARSING_ERROR' as const,
96+
originalStatus: 200,
97+
data: '<html>Not JSON</html>',
98+
error: 'JSON parse error',
99+
};
100+
101+
const result = handleJourneyResponse(undefined, error);
102+
103+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
104+
expect((result as { message: string }).message).toContain('JSON parse error');
105+
});
106+
107+
it('returns GenericError for TIMEOUT_ERROR', () => {
108+
const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' };
109+
110+
const result = handleJourneyResponse(undefined, error);
111+
112+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
113+
expect((result as { message: string }).message).toContain('Request timed out');
114+
});
115+
116+
it('returns GenericError for CUSTOM_ERROR', () => {
117+
const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' };
118+
119+
const result = handleJourneyResponse(undefined, error);
120+
121+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
122+
expect((result as { message: string }).message).toContain('Custom error occurred');
123+
});
124+
125+
it('returns GenericError for SerializedError', () => {
126+
const error = { name: 'Error', message: 'Something went wrong', stack: '...' };
127+
128+
const result = handleJourneyResponse(undefined, error);
129+
130+
expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' });
131+
expect((result as { message: string }).message).toContain('Something went wrong');
132+
});
133+
134+
it('returns GenericError when no data and no error', () => {
135+
const result = handleJourneyResponse(undefined, undefined);
136+
137+
expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' });
138+
});
139+
140+
it('returns data when no error and data is present', () => {
141+
const data: Step = { authId: 'test-auth-id', callbacks: [] };
142+
143+
const result = handleJourneyResponse(data, undefined);
144+
145+
expect(result).toBe(data);
146+
});
147+
});

0 commit comments

Comments
 (0)