test: Migrate user management tests to Playwright (#20210)

This commit is contained in:
Declan Carroll 2025-09-30 19:37:57 +01:00 committed by GitHub
parent 3d30f959c2
commit 96aba18605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 468 additions and 293 deletions

View File

@ -25,7 +25,7 @@ on:
containers:
description: 'Number of containers to run tests in.'
required: false
default: '[1, 2, 3, 4, 5, 6]'
default: '[1, 2, 3, 4, 5]'
type: string
pr_number:
description: 'PR number to run tests for.'

View File

@ -16,7 +16,7 @@ on:
shards:
description: 'Shards for parallel execution'
required: false
default: '[1, 2, 3, 4, 5, 6]'
default: '[1, 2, 3, 4, 5, 6, 7]'
type: string
docker-image:
description: 'Docker image to use (for docker-pull mode)'
@ -52,7 +52,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6]') }}
shard: ${{ fromJSON(inputs.shards || '[1, 2, 3, 4, 5, 6, 7]') }}
name: Test (Shard ${{ matrix.shard }}/${{ strategy.job-total }})
steps:

View File

@ -1,251 +0,0 @@
import { expandSidebar } from '../../composables/sidebar';
import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../../constants';
import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../../pages';
import { errorToast, successToast } from '../../pages/notifications';
import { PersonalSettingsPage } from '../../pages/settings-personal';
import { getVisiblePopper } from '../../utils';
/**
* User A - Instance owner
* User B - User, owns C1, W1, W2
* User C - User, owns C2
*
* W1 - Workflow owned by User B, shared with User C
* W2 - Workflow owned by User B
*
* C1 - Credential owned by User B
* C2 - Credential owned by User C, shared with User A and User B
*/
const updatedPersonalData = {
newFirstName: 'Something',
newLastName: 'Else',
newEmail: 'something_else@acme.corp',
newPassword: 'Keybo4rd',
invalidPasswords: ['abc', 'longEnough', 'longenough123'],
};
const usersSettingsPage = new SettingsUsersPage();
const personalSettingsPage = new PersonalSettingsPage();
const settingsSidebar = new SettingsSidebar();
const mainSidebar = new MainSidebar();
describe('User Management', { disableAutoLogin: true }, () => {
before(() => {
cy.enableFeature('sharing');
});
it('should login and logout', () => {
cy.visit('/');
cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_OWNER.email);
cy.get('input[name="password"]').type(INSTANCE_OWNER.password);
cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible');
expandSidebar();
mainSidebar.actions.goToSettings();
settingsSidebar.getters.users().should('be.visible');
mainSidebar.actions.closeSettings();
mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click();
cy.get('input[name="emailOrLdapLoginId"]').type(INSTANCE_MEMBERS[0].email);
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click();
mainSidebar.getters.logo().should('be.visible');
mainSidebar.actions.goToSettings();
cy.getByTestId('menu-item').filter('#settings-users').should('not.exist');
});
it('should prevent non-owners to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
false,
);
});
it('should allow instance owner to access UM settings', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
});
it('should properly render UM settings page for instance owners', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
// All items in user list should be there
usersSettingsPage.getters.userListItems().should('have.length', 4);
// List item for current user should have the `Owner` badge
usersSettingsPage.getters
.userItem(INSTANCE_OWNER.email)
.find('td:contains("Owner")')
.should('be.visible');
// Other users list items should contain action pop-up list
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[0].email).should('exist');
usersSettingsPage.getters.userActionsToggle(INSTANCE_MEMBERS[1].email).should('exist');
usersSettingsPage.getters.userActionsToggle(INSTANCE_ADMIN.email).should('exist');
});
it('should be able to change user role to Admin and back', () => {
cy.enableFeature('advancedPermissions');
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
// Change role from Member to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Member")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Admin")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);
// Change role from Admin to Member, then back to Admin
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Admin")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Member")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, false);
usersSettingsPage.actions.loginAndVisit(
INSTANCE_MEMBERS[0].email,
INSTANCE_MEMBERS[0].password,
true,
);
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Member")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Admin').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_ADMIN.email)
.find('button:contains("Admin")')
.should('be.visible');
usersSettingsPage.actions.loginAndVisit(INSTANCE_ADMIN.email, INSTANCE_ADMIN.password, true);
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Admin")')
.should('be.visible')
.click();
getVisiblePopper().find('label').contains('Member').click();
usersSettingsPage.getters
.userRoleSelect(INSTANCE_MEMBERS[0].email)
.find('button:contains("Member")')
.should('be.visible');
cy.disableFeature('advancedPermissions');
});
it('should be able to change theme', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.actions.changeTheme('Dark');
cy.get('body').should('have.attr', 'data-theme', 'dark');
personalSettingsPage.actions.changeTheme('Light');
cy.get('body').should('have.attr', 'data-theme', 'light');
});
it('should delete user and their data', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
usersSettingsPage.actions.opedDeleteDialog(INSTANCE_MEMBERS[0].email);
usersSettingsPage.getters.deleteDataRadioButton().click();
usersSettingsPage.getters.deleteDataInput().type('delete all data');
usersSettingsPage.getters.deleteUserButton().click();
successToast().should('contain', 'User deleted');
});
it('should delete user and transfer their data', () => {
usersSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password, true);
usersSettingsPage.actions.opedDeleteDialog(INSTANCE_MEMBERS[1].email);
usersSettingsPage.getters.transferDataRadioButton().click();
usersSettingsPage.getters.userSelectDropDown().click();
usersSettingsPage.getters.userSelectOptions().first().click();
usersSettingsPage.getters.deleteUserButton().click();
successToast().should('contain', 'User deleted');
});
it('should allow user to change their personal data', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.actions.updateFirstAndLastName(
updatedPersonalData.newFirstName,
updatedPersonalData.newLastName,
);
personalSettingsPage.getters
.currentUserName()
.should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`);
successToast().should('contain', 'Personal details updated');
});
it("shouldn't allow user to set weak password", () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
for (const weakPass of updatedPersonalData.invalidPasswords) {
personalSettingsPage.actions.tryToSetWeakPassword(INSTANCE_OWNER.password, weakPass);
}
});
it("shouldn't allow user to change password if old password is wrong", () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword);
errorToast().closest('div').should('contain', 'Provided current password is incorrect.');
});
it('should change current user password', () => {
personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password);
personalSettingsPage.getters.changePasswordLink().click();
personalSettingsPage.actions.updatePassword(
INSTANCE_OWNER.password,
updatedPersonalData.newPassword,
);
successToast().should('contain', 'Password updated');
personalSettingsPage.actions.loginWithNewData(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
});
it("shouldn't allow users to set invalid email", () => {
personalSettingsPage.actions.loginAndVisit(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
// try without @ part
personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('@')[0]);
// try without domain
personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('.')[0]);
});
it('should change user email', () => {
personalSettingsPage.actions.loginAndVisit(
INSTANCE_OWNER.email,
updatedPersonalData.newPassword,
);
personalSettingsPage.actions.updateEmail(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,
);
successToast().should('contain', 'Personal details updated');
personalSettingsPage.actions.loginWithNewData(
updatedPersonalData.newEmail,
updatedPersonalData.newPassword,
);
});
});

View File

@ -14,9 +14,9 @@ export class MfaComposer {
*/
async enableMfa(email: string, password: string, mfaSecret: string): Promise<void> {
await this.n8n.signIn.loginWithEmailAndPassword(email, password, true);
await this.n8n.settings.goToPersonalSettings();
await this.n8n.settingsPersonal.goToPersonalSettings();
await this.n8n.settings.clickEnableMfa();
await this.n8n.settingsPersonal.clickEnableMfa();
await this.n8n.mfaSetupModal.getModalContainer().waitFor({ state: 'visible' });

View File

@ -1,6 +1,7 @@
import type { Page } from '@playwright/test';
import type { n8nPage } from '../pages/n8nPage';
import type { TestUser } from '../services/user-api-helper';
/**
* Composer for UI test entry points. All methods in this class navigate to or verify UI state.
@ -89,4 +90,18 @@ export class TestEntryComposer {
await this.n8n.api.enableFeature('projectRole:editor');
await this.n8n.api.setMaxTeamProjectsQuota(-1);
}
/**
* Create a new isolated user context with fresh page and authentication
* @param user - User with email and password
* @returns Fresh n8nPage instance with user authentication
*/
async withUser(user: Pick<TestUser, 'email' | 'password'>): Promise<n8nPage> {
const browser = this.n8n.page.context().browser()!;
const context = await browser.newContext();
const page = await context.newPage();
const newN8n = new (this.n8n.constructor as new (page: Page) => n8nPage)(page);
await newN8n.api.login({ email: user.email, password: user.password });
return newN8n;
}
}

View File

@ -5,7 +5,11 @@ import { BasePage } from './BasePage';
/**
* Page object for Settings including Personal Settings where users can update their profile and manage MFA.
*/
export class SettingsPage extends BasePage {
export class SettingsPersonalPage extends BasePage {
getChangePasswordLink(): Locator {
return this.page.getByTestId('change-password-link');
}
getMenuItems() {
return this.page.getByTestId('menu-item');
}
@ -22,6 +26,10 @@ export class SettingsPage extends BasePage {
await this.page.goto('/settings');
}
getUserRole(): Locator {
return this.page.getByTestId('current-user-role');
}
async goToPersonalSettings(): Promise<void> {
await this.page.goto('/settings/personal');
}
@ -128,4 +136,34 @@ export class SettingsPage extends BasePage {
getUpgradeCta(): Locator {
return this.page.getByTestId('public-api-upgrade-cta');
}
async changeTheme(theme: 'System default' | 'Light theme' | 'Dark theme') {
await this.page.getByTestId('theme-select').click();
await this.page.getByRole('option', { name: theme }).click();
await this.getSaveSettingsButton().click();
}
currentPassword(): Locator {
return this.page.locator('input[name="currentPassword"]');
}
newPassword(): Locator {
return this.page.locator('input[name="password"]');
}
repeatPassword(): Locator {
return this.page.locator('input[name="password2"]');
}
changePasswordModal(): Locator {
return this.page.getByTestId('changePassword-modal');
}
changePasswordButton(): Locator {
return this.changePasswordModal().getByRole('button', { name: 'Change password' });
}
emailBox(): Locator {
return this.page.getByTestId('email');
}
}

View File

@ -0,0 +1,72 @@
import type { Locator } from '@playwright/test';
import { BasePage } from './BasePage';
export class SettingsUsersPage extends BasePage {
getSearchInput(): Locator {
return this.page.getByTestId('users-list-search');
}
getRow(email: string): Locator {
return this.page.getByRole('row', { name: email });
}
getAccountType(email: string) {
return this.getRow(email).getByTestId('user-role-dropdown');
}
clickAccountType(email: string) {
return this.getRow(email).getByTestId('user-role-dropdown').getByRole('button').click();
}
async search(email: string) {
const searchInput = this.getSearchInput();
await searchInput.click();
await searchInput.fill(email);
}
async clickTransferUser(email: string) {
await this.openActions(email);
await this.page.getByTestId('action-transfer').click();
}
async transferData(email: string) {
await this.page
.getByRole('radio', {
name: 'Transfer their workflows and credentials to another user or project',
})
// This doesn't work without force: true
// eslint-disable-next-line playwright/no-force-option
.click({ force: true });
await this.page.getByPlaceholder('Select project or user').click();
await this.page.getByTestId('project-sharing-info').filter({ hasText: email }).click();
await this.page.getByRole('button', { name: 'Delete' }).click();
}
async deleteData() {
await this.page
.getByRole('radio', {
name: 'Delete their workflows and credentials',
})
// This doesn't work without force: true
// eslint-disable-next-line playwright/no-force-option
.check({ force: true });
await this.page.getByPlaceholder('delete all data').fill('delete all data');
await this.page.getByRole('button', { name: 'Delete' }).click();
}
async selectAccountType(email: string, type: 'Admin' | 'Member') {
await this.clickAccountType(email);
await this.page.getByRole('menuitem', { name: type }).click();
}
async openActions(email: string) {
await this.getRow(email).getByTestId('action-toggle').click();
}
async clickDeleteUser(email: string) {
await this.openActions(email);
await this.page.getByTestId('action-delete').filter({ visible: true }).click();
}
}

View File

@ -16,7 +16,7 @@ import { NotificationsPage } from './NotificationsPage';
import { NpsSurveyPage } from './NpsSurveyPage';
import { ProjectSettingsPage } from './ProjectSettingsPage';
import { SettingsLogStreamingPage } from './SettingsLogStreamingPage';
import { SettingsPage } from './SettingsPage';
import { SettingsPersonalPage } from './SettingsPersonalPage';
import { SidebarPage } from './SidebarPage';
import { SignInPage } from './SignInPage';
import { VariablesPage } from './VariablesPage';
@ -38,6 +38,7 @@ import { NavigationHelper } from '../helpers/NavigationHelper';
import { ApiHelpers } from '../services/api-helper';
import { BaseModal } from './components/BaseModal';
import { Breadcrumbs } from './components/Breadcrumbs';
import { SettingsUsersPage } from './SettingsUsersPage';
// eslint-disable-next-line @typescript-eslint/naming-convention
export class n8nPage {
@ -56,7 +57,7 @@ export class n8nPage {
readonly ndv: NodeDetailsViewPage;
readonly npsSurvey: NpsSurveyPage;
readonly projectSettings: ProjectSettingsPage;
readonly settings: SettingsPage;
readonly settingsPersonal: SettingsPersonalPage;
readonly settingsLogStreaming: SettingsLogStreamingPage;
readonly variables: VariablesPage;
readonly versions: VersionsPage;
@ -67,7 +68,7 @@ export class n8nPage {
readonly executions: ExecutionsPage;
readonly sideBar: SidebarPage;
readonly signIn: SignInPage;
readonly settingsUsers: SettingsUsersPage;
// Modals
readonly workflowActivationModal: WorkflowActivationModal;
readonly workflowSettingsModal: WorkflowSettingsModal;
@ -105,7 +106,7 @@ export class n8nPage {
this.ndv = new NodeDetailsViewPage(page);
this.npsSurvey = new NpsSurveyPage(page);
this.projectSettings = new ProjectSettingsPage(page);
this.settings = new SettingsPage(page);
this.settingsPersonal = new SettingsPersonalPage(page);
this.settingsLogStreaming = new SettingsLogStreamingPage(page);
this.variables = new VariablesPage(page);
this.versions = new VersionsPage(page);
@ -117,6 +118,7 @@ export class n8nPage {
this.sideBar = new SidebarPage(page);
this.signIn = new SignInPage(page);
this.workflowSharingModal = new WorkflowSharingModal(page);
this.settingsUsers = new SettingsUsersPage(page);
// Modals
this.workflowActivationModal = new WorkflowActivationModal(page);
this.workflowSettingsModal = new WorkflowSettingsModal(page);

View File

@ -11,6 +11,7 @@ import {
import { TestError } from '../Types';
import { CredentialApiHelper } from './credential-api-helper';
import { ProjectApiHelper } from './project-api-helper';
import { UserApiHelper } from './user-api-helper';
import { VariablesApiHelper } from './variables-api-helper';
import { WorkflowApiHelper } from './workflow-api-helper';
@ -39,6 +40,7 @@ export class ApiHelpers {
projects: ProjectApiHelper;
credentials: CredentialApiHelper;
variables: VariablesApiHelper;
users: UserApiHelper;
constructor(requestContext: APIRequestContext) {
this.request = requestContext;
@ -46,6 +48,7 @@ export class ApiHelpers {
this.projects = new ProjectApiHelper(this);
this.credentials = new CredentialApiHelper(this);
this.variables = new VariablesApiHelper(this);
this.users = new UserApiHelper(this);
}
// ===== MAIN SETUP METHODS =====
@ -136,6 +139,10 @@ export class ApiHelpers {
return await this.loginAndSetCookies(credentials);
}
async login(credentials: { email: string; password: string }): Promise<LoginResponseData> {
return await this.loginAndSetCookies(credentials);
}
// ===== CONFIGURATION METHODS =====
async setFeature(feature: string, enabled: boolean): Promise<void> {

View File

@ -0,0 +1,71 @@
import { customAlphabet } from 'nanoid';
import type { ApiHelpers } from './api-helper';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
export interface TestUser {
id: string;
email: string;
password: string;
firstName: string;
lastName: string;
role: 'global:owner' | 'global:admin' | 'global:member';
}
/**
* Creates test users via n8n's invitation API.
* Note: Using this with n8n.api will affect browser cookies. Use with the isolated api fixture instead unless you want to overwrite the existing user
*/
export class UserApiHelper {
constructor(private api: ApiHelpers) {}
/**
* Create and activate a test user
*/
async create(options: Partial<TestUser> = {}): Promise<TestUser> {
const user = {
email: options.email?.toLowerCase() ?? `testuser${nanoid()}@test.com`,
password: options.password ?? 'PlaywrightTest123',
firstName: options.firstName ?? 'Test',
lastName: options.lastName ?? `User${nanoid()}`,
role: options.role ?? 'global:member',
};
// Invite user
const inviteResponse = await this.api.request.post('/rest/invitations', {
data: [{ email: user.email, role: user.role }],
});
if (!inviteResponse.ok()) {
throw new Error(`Failed to invite user: ${inviteResponse.status()}`);
}
const inviteData = await inviteResponse.json();
const { id, inviteAcceptUrl } = inviteData.data[0].user;
// Accept invitation
const url = new URL(inviteAcceptUrl);
const inviterId = url.searchParams.get('inviterId');
const inviteeId = url.searchParams.get('inviteeId');
const acceptResponse = await this.api.request.post(`/rest/invitations/${inviteeId}/accept`, {
data: {
inviterId,
firstName: user.firstName,
lastName: user.lastName,
password: user.password,
},
});
if (!acceptResponse.ok()) {
throw new Error(`Failed to accept invitation: ${acceptResponse.status()}`);
}
return { id, ...user };
}
/**
* Delete a user
*/
async delete(userId: string): Promise<void> {
await this.api.request.delete(`/rest/users/${userId}`);
}
}

View File

@ -0,0 +1,158 @@
import { customAlphabet } from 'nanoid';
import { INSTANCE_OWNER_CREDENTIALS } from '../../config/test-users';
import { test, expect } from '../../fixtures/base';
test.describe('User Management', () => {
test('should login and logout @auth:none', async ({ n8n }) => {
await n8n.goHome();
await n8n.signIn.goToSignIn();
await n8n.signIn.loginWithEmailAndPassword(
INSTANCE_OWNER_CREDENTIALS.email,
INSTANCE_OWNER_CREDENTIALS.password,
);
await expect(n8n.workflows.getProjectName()).toBeVisible();
});
test('should prevent non-owners to access UM settings', async ({ n8n }) => {
// This creates a new user in the same context, so the cookies are refreshed and owner is no longer logged in
await n8n.api.users.create();
await n8n.navigate.toUsers();
await expect(n8n.workflows.getProjectName()).toBeVisible();
});
test('should allow instance owner to access UM settings', async ({ n8n }) => {
await n8n.navigate.toUsers();
expect(n8n.page.url()).toContain('/settings/users');
});
test('should be able to change user role to Admin and back', async ({ n8n, api }) => {
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.settingsUsers.search(user.email);
await n8n.settingsUsers.selectAccountType(user.email, 'Admin');
await expect(n8n.settingsUsers.getAccountType(user.email)).toHaveText('Admin');
await n8n.settingsUsers.selectAccountType(user.email, 'Member');
await expect(n8n.settingsUsers.getAccountType(user.email)).toHaveText('Member');
});
test('should be able to change theme', async ({ n8n }) => {
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.changeTheme('Dark theme');
await expect(
n8n.notifications.getNotificationByTitleOrContent('Personal details updated'),
).toBeVisible();
await expect(n8n.page.locator('body')).toHaveAttribute('data-theme', 'dark');
});
test('should delete user and their data', async ({ n8n, api }) => {
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.page.reload();
await n8n.settingsUsers.search(user.email);
await expect(n8n.settingsUsers.getRow(user.email)).toBeVisible();
await n8n.settingsUsers.clickDeleteUser(user.email);
await n8n.settingsUsers.deleteData();
await expect(n8n.notifications.getNotificationByTitleOrContent('User deleted')).toBeVisible();
});
test('should delete user and transfer their data', async ({ n8n, api }) => {
const ownerEmail = INSTANCE_OWNER_CREDENTIALS.email;
const user = await api.users.create();
await n8n.navigate.toUsers();
await n8n.page.reload();
await n8n.settingsUsers.search(user.email);
await n8n.settingsUsers.getRow(user.email).isVisible();
await n8n.settingsUsers.clickDeleteUser(user.email);
await n8n.settingsUsers.transferData(ownerEmail);
await expect(n8n.notifications.getNotificationByTitleOrContent('User deleted')).toBeVisible();
});
test('should allow user to change their personal data', async ({ n8n }) => {
await n8n.api.users.create();
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.fillPersonalData('Something', 'Else');
await n8n.settingsPersonal.saveSettings();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Personal details updated'),
).toBeVisible();
await n8n.page.reload();
await expect(n8n.settingsPersonal.getFirstNameField()).toHaveValue('Something');
await expect(n8n.settingsPersonal.getLastNameField()).toHaveValue('Else');
});
test("shouldn't allow user to set weak password", async ({ n8n }) => {
const user = await n8n.api.users.create();
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.getChangePasswordLink().click();
await n8n.settingsPersonal.currentPassword().fill(user.password);
await n8n.settingsPersonal.newPassword().fill('abc');
await n8n.settingsPersonal.repeatPassword().fill('abc');
await expect(
n8n.settingsPersonal
.changePasswordModal()
.getByText('8+ characters, at least 1 number and 1 capital letter'),
).toBeVisible();
});
test("shouldn't allow user to change password if old password is wrong", async ({ n8n }) => {
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.getChangePasswordLink().click();
await n8n.settingsPersonal.currentPassword().fill('wrong');
await n8n.settingsPersonal.newPassword().fill('Keybo4rd');
await n8n.settingsPersonal.repeatPassword().fill('Keybo4rd');
await n8n.settingsPersonal.changePasswordButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Provided current password is incorrect.'),
).toBeVisible();
});
test('should change current user password', async ({ n8n }) => {
const user = await n8n.api.users.create();
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.getChangePasswordLink().click();
await n8n.settingsPersonal.currentPassword().fill(user.password);
await n8n.settingsPersonal.newPassword().fill('Keybo4rd');
await n8n.settingsPersonal.repeatPassword().fill('Keybo4rd');
await n8n.settingsPersonal.changePasswordButton().click();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Password updated'),
).toBeVisible();
});
test("shouldn't allow users to set invalid email", async ({ n8n }) => {
await n8n.api.users.create();
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.fillEmail('something_else');
await expect(n8n.settingsPersonal.getSaveSettingsButton()).toBeDisabled();
});
test('should change user email', async ({ n8n }) => {
const user = await n8n.api.users.create();
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 8);
const newEmail = `something${nanoid()}@acme.corp`;
await n8n.navigate.toPersonalSettings();
await n8n.settingsPersonal.fillEmail(newEmail);
await n8n.settingsPersonal.saveSettings();
await n8n.settingsPersonal.currentPassword().fill(user.password);
await n8n.modal.clickButton('Confirm');
await expect(
n8n.notifications.getNotificationByTitleOrContent('Personal details updated'),
).toBeVisible();
const newTestUser = {
email: newEmail,
password: user.password,
};
const secondBrowser = await n8n.start.withUser(newTestUser);
await secondBrowser.navigate.toPersonalSettings();
await expect(secondBrowser.settingsPersonal.getEmailField()).toHaveValue(newEmail);
});
});

View File

@ -0,0 +1,60 @@
import { expect, test } from '../../fixtures/base';
test.describe('User API Service', () => {
test('should create a user with default values', async ({ api }) => {
const user = await api.users.create();
expect(user.email).toContain('testuser');
expect(user.email).toContain('@test.com');
expect(user.firstName).toBe('Test');
expect(user.lastName).toContain('User');
expect(user.role).toContain('member');
});
test('should create a user with custom values', async ({ api }) => {
const customEmail = `custom-${Date.now()}@test.com`;
const customPassword = 'CustomPass123!';
const user = await api.users.create({
email: customEmail,
password: customPassword,
firstName: 'John',
lastName: 'Doe',
role: 'global:member',
});
expect(user.email.toLowerCase()).toBe(customEmail.toLowerCase());
expect(user.firstName).toBe('John');
expect(user.lastName).toBe('Doe');
expect(user.role).toContain('member');
});
test('should create a member user by default', async ({ api }) => {
const user = await api.users.create();
expect(user.role).toContain('member');
expect(user.role).toBe('global:member');
});
test('should maintain separate sessions for multiple users', async ({ n8n, api }) => {
await n8n.navigate.toPersonalSettings();
const user = await api.users.create();
await n8n.page.reload();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
// New user page should have test name
const memberN8n = await n8n.start.withUser(user);
await memberN8n.navigate.toPersonalSettings();
await expect(memberN8n.settingsPersonal.getUserRole()).toHaveText('Member');
// n8n main should still have owner context
await n8n.page.reload();
await expect(n8n.settingsPersonal.getUserRole()).toHaveText('Owner');
// user page should still have member role
await memberN8n.page.reload();
await expect(memberN8n.settingsPersonal.getUserRole()).toHaveText('Member');
});
});

View File

@ -89,7 +89,7 @@ test.describe('Cloud @db:reset @auth:owner', () => {
await n8n.page.waitForLoadState();
await expect(n8n.settings.getUpgradeCta()).toBeVisible();
await expect(n8n.settingsPersonal.getUpgradeCta()).toBeVisible();
});
});

View File

@ -42,21 +42,21 @@ test.describe('Two-factor authentication @auth:none @db:reset', () => {
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
const disableToken = authenticator.generate(mfaSecret!);
await n8n.settings.triggerDisableMfa();
await n8n.settings.fillMfaCodeAndSave(disableToken);
await n8n.settingsPersonal.triggerDisableMfa();
await n8n.settingsPersonal.fillMfaCodeAndSave(disableToken);
await expect(n8n.settings.getEnableMfaButton()).toBeVisible();
await expect(n8n.settingsPersonal.getEnableMfaButton()).toBeVisible();
});
test('Should prompt for MFA code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.goToPersonalSettings();
await n8n.settings.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settings.pressEnterOnEmail();
await n8n.settingsPersonal.goToPersonalSettings();
await n8n.settingsPersonal.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settingsPersonal.pressEnterOnEmail();
const mfaCode = authenticator.generate(mfaSecret!);
await n8n.settings.fillMfaCodeAndSave(mfaCode);
await n8n.settingsPersonal.fillMfaCodeAndSave(mfaCode);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
@ -66,11 +66,11 @@ test.describe('Two-factor authentication @auth:none @db:reset', () => {
test('Should prompt for MFA recovery code when email changes', async ({ n8n }) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.goToPersonalSettings();
await n8n.settings.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settings.pressEnterOnEmail();
await n8n.settingsPersonal.goToPersonalSettings();
await n8n.settingsPersonal.fillEmail(TEST_DATA.NEW_EMAIL);
await n8n.settingsPersonal.pressEnterOnEmail();
await expect(n8n.settings.getMfaCodeOrRecoveryCodeInput()).toBeVisible();
await expect(n8n.settingsPersonal.getMfaCodeOrRecoveryCodeInput()).toBeVisible();
});
test('Should not prompt for MFA code or recovery code when first name or last name changes', async ({
@ -78,7 +78,10 @@ test.describe('Two-factor authentication @auth:none @db:reset', () => {
}) => {
await n8n.mfaComposer.enableMfa(email, password, mfaSecret!);
await n8n.settings.updateFirstAndLastName(TEST_DATA.NEW_FIRST_NAME, TEST_DATA.NEW_LAST_NAME);
await n8n.settingsPersonal.updateFirstAndLastName(
TEST_DATA.NEW_FIRST_NAME,
TEST_DATA.NEW_LAST_NAME,
);
await expect(
n8n.notifications.getNotificationByTitleOrContent(NOTIFICATIONS.PERSONAL_DETAILS_UPDATED),
@ -91,9 +94,9 @@ test.describe('Two-factor authentication @auth:none @db:reset', () => {
await n8n.mfaComposer.loginWithMfaCode(email, password, mfaSecret!);
await n8n.settings.triggerDisableMfa();
await n8n.settings.fillMfaCodeAndSave(RECOVERY_CODE);
await n8n.settingsPersonal.triggerDisableMfa();
await n8n.settingsPersonal.fillMfaCodeAndSave(RECOVERY_CODE);
await expect(n8n.settings.getEnableMfaButton()).toBeVisible();
await expect(n8n.settingsPersonal.getEnableMfaButton()).toBeVisible();
});
});

View File

@ -26,11 +26,11 @@ const VALID_NAMES = [
test.describe('Personal Settings', () => {
test('should allow to change first and last name', async ({ n8n }) => {
await n8n.settings.goToPersonalSettings();
await n8n.settingsPersonal.goToPersonalSettings();
for (const name of VALID_NAMES) {
await n8n.settings.fillPersonalData(name[0], name[1]);
await n8n.settings.saveSettings();
await n8n.settingsPersonal.fillPersonalData(name[0], name[1]);
await n8n.settingsPersonal.saveSettings();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Personal details updated'),
@ -40,11 +40,11 @@ test.describe('Personal Settings', () => {
});
test('should not allow malicious values for personal data', async ({ n8n }) => {
await n8n.settings.goToPersonalSettings();
await n8n.settingsPersonal.goToPersonalSettings();
for (const name of INVALID_NAMES) {
await n8n.settings.fillPersonalData(name, name);
await n8n.settings.saveSettings();
await n8n.settingsPersonal.fillPersonalData(name, name);
await n8n.settingsPersonal.saveSettings();
await expect(
n8n.notifications.getNotificationByTitleOrContent('Problem updating your details'),

View File

@ -3,13 +3,13 @@ import { test, expect } from '../../fixtures/base';
test.describe('Admin user', () => {
test('should see same Settings sub menu items as instance owner', async ({ n8n }) => {
await n8n.api.setupTest('signin-only', 'owner');
await n8n.settings.goToSettings();
await n8n.settingsPersonal.goToSettings();
const ownerMenuItems = await n8n.settings.getMenuItems().count();
const ownerMenuItems = await n8n.settingsPersonal.getMenuItems().count();
await n8n.api.setupTest('signin-only', 'admin');
await n8n.settings.goToSettings();
await n8n.settingsPersonal.goToSettings();
await expect(n8n.settings.getMenuItems()).toHaveCount(ownerMenuItems);
await expect(n8n.settingsPersonal.getMenuItems()).toHaveCount(ownerMenuItems);
});
});

View File

@ -116,8 +116,8 @@ test.describe('Projects', () => {
await n8n.projectSettings.expectTableHasMemberCount(1);
// Verify save/cancel buttons are not visible initially (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeHidden();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeHidden();
// Delete button should always be visible
await expect(n8n.page.getByTestId('project-settings-delete-button')).toBeVisible();
@ -249,8 +249,8 @@ test.describe('Projects', () => {
await expect(n8n.projectSettings.getTitle()).toHaveText('Unsaved Changes Test');
// Initially, save and cancel buttons should not be visible (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeHidden();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeHidden();
// Make a change to the project name
await n8n.projectSettings.fillProjectName('Modified Name');
@ -266,8 +266,8 @@ test.describe('Projects', () => {
await n8n.projectSettings.clickCancelButton();
// Buttons should not be visible again (no changes)
await expect(n8n.page.getByTestId('project-settings-save-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).not.toBeVisible();
await expect(n8n.page.getByTestId('project-settings-save-button')).toBeHidden();
await expect(n8n.page.getByTestId('project-settings-cancel-button')).toBeHidden();
});
test('should display delete project section with warning @auth:owner', async ({ n8n }) => {