Skip to content

Commit f3f5efa

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

6 files changed

Lines changed: 180 additions & 77 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: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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, resolveJourneyResult } 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+
46+
describe('resolveJourneyResult - no_response_data', () => {
47+
it('returns no_response_data GenericError when no data and no error', () => {
48+
const result = resolveJourneyResult(undefined, undefined);
49+
50+
expect(result).toMatchObject({
51+
error: 'no_response_data',
52+
message: 'No data received from server',
53+
type: 'unknown_error',
54+
});
55+
});
56+
});
57+
58+
describe('toJourneyResult', () => {
59+
it('returns request_failed GenericError for FetchBaseQueryError without Step payload', () => {
60+
const result = resolveJourneyResult(undefined, { status: 500, data: { foo: 'bar' } });
61+
62+
expect(result).toMatchObject({
63+
error: 'request_failed',
64+
message: 'Request failed: {"foo":"bar"}',
65+
type: 'unknown_error',
66+
});
67+
});
68+
69+
it('returns request_failed GenericError for SerializedError', () => {
70+
const result = resolveJourneyResult(undefined, { message: 'Network failure' });
71+
72+
expect(result).toMatchObject({
73+
error: 'request_failed',
74+
message: 'Request failed: Network failure',
75+
type: 'unknown_error',
76+
});
77+
});
78+
79+
it('returns LoginFailure when FetchBaseQueryError contains a failure Step payload', () => {
80+
const failurePayload: Step = {
81+
code: 401,
82+
message: 'Access Denied',
83+
reason: 'Unauthorized',
84+
detail: { failureUrl: 'https://example.com/failure' },
85+
};
86+
87+
const result = resolveJourneyResult(undefined, { status: 401, data: failurePayload });
88+
89+
expect(result).not.toHaveProperty('error');
90+
expect(result).toHaveProperty('type', StepType.LoginFailure);
91+
expect(result).toHaveProperty('payload', failurePayload);
92+
93+
const failure = result as JourneyLoginFailure;
94+
expect(failure.getCode()).toBe(401);
95+
expect(failure.getMessage()).toBe('Access Denied');
96+
expect(failure.getReason()).toBe('Unauthorized');
97+
});
98+
});

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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export type {
1212
Step,
1313
Callback,
1414
CallbackType,
15-
StepType,
1615
GenericError,
16+
WellknownResponse,
1717
PolicyRequirement,
1818
FailedPolicyRequirement,
1919
NameValue,
@@ -22,7 +22,7 @@ export type {
2222
FailureDetail,
2323
} from '@forgerock/sdk-types';
2424

25-
export { PolicyKey } from '@forgerock/sdk-types';
25+
export { PolicyKey, StepType } from '@forgerock/sdk-types';
2626

2727
// Re-export local types
2828
export * from './lib/client.types.js';

0 commit comments

Comments
 (0)