From f66b1fda628fd7d694ad646d173fab3d51de0832 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Mon, 20 Apr 2026 15:24:27 -0700 Subject: [PATCH 1/4] fix(journey-client): create JourneyLoginFailure step and handle LoginFailure case --- .../journey-loginfailure-on-error-code.md | 5 ++ e2e/journey-app/main.ts | 1 - .../src/lib/client.store.test.ts | 73 ++++++++++++++++++- .../journey-client/src/lib/client.store.ts | 53 +++++++++----- 4 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 .changeset/journey-loginfailure-on-error-code.md diff --git a/.changeset/journey-loginfailure-on-error-code.md b/.changeset/journey-loginfailure-on-error-code.md new file mode 100644 index 0000000000..c9260d6de9 --- /dev/null +++ b/.changeset/journey-loginfailure-on-error-code.md @@ -0,0 +1,5 @@ +--- +'@forgerock/journey-client': patch +--- + +Return `JourneyLoginFailure` by hitting the previously-unreached `LoginFailure` branch when `start()`/`next()` receives a failure payload with a `code`. diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 3b61558b4d..2d28bab76b 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -206,7 +206,6 @@ if (searchParams.get('middleware') === 'true') { renderComplete(); } else if (step?.type === 'LoginFailure') { console.error('Journey failed'); - renderForm(); renderError(); } else { console.error('Unknown node status', step); diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index 69b12553fb..02325591b0 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment node /* * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. * @@ -75,7 +76,7 @@ function getUrlFromInput(input: RequestInfo | URL): string { /** * Helper to setup mock fetch for wellknown + journey responses */ -function setupMockFetch(journeyResponse: Step | null = null) { +function setupMockFetch(journeyResponse: Step | null = null, authenticateStatus = 200) { mockFetch.mockImplementation((input: RequestInfo | URL) => { const url = getUrlFromInput(input); @@ -85,8 +86,13 @@ function setupMockFetch(journeyResponse: Step | null = null) { } // Journey authenticate endpoint - if (journeyResponse && url.includes('/authenticate')) { - return Promise.resolve(new Response(JSON.stringify(journeyResponse))); + if (url.includes('/authenticate')) { + if (journeyResponse === null) { + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + } + return Promise.resolve( + new Response(JSON.stringify(journeyResponse), { status: authenticateStatus }), + ); } return Promise.reject(new Error(`Unexpected fetch: ${url}`)); @@ -152,6 +158,30 @@ describe('journey-client', () => { } }); + test('start_401WithCodeInBody_ReturnsLoginFailure', async () => { + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + setupMockFetch(failurePayload, 401); + + const client = await journey({ config: mockConfig }); + const result = await client.start(); + + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', 'LoginFailure'); + + if (!isGenericError(result) && result.type === 'LoginFailure') { + expect(result.payload).toEqual(failurePayload); + expect(result.getCode()).toBe(401); + expect(result.getMessage()).toBe('Access Denied'); + expect(result.getReason()).toBe('Unauthorized'); + } + }); + test('next_WellknownConfig_SendsStepAndReturnsNext', async () => { const initialStep = createJourneyStep({ authId: 'test-auth-id', @@ -192,6 +222,34 @@ describe('journey-client', () => { } }); + test('next_401WithCodeInBody_ReturnsLoginFailure', async () => { + const initialStep = createJourneyStep({ + authId: 'test-auth-id', + callbacks: [], + }); + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + setupMockFetch(failurePayload, 401); + + const client = await journey({ config: mockConfig }); + const result = await client.next(initialStep, {}); + + expect(result).toBeDefined(); + expect(isGenericError(result)).toBe(false); + expect(result).toHaveProperty('type', 'LoginFailure'); + + if (!isGenericError(result) && result.type === 'LoginFailure') { + expect(result.payload).toEqual(failurePayload); + expect(result.getCode()).toBe(401); + expect(result.getMessage()).toBe('Access Denied'); + expect(result.getReason()).toBe('Unauthorized'); + } + }); + test('redirect_WellknownConfig_StoresStepAndCallsLocationAssign', async () => { const mockStepPayload: Step = { callbacks: [ @@ -204,6 +262,15 @@ describe('journey-client', () => { }; const step = createJourneyStep(mockStepPayload); const assignMock = vi.fn(); + // Node test environment doesn't provide `window`, so create a minimal shim + // with a real `location` getter so we can keep using vi.spyOn(..., 'get'). + (globalThis as unknown as { window?: unknown }).window = {}; + Object.defineProperty(window, 'location', { + configurable: true, + get: () => ({ + assign: vi.fn(), + }), + }); const locationSpy = vi.spyOn(window, 'location', 'get').mockReturnValue({ ...window.location, assign: assignMock, diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index 129386a864..ecb95bb802 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -16,6 +16,7 @@ import { import type { GenericError } from '@forgerock/sdk-types'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { Step } from '@forgerock/sdk-types'; +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { createJourneyStore } from './client.store.utils.js'; import { configSlice } from './config.slice.js'; @@ -155,32 +156,48 @@ export async function journey({ const self: JourneyClient = { start: async (options?: StartParam) => { - const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); - if (!data) { - const error: GenericError = { - error: 'no_response_data', - message: 'No data received from server when starting journey', - type: 'unknown_error', - }; - return error; + const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + if (data) { + return createJourneyObject(data); + } + + const errorData = (error as FetchBaseQueryError | undefined)?.data; + const errorStep = errorData as Step | undefined; + if (errorStep?.code !== undefined) { + return createJourneyObject(errorStep); } - return createJourneyObject(data); + + const genericError: GenericError = { + error: 'no_response_data', + message: 'No data received from server when starting journey', + type: 'unknown_error', + }; + return genericError; }, /** * Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey. */ next: async (step: JourneyStep, options?: NextOptions) => { - const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); - if (!data) { - const error: GenericError = { - error: 'no_response_data', - message: 'No data received from server when submitting step', - type: 'unknown_error', - }; - return error; + const { data, error } = await store.dispatch( + journeyApi.endpoints.next.initiate({ step, options }), + ); + if (data) { + return createJourneyObject(data); + } + + const errorData = (error as FetchBaseQueryError | undefined)?.data; + const errorStep = errorData as Step | undefined; + if (errorStep?.code !== undefined) { + return createJourneyObject(errorStep); } - return createJourneyObject(data); + + const genericError: GenericError = { + error: 'no_response_data', + message: 'No data received from server when submitting step', + type: 'unknown_error', + }; + return genericError; }, // TODO: Remove the actual redirect from this method and just return the URL to the caller From 13326e0b4bad1913575129ef3d58c1b92ff463c9 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Mon, 20 Apr 2026 15:24:27 -0700 Subject: [PATCH 2/4] fix(journey-client): create JourneyLoginFailure step and handle LoginFailure case --- ...-on-error-code.md => whole-mangos-find.md} | 2 +- .../src/lib/client.store.test.ts | 2 +- .../journey-client/src/lib/client.store.ts | 21 ++-------- .../journey-client/src/lib/journey.api.ts | 39 +++++++++++++++++++ 4 files changed, 44 insertions(+), 20 deletions(-) rename .changeset/{journey-loginfailure-on-error-code.md => whole-mangos-find.md} (81%) diff --git a/.changeset/journey-loginfailure-on-error-code.md b/.changeset/whole-mangos-find.md similarity index 81% rename from .changeset/journey-loginfailure-on-error-code.md rename to .changeset/whole-mangos-find.md index c9260d6de9..6b36eacfb2 100644 --- a/.changeset/journey-loginfailure-on-error-code.md +++ b/.changeset/whole-mangos-find.md @@ -2,4 +2,4 @@ '@forgerock/journey-client': patch --- -Return `JourneyLoginFailure` by hitting the previously-unreached `LoginFailure` branch when `start()`/`next()` receives a failure payload with a `code`. +Return `JourneyLoginFailure` by hitting the previously-unreached `LoginFailure` branch when `start()`/`next()` receives a failure payload with a login failure `code` diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index 02325591b0..2304458eaf 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index ecb95bb802..e50412f8f2 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -16,7 +16,6 @@ import { import type { GenericError } from '@forgerock/sdk-types'; import type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; import type { Step } from '@forgerock/sdk-types'; -import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { createJourneyStore } from './client.store.utils.js'; import { configSlice } from './config.slice.js'; @@ -156,17 +155,11 @@ export async function journey({ const self: JourneyClient = { start: async (options?: StartParam) => { - const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); if (data) { return createJourneyObject(data); } - const errorData = (error as FetchBaseQueryError | undefined)?.data; - const errorStep = errorData as Step | undefined; - if (errorStep?.code !== undefined) { - return createJourneyObject(errorStep); - } - const genericError: GenericError = { error: 'no_response_data', message: 'No data received from server when starting journey', @@ -179,19 +172,11 @@ export async function journey({ * Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey. */ next: async (step: JourneyStep, options?: NextOptions) => { - const { data, error } = await store.dispatch( - journeyApi.endpoints.next.initiate({ step, options }), - ); + const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); if (data) { return createJourneyObject(data); } - const errorData = (error as FetchBaseQueryError | undefined)?.data; - const errorStep = errorData as Step | undefined; - if (errorStep?.code !== undefined) { - return createJourneyObject(errorStep); - } - const genericError: GenericError = { error: 'no_response_data', message: 'No data received from server when submitting step', diff --git a/packages/journey-client/src/lib/journey.api.ts b/packages/journey-client/src/lib/journey.api.ts index 66c4da9a29..70e4692f2d 100644 --- a/packages/journey-client/src/lib/journey.api.ts +++ b/packages/journey-client/src/lib/journey.api.ts @@ -89,6 +89,9 @@ interface Extras { logger: ReturnType; } +// Only treat these numeric codes as login failures coming back in the Step payload. +const LOGIN_FAILURE_CODES = [400, 401, 403, 412, 423, 429]; + export const journeyApi = createApi({ reducerPath: 'journeyReducer', baseQuery: fetchBaseQuery({ @@ -133,6 +136,24 @@ export const journeyApi = createApi({ return result as QueryReturnValue; }); + /** + * If the endpoint returned an HTTP error whose body is an AM Step with a + * login-failure code, treat it as successful data so callers receive the + * Step via the `data` path (keeps downstream logic simpler). + */ + if ('error' in response) { + const errorData = (response.error as FetchBaseQueryError | undefined)?.data as + | Step + | undefined; + if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) { + return { data: errorData } as QueryReturnValue< + Step, + FetchBaseQueryError, + FetchBaseQueryMeta + >; + } + } + return response as QueryReturnValue; }, }), @@ -162,6 +183,24 @@ export const journeyApi = createApi({ return result as QueryReturnValue; }); + /** + * If the endpoint returned an HTTP error whose body is an AM Step with a + * login-failure code, treat it as successful data so callers receive the + * Step via the `data` path (keeps downstream logic simpler). + */ + if ('error' in response) { + const errorData = (response.error as FetchBaseQueryError | undefined)?.data as + | Step + | undefined; + if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) { + return { data: errorData } as QueryReturnValue< + Step, + FetchBaseQueryError, + FetchBaseQueryMeta + >; + } + } + return response as QueryReturnValue; }, }), From 67086108fefe2ff2e90fcd41711439d8b73d28e7 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Wed, 22 Apr 2026 16:56:33 -0700 Subject: [PATCH 3/4] fix(journey-client): handle errors in journey utils with RTK narrowing --- .../api-report/davinci-client.api.md | 14 +-- .../api-report/davinci-client.types.api.md | 14 +-- .../api-report/journey-client.api.md | 3 + .../api-report/journey-client.types.api.md | 3 + .../src/lib/client.store.test.ts | 12 +-- .../journey-client/src/lib/client.store.ts | 30 ++---- .../journey-client/src/lib/journey.api.ts | 39 -------- .../src/lib/journey.utils.test.ts | 98 +++++++++++++++++++ .../journey-client/src/lib/journey.utils.ts | 88 +++++++++++++++-- packages/journey-client/src/types.ts | 4 +- 10 files changed, 214 insertions(+), 91 deletions(-) create mode 100644 packages/journey-client/src/lib/journey.utils.test.ts diff --git a/packages/davinci-client/api-report/davinci-client.api.md b/packages/davinci-client/api-report/davinci-client.api.md index b2528bf664..289c8a5276 100644 --- a/packages/davinci-client/api-report/davinci-client.api.md +++ b/packages/davinci-client/api-report/davinci-client.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/davinci-client/api-report/davinci-client.types.api.md b/packages/davinci-client/api-report/davinci-client.types.api.md index 2321431a0a..05f38634dc 100644 --- a/packages/davinci-client/api-report/davinci-client.types.api.md +++ b/packages/davinci-client/api-report/davinci-client.types.api.md @@ -267,13 +267,11 @@ export function davinci(input: { resume: (input: { continueToken: string; }) => Promise; - start: (options?: StartOptions | undefined) => Promise; + start: (options?: StartOptions | undefined) => Promise; update: (collector: T) => Updater; validate: (collector: SingleValueCollectors | ObjectValueCollectors | MultiValueCollectors | AutoCollectors) => Validator; - poll: (collector: PollingCollector) => Poller; + pollStatus: (collector: PollingCollector) => Poller; getClient: () => { - status: "start"; - } | { action: string; collectors: Collectors[]; description?: string; @@ -287,6 +285,8 @@ export function davinci(input: { status: "error"; } | { status: "failure"; + } | { + status: "start"; } | { authorization?: { code?: string; @@ -297,7 +297,7 @@ export function davinci(input: { getCollectors: () => Collectors[]; getError: () => DaVinciError | null; getErrorCollectors: () => CollectorErrors[]; - getNode: () => ContinueNode | StartNode | ErrorNode | FailureNode | SuccessNode; + getNode: () => ContinueNode | ErrorNode | FailureNode | StartNode | SuccessNode; getServer: () => { _links?: Links; id?: string; @@ -306,8 +306,6 @@ export function davinci(input: { href?: string; eventName?: string; status: "continue"; - } | { - status: "start"; } | { _links?: Links; eventName?: string; @@ -323,6 +321,8 @@ export function davinci(input: { interactionId?: string; interactionToken?: string; status: "failure"; + } | { + status: "start"; } | { _links?: Links; eventName?: string; diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 9e471c8784..269fdec7e2 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -24,6 +24,7 @@ import { RequestMiddleware } from '@forgerock/sdk-request-middleware'; import { Step } from '@forgerock/sdk-types'; import { StepDetail } from '@forgerock/sdk-types'; import { StepType } from '@forgerock/sdk-types'; +import { WellknownResponse } from '@forgerock/sdk-types'; export { ActionTypes } @@ -494,6 +495,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback { setValidateOnly(value: boolean): void; } +export { WellknownResponse } + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/journey-client/api-report/journey-client.types.api.md b/packages/journey-client/api-report/journey-client.types.api.md index c9d45ac5a5..26beb8ebe2 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -23,6 +23,7 @@ import { RequestMiddleware } from '@forgerock/sdk-request-middleware'; import { Step } from '@forgerock/sdk-types'; import { StepDetail } from '@forgerock/sdk-types'; import { StepType } from '@forgerock/sdk-types'; +import { WellknownResponse } from '@forgerock/sdk-types'; export { ActionTypes } @@ -481,6 +482,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback { setValidateOnly(value: boolean): void; } +export { WellknownResponse } + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/journey-client/src/lib/client.store.test.ts b/packages/journey-client/src/lib/client.store.test.ts index 2304458eaf..6ba05edbd3 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -6,13 +6,13 @@ * of the MIT license. See the LICENSE file for details. */ -import { callbackType } from '@forgerock/sdk-types'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { GenericError, Step, WellknownResponse } from '@forgerock/sdk-types'; - import { journey } from './client.store.js'; import { createJourneyStep } from './step.utils.js'; + +import { callbackType, type GenericError, type Step, type WellknownResponse } from '../index.js'; + import { JourneyClientConfig } from './config.types.js'; /** @@ -158,7 +158,7 @@ describe('journey-client', () => { } }); - test('start_401WithCodeInBody_ReturnsLoginFailure', async () => { + test('start_401WithStepPayload_ReturnsLoginFailure', async () => { const failurePayload: Step = { code: 401, message: 'Access Denied', @@ -222,7 +222,7 @@ describe('journey-client', () => { } }); - test('next_401WithCodeInBody_ReturnsLoginFailure', async () => { + test('next_401WithStepPayload_ReturnsLoginFailure', async () => { const initialStep = createJourneyStep({ authId: 'test-auth-id', callbacks: [], @@ -434,7 +434,7 @@ describe('journey-client', () => { expect(isGenericError(result)).toBe(true); if (isGenericError(result)) { - expect(result.error).toBe('no_response_data'); + expect(result.error).toBe('request_failed'); expect(result.type).toBe('unknown_error'); } }); diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index e50412f8f2..1b1caed4ef 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -21,7 +21,7 @@ import { createJourneyStore } from './client.store.utils.js'; import { configSlice } from './config.slice.js'; import { journeyApi } from './journey.api.js'; import { createStorage } from '@forgerock/storage'; -import { createJourneyObject } from './journey.utils.js'; +import { createJourneyObject, resolveJourneyResult } from './journey.utils.js'; import { wellknownApi } from './wellknown.api.js'; import type { JourneyStep } from './step.utils.js'; @@ -155,34 +155,18 @@ export async function journey({ const self: JourneyClient = { start: async (options?: StartParam) => { - const { data } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); - if (data) { - return createJourneyObject(data); - } - - const genericError: GenericError = { - error: 'no_response_data', - message: 'No data received from server when starting journey', - type: 'unknown_error', - }; - return genericError; + const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + return resolveJourneyResult(data, error); }, /** * Submits the current Step payload to the authentication API and retrieves the next JourneyStep in the journey. */ next: async (step: JourneyStep, options?: NextOptions) => { - const { data } = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); - if (data) { - return createJourneyObject(data); - } - - const genericError: GenericError = { - error: 'no_response_data', - message: 'No data received from server when submitting step', - type: 'unknown_error', - }; - return genericError; + const { data, error } = await store.dispatch( + journeyApi.endpoints.next.initiate({ step, options }), + ); + return resolveJourneyResult(data, error); }, // TODO: Remove the actual redirect from this method and just return the URL to the caller diff --git a/packages/journey-client/src/lib/journey.api.ts b/packages/journey-client/src/lib/journey.api.ts index 70e4692f2d..66c4da9a29 100644 --- a/packages/journey-client/src/lib/journey.api.ts +++ b/packages/journey-client/src/lib/journey.api.ts @@ -89,9 +89,6 @@ interface Extras { logger: ReturnType; } -// Only treat these numeric codes as login failures coming back in the Step payload. -const LOGIN_FAILURE_CODES = [400, 401, 403, 412, 423, 429]; - export const journeyApi = createApi({ reducerPath: 'journeyReducer', baseQuery: fetchBaseQuery({ @@ -136,24 +133,6 @@ export const journeyApi = createApi({ return result as QueryReturnValue; }); - /** - * If the endpoint returned an HTTP error whose body is an AM Step with a - * login-failure code, treat it as successful data so callers receive the - * Step via the `data` path (keeps downstream logic simpler). - */ - if ('error' in response) { - const errorData = (response.error as FetchBaseQueryError | undefined)?.data as - | Step - | undefined; - if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) { - return { data: errorData } as QueryReturnValue< - Step, - FetchBaseQueryError, - FetchBaseQueryMeta - >; - } - } - return response as QueryReturnValue; }, }), @@ -183,24 +162,6 @@ export const journeyApi = createApi({ return result as QueryReturnValue; }); - /** - * If the endpoint returned an HTTP error whose body is an AM Step with a - * login-failure code, treat it as successful data so callers receive the - * Step via the `data` path (keeps downstream logic simpler). - */ - if ('error' in response) { - const errorData = (response.error as FetchBaseQueryError | undefined)?.data as - | Step - | undefined; - if (errorData && errorData.code && LOGIN_FAILURE_CODES.includes(errorData.code)) { - return { data: errorData } as QueryReturnValue< - Step, - FetchBaseQueryError, - FetchBaseQueryMeta - >; - } - } - return response as QueryReturnValue; }, }), diff --git a/packages/journey-client/src/lib/journey.utils.test.ts b/packages/journey-client/src/lib/journey.utils.test.ts new file mode 100644 index 0000000000..3cefb68ef9 --- /dev/null +++ b/packages/journey-client/src/lib/journey.utils.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Ping Identity Corporation. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +import { describe, expect, it } from 'vitest'; + +import { StepType } from '../types.js'; +import { type Step } from '../index.js'; + +import { createJourneyObject, resolveJourneyResult } from './journey.utils.js'; +import type { JourneyLoginFailure } from './login-failure.utils.js'; + +describe('createJourneyObject', () => { + it('returns Step when provided a step with authId', () => { + const stepPayload: Step = { + authId: 'test-auth-id', + callbacks: [], + }; + + const result = createJourneyObject(stepPayload); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.Step); + expect(result).toHaveProperty('payload'); + expect((result as { payload: Step }).payload).toEqual(stepPayload); + }); + + it('returns LoginSuccess when provided a step with successUrl', () => { + const successPayload: Step = { + successUrl: 'https://example.com/success', + realm: 'root', + tokenId: 'token-123', + }; + + const result = createJourneyObject(successPayload); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.LoginSuccess); + expect(result).toHaveProperty('payload', successPayload); + }); +}); + +describe('resolveJourneyResult - no_response_data', () => { + it('returns no_response_data GenericError when no data and no error', () => { + const result = resolveJourneyResult(undefined, undefined); + + expect(result).toMatchObject({ + error: 'no_response_data', + message: 'No data received from server', + type: 'unknown_error', + }); + }); +}); + +describe('toJourneyResult', () => { + it('returns request_failed GenericError for FetchBaseQueryError without Step payload', () => { + const result = resolveJourneyResult(undefined, { status: 500, data: { foo: 'bar' } }); + + expect(result).toMatchObject({ + error: 'request_failed', + message: 'Request failed: {"foo":"bar"}', + type: 'unknown_error', + }); + }); + + it('returns request_failed GenericError for SerializedError', () => { + const result = resolveJourneyResult(undefined, { message: 'Network failure' }); + + expect(result).toMatchObject({ + error: 'request_failed', + message: 'Request failed: Network failure', + type: 'unknown_error', + }); + }); + + it('returns LoginFailure when FetchBaseQueryError contains a failure Step payload', () => { + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + + const result = resolveJourneyResult(undefined, { status: 401, data: failurePayload }); + + expect(result).not.toHaveProperty('error'); + expect(result).toHaveProperty('type', StepType.LoginFailure); + expect(result).toHaveProperty('payload', failurePayload); + + const failure = result as JourneyLoginFailure; + expect(failure.getCode()).toBe(401); + expect(failure.getMessage()).toBe('Access Denied'); + expect(failure.getReason()).toBe('Unauthorized'); + }); +}); diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts index 4a42cae87e..6d056da65a 100644 --- a/packages/journey-client/src/lib/journey.utils.ts +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -1,11 +1,13 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025-2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ import { StepType } from '@forgerock/sdk-types'; +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import type { SerializedError } from '@reduxjs/toolkit'; import type { GenericError, Step } from '@forgerock/sdk-types'; @@ -17,15 +19,15 @@ import type { JourneyStep } from './step.utils.js'; import type { JourneyLoginFailure } from './login-failure.utils.js'; import type { JourneyLoginSuccess } from './login-success.utils.js'; +export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; + /** - * Creates a journey object from a raw Step response. - * Determines the step type based on the presence of authId or successUrl properties - * and returns the appropriate journey object type. + * Creates a typed journey object from a raw Step response. * * @param step - The raw Step response from the authentication API - * @returns A JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError if the step type cannot be determined + * @returns A JourneyStep, JourneyLoginSuccess, or JourneyLoginFailure, or a GenericError if the step type cannot be determined */ -function createJourneyObject( +export function createJourneyObject( step: Step, ): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError { let type; @@ -53,4 +55,76 @@ function createJourneyObject( } } -export { createJourneyObject }; +/** + * Type guard that checks whether a value resembles a Step response. + * Used to detect step-shaped error payloads that should be treated as journey steps. + * + * @param value - The value to check + * @returns True if the value is an object containing at least one known Step key + */ +function isStepLike(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + [ + 'authId', + 'callbacks', + 'code', + 'description', + 'detail', + 'header', + 'ok', + 'realm', + 'reason', + 'stage', + 'status', + 'successUrl', + 'tokenId', + ].some((key) => key in value) + ); +} + +/** + * Maps an RTK Query dispatch result to a JourneyResult. + * Narrows RTK errors first; only calls createJourneyObject when data is confirmed present. + * + * @param data - The Step data returned by the RTK Query endpoint, if any + * @param error - The error returned by the RTK Query endpoint, if any + * @returns A JourneyResult representing the outcome of the journey step + */ +export function resolveJourneyResult( + data: Step | undefined, + error: FetchBaseQueryError | SerializedError | undefined, +): JourneyResult { + if (error && 'status' in error) { + const stepData = error.data; + if (isStepLike(stepData)) { + return createJourneyObject(stepData as Step); + } + + const errMsg = 'error' in error ? error.error : JSON.stringify(error.data); + return { + error: 'request_failed', + message: `Request failed: ${errMsg}`, + type: 'unknown_error', + }; + } + + if (error && 'message' in error) { + return { + error: 'request_failed', + message: `Request failed: ${error.message ?? 'Unknown error'}`, + type: 'unknown_error', + }; + } + + if (!data) { + return { + error: 'no_response_data', + message: 'No data received from server', + type: 'unknown_error', + }; + } + + return createJourneyObject(data); +} diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index 4b547efb23..6310ddbda0 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -12,8 +12,8 @@ export type { Step, Callback, CallbackType, - StepType, GenericError, + WellknownResponse, PolicyRequirement, FailedPolicyRequirement, NameValue, @@ -22,7 +22,7 @@ export type { FailureDetail, } from '@forgerock/sdk-types'; -export { PolicyKey } from '@forgerock/sdk-types'; +export { PolicyKey, StepType } from '@forgerock/sdk-types'; // Re-export local types export * from './lib/client.types.js'; From dcf367f34a58439902df38f476e30841916d1b04 Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Wed, 6 May 2026 17:40:04 -0700 Subject: [PATCH 4/4] fix(journey-client): treat object error.data as LoginFailure and remove isStepLike --- .../src/lib/journey.utils.test.ts | 9 ++---- .../journey-client/src/lib/journey.utils.ts | 32 ++----------------- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/packages/journey-client/src/lib/journey.utils.test.ts b/packages/journey-client/src/lib/journey.utils.test.ts index 3cefb68ef9..47d9c87148 100644 --- a/packages/journey-client/src/lib/journey.utils.test.ts +++ b/packages/journey-client/src/lib/journey.utils.test.ts @@ -56,14 +56,11 @@ describe('resolveJourneyResult - no_response_data', () => { }); describe('toJourneyResult', () => { - it('returns request_failed GenericError for FetchBaseQueryError without Step payload', () => { + it('returns LoginFailure when FetchBaseQueryError has an object payload', () => { const result = resolveJourneyResult(undefined, { status: 500, data: { foo: 'bar' } }); - expect(result).toMatchObject({ - error: 'request_failed', - message: 'Request failed: {"foo":"bar"}', - type: 'unknown_error', - }); + expect(result).toHaveProperty('type', StepType.LoginFailure); + expect(result).toHaveProperty('payload', { foo: 'bar' }); }); it('returns request_failed GenericError for SerializedError', () => { diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts index 6d056da65a..722cb689a0 100644 --- a/packages/journey-client/src/lib/journey.utils.ts +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -55,35 +55,6 @@ export function createJourneyObject( } } -/** - * Type guard that checks whether a value resembles a Step response. - * Used to detect step-shaped error payloads that should be treated as journey steps. - * - * @param value - The value to check - * @returns True if the value is an object containing at least one known Step key - */ -function isStepLike(value: unknown): boolean { - return ( - typeof value === 'object' && - value !== null && - [ - 'authId', - 'callbacks', - 'code', - 'description', - 'detail', - 'header', - 'ok', - 'realm', - 'reason', - 'stage', - 'status', - 'successUrl', - 'tokenId', - ].some((key) => key in value) - ); -} - /** * Maps an RTK Query dispatch result to a JourneyResult. * Narrows RTK errors first; only calls createJourneyObject when data is confirmed present. @@ -98,7 +69,8 @@ export function resolveJourneyResult( ): JourneyResult { if (error && 'status' in error) { const stepData = error.data; - if (isStepLike(stepData)) { + // If error.data is an object, we treat it as login failure + if (typeof stepData === 'object' && stepData !== null) { return createJourneyObject(stepData as Step); }