Skip to content

Commit 48279d3

Browse files
authored
Merge pull request #657 from ForgeRock/fix/cache-selector-password-policy-e2e
fix(davinci-client): invoke RTK Query selectors with state for cache lookups
2 parents 354a238 + ad3251e commit 48279d3

11 files changed

Lines changed: 419 additions & 212 deletions

File tree

e2e/davinci-app/server-configs.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,23 @@ export const serverConfigs: Record<string, DaVinciConfig> = {
9292
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
9393
},
9494
},
95+
/**
96+
* ValidatedPasswordCollector / Password Policy
97+
* Flow: Andy - MFA Device Registration/Authentication (PingOne Forms)
98+
* Policy ID (acr_values): 769eecb92f8e66f88005a85e8b939a01
99+
* Environment: 356a254c-cba3-4ade-be1a-860136e8df01
100+
*
101+
* New client created 2026-05-28 with:
102+
* - http://localhost:5829 in Redirect URIs
103+
* - http://localhost:5829 in CORS Allowed Origins
104+
*/
105+
'fb456db5-2e08-46d3-adf0-05bf8d26ad60': {
106+
clientId: 'fb456db5-2e08-46d3-adf0-05bf8d26ad60',
107+
redirectUri: window.location.origin + '/',
108+
scope: 'openid profile email revoke',
109+
serverConfig: {
110+
wellknown:
111+
'https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration',
112+
},
113+
},
95114
};

