0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

test(test): add tests for console user management (#3863)

* test(test): add tests for console user management

* chore: remove unused dependency

* chore: remove testing screenshots
This commit is contained in:
Charles Zhao 2023-05-22 13:31:45 +08:00 committed by GitHub
parent db2bc1a5a6
commit f1730db70b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 321 additions and 23 deletions

View file

@ -7,6 +7,7 @@ const config = {
'^#src/(.*)\\.js(x)?$': '<rootDir>/lib/$1',
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
},
testSequencer: './ui-test-sequencer.js',
};
export default config;

View file

@ -21,9 +21,9 @@
"start": "pnpm test"
},
"devDependencies": {
"@jest/test-sequencer": "^29.5.0",
"@jest/types": "^29.1.2",
"@logto/connector-kit": "workspace:^1.1.0",
"@logto/language-kit": "workspace:^1.0.0",
"@logto/js": "^2.0.1",
"@logto/node": "^2.0.0",
"@logto/schemas": "workspace:^1.1.0",

View file

@ -20,3 +20,6 @@ export const signUpIdentifiers = {
emailOrSms: [SignInIdentifier.Email, SignInIdentifier.Phone],
none: [],
};
export const consoleUsername = 'admin';
export const consolePassword = 'abcd1234';

View file

@ -57,6 +57,9 @@ describe('admin console user management', () => {
const newUserData = {
name: 'new name',
primaryEmail: generateEmail(),
primaryPhone: generatePhone(),
username: generateUsername(),
avatar: 'https://new.avatar.com/avatar.png',
customData: {
level: 1,
@ -69,10 +72,17 @@ describe('admin console user management', () => {
});
it('should fail when update userinfo with conflict identifiers', async () => {
const user = await createUserByAdmin();
const [username, email, phone] = [generateUsername(), generateEmail(), generatePhone()];
await createUserByAdmin(username, undefined, email, phone);
const anotherUser = await createUserByAdmin();
await expect(updateUser(user.id, { username: anotherUser.username })).rejects.toMatchObject(
await expect(updateUser(anotherUser.id, { username })).rejects.toMatchObject(
createResponseWithCode(422)
);
await expect(updateUser(anotherUser.id, { primaryEmail: email })).rejects.toMatchObject(
createResponseWithCode(422)
);
await expect(updateUser(anotherUser.id, { primaryPhone: phone })).rejects.toMatchObject(
createResponseWithCode(422)
);
});

View file

@ -1,16 +1,11 @@
import path from 'node:path';
import { setDefaultOptions } from 'expect-puppeteer';
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
import { generatePassword } from '#src/utils.js';
setDefaultOptions({ timeout: 2000 });
const appendPathname = (pathname: string, baseUrl: URL) =>
new URL(path.join(baseUrl.pathname, pathname), baseUrl);
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
import {
consolePassword,
consoleUsername,
logtoConsoleUrl as logtoConsoleUrlString,
} from '#src/constants.js';
import { appendPathname } from '#src/utils.js';
/**
* NOTE: This test suite assumes test cases will run sequentially (which is Jest default).
@ -18,9 +13,9 @@ const logtoConsoleUrl = new URL(logtoConsoleUrlString);
*/
// Tip: See https://github.com/argos-ci/jest-puppeteer/blob/main/packages/expect-puppeteer/README.md
// for convenient expect methods
describe('smoke testing', () => {
const consoleUsername = 'admin';
const consolePassword = generatePassword();
describe('smoke testing for console admin account creation and sign-in', () => {
setDefaultOptions({ timeout: 2000 });
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
it('can open with app element and navigate to welcome page', async () => {
await page.goto(logtoConsoleUrl.href);
@ -68,6 +63,8 @@ describe('smoke testing', () => {
});
it('can sign in to admin console', async () => {
await page.goto(logtoConsoleUrl.href);
await page.waitForNavigation({ waitUntil: 'networkidle0' });
await expect(page).toFillForm('form', {
identifier: consoleUsername,
password: consolePassword,
@ -83,6 +80,8 @@ describe('smoke testing', () => {
await expect(userMenu).toMatchElement('div[class$=nameWrapper] > div[class$=name]', {
text: consoleUsername,
});
await expect(page).toClick('div[class^=ReactModal__Overlay]');
});
it('renders SVG correctly with viewbox property', async () => {

View file

@ -0,0 +1,236 @@
import {
consolePassword,
consoleUsername,
logtoConsoleUrl as logtoConsoleUrlString,
} from '#src/constants.js';
import {
appendPathname,
formatPhoneNumberToInternational,
generateEmail,
generateName,
generatePhone,
generateUsername,
} from '#src/utils.js';
await page.setViewport({ width: 1280, height: 720 });
describe('user management', () => {
const logtoConsoleUrl = new URL(logtoConsoleUrlString);
beforeAll(async () => {
await page.goto(logtoConsoleUrl.href);
await page.waitForNavigation({ waitUntil: 'networkidle0' });
if (page.url() === new URL('sign-in', logtoConsoleUrl).href) {
await expect(page).toFillForm('form', {
identifier: consoleUsername,
password: consolePassword,
});
await expect(page).toClick('button[name=submit]');
await page.waitForNavigation({ waitUntil: 'networkidle0' });
}
});
it('navigates to user management page on clicking sidebar menu', async () => {
await page.goto(appendPathname('/console/users', logtoConsoleUrl).href);
await page.waitForNavigation({ waitUntil: 'networkidle0' });
await expect(page).toMatchElement(
'div[class$=main] div[class$=headline] div[class$=titleEllipsis]',
{
text: 'User Management',
}
);
});
it('can create a new user', async () => {
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toFillForm('form', {
primaryEmail: 'jdoe@gmail.com',
primaryPhone: '+18105555555',
username: 'johndoe',
});
await expect(page).toClick('button[type=submit]');
await page.waitForSelector('div[class$=infoLine');
await expect(page).toMatchElement(
'.ReactModalPortal div[class$=header] div[class$=titleEllipsis]',
{
text: 'This user has been successfully created',
}
);
// Go to user details page
await expect(page).toClick('div.ReactModalPortal div[class$=footer] button:first-of-type');
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: 'jdoe@gmail.com',
});
const userId = await page.$eval(
'div[class$=main] div[class$=metadata] div[class$=row] div:first-of-type',
(element) => element.textContent
);
if (userId) {
expect(page.url()).toBe(new URL(`console/users/${userId}/settings`, logtoConsoleUrl).href);
}
const email = await page.$eval('form input[name=primaryEmail]', (element) =>
element instanceof HTMLInputElement ? element.value : null
);
const phone = await page.$eval('form input[name=primaryPhone]', (element) =>
element instanceof HTMLInputElement ? element.value : null
);
const username = await page.$eval('form input[name=username]', (element) =>
element instanceof HTMLInputElement ? element.value : null
);
expect(email).toBe('jdoe@gmail.com');
expect(phone).toBe('+1 810 555 5555');
expect(username).toBe('johndoe');
});
it('fails to create user if no identifier is provided', async () => {
await page.goto(appendPathname('/console/users', logtoConsoleUrl).href);
await page.waitForNavigation({ waitUntil: 'networkidle0' });
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toClick('button[type=submit]');
await expect(page).toMatchElement('.ReactModalPortal div[class$=error]', {
text: 'You must provide at least one identifier to create a user.',
});
});
it('fails to create user if any of the identifiers are existed', async () => {
await page.goto(appendPathname('/console/users', logtoConsoleUrl).href);
await page.waitForNavigation({ waitUntil: 'networkidle0' });
// Conflicted email
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toFillForm('form', { primaryEmail: 'jdoe@gmail.com' });
await expect(page).toClick('button[type=submit]');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This email is associated with an existing account.',
});
await expect(page).toClick('.ReactModalPortal div[class$=header] button');
// Conflicted phone number
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toFillForm('form', { primaryPhone: '+1 810 555 5555' });
await expect(page).toClick('button[type=submit]');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This phone number is associated with an existing account.',
});
await expect(page).toClick('.ReactModalPortal div[class$=header] button');
// Conflicted username
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toFillForm('form', { username: 'johndoe' });
await expect(page).toClick('button[type=submit]');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This username is already in use.',
});
await expect(page).toClick('.ReactModalPortal div[class$=header] button');
// Wait for 5 seconds for the error toasts to dismiss
await page.waitForTimeout(4000);
});
it('can update user details', async () => {
// Create a new user and navigates to the user details page
const username = generateUsername();
await expect(page).toClick('div[class$=main] div[class$=headline] > button');
await expect(page).toFillForm('form', { username });
await expect(page).toClick('button[type=submit]');
await page.waitForSelector('div[class$=infoLine');
// Go to the user details page
await expect(page).toClick('div.ReactModalPortal div[class$=footer] button:nth-of-type(1)');
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: username,
});
const newUsername = generateUsername();
const newEmail = generateEmail();
const newPhone = generatePhone(true);
const newFullName = generateName();
await expect(page).toFillForm('form', {
primaryEmail: newEmail,
primaryPhone: newPhone,
username: newUsername,
name: newFullName,
});
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
const successToastHandle = await page.waitForSelector('div[class$=success]');
await expect(successToastHandle).toMatchElement('span[class$=message]', {
text: 'Saved!',
});
// Top userinfo card shows the updated user full name as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: newFullName,
});
await expect(page).toFillForm('form', { name: '' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
// After removing full name, top userinfo card shows the email as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: newEmail,
});
await page.waitForTimeout(500);
await expect(page).toFillForm('form', { primaryEmail: '' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
// After removing email, top userinfo card shows the phone number as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: formatPhoneNumberToInternational(newPhone),
});
await page.waitForTimeout(500);
await expect(page).toFillForm('form', { primaryPhone: '' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
// After removing phone number, top userinfo card shows the username as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: newUsername,
});
await page.waitForTimeout(500);
await expect(page).toFillForm('form', { username: '' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
// After removing all identifiers, saving the form will pop up a confirm dialog
await expect(page).toMatchElement('.ReactModalPortal div[class$=medium] div[class$=content]', {
text: 'User needs to have at least one of the sign-in identifiers (username, email, phone number or social) to sign in. Are you sure you want to continue?',
});
await expect(page).toClick('div.ReactModalPortal div[class$=footer] button:nth-of-type(2)');
// After all identifiers, top userinfo card shows 'Unnamed' as the title
await expect(page).toMatchElement('div[class$=main] div[class$=metadata] div[class$=title]', {
text: 'Unnamed',
});
});
it('fails to update if any of the identifiers are conflicted with existing users', async () => {
await page.reload();
await page.waitForSelector('form');
// Conflicted email
await expect(page).toFillForm('form', { primaryEmail: 'jdoe@gmail.com' });
await page.screenshot({ path: 'user-conflict-email.png' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This email is associated with an existing account.',
});
// Discard changes
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(1)');
// Conflicted phone number
await expect(page).toFillForm('form', { primaryPhone: '+1 810 555 5555' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This phone number is associated with an existing account.',
});
// Discard changes
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(1)');
// Conflicted username
await expect(page).toFillForm('form', { username: 'johndoe' });
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(2)');
await expect(page).toMatchElement('div[class$=error] span[class$=message]', {
text: 'This username is already in use.',
});
// Discard changes
await expect(page).toClick('form div[class$=actionBar] button:nth-of-type(1)');
});
});

View file

@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import path from 'node:path';
import { assert } from '@silverhand/essentials';
@ -13,12 +14,39 @@ export const generateEmail = () => `${crypto.randomUUID().toLowerCase()}@logto.i
export const generateScopeName = () => `sc:${crypto.randomUUID()}`;
export const generateRoleName = () => `role_${crypto.randomUUID()}`;
export const generatePhone = () => {
const array = new Uint32Array(1);
export const generatePhone = (isE164?: boolean) => {
const plus = isE164 ? '+' : '';
const countryAndAreaCode = '1310'; // California, US
const validCentralOfficeCodes = [
'205',
'208',
'215',
'216',
'220',
'228',
'229',
'230',
'231',
'232',
];
const centralOfficeCode =
validCentralOfficeCodes[Math.floor(Math.random() * validCentralOfficeCodes.length)] ?? '205';
const phoneNumber = Math.floor(Math.random() * 10_000)
.toString()
.padStart(4, '0');
return crypto.getRandomValues(array).join('');
return plus + countryAndAreaCode + centralOfficeCode + phoneNumber;
};
export const formatPhoneNumberToInternational = (phoneNumber: string) =>
phoneNumber.slice(0, 2) +
' ' +
phoneNumber.slice(2, 5) +
' ' +
phoneNumber.slice(5, 8) +
' ' +
phoneNumber.slice(8);
export const waitFor = async (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms);
@ -32,3 +60,6 @@ export const getAccessTokenPayload = (accessToken: string): Record<string, unkno
// eslint-disable-next-line no-restricted-syntax
return JSON.parse(payload) as Record<string, unknown>;
};
export const appendPathname = (pathname: string, baseUrl: URL) =>
new URL(path.join(baseUrl.pathname, pathname), baseUrl);

View file

@ -0,0 +1,18 @@
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const Sequencer = require('@jest/test-sequencer').default;
const bootstrapTestSuitePathSuffix = '/tests/ui/bootstrap.test.js';
class CustomSequencer extends Sequencer {
sort(tests) {
const bootstrap = tests.find(({ path }) => path.includes(bootstrapTestSuitePathSuffix));
return [
bootstrap,
...tests.filter(({ path }) => !path.includes(bootstrapTestSuitePathSuffix)),
].filter(Boolean);
}
}
export default CustomSequencer;

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View file

@ -3387,6 +3387,9 @@ importers:
packages/integration-tests:
devDependencies:
'@jest/test-sequencer':
specifier: ^29.5.0
version: 29.5.0
'@jest/types':
specifier: ^29.1.2
version: 29.1.2
@ -3396,9 +3399,6 @@ importers:
'@logto/js':
specifier: ^2.0.1
version: 2.0.1
'@logto/language-kit':
specifier: workspace:^1.0.0
version: link:../toolkit/language-kit
'@logto/node':
specifier: ^2.0.0
version: 2.0.0