Skip to content

Commit c00f12b

Browse files
committed
fix(journey-client): handle errors in journey utils with RTK narrowing
1 parent 13326e0 commit c00f12b

6 files changed

Lines changed: 181 additions & 78 deletions

File tree

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 '../index.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: [],
@@ -434,7 +434,7 @@ describe('journey-client', () => {
434434

435435
expect(isGenericError(result)).toBe(true);
436436
if (isGenericError(result)) {
437-
expect(result.error).toBe('no_response_data');
437+
expect(result.error).toBe('request_failed');
438438
expect(result.type).toBe('unknown_error');
439439
}
440440
});

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

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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, resolveJourneyResult } from './journey.utils.js';
2525
import { wellknownApi } from './wellknown.api.js';
2626

2727
import type { JourneyStep } from './step.utils.js';
@@ -155,34 +155,18 @@ 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-
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;
158+
const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options));
159+
return resolveJourneyResult(data, error);
169160
},
170161

171162
/**
172163
* Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey.
173164
*/
174165
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;
166+
const { data, error } = await store.dispatch(
167+
journeyApi.endpoints.next.initiate({ step, options }),
168+
);
169+
return resolveJourneyResult(data, error);
186170
},
187171

188172
// 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+
});

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

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
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.
66
*/
77

88
import { StepType } from '@forgerock/sdk-types';
9+
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
10+
import type { SerializedError } from '@reduxjs/toolkit';
911

1012
import type { GenericError, Step } from '@forgerock/sdk-types';
1113

@@ -17,15 +19,31 @@ import type { JourneyStep } from './step.utils.js';
1719
import type { JourneyLoginFailure } from './login-failure.utils.js';
1820
import type { JourneyLoginSuccess } from './login-success.utils.js';
1921

22+
export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError;
23+
24+
const STEP_LIKE_KEYS = [
25+
'authId',
26+
'callbacks',
27+
'code',
28+
'description',
29+
'detail',
30+
'header',
31+
'ok',
32+
'realm',
33+
'reason',
34+
'stage',
35+
'status',
36+
'successUrl',
37+
'tokenId',
38+
] as const;
39+
2040
/**
21-
* Creates a journey object from a raw Step response.
22-
* Determines the step type based on the presence of authId or successUrl properties
23-
* and returns the appropriate journey object type.
41+
* Creates a typed journey object from a raw Step response.
2442
*
2543
* @param step - The raw Step response from the authentication API
26-
* @returns A JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError if the step type cannot be determined
44+
* @returns A JourneyStep, JourneyLoginSuccess, or JourneyLoginFailure, or a GenericError if the step type cannot be determined
2745
*/
28-
function createJourneyObject(
46+
export function createJourneyObject(
2947
step: Step,
3048
): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError {
3149
let type;
@@ -53,4 +71,46 @@ function createJourneyObject(
5371
}
5472
}
5573

56-
export { createJourneyObject };
74+
/**
75+
* Maps an RTK Query dispatch result to a JourneyResult.
76+
* Narrows RTK errors first; only calls createJourneyObject when data is confirmed present.
77+
*
78+
* @param data - The Step data returned by the RTK Query endpoint, if any
79+
* @param error - The error returned by the RTK Query endpoint, if any
80+
* @returns A JourneyResult representing the outcome of the journey step
81+
*/
82+
export function resolveJourneyResult(data: Step | undefined, error: unknown): JourneyResult {
83+
if (error && typeof error === 'object' && 'status' in error) {
84+
const fetchError = error as FetchBaseQueryError;
85+
const stepData = fetchError.data;
86+
if (stepData && typeof stepData === 'object' && STEP_LIKE_KEYS.some((key) => key in stepData)) {
87+
return createJourneyObject(stepData as Step);
88+
}
89+
90+
const errMsg = 'error' in fetchError ? fetchError.error : JSON.stringify(fetchError.data);
91+
return {
92+
error: 'request_failed',
93+
message: `Request failed: ${errMsg}`,
94+
type: 'unknown_error',
95+
};
96+
}
97+
98+
if (error && typeof error === 'object' && 'message' in error) {
99+
const serializedError = error as SerializedError;
100+
return {
101+
error: 'request_failed',
102+
message: `Request failed: ${serializedError.message ?? 'Unknown error'}`,
103+
type: 'unknown_error',
104+
};
105+
}
106+
107+
if (!data) {
108+
return {
109+
error: 'no_response_data',
110+
message: 'No data received from server',
111+
type: 'unknown_error',
112+
};
113+
}
114+
115+
return createJourneyObject(data);
116+
}

packages/journey-client/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type {
1414
CallbackType,
1515
StepType,
1616
GenericError,
17+
WellknownResponse,
1718
PolicyRequirement,
1819
FailedPolicyRequirement,
1920
NameValue,

0 commit comments

Comments
 (0)