e2e/davinci-suites/playwright.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ const config: PlaywrightTestConfig = {
2929
command: 'pnpm watch @forgerock/davinci-app',
3030
port: 5829,
3131
ignoreHTTPSErrors: true,
32-
reuseExistingServer: !process.env.CI,
32+
reuseExistingServer: true,
3333
cwd: workspaceRoot,
3434
}
3535
: undefined,
3636
{
3737
command: 'pnpm nx serve @forgerock/davinci-app',
3838
port: 5829,
3939
ignoreHTTPSErrors: true,
40-
reuseExistingServer: !process.env.CI,
40+
reuseExistingServer: true,
4141
cwd: workspaceRoot,
4242
},
4343
].filter(Boolean),
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
import { expect, test } from '@playwright/test';
8+
import { asyncEvents } from './utils/async-events.js';
9+
import { password } from './utils/demo-user.js';
10+
11+
/**
12+
* DaVinci flow: Andy - MFA Device Registration/Authentication (PingOne Forms)
13+
* Client ID: fb456db5-2e08-46d3-adf0-05bf8d26ad60
14+
* Flow ID (acr_values): 769eecb92f8e66f88005a85e8b939a01
15+
* Wellknown: https://auth.pingone.ca/356a254c-cba3-4ade-be1a-860136e8df01/as/.well-known/openid-configuration
16+
*/
17+
const CLIENT_ID = 'fb456db5-2e08-46d3-adf0-05bf8d26ad60';
18+
const FLOW_ID = '769eecb92f8e66f88005a85e8b939a01';
19+
20+
/**
21+
* A unique email per test run to avoid conflicts with existing accounts.
22+
* The USER_DELETE cleanup step removes the account regardless.
23+
*/
24+
function uniqueEmail() {
25+
return `pwpolicy+${Date.now()}@user.com`;
26+
}
27+
28+
async function navigateToRegistration(page: Parameters<typeof asyncEvents>[0]) {
29+
const { navigate } = asyncEvents(page);
30+
await navigate(`/?clientId=${CLIENT_ID}&acr_values=${FLOW_ID}`);
31+
32+
// Wait for flow buttons to render (they have class 'flow-link')
33+
await page.waitForSelector('button.flow-link', { timeout: 10000 });
34+
35+
// Click USER_REGISTRATION button
36+
await page.getByRole('button', { name: 'USER_REGISTRATION' }).click();
37+
38+
/**
39+
* The registration form is the PingOne Forms 'Example - Registration 1' form.
40+
* It renders: heading 'Example - Registration 1', username, email, password +
41+
* requirements list, verify password, and a Submit button.
42+
*/
43+
await expect(page.getByRole('heading', { name: /Example - Registration/i })).toBeVisible({
44+
timeout: 10000,
45+
});
46+
}
47+
48+
async function deleteTestUser(page: Parameters<typeof asyncEvents>[0], email: string) {
49+
await page.goto(`/?clientId=${CLIENT_ID}&acr_values=${FLOW_ID}`);
50+
51+
// Wait for flow buttons to render
52+
await page.waitForSelector('button.flow-link', { timeout: 10000 });
53+
54+
// Click USER_LOGIN button
55+
await page.getByRole('button', { name: 'USER_LOGIN' }).click();
56+
57+
// Fill login credentials
58+
await page.getByRole('textbox', { name: /Username/i }).fill(email);
59+
await page.getByRole('textbox', { name: 'Password' }).fill(password);
60+
await page.getByRole('button', { name: 'Sign On' }).click();
61+
62+
// Wait for next set of flow buttons (delete option)
63+
await page.waitForSelector('button.flow-link', { timeout: 10000 });
64+
65+
// Click USER_DELETE button
66+
await page.getByRole('button', { name: 'USER_DELETE' }).click();
67+
68+
await expect(page.getByRole('heading', { name: /Success/i })).toBeVisible();
69+
}
70+
71+
test.describe('ValidatedPasswordCollector — password policy (Example - Registration form)', () => {
72+
let createdEmail: string | null = null;
73+
74+
test.afterEach(async ({ page }) => {
75+
if (createdEmail) {
76+
const email = createdEmail;
77+
createdEmail = null;
78+
try {
79+
await deleteTestUser(page, email);
80+
} catch (err) {
81+
console.error(`[cleanup] Failed to delete test user ${email}:`, err);
82+
}
83+
}
84+
});
85+
86+
test('renders password requirements list from the PingOne password policy', async ({ page }) => {
87+
await navigateToRegistration(page);
88+
89+
/**
90+
* The SDK maps the PASSWORD_VERIFY field (with showPasswordRequirements=true)
91+
* to a ValidatedPasswordCollector. passwordComponent renders a
92+
* <ul class="password-requirements"> with one <li> per policy rule.
93+
*
94+
* The field key is 'user.password', so:
95+
* dotToCamelCase('user.password') => 'userPassword'
96+
* input id => #userPassword
97+
*/
98+
await expect(page.locator('.password-requirements')).toBeVisible();
99+
const items = page.locator('.password-requirements li');
100+
expect(await items.count()).toBeGreaterThan(0);
101+
});
102+
103+
test('validate() shows inline errors when password is too short', async ({ page }) => {
104+
await navigateToRegistration(page);
105+
106+
// A single character will trigger the length rule violation
107+
await page.locator('#userPassword').fill('a');
108+
109+
/**
110+
* passwordComponent creates a <ul class="{key}-error"> when validate()
111+
* returns errors. For field key 'user.password' that is class 'userPassword-error'.
112+
*/
113+
await expect(page.locator('.userPassword-error')).toBeVisible();
114+
const errors = page.locator('.userPassword-error li');
115+
expect(await errors.count()).toBeGreaterThan(0);
116+
});
117+
118+
test('validate() clears errors once all password policy requirements are satisfied', async ({
119+
page,
120+
}) => {
121+
await navigateToRegistration(page);
122+
123+
// Trigger an error first
124+
await page.locator('#userPassword').fill('a');
125+
await expect(page.locator('.userPassword-error')).toBeVisible();
126+
127+
/**
128+
* The existing demo password satisfies any reasonable PingOne password
129+
* policy: it is long, contains uppercase (D), digits, and a special char (@).
130+
*/
131+
await page.locator('#userPassword').fill(password);
132+
await expect(page.locator('.userPassword-error')).not.toBeAttached();
133+
});
134+
135+
test('submits registration form successfully with a policy-compliant password', async ({
136+
page,
137+
}) => {
138+
createdEmail = uniqueEmail();
139+
const email = createdEmail;
140+
141+
await navigateToRegistration(page);
142+
143+
// Fill all required fields
144+
await page.locator('#userUsername').fill(email);
145+
await page.locator('#userEmail').fill(email);
146+
await page.locator('#userPassword').fill(password);
147+
148+
// Submit the form by calling submit() on the form element
149+
await page.locator('form').evaluate((form: HTMLFormElement) => form.submit());
150+
151+
// Wait for the page to navigate to the next step
152+
// The heading should change from "Example - Registration 1" to something else
153+
await page.waitForFunction(
154+
() => {
155+
const heading = document.querySelector('h2');
156+
return heading && !heading.textContent?.includes('Example - Registration');
157+
},
158+
{ timeout: 10000 },
159+
);
160+
161+
// Verify we've moved to the next step
162+
const heading = page.locator('h2').first();
163+
await expect(heading).toBeVisible();
164+
165+
// If the flow shows a "Continue" button, click through to complete it
166+
const continueBtn = page.getByRole('button', { name: 'Continue' });
167+
if (await continueBtn.isVisible()) {
168+
await continueBtn.click();
169+
}
170+
});
171+
});

