From 091667c2c70c3c258f204b53d83d11a274e8570e Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Mon, 1 Jun 2026 14:30:46 -0700 Subject: [PATCH 1/2] fix(journey-client): create JourneyLoginFailure step, handle Login Failure case --- .changeset/whole-mangos-find.md | 5 + e2e/journey-app/main.ts | 5 +- .../api-report/journey-client.api.md | 3 + .../api-report/journey-client.types.api.md | 3 + .../src/lib/client.store.test.ts | 73 ++++++++- .../journey-client/src/lib/client.store.ts | 36 ++--- .../src/lib/journey.utils.test.ts | 147 ++++++++++++++++++ .../journey-client/src/lib/journey.utils.ts | 56 ++++++- packages/journey-client/src/types.ts | 1 + 9 files changed, 298 insertions(+), 31 deletions(-) create mode 100644 .changeset/whole-mangos-find.md create mode 100644 packages/journey-client/src/lib/journey.utils.test.ts diff --git a/.changeset/whole-mangos-find.md b/.changeset/whole-mangos-find.md new file mode 100644 index 0000000000..6b36eacfb2 --- /dev/null +++ b/.changeset/whole-mangos-find.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 login failure `code` diff --git a/e2e/journey-app/main.ts b/e2e/journey-app/main.ts index 3b61558b4d..18adaa6600 100644 --- a/e2e/journey-app/main.ts +++ b/e2e/journey-app/main.ts @@ -206,8 +206,11 @@ if (searchParams.get('middleware') === 'true') { renderComplete(); } else if (step?.type === 'LoginFailure') { console.error('Journey failed'); - renderForm(); renderError(); + const errorHtml = errorEl.innerHTML; + step = await journeyClient.start({ journey: journeyName }); + renderForm(); + errorEl.innerHTML = errorHtml; } else { console.error('Unknown node status', step); } diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index 368fa625f2..a545df2527 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -25,6 +25,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 } @@ -499,6 +500,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 9d49d2fedd..6425d7315e 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.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 } @@ -486,6 +487,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 180188c6bc..3d150328bf 100644 --- a/packages/journey-client/src/lib/client.store.test.ts +++ b/packages/journey-client/src/lib/client.store.test.ts @@ -1,18 +1,18 @@ // @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. */ -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'; /** @@ -76,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); @@ -86,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}`)); @@ -154,6 +159,30 @@ describe('journey-client', () => { } }); + test('start_401WithStepPayload_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', @@ -194,6 +223,34 @@ describe('journey-client', () => { } }); + test('next_401WithStepPayload_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: [ @@ -366,7 +423,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 57bc2e7f03..a779218698 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. @@ -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, handleJourneyResponse } from './journey.utils.js'; import { wellknownApi } from './wellknown.api.js'; import type { JourneyStep } from './step.utils.js'; @@ -158,32 +158,28 @@ export async function journey({ subscribe: store.subscribe, 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)); + const result = handleJourneyResponse(data, error); + if ('error' in result) { + return result; } - return createJourneyObject(data); + + return createJourneyObject(result); }, /** * 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 }), + ); + const result = handleJourneyResponse(data, error); + if ('error' in result) { + return result; } - return createJourneyObject(data); + + return createJourneyObject(result); }, // 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.utils.test.ts b/packages/journey-client/src/lib/journey.utils.test.ts new file mode 100644 index 0000000000..5d96b58d5f --- /dev/null +++ b/packages/journey-client/src/lib/journey.utils.test.ts @@ -0,0 +1,147 @@ +/* + * 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, handleJourneyResponse } 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); + }); + + it('returns LoginFailure when provided a step without authId or successUrl', () => { + const failurePayload: Step = { + code: 401, + message: 'Access Denied', + reason: 'Unauthorized', + detail: { failureUrl: 'https://example.com/failure' }, + }; + + const result = createJourneyObject(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'); + }); +}); + +describe('handleJourneyResponse', () => { + it('returns Step data when FetchBaseQueryError has numeric status and object body', () => { + const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' }; + const error = { status: 401, data: body }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toBe(body); + }); + + it('returns GenericError when FetchBaseQueryError has numeric status but non-object body', () => { + const error = { status: 500, data: 'Internal Server Error' }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + }); + + it('returns GenericError for FETCH_ERROR', () => { + const error = { status: 'FETCH_ERROR' as const, error: 'Network error' }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect((result as { message: string }).message).toContain('Network error'); + }); + + it('returns GenericError for PARSING_ERROR', () => { + const error = { + status: 'PARSING_ERROR' as const, + originalStatus: 200, + data: 'Not JSON', + error: 'JSON parse error', + }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect((result as { message: string }).message).toContain('JSON parse error'); + }); + + it('returns GenericError for TIMEOUT_ERROR', () => { + const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect((result as { message: string }).message).toContain('Request timed out'); + }); + + it('returns GenericError for CUSTOM_ERROR', () => { + const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect((result as { message: string }).message).toContain('Custom error occurred'); + }); + + it('returns GenericError for SerializedError', () => { + const error = { name: 'Error', message: 'Something went wrong', stack: '...' }; + + const result = handleJourneyResponse(undefined, error); + + expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect((result as { message: string }).message).toContain('Something went wrong'); + }); + + it('returns GenericError when no data and no error', () => { + const result = handleJourneyResponse(undefined, undefined); + + expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' }); + }); + + it('returns data when no error and data is present', () => { + const data: Step = { authId: 'test-auth-id', callbacks: [] }; + + const result = handleJourneyResponse(data, undefined); + + expect(result).toBe(data); + }); +}); diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts index 4a42cae87e..e87b97a604 100644 --- a/packages/journey-client/src/lib/journey.utils.ts +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -8,6 +8,8 @@ import { StepType } from '@forgerock/sdk-types'; import type { GenericError, Step } from '@forgerock/sdk-types'; +import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import type { SerializedError } from '@reduxjs/toolkit'; import { createJourneyLoginSuccess } from './login-success.utils.js'; import { createJourneyLoginFailure } from './login-failure.utils.js'; @@ -25,7 +27,7 @@ import type { JourneyLoginSuccess } from './login-success.utils.js'; * @param step - The raw Step response from the authentication API * @returns A JourneyStep, JourneyLoginSuccess, JourneyLoginFailure, or GenericError if the step type cannot be determined */ -function createJourneyObject( +export function createJourneyObject( step: Step, ): JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError { let type; @@ -53,4 +55,54 @@ function createJourneyObject( } } -export { createJourneyObject }; +/** + * Resolves an RTK Query response to a Step or GenericError. + * + * @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 Step on success, GenericError on failure + */ +export function handleJourneyResponse( + data: Step | undefined, + error: FetchBaseQueryError | SerializedError | undefined, +): Step | GenericError { + /** + * https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature + * FetchBaseQueryError with status: number means AM returned an HTTP response with a JSON body. + * Only this variant can carry an AM failure payload — FETCH_ERROR, PARSING_ERROR, TIMEOUT_ERROR, + * and CUSTOM_ERROR either have no body or a non-object body (raw string for PARSING_ERROR). + */ + if ( + error && + 'status' in error && + typeof error.status === 'number' && + typeof error.data === 'object' && + error.data !== null + ) { + return error.data as Step; + } + + /** + * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling + * All other FetchBaseQueryError variants expose an `error` string; SerializedError exposes `message`. + * Both represent infrastructure failures with no usable AM response body. + */ + if (error) { + const msg = 'error' in error ? error.error : 'message' in error ? error.message : undefined; + return { + error: 'request_failed', + message: `Request failed: ${msg ?? 'Unknown error'}`, + type: 'unknown_error', + }; + } + + if (!data) { + return { + error: 'no_response_data', + message: 'No data received from server', + type: 'unknown_error', + }; + } + + return data; +} diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index a6f302f91d..e9b35c6e65 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -13,6 +13,7 @@ export type { Callback, CallbackType, GenericError, + WellknownResponse, PolicyRequirement, FailedPolicyRequirement, NameValue, From 7bac6f35de09c84d74b3a84b04086089db4858ee Mon Sep 17 00:00:00 2001 From: Vatsal Parikh Date: Thu, 4 Jun 2026 09:18:58 -0700 Subject: [PATCH 2/2] refactor(journey-client): use Either for response parsing in start and next --- .../api-report/journey-client.api.md | 2 +- .../api-report/journey-client.types.api.md | 2 +- packages/journey-client/package.json | 1 + .../journey-client/src/lib/client.store.ts | 35 +++----- .../src/lib/journey.utils.test.ts | 82 +++++++++++-------- .../journey-client/src/lib/journey.utils.ts | 80 ++++++++++-------- packages/journey-client/src/types.ts | 2 +- pnpm-lock.yaml | 4 + 8 files changed, 114 insertions(+), 94 deletions(-) diff --git a/packages/journey-client/api-report/journey-client.api.md b/packages/journey-client/api-report/journey-client.api.md index a545df2527..d0dce2aae6 100644 --- a/packages/journey-client/api-report/journey-client.api.md +++ b/packages/journey-client/api-report/journey-client.api.md @@ -229,7 +229,7 @@ export type JourneyLoginSuccess = AuthResponse & { getSuccessUrl: () => string | undefined; }; -// @public +// @public (undocumented) export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; // @public 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 6425d7315e..2c6c1c8791 100644 --- a/packages/journey-client/api-report/journey-client.types.api.md +++ b/packages/journey-client/api-report/journey-client.types.api.md @@ -216,7 +216,7 @@ export type JourneyLoginSuccess = AuthResponse & { getSuccessUrl: () => string | undefined; }; -// @public +// @public (undocumented) export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; // @public diff --git a/packages/journey-client/package.json b/packages/journey-client/package.json index d73f14fd49..26b1be72be 100644 --- a/packages/journey-client/package.json +++ b/packages/journey-client/package.json @@ -39,6 +39,7 @@ "@forgerock/sdk-utilities": "workspace:*", "@forgerock/storage": "workspace:*", "@reduxjs/toolkit": "catalog:", + "effect": "catalog:effect", "tslib": "catalog:" }, "devDependencies": { diff --git a/packages/journey-client/src/lib/client.store.ts b/packages/journey-client/src/lib/client.store.ts index a779218698..0ec8039cce 100644 --- a/packages/journey-client/src/lib/client.store.ts +++ b/packages/journey-client/src/lib/client.store.ts @@ -21,18 +21,15 @@ 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, handleJourneyResponse } from './journey.utils.js'; +import { match } from 'effect/Either'; +import { createJourneyObject, parseJourneyResponse } from './journey.utils.js'; +import type { JourneyResult } from './journey.utils.js'; import { wellknownApi } from './wellknown.api.js'; import type { JourneyStep } from './step.utils.js'; import type { JourneyClientConfig } from './config.types.js'; import type { RedirectCallback } from './callbacks/redirect-callback.js'; import type { NextOptions, StartParam, ResumeOptions } from './interfaces.js'; -import type { JourneyLoginFailure } from './login-failure.utils.js'; -import type { JourneyLoginSuccess } from './login-success.utils.js'; - -/** Result type for journey client methods. */ -export type JourneyResult = JourneyStep | JourneyLoginSuccess | JourneyLoginFailure | GenericError; /** The journey client instance returned by the `journey()` function. */ export interface JourneyClient { @@ -158,28 +155,22 @@ export async function journey({ subscribe: store.subscribe, start: async (options?: StartParam) => { - const { data, error } = await store.dispatch(journeyApi.endpoints.start.initiate(options)); - const result = handleJourneyResponse(data, error); - if ('error' in result) { - return result; - } - - return createJourneyObject(result); + const response = await store.dispatch(journeyApi.endpoints.start.initiate(options)); + return match(parseJourneyResponse(response), { + onLeft: (err): JourneyResult => err, + onRight: (step): JourneyResult => createJourneyObject(step), + }); }, /** * 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 result = handleJourneyResponse(data, error); - if ('error' in result) { - return result; - } - - return createJourneyObject(result); + const response = await store.dispatch(journeyApi.endpoints.next.initiate({ step, options })); + return match(parseJourneyResponse(response), { + onLeft: (err): JourneyResult => err, + onRight: (step): JourneyResult => createJourneyObject(step), + }); }, // 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.utils.test.ts b/packages/journey-client/src/lib/journey.utils.test.ts index 5d96b58d5f..30718d8159 100644 --- a/packages/journey-client/src/lib/journey.utils.test.ts +++ b/packages/journey-client/src/lib/journey.utils.test.ts @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest'; import { StepType } from '../types.js'; import { type Step } from '../index.js'; -import { createJourneyObject, handleJourneyResponse } from './journey.utils.js'; +import { createJourneyObject, parseJourneyResponse } from './journey.utils.js'; import type { JourneyLoginFailure } from './login-failure.utils.js'; describe('createJourneyObject', () => { @@ -63,34 +63,39 @@ describe('createJourneyObject', () => { }); }); -describe('handleJourneyResponse', () => { - it('returns Step data when FetchBaseQueryError has numeric status and object body', () => { +describe('parseJourneyResponse', () => { + it('returns right(Step) when FetchBaseQueryError has numeric status and object body', () => { const body = { code: 401, message: 'Access Denied', reason: 'Unauthorized' }; const error = { status: 401, data: body }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toBe(body); + expect(result._tag).toBe('Right'); + expect((result as { right: unknown }).right).toBe(body); }); - it('returns GenericError when FetchBaseQueryError has numeric status but non-object body', () => { + it('returns left(GenericError) when FetchBaseQueryError has numeric status but non-object body', () => { const error = { status: 500, data: 'Internal Server Error' }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); + expect(result._tag).toBe('Left'); + expect((result as { left: unknown }).left).toMatchObject({ + error: 'request_failed', + type: 'unknown_error', + }); }); - it('returns GenericError for FETCH_ERROR', () => { + it('returns left(GenericError) for FETCH_ERROR', () => { const error = { status: 'FETCH_ERROR' as const, error: 'Network error' }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); - expect((result as { message: string }).message).toContain('Network error'); + expect(result._tag).toBe('Left'); + expect((result as { left: { message: string } }).left.message).toContain('Network error'); }); - it('returns GenericError for PARSING_ERROR', () => { + it('returns left(GenericError) for PARSING_ERROR', () => { const error = { status: 'PARSING_ERROR' as const, originalStatus: 200, @@ -98,50 +103,59 @@ describe('handleJourneyResponse', () => { error: 'JSON parse error', }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); - expect((result as { message: string }).message).toContain('JSON parse error'); + expect(result._tag).toBe('Left'); + expect((result as { left: { message: string } }).left.message).toContain('JSON parse error'); }); - it('returns GenericError for TIMEOUT_ERROR', () => { + it('returns left(GenericError) for TIMEOUT_ERROR', () => { const error = { status: 'TIMEOUT_ERROR' as const, error: 'Request timed out' }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); - expect((result as { message: string }).message).toContain('Request timed out'); + expect(result._tag).toBe('Left'); + expect((result as { left: { message: string } }).left.message).toContain('Request timed out'); }); - it('returns GenericError for CUSTOM_ERROR', () => { + it('returns left(GenericError) for CUSTOM_ERROR', () => { const error = { status: 'CUSTOM_ERROR' as const, error: 'Custom error occurred' }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); - expect((result as { message: string }).message).toContain('Custom error occurred'); + expect(result._tag).toBe('Left'); + expect((result as { left: { message: string } }).left.message).toContain( + 'Custom error occurred', + ); }); - it('returns GenericError for SerializedError', () => { + it('returns left(GenericError) for SerializedError', () => { const error = { name: 'Error', message: 'Something went wrong', stack: '...' }; - const result = handleJourneyResponse(undefined, error); + const result = parseJourneyResponse({ data: undefined, error }); - expect(result).toMatchObject({ error: 'request_failed', type: 'unknown_error' }); - expect((result as { message: string }).message).toContain('Something went wrong'); + expect(result._tag).toBe('Left'); + expect((result as { left: { message: string } }).left.message).toContain( + 'Something went wrong', + ); }); - it('returns GenericError when no data and no error', () => { - const result = handleJourneyResponse(undefined, undefined); + it('returns left(GenericError) when no data and no error', () => { + const result = parseJourneyResponse({ data: undefined, error: undefined }); - expect(result).toMatchObject({ error: 'no_response_data', type: 'unknown_error' }); + expect(result._tag).toBe('Left'); + expect((result as { left: unknown }).left).toMatchObject({ + error: 'no_response_data', + type: 'unknown_error', + }); }); - it('returns data when no error and data is present', () => { + it('returns right(Step) when no error and data is present', () => { const data: Step = { authId: 'test-auth-id', callbacks: [] }; - const result = handleJourneyResponse(data, undefined); + const result = parseJourneyResponse({ data, error: undefined }); - expect(result).toBe(data); + expect(result._tag).toBe('Right'); + expect((result as { right: unknown }).right).toBe(data); }); }); diff --git a/packages/journey-client/src/lib/journey.utils.ts b/packages/journey-client/src/lib/journey.utils.ts index e87b97a604..3255d7de46 100644 --- a/packages/journey-client/src/lib/journey.utils.ts +++ b/packages/journey-client/src/lib/journey.utils.ts @@ -5,6 +5,8 @@ * of the MIT license. See the LICENSE file for details. */ +import { left, right, type Either } from 'effect/Either'; + import { StepType } from '@forgerock/sdk-types'; import type { GenericError, Step } from '@forgerock/sdk-types'; @@ -19,6 +21,8 @@ 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 @@ -56,53 +60,59 @@ export function createJourneyObject( } /** - * Resolves an RTK Query response to a Step or GenericError. - * - * @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 Step on success, GenericError on failure + * Parses a resolved RTK Query journey response into Either. + * Right = valid Step to classify; Left = infrastructure failure. */ -export function handleJourneyResponse( - data: Step | undefined, - error: FetchBaseQueryError | SerializedError | undefined, -): Step | GenericError { - /** - * https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature - * FetchBaseQueryError with status: number means AM returned an HTTP response with a JSON body. - * Only this variant can carry an AM failure payload — FETCH_ERROR, PARSING_ERROR, TIMEOUT_ERROR, - * and CUSTOM_ERROR either have no body or a non-object body (raw string for PARSING_ERROR). - */ +export function parseJourneyResponse(res: { + data?: Step; + error?: FetchBaseQueryError | SerializedError; +}): Either { + // https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling + // FetchBaseQueryError non-HTTP variants expose `error` string + if (res.error && 'error' in res.error) { + return left({ + error: 'request_failed', + message: `Request failed: ${res.error.error}`, + type: 'unknown_error', + }); + } + + // SerializedError exposes `message` + if (res.error && 'message' in res.error) { + return left({ + error: 'request_failed', + message: `Request failed: ${res.error.message ?? 'Unknown error'}`, + type: 'unknown_error', + }); + } + + // https://redux-toolkit.js.org/rtk-query/api/fetchBaseQuery#signature + // FetchBaseQueryError with numeric status + object body = AM failure step over HTTP error if ( - error && - 'status' in error && - typeof error.status === 'number' && - typeof error.data === 'object' && - error.data !== null + res.error && + 'status' in res.error && + typeof res.error.status === 'number' && + typeof res.error.data === 'object' && + res.error.data !== null ) { - return error.data as Step; + return right(res.error.data); } - /** - * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#type-safe-error-handling - * All other FetchBaseQueryError variants expose an `error` string; SerializedError exposes `message`. - * Both represent infrastructure failures with no usable AM response body. - */ - if (error) { - const msg = 'error' in error ? error.error : 'message' in error ? error.message : undefined; - return { + if (res.error) { + return left({ error: 'request_failed', - message: `Request failed: ${msg ?? 'Unknown error'}`, + message: 'Request failed: Unknown error', type: 'unknown_error', - }; + }); } - if (!data) { - return { + if (!res.data) { + return left({ error: 'no_response_data', message: 'No data received from server', type: 'unknown_error', - }; + }); } - return data; + return right(res.data); } diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts index e9b35c6e65..e4802c9db3 100644 --- a/packages/journey-client/src/types.ts +++ b/packages/journey-client/src/types.ts @@ -61,7 +61,7 @@ export * from './lib/callbacks/validated-create-username-callback.js'; // Re-export types used in public API signatures that aren't covered above export type { DeviceProfileData, Geolocation } from './lib/device/interfaces.js'; -export type { JourneyResult } from './lib/client.store.js'; +export type { JourneyResult } from './lib/journey.utils.js'; export type { ResolvedServerConfig } from './lib/wellknown.utils.js'; export type { JourneyLoginSuccess } from './lib/login-success.utils.js'; export type { JourneyLoginFailure } from './lib/login-failure.utils.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eeb837145b..3ae7055ff3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -498,6 +498,9 @@ importers: '@reduxjs/toolkit': specifier: 'catalog:' version: 2.10.1 + effect: + specifier: catalog:effect + version: 3.21.0 tslib: specifier: 'catalog:' version: 2.8.1 @@ -3247,6 +3250,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}