diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index cbb94d267..3a0f486de 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -14,7 +14,7 @@ "test:only": "NODE_OPTIONS=--experimental-vm-modules jest", "test": "pnpm build && pnpm test:api && pnpm test:ui", "test:api": "pnpm test:only -i ./lib/tests/api/", - "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/console/ ./lib/tests/flows/", + "test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/flows/", "lint": "eslint --ext .ts src", "lint:report": "pnpm lint --format json --output-file report.json", "start": "pnpm test" @@ -30,12 +30,11 @@ "@silverhand/eslint-config": "4.0.1", "@silverhand/essentials": "^2.8.4", "@silverhand/ts-config": "4.0.0", - "@types/expect-puppeteer": "^5.0.3", "@types/jest": "^29.4.0", - "@types/jest-environment-puppeteer": "^5.0.3", "@types/node": "^18.11.18", "dotenv": "^16.0.0", "eslint": "^8.44.0", + "expect-puppeteer": "^9.0.0", "got": "^13.0.0", "jest": "^29.5.0", "jest-puppeteer": "^9.0.0", diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index f349c409c..b3b9dbd4b 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -1,5 +1,5 @@ import { SignInIdentifier, demoAppApplicationId } from '@logto/schemas'; -import { getEnv } from '@silverhand/essentials'; +import { appendPath, getEnv } from '@silverhand/essentials'; export const logtoUrl = getEnv('INTEGRATION_TESTS_LOGTO_URL', 'http://localhost:3001'); export const logtoConsoleUrl = getEnv( @@ -7,6 +7,7 @@ export const logtoConsoleUrl = getEnv( 'http://localhost:3002' ); export const logtoCloudUrl = getEnv('INTEGRATION_TESTS_LOGTO_CLOUD_URL', 'http://localhost:3003'); +export const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); export const discoveryUrl = `${logtoUrl}/oidc/.well-known/openid-configuration`; diff --git a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts index aaeace2e5..a286c3f4d 100644 --- a/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/register-with-identifier/happy-path.test.ts @@ -24,7 +24,7 @@ import { } from '#src/helpers/sign-in-experience.js'; import { generateNewUserProfile, generateNewUser } from '#src/helpers/user.js'; -describe('Register with username and password', () => { +describe('register with username and password', () => { it('register with username and password', async () => { await enableAllPasswordSignInMethods({ identifiers: [SignInIdentifier.Username], @@ -51,7 +51,7 @@ describe('Register with username and password', () => { }); }); -describe('Register with passwordless identifier', () => { +describe('register with passwordless identifier', () => { beforeAll(async () => { await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); await setEmailConnector(); diff --git a/packages/integration-tests/src/tests/flows/bootstrap.test.ts b/packages/integration-tests/src/tests/flows/bootstrap.test.ts index 736d6ba79..336e69b83 100644 --- a/packages/integration-tests/src/tests/flows/bootstrap.test.ts +++ b/packages/integration-tests/src/tests/flows/bootstrap.test.ts @@ -1,11 +1,60 @@ +import { SignInMode, SignInIdentifier, ConnectorType } from '@logto/schemas'; + +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes } from '#src/helpers/connector.js'; +import ExpectFlows from '#src/ui-helpers/expect-flows.js'; + +const credentials = { + username: 'test_bootstrap', + pwnedPassword: 'test_password', + password: 'test_passWorD_not_PWNED:-)', +}; + /** * NOTE: This test suite assumes test cases will run sequentially (which is Jest default). * Parallel execution will lead to errors. */ // Tip: See https://github.com/argos-ci/jest-puppeteer/blob/main/packages/expect-puppeteer/README.md // for convenient expect methods -describe('smoke testing for the main flow', () => { - it('should not explode', async () => { - expect(true); +describe('smoke testing on the demo app', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + passwordPolicy: {}, + }); + }); + + it('should be able to create a new account with a credential preset', async () => { + const journey = new ExpectFlows(await browser.newPage()); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + await journey.toFillInput('identifier', credentials.username, { submit: true }); + + // Simple password tests + journey.toBeAt('register/password'); + await journey.toFillPasswords( + [credentials.pwnedPassword, 'simple password'], + credentials.password + ); + + await journey.verifyThenEnd(); }); }); diff --git a/packages/integration-tests/src/tests/flows/password-policy.test.ts b/packages/integration-tests/src/tests/flows/password-policy.test.ts new file mode 100644 index 000000000..e691a0130 --- /dev/null +++ b/packages/integration-tests/src/tests/flows/password-policy.test.ts @@ -0,0 +1,141 @@ +/* Test the sign-in with different password policies. */ + +import { ConnectorType, SignInIdentifier, SignInMode } from '@logto/schemas'; + +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { demoAppUrl } from '#src/constants.js'; +import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js'; +import ExpectFlows from '#src/ui-helpers/expect-flows.js'; +import { waitFor } from '#src/utils.js'; + +describe('password policy', () => { + const username = 'test_pass_policy_30'; + const emailName = 'a_good_foo_30'; + const email = emailName + '@bar.com'; + const invalidPasswords: Array<[string, string | RegExp]> = [ + ['123', 'minimum length'], + ['12345678', 'at least 3 types'], + ['123456aA', 'simple password'], + ['defghiZ@', 'sequential characters'], + ['TTTTTT@z', 'repeated characters'], + ]; + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await updateSignInExperience({ + signInMode: SignInMode.SignInAndRegister, + signUp: { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + { + identifier: SignInIdentifier.Email, + password: true, + verificationCode: true, + isPasswordPrimary: true, + }, + ], + }, + passwordPolicy: { + length: { min: 8, max: 32 }, + characterTypes: { min: 3 }, + rejects: { + pwned: true, + repetitionAndSequence: true, + userInfo: true, + words: [username], + }, + }, + }); + }); + + it('should work for username + password', async () => { + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + await journey.toFillInput('identifier', username, { submit: true }); + + // Password tests + journey.toBeAt('register/password'); + await journey.toFillPasswords( + ...invalidPasswords, + [username + 'A', /product context .* personal information/], + username + 'ABCD_ok' + ); + + await journey.verifyThenEnd(); + }); + + it('should work for email + password', async () => { + // Enable email verification and make password primary + await updateSignInExperience({ + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + }); + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'register'); + + // Complete verification code flow + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toCompleteVerification('register'); + + // Wait for the password page to load + await waitFor(100); + journey.toBeAt('continue/password'); + await journey.toFillPasswords( + ...invalidPasswords, + [emailName, 'personal information'], + emailName + 'ABCD@# $' + ); + + await journey.verifyThenEnd(); + }); + + it('should work for forgot password', async () => { + const journey = new ExpectFlows(await browser.newPage(), { forgotPassword: true }); + + // Open the demo app and navigate to the register page + await journey.startWith(demoAppUrl, 'sign-in'); + + // Click the forgot password link + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toClick('a', 'Forgot your password'); + + // Submit to continue + await journey.toClickSubmit(); + + // Complete verification code flow + await journey.toCompleteVerification('forgot-password'); + + // Wait for the password page to load + await waitFor(100); + journey.toBeAt('forgot-password/reset'); + await journey.toFillPasswords( + ...invalidPasswords, + [emailName, 'personal information'], + [emailName + 'ABCD@# $', 'be the same as'], + emailName + 'ABCD135' + ); + + await journey.waitForToast(/password changed/i); + await journey.toFillInput('identifier', email, { submit: true }); + await journey.toFillInput('password', emailName + 'ABCD135', { submit: true }); + await journey.verifyThenEnd(); + }); +}); diff --git a/packages/integration-tests/src/ui-helpers/expect-flows.ts b/packages/integration-tests/src/ui-helpers/expect-flows.ts new file mode 100644 index 000000000..84dbd8d8a --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-flows.ts @@ -0,0 +1,186 @@ +import { appendPath } from '@silverhand/essentials'; + +import { logtoUrl } from '#src/constants.js'; +import { readVerificationCode } from '#src/helpers/index.js'; + +import ExpectPage from './expect-page.js'; + +const demoAppUrl = appendPath(new URL(logtoUrl), 'demo-app'); + +/** Remove the query string together with the `?` from a URL string. */ +const stripQuery = (url: string) => url.split('?')[0]; + +export type FlowsType = 'sign-in' | 'register' | 'continue' | 'forgot-password'; + +export type FlowsPath = + | FlowsType + | `${FlowsType}/password` + | `${FlowsType}/verify` + | `${FlowsType}/verification-code` + | `forgot-password/reset`; + +export type ExpectFlowsOptions = { + /** The URL of the flows endpoint. */ + endpoint?: URL; + /** + * Whether the forgot password flow is enabled. + * + * @default false + */ + forgotPassword?: boolean; +}; + +type OngoingFlows = { + type: FlowsType; + initialUrl: URL; +}; + +/** + * A class that provides: + * + * - A set of methods to navigate to a specific page for a flows. + * - A set of methods to assert the state of a flows and its side effects. + */ +export default class ExpectFlows extends ExpectPage { + readonly options: Required; + + protected get flowsType() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingFlowsError(); + } + return this.#ongoing.type; + } + + #ongoing?: OngoingFlows; + + constructor(thePage = global.page, options: ExpectFlowsOptions = {}) { + super(thePage); + this.options = { + endpoint: new URL(logtoUrl), + forgotPassword: false, + ...options, + }; + } + + /** + * Start flows with the given initial URL. Expect the initial URL is protected by Logto, and + * navigate to the flows sign-in page if unauthenticated. + * + * If the flows can be started, the instance will be marked as ongoing. + * + * @param initialUrl The initial URL to start the flows with. + * @param type The type of flows to expect. If it's `register`, it will try to click the "Create + * account" link on the sign-in page. + */ + async startWith(initialUrl = demoAppUrl, type: FlowsType = 'sign-in') { + await this.toStart(initialUrl); + this.toBeAt('sign-in'); + + if (type === 'register') { + await this.toClick('a', 'Create account'); + this.toBeAt('register'); + } + + this.#ongoing = { type, initialUrl }; + } + + /** + * Ensure the flows is ongoing and the page is at the initial URL; then try to click the "sign out" + * button (case-insensitive) and close the page. + * + * It will clear the ongoing flows if the flows is ended successfully. + */ + async verifyThenEnd() { + if (this.#ongoing === undefined) { + return this.throwNoOngoingFlowsError(); + } + + this.toMatchUrl(this.#ongoing.initialUrl); + await this.toClick('div[role=button]', /sign out/i); + + this.#ongoing = undefined; + await this.page.close(); + } + + /** + * Assert the page is at the given flows path. + * + * @param pathname The flows path to assert. + */ + toBeAt(pathname: FlowsPath) { + const stripped = stripQuery(this.page.url()); + expect(stripped).toBe(this.buildFlowsUrl(pathname).href); + } + + /** + * Assert the page is at the verification code page and fill the verification code input with the + * code from Logto database. + * + * @param type The type of flows to expect. + */ + async toCompleteVerification(type: FlowsType) { + this.toBeAt(`${type}/verification-code`); + const { code } = await readVerificationCode(); + + for (const [index, char] of code.split('').entries()) { + // eslint-disable-next-line no-await-in-loop + await this.toFillInput(`passcode_${index}`, char); + } + } + + /** + * Fill the password inputs with the given passwords. If the password is an array, the second + * element will be used to assert the error message; otherwise, the password is expected to be + * valid and the form will be submitted. + * + * @param passwords The passwords to fill. + * @example + * + * In the following example, the first password is expected to be rejected with the error message + * "simple password" (case-insensitive), and the second password is expected to be accepted. + * + * ```ts + * await journey.toFillPasswords( + * [credentials.pwnedPassword, 'simple password'], + * credentials.password, + * ); + * ``` + */ + async toFillPasswords( + ...passwords: Array + ) { + for (const element of passwords) { + const [password, errorMessage] = Array.isArray(element) ? element : [element, undefined]; + + // eslint-disable-next-line no-await-in-loop + await this.toFillForm( + this.options.forgotPassword + ? { newPassword: password } + : { + newPassword: password, + confirmPassword: password, + }, + { submit: true, shouldNavigate: errorMessage === undefined } + ); + + if (errorMessage === undefined) { + break; + } else { + // Reject the password and assert the error message + // eslint-disable-next-line no-await-in-loop + await this.toMatchAlert( + typeof errorMessage === 'string' ? new RegExp(errorMessage, 'i') : errorMessage + ); + } + } + } + + /** Build a full flows URL from a pathname. */ + protected buildFlowsUrl(pathname = '') { + return appendPath(this.options.endpoint, pathname); + } + + protected throwNoOngoingFlowsError() { + return this.throwError('The flows has not started yet. Use `startWith` to start the flows.'); + } +} diff --git a/packages/integration-tests/src/ui-helpers/expect-page.ts b/packages/integration-tests/src/ui-helpers/expect-page.ts new file mode 100644 index 000000000..66913bd63 --- /dev/null +++ b/packages/integration-tests/src/ui-helpers/expect-page.ts @@ -0,0 +1,144 @@ +import { type ElementHandle, type Page } from 'puppeteer'; + +import { expectNavigation } from '#src/utils.js'; + +/** Error thrown by {@link ExpectPage}. */ +class ExpectPageError extends Error { + constructor( + message: string, + public readonly page: Page + ) { + super(message); + } +} + +/** + * A class that provides a set of methods to assert the state of page tests and its side effects. + */ +export default class ExpectPage { + constructor(public readonly page = global.page) {} + + /** + * Navigate to the given URL and wait for the page to load. Assert that an element with ID `app` + * is present. + * + * @param initialUrl The URL to navigate to. + */ + async toStart(initialUrl: URL) { + await expectNavigation(this.page.goto(initialUrl.href), this.page); + await expect(this.page).toMatchElement('#app'); + } + + /** + * Click on the element matching the given selector and text. + * + * @param selector The selector to match. + * @param text The text to match, if provided. + * @param shouldNavigate Whether the click should trigger a navigation. Defaults to `true`. + */ + async toClick(selector: string, text?: string | RegExp, shouldNavigate = true) { + const clicked = expect(this.page).toClick(selector, { text }); + return shouldNavigate ? expectNavigation(clicked, this.page) : clicked; + } + + /** + * Click on the `