e2e/mock-api-v2/tsconfig.app.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
"noErrorTruncation": true,
1111
"plugins": [
1212
{
13-
"name": "@effect/language-service",
14-
"transform": "@effect/language-service/transform"
13+
"name": "@effect/language-service"
1514
}
1615
]
1716
},

packages/davinci-client/api-report/davinci-client.api.md

Lines changed: 5 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,15 @@
77
import { ActionCreatorWithPayload } from '@reduxjs/toolkit';
88
import { ActionTypes } from '@forgerock/sdk-request-middleware';
99
import type { AsyncLegacyConfigOptions } from '@forgerock/sdk-types';
10-
import { BaseQueryFn } from '@reduxjs/toolkit/query';
1110
import { CustomLogger } from '@forgerock/sdk-logger';
12-
import { FetchArgs } from '@reduxjs/toolkit/query';
1311
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
14-
import { FetchBaseQueryMeta } from '@reduxjs/toolkit/query';
12+
import type { FetchBaseQueryMeta } from '@reduxjs/toolkit/query';
1513
import { GenericError } from '@forgerock/sdk-types';
1614
import { LogLevel } from '@forgerock/sdk-logger';
17-
import { MutationDefinition } from '@reduxjs/toolkit/query';
1815
import type { MutationResultSelectorResult } from '@reduxjs/toolkit/query';
19-
import { QueryDefinition } from '@reduxjs/toolkit/query';
2016
import { QueryStatus } from '@reduxjs/toolkit/query';
2117
import { Reducer } from '@reduxjs/toolkit';
2218
import { RequestMiddleware } from '@forgerock/sdk-request-middleware';
23-
import { RootState } from '@reduxjs/toolkit/query';
2419
import { SerializedError } from '@reduxjs/toolkit';
2520
import { Unsubscribe } from '@reduxjs/toolkit';
2621

