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:
parent
db2bc1a5a6
commit
f1730db70b
10 changed files with 321 additions and 23 deletions
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -20,3 +20,6 @@ export const signUpIdentifiers = {
|
|||
emailOrSms: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||
none: [],
|
||||
};
|
||||
|
||||
export const consoleUsername = 'admin';
|
||||
export const consolePassword = 'abcd1234';
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
236
packages/integration-tests/src/tests/ui/user-management.test.ts
Normal file
236
packages/integration-tests/src/tests/ui/user-management.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
18
packages/integration-tests/ui-test-sequencer.js
Normal file
18
packages/integration-tests/ui-test-sequencer.js
Normal 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;
|
BIN
packages/integration-tests/user-conflict-email.png
Normal file
BIN
packages/integration-tests/user-conflict-email.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 95 KiB |
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue