Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/whole-mangos-find.md
Original file line number Diff line number Diff line change
@@ -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`
5 changes: 4 additions & 1 deletion e2e/journey-app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/journey-client/api-report/journey-client.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -499,6 +500,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
setValidateOnly(value: boolean): void;
}

export { WellknownResponse }

// (No @packageDocumentation comment for this package)

```
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -486,6 +487,8 @@ export class ValidatedCreateUsernameCallback extends BaseCallback {
setValidateOnly(value: boolean): void;
}

export { WellknownResponse }

// (No @packageDocumentation comment for this package)

```
73 changes: 65 additions & 8 deletions packages/journey-client/src/lib/client.store.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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);

Expand All @@ -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}`));
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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');
}
});
Expand Down
36 changes: 16 additions & 20 deletions packages/journey-client/src/lib/client.store.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';
Expand Down Expand Up @@ -158,32 +158,28 @@ export async function journey<ActionType extends ActionTypes = ActionTypes>({
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
Expand Down
147 changes: 147 additions & 0 deletions packages/journey-client/src/lib/journey.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<html>Not JSON</html>',
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);
});
});
Loading
Loading