@@ -361,33 +356,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
361356
status: "success";
362357
} | null;
363358
cache: {
364-
getLatestResponse: () => ((state: RootState< {
365-
flow: MutationDefinition<any, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", any>;
366-
next: MutationDefinition<any, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", any>;
367-
start: MutationDefinition<StartOptions<OutgoingQueryParams> | undefined, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
368-
resume: QueryDefinition< {
369-
serverInfo: ContinueNode["server"];
370-
continueToken: string;
371-
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
372-
poll: MutationDefinition< {
373-
endpoint: string;
374-
interactionId: string;
375-
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
376-
}, never, "davinci">) => ({
377-
requestId?: undefined;
378-
status: QueryStatus.uninitialized;
379-
data?: undefined;
380-
error?: undefined;
381-
endpointName?: string;
382-
startedTimeStamp?: undefined;
383-
fulfilledTimeStamp?: undefined;
384-
} & {
385-
status: QueryStatus.uninitialized;
386-
isUninitialized: true;
387-
isLoading: false;
388-
isSuccess: false;
389-
isError: false;
390-
}) | ({
359+
getLatestResponse: () => ({
391360
status: QueryStatus.fulfilled;
392361
} & Omit<{
393362
requestId: string;
@@ -411,23 +380,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
411380
isLoading: false;
412381
isSuccess: true;
413382
isError: false;
414-
}) | ({
415-
status: QueryStatus.pending;
416-
} & {
417-
requestId: string;
418-
data?: unknown;
419-
error?: FetchBaseQueryError | SerializedError | undefined;
420-
endpointName: string;
421-
startedTimeStamp: number;
422-
fulfilledTimeStamp?: number;
423-
} & {
424-
data?: undefined;
425-
} & {
426-
status: QueryStatus.pending;
427-
isUninitialized: false;
428-
isLoading: true;
429-
isSuccess: false;
430-
isError: false;
431383
}) | ({
432384
status: QueryStatus.rejected;
433385
} & Omit<{
@@ -450,39 +402,13 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
450402
isLoading: false;
451403
isSuccess: false;
452404
isError: true;
453-
})) | {
405+
}) | {
454406
error: {
455407
message: string;
456408
type: string;
457409
};
458410
};
459-
getResponseWithId: (requestId: string) => ((state: RootState< {
460-
flow: MutationDefinition<any, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", any>;
461-
next: MutationDefinition<any, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", any>;
462-
start: MutationDefinition<StartOptions<OutgoingQueryParams> | undefined, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
463-
resume: QueryDefinition< {
464-
serverInfo: ContinueNode["server"];
465-
continueToken: string;
466-
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
467-
poll: MutationDefinition< {
468-
endpoint: string;
469-
interactionId: string;
470-
}, BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError, {}, FetchBaseQueryMeta>, never, unknown, "davinci", unknown>;
471-
}, never, "davinci">) => ({
472-
requestId?: undefined;
473-
status: QueryStatus.uninitialized;
474-
data?: undefined;
475-
error?: undefined;
476-
endpointName?: string;
477-
startedTimeStamp?: undefined;
478-
fulfilledTimeStamp?: undefined;
479-
} & {
480-
status: QueryStatus.uninitialized;
481-
isUninitialized: true;
482-
isLoading: false;
483-
isSuccess: false;
484-
isError: false;
485-
}) | ({
411+
getResponseWithId: (requestId: string) => ({
486412
status: QueryStatus.fulfilled;
487413
} & Omit<{
488414
requestId: string;
@@ -506,23 +432,6 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
506432
isLoading: false;
507433
isSuccess: true;
508434
isError: false;
509-
}) | ({
510-
status: QueryStatus.pending;
511-
} & {
512-
requestId: string;
513-
data?: unknown;
514-
error?: FetchBaseQueryError | SerializedError | undefined;
515-
endpointName: string;
516-
startedTimeStamp: number;
517-
fulfilledTimeStamp?: number;
518-
} & {
519-
data?: undefined;
520-
} & {
521-
status: QueryStatus.pending;
522-
isUninitialized: false;
523-
isLoading: true;
524-
isSuccess: false;
525-
isError: false;
526435
}) | ({
527436
status: QueryStatus.rejected;
528437
} & Omit<{
@@ -545,7 +454,7 @@ export function davinci<ActionType extends ActionTypes = ActionTypes>(input: {
545454
isLoading: false;
546455
isSuccess: false;
547456
isError: true;
548-
})) | {
457+
}) | {
549458
error: {
550459
message: string;
551460
type: string;

0 commit comments

Comments
 (0)