Skip to content

Commit 14a5f3d

Browse files
committed
fix(journey-client): handle errors in journey utils with RTK narrowing
1 parent 927e317 commit 14a5f3d

7 files changed

Lines changed: 211 additions & 85 deletions

File tree

e2e/journey-app/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import './style.css';
99
import { journey } from '@forgerock/journey-client';
1010
import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn';
1111

12-
import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
13-
1412
import { renderCallbacks } from './callback-map.js';
1513
import { renderDeleteDevicesSection } from './components/delete-device.js';
1614
import { renderQRCodeStep } from './components/qr-code.js';
@@ -19,6 +17,8 @@ import { deleteWebAuthnDevice } from './services/delete-webauthn-device.js';
1917
import { webauthnComponent } from './components/webauthn-step.js';
2018
import { serverConfigs } from './server-configs.js';
2119

20+
import type { JourneyClient, RequestMiddleware } from '@forgerock/journey-client/types';
21+
2222
const qs = window.location.search;
2323
const searchParams = new URLSearchParams(qs);
2424

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
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 '../types.js';
15+
1616
import { JourneyClientConfig } from './config.types.js';
1717

1818
/**
@@ -158,7 +158,7 @@ describe('journey-client', () => {
158158
}
159159
});
160160

161-
test('start_401WithCodeInBody_ReturnsLoginFailure', async () => {
161+
test('start_401WithStepPayload_ReturnsLoginFailure', async () => {
162162
const failurePayload: Step = {
163163
code: 401,
164164
message: 'Access Denied',
@@ -222,7 +222,7 @@ describe('journey-client', () => {
222222
}
223223
});
224224

225-
test('next_401WithCodeInBody_ReturnsLoginFailure', async () => {
225+
test('next_401WithStepPayload_ReturnsLoginFailure', async () => {
226226
const initialStep = createJourneyStep({
227227
authId: 'test-auth-id',
228228
callbacks: [],
@@ -388,7 +388,7 @@ describe('journey-client', () => {
388388

389389
expect(isGenericError(result)).toBe(true);
390390
if (isGenericError(result)) {
391-
expect(result.error).toBe('no_response_data');
391+
expect(result.error).toBe('request_failed');
392392
expect(result.type).toBe('unknown_error');
393393
}
394394
});

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

Lines changed: 15 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,27 @@
66
*/
77

88
import { logger as loggerFn, LogLevel, CustomLogger } from '@forgerock/sdk-logger';
9-
import { callbackType } from '@forgerock/sdk-types';
109
import {
1110
isGenericError,
1211
isValidWellknownUrl,
1312
createWellknownError,
1413
} from '@forgerock/sdk-utilities';
1514

16-
import type { GenericError } from '@forgerock/sdk-types';
17-
import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware';
18-
import type { Step } from '@forgerock/sdk-types';
19-
2015
import { createJourneyStore } from './client.store.utils.js';
2116
import { configSlice } from './config.slice.js';
2217
import { journeyApi } from './journey.api.js';
2318
import { createStorage } from '@forgerock/storage';
24-
import { createJourneyObject } from './journey.utils.js';
19+
import { createJourneyObject, resolveJourneyResult } from './journey.utils.js';
2520
import { wellknownApi } from './wellknown.api.js';
2621

22+
import {
23+
callbackType,
24+
type ActionTypes,
25+
type GenericError,
26+
type RequestMiddleware,
27+
type Step,
28+
} from '../types.js';
29+
2730
import type { JourneyStep } from './step.utils.js';
2831
import type { JourneyClientConfig } from './config.types.js';
2932
import type { RedirectCallback } from './callbacks/redirect-callback.js';
@@ -155,34 +158,18 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
155158

156159
const self: JourneyClient = {
157160
start: async (options?: StartParam) => {
158-
const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159-
if (data) {
160-
return createJourneyObject(data);
161-
}
162-
163-
const genericError: GenericError = {
164-
error: 'no_response_data',
165-
message: 'No data received from server when starting journey',
166-
type: 'unknown_error',
167-
};
168-
return genericError;
161+
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
162+
return resolveJourneyResult(data, error);
169163
},
170164

171165
/**
172166
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
173167
*/
174168
next: async (step: JourneyStep, options?: NextOptions) => {
175-
const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options }));
176-
if (data) {
177-
return createJourneyObject(data);
178-
}
179-
180-
const genericError: GenericError = {
181-
error: 'no_response_data',
182-
message: 'No data received from server when submitting step',
183-
type: 'unknown_error',
184-
};
185-
return genericError;
169+
const { data, error } = await store.dispatch(
170+
journeyApi.endpoints.next.initiate({ step, options }),
171+
);
172+
return resolveJourneyResult(data, error);
186173
},
187174

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

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

Lines changed: 3 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved.
2+
* Copyright (c) 2020 - 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.
@@ -9,6 +9,8 @@ import { initQuery, RequestMiddleware } from '@forgerock/sdk-request-middleware'
99
import { REQUESTED_WITH, getEndpointPath, stringify, resolve } from '@forgerock/sdk-utilities';
1010
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query';
1111

12+
import { NextOptions, StartParam } from './interfaces.js';
13+
1214
import type { Step } from '@forgerock/sdk-types';
1315
import type { logger as loggerFn } from '@forgerock/sdk-logger';
1416
import type {
@@ -21,9 +23,7 @@ import type {
2123
} from '@reduxjs/toolkit/query';
2224

2325
import { JourneyStep } from './step.types.js';
24-
2526
import type { InternalJourneyClientConfig } from './config.types.js';
26-
import { NextOptions, StartParam } from './interfaces.js';
2727

2828
/**
2929
* Minimal state type for accessing journey config from RTK Query endpoints.
@@ -89,9 +89,6 @@ interface Extras {
8989
logger: ReturnType<typeof loggerFn>;
9090
}
9191

92-
// Only treat these numeric codes as login failures coming back in the Step payload.
93-
const LOGIN_FAILURE_CODES = [400, 401, 403, 412, 423, 429];
94-
9592
export const journeyApi = createApi({
9693
reducerPath: 'journeyReducer',
9794
baseQuery: fetchBaseQuery({
@@ -136,24 +133,6 @@ export const journeyApi = createApi({
136133
return result as QueryReturnValue<unknown, FetchBaseQueryError, FetchBaseQueryMeta>;
137134
});
138135

139-
/**
140-
* If the endpoint returned an HTTP error whose body is an AM Step with a
141-
* login-failure code, treat it as successful data so callers receive the
142-
* Step via the `data` path (keeps downstream logic simpler).
143-
*/
144-
if ('error' in response) {
145-
const errorData = (response.error as FetchBaseQueryError | undefined)?.data as
146-
| Step
147-
| undefined;
148-
if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) {
149-
return { data: errorData } as QueryReturnValue<
150-
Step,
151-
FetchBaseQueryError,
152-
FetchBaseQueryMeta
153-
>;
154-
}
155-
}
156-
157136
return response as QueryReturnValue<Step, FetchBaseQueryError, FetchBaseQueryMeta>;
158137
},
159138
}),
@@ -183,24 +162,6 @@ export const journeyApi = createApi({
183162
return result as QueryReturnValue<unknown, FetchBaseQueryError, FetchBaseQueryMeta>;
184163
});
185164

186-
/**
187-
* If the endpoint returned an HTTP error whose body is an AM Step with a
188-
* login-failure code, treat it as successful data so callers receive the
189-
* Step via the `data` path (keeps downstream logic simpler).
190-
*/
191-
if ('error' in response) {
192-
const errorData = (response.error as FetchBaseQueryError | undefined)?.data as
193-
| Step
194-
| undefined;
195-
if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) {
196-
return { data: errorData } as QueryReturnValue<
197-
Step,
198-
FetchBaseQueryError,
199-
FetchBaseQueryMeta
200-
>;
201-
}
202-
}
203-
204165
return response as QueryReturnValue<Step, FetchBaseQueryError, FetchBaseQueryMeta>;
205166
},
206167
}),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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, type Step } from '@forgerock/sdk-types';
11+
12+
import { createJourneyObject, resolveJourneyResult } from './journey.utils.js';
13+
import type { JourneyLoginFailure } from './login-failure.utils.js';
14+
15+
describe('createJourneyObject', () => {
16+
it('returns Step when provided a step with authId', () => {
17+
const stepPayload: Step = {
18+
authId: 'test-auth-id',
19+
callbacks: [],
20+
};
21+
22+
const result = createJourneyObject(stepPayload);
23+
24+
expect(result).not.toHaveProperty('error');
25+
expect(result).toHaveProperty('type', StepType.Step);
26+
expect(result).toHaveProperty('payload');
27+
expect((result as { payload: Step }).payload).toEqual(stepPayload);
28+
});
29+
30+
it('returns LoginSuccess when provided a step with successUrl', () => {
31+
const successPayload: Step = {
32+
successUrl: 'https://example.com/success',
33+
realm: 'root',
34+
tokenId: 'token-123',
35+
};
36+
37+
const result = createJourneyObject(successPayload);
38+
39+
expect(result).not.toHaveProperty('error');
40+
expect(result).toHaveProperty('type', StepType.LoginSuccess);
41+
expect(result).toHaveProperty('payload', successPayload);
42+
});
43+
});
44+
45+
describe('resolveJourneyResult - no_response_data', () => {
46+
it('returns no_response_data GenericError when no data and no error', () => {
47+
const result = resolveJourneyResult(undefined, undefined);
48+
49+
expect(result).toMatchObject({
50+
error: 'no_response_data',
51+
message: 'No data received from server',
52+
type: 'unknown_error',
53+
});
54+
});
55+
});
56+
57+
describe('toJourneyResult', () => {
58+
it('returns request_failed GenericError for FetchBaseQueryError without Step payload', () => {
59+
const result = resolveJourneyResult(undefined, { status: 500, data: { foo: 'bar' } });
60+
61+
expect(result).toMatchObject({
62+
error: 'request_failed',
63+
message: 'Request failed: {"foo":"bar"}',
64+
type: 'unknown_error',
65+
});
66+
});
67+
68+
it('returns request_failed GenericError for SerializedError', () => {
69+
const result = resolveJourneyResult(undefined, { message: 'Network failure' });
70+
71+
expect(result).toMatchObject({
72+
error: 'request_failed',
73+
message: 'Request failed: Network failure',
74+
type: 'unknown_error',
75+
});
76+
});
77+
78+
it('returns LoginFailure when FetchBaseQueryError contains a failure Step payload', () => {
79+
const failurePayload: Step = {
80+
code: 401,
81+
message: 'Access Denied',
82+
reason: 'Unauthorized',
83+
detail: { failureUrl: 'https://example.com/failure' },
84+
};
85+
86+
const result = resolveJourneyResult(undefined, { status: 401, data: failurePayload });
87+
88+
expect(result).not.toHaveProperty('error');
89+
expect(result).toHaveProperty('type', StepType.LoginFailure);
90+
expect(result).toHaveProperty('payload', failurePayload);
91+
92+
const failure = result as JourneyLoginFailure;
93+
expect(failure.getCode()).toBe(401);
94+
expect(failure.getMessage()).toBe('Access Denied');
95+
expect(failure.getReason()).toBe('Unauthorized');
96+
});
97+
});

0 commit comments

Comments
 (0)