[E2E/Playwright] Add password, MFA, authentication-method, and claim tests (#38520)

* Add Playwright password, MFA, authentication-method, and claim tests

Adds password requirements, MFA visibility/enforcement, System Console
authentication-method column, and authentication-transfer claim coverage.
Deletes the ported Cypress specs.

Locates the user row by exact username instead of assuming index 0, and waits
for the users-report response matching the search term before reading it.
Anchors the password-length error locator so it can't match the longer
complexity message, and asserts the length reset to the compiled default (5).
MM-T1778 skips when testcontainers isn't available, since its enforced-MFA
cleanup depends on the local mmctl path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix Cypress override-setting test pollution and gh api --slurp/--jq CI script bug

- helpers.js: make enableUsernameAndIconOverrideInt idempotent so a sibling
  spec leaving EnablePostUsernameOverride/EnablePostIconOverride already
  enabled doesn't leave #saveSetting permanently disabled (30s timeout in
  disallow_username_profile_override_spec.js). Replace the spec's dead
  mattermost.com icon fixture URL (404) with a stable in-repo asset.
- e2e-tests-override-status.yml / e2e-tests-verified-label.yml: gh api
  rejects --slurp combined with --jq in one call; split into a pipe to a
  separate jq invocation instead.
- e2e-tests-playwright.yml: bump playwright-full workers 20 -> 30.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sabrilandClaude Sonnet 5 authored and GitHub committed 2026-09-28 11:32:51 +08:00
1 parent b5c970cdbb
commit 8bfa298a54
40 files changed
+1332 -473

No files matched your search

@@ -56,8 +56,8 @@ jobs:
echo "Checking: $CONTEXT_NAME"
# Get current status
STATUS_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${COMMIT_SHA}/statuses" --paginate --slurp \
--jq "[.[][] | select(.context == \"$CONTEXT_NAME\")] | first // empty")
STATUS_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${COMMIT_SHA}/statuses" --paginate --slurp | \
jq "[.[][] | select(.context == \"$CONTEXT_NAME\")] | first // empty")
if [ -z "$STATUS_JSON" ]; then
echo " No status found, skipping"
+1 -1
View File
@@ -182,7 +182,7 @@ jobs:
pull-requests: write
uses: ./.github/workflows/e2e-tests-playwright-template.yml
with:
workers: 20
workers: 30
enabled_docker_services: "postgres inbucket"
commit_sha: ${{ inputs.commit_sha }}
branch: ${{ needs.generate-build-variables.outputs.branch }}
@@ -56,8 +56,8 @@ jobs:
echo "Checking: $CONTEXT_NAME"
# Get current status
STATUS_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${COMMIT_SHA}/statuses" --paginate --slurp \
--jq "[.[][] | select(.context == \"$CONTEXT_NAME\")] | first // empty")
STATUS_JSON=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${COMMIT_SHA}/statuses" --paginate --slurp | \
jq "[.[][] | select(.context == \"$CONTEXT_NAME\")] | first // empty")
if [ -z "$STATUS_JSON" ]; then
echo " No status found, skipping"
@@ -1,188 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @system_console @authentication @mfa
import * as TIMEOUTS from '@/fixtures/timeouts';
import {getRandomId} from '@/utils';
describe('Authentication', () => {
beforeEach(() => {
// # Log in as admin.
cy.apiAdminLogin();
});
it('MM-T1771 - Minimum password length error field shows below minimum and above 72', () => {
cy.visit('/admin_console/authentication/password');
cy.findByPlaceholderText('E.g.: "5"', {timeout: TIMEOUTS.ONE_MIN}).clear().type('88');
cy.uiSave();
// * Ensure error appears when saving a password outside of the limits
// Note: minimum is 5 on non-FIPS builds and 14 on FIPS builds
cy.contains(/Minimum password length must be a whole number greater than or equal to (5|14) and less than or equal to 72\./, {timeout: TIMEOUTS.ONE_MIN}).
should('exist').
and('be.visible');
cy.findByPlaceholderText('E.g.: "5"').clear().type('3');
cy.uiSave();
// * Ensure error appears when saving a password outside of the limits
cy.contains(/Minimum password length must be a whole number greater than or equal to (5|14) and less than or equal to 72\./, {timeout: TIMEOUTS.ONE_MIN}).
should('exist').
and('be.visible');
});
it('MM-T1772 - Change minimum password length, verify help text and error message', () => {
cy.visit('/admin_console/authentication/password');
cy.findByPlaceholderText('E.g.: "5"', {timeout: TIMEOUTS.ONE_MIN}).clear().type('15');
cy.uiSave();
cy.findByText('Your password must be 15-72 characters long.').should('be.visible');
cy.apiLogout();
// # Go to sign up with email page
cy.visit('/signup_user_complete');
cy.get('#input_email', {timeout: TIMEOUTS.ONE_MIN}).type(`test-${getRandomId()}@example.com`);
cy.get('#input_name').clear().type(`BestUsername${getRandomId()}`);
cy.get('#input_password-input').clear().type('less');
cy.get('#signup-body-card-form-check-terms-and-privacy').check();
cy.findByText('Create account').click();
// * Assert the error is what is expected;
cy.findByText('Your password must be 15-72 characters long.').should('be.visible');
cy.get('#input_password-input').clear().type('GreaterThan15Chr!');
cy.get('#signup-body-card-form-check-terms-and-privacy').check();
cy.findByText('Create account').click();
// * Assert that we are not shown an MFA screen and instead a Teams You Can join page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
});
it('MM-T1773 - Minimum password length field resets to default after saving invalid value', () => {
cy.visit('/admin_console/authentication/password');
cy.findByPlaceholderText('E.g.: "5"', {timeout: TIMEOUTS.ONE_MIN}).clear().type('20');
cy.uiSave();
cy.reload();
// * Ensure the limit 20 appears
cy.findByPlaceholderText('E.g.: "5"').invoke('val').should('equal', '20');
cy.findByPlaceholderText('E.g.: "5"').clear();
cy.uiSave();
// Reload to see the actual server state, since on FIPS builds saving
// the webapp default of 5 is rejected (below the FIPS minimum of 14).
cy.reload();
// * Ensure the field reflects the server's current minimum password length
cy.apiGetConfig().then(({config: {PasswordSettings}}) => {
cy.findByPlaceholderText('E.g.: "5"').invoke('val').should('equal', String(PasswordSettings.MinimumLength));
});
});
it('MM-T1774 - Select all Password Requirements, verify help text and error on bad password', () => {
cy.apiUpdateConfig({
PasswordSettings: {
Lowercase: true,
Number: true,
Uppercase: true,
Symbol: true,
},
});
cy.apiLogout();
// # Go to sign up with email page
cy.visit('/signup_user_complete');
cy.get('#input_email', {timeout: TIMEOUTS.ONE_MIN}).type(`test-${getRandomId()}@example.com`);
cy.get('#input_name').clear().type(`BestUsernameInTheWorld${getRandomId()}`);
cy.get('#signup-body-card-form-check-terms-and-privacy').check();
['NOLOWERCASE12345!', 'nouppercase12345!', 'NoNumberHere!!!', 'NoSymbol1234567'].forEach((option) => {
cy.get('#input_password-input').clear().type(option);
cy.findByText('Create account').click();
// * Assert the error is what is expected;
cy.findByText('Your password must be 14-72 characters long and include both lowercase and uppercase letters, numbers, and special characters.').should('be.visible');
});
});
it('MM-T1777 - Multi-factor Authentication option hidden in Profile when disabled', () => {
cy.apiUpdateConfig({
ServiceSettings: {
EnableMultifactorAuthentication: false,
},
});
cy.visit('/');
// # Go to Profile
cy.uiOpenProfileModal('Security');
// * Assert that Multi-factor Authentication text does not exist
cy.findByText('Multi-factor Authentication').should('not.exist');
});
it('MM-T1779 - Multi-factor Authentication option appears in Profile when enabled', () => {
cy.apiUpdateConfig({
ServiceSettings: {
EnableMultifactorAuthentication: true,
},
});
cy.visit('/');
// # Go to Profile
cy.uiOpenProfileModal('Security');
// * Assert that Multi-factor Authentication text does exist
cy.findByText('Multi-factor Authentication').should('be.visible');
});
it('MM-T1780 - Multi-factor Authentication false: User can log in without being prompted for MFA', () => {
cy.apiUpdateConfig({
ServiceSettings: {
EnableMultifactorAuthentication: true,
EnforceMultifactorAuthentication: false,
},
});
cy.apiCreateUser().then(({user: newUser}) => {
cy.apiLogout();
// # Login as a new user and visit default page
cy.apiLogin(newUser);
cy.visit('/');
// * Assert that we are not shown a MFA screen and instead a Teams You Can join page
cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible');
});
});
});
@@ -1,155 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @enterprise @system_console @authentication @mfa
import type {UserProfile} from '@mattermost/types/users';
import * as authenticator from 'authenticator';
import * as TIMEOUTS from '@/fixtures/timeouts';
describe('Authentication', () => {
let mfaSysAdmin: UserProfile;
let testUser: UserProfile;
let adminMFASecret: string;
before(() => {
cy.apiRequireLicenseForFeature('MFA');
// # Do email test if setup properly
cy.shouldHaveEmailEnabled();
cy.apiInitSetup().then(({user}) => {
testUser = user;
});
// # Create and login a newly created user as sysadmin
cy.apiCreateCustomAdmin().then(({sysadmin}) => {
mfaSysAdmin = sysadmin;
});
});
it('MM-T1778 - MFA - Enforced', () => {
// # Log in as a admin.
cy.apiLogin(mfaSysAdmin);
// # Navigate to System Console -> Authentication -> MFA Page.
cy.visit('/admin_console/authentication/mfa');
cy.findByText('Multi-factor Authentication', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and('exist');
// # Ensure the setting 'Enable Multi factor authentication' is set to true in the MFA page.
cy.findByTestId('ServiceSettings.EnableMultifactorAuthenticationtrue').check();
// # Also ensure that this MFA setting is enforced.
cy.findByTestId('ServiceSettings.EnforceMultifactorAuthenticationtrue').check();
// # Click "Save".
cy.findByText('Save').click().wait(TIMEOUTS.ONE_SEC);
// # Get MFA secret
cy.uiGetMFASecret(mfaSysAdmin.id).then((secret) => {
adminMFASecret = secret;
// # Navigate to System Console -> User Management -> Users
cy.visit('/admin_console/user_management/users');
cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC);
// * Remove MFA option not available for the user
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
cy.findByText('Remove MFA').should('not.exist');
cy.apiLogout();
});
// # Login as test user
cy.uiLogin(testUser);
cy.wait(TIMEOUTS.THREE_SEC);
// * MFA page is shown
cy.findByText('Multi-factor Authentication Setup', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and('exist');
});
// This test relies on the previous test for having MFA enabled (MM-T1778)
it('MM-T1781 - MFA - Admin removes another users MFA', () => {
// # Login as test user
cy.apiLogin(testUser);
cy.wait(TIMEOUTS.THREE_SEC);
// # Complete MFA setup which we didnt do for the test user
cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('.col-sm-12').then((p) => {
const secretp = p.text();
const testUserMFASecret = secretp.split(' ')[1];
const token = authenticator.generateToken(testUserMFASecret);
cy.findByPlaceholderText('MFA Code').type(token);
cy.findByText('Save').click();
cy.wait(TIMEOUTS.HALF_SEC);
cy.findByText('Okay').click();
cy.apiLogout();
});
cy.wait(TIMEOUTS.THREE_SEC);
// # Login back as admin.
const adminMFAToken = authenticator.generateToken(adminMFASecret);
cy.apiLoginWithMFA(mfaSysAdmin, adminMFAToken);
// # Navigate to System Console -> User Management -> Users
cy.visit('/admin_console/user_management/users');
cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC);
// * Remove MFA option available for the user and click it
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
cy.findByText('Remove MFA').should('be.visible').click();
// # Navigate to System Console -> Authentication -> MFA Page.
cy.visit('/admin_console/authentication/mfa');
cy.findByText('Multi-factor Authentication', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible').and('exist');
// # Also ensure that this MFA setting is enforced.
cy.findByTestId('ServiceSettings.EnforceMultifactorAuthenticationfalse').check();
// # Click "Save".
cy.findByText('Save').click().wait(TIMEOUTS.ONE_SEC);
cy.apiLogout();
// # Login as test user
cy.apiLogin(testUser);
cy.visit('/');
// * No MFA page is shown
cy.findByText('Multi-factor Authentication Setup', {timeout: TIMEOUTS.ONE_MIN}).should('not.exist').and('not.exist');
});
// This test relies on the previous test for having MFA enabled (MM-T1781)
it('MM-T1782 - MFA - Removing MFA option hidden for users without MFA set up', () => {
// # Login back as admin.
const token = authenticator.generateToken(adminMFASecret);
cy.apiLoginWithMFA(mfaSysAdmin, token);
// # Navigate to System Console -> User Management -> Users
cy.visit('/admin_console/user_management/users');
cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC);
// * Remove MFA option available for the user and click it
cy.wait(TIMEOUTS.HALF_SEC);
cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC);
cy.findByText('Remove MFA').should('not.exist');
// # Done with that MFA stuff so we disable it all
cy.apiUpdateConfig({
ServiceSettings: {
EnableMultifactorAuthentication: false,
EnforceMultifactorAuthentication: false,
},
});
});
});
@@ -1,111 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Group: @channels @enterprise @system_console @mfa
import ldapUsers from '@/fixtures/ldap_users.json';
import * as TIMEOUTS from '@/fixtures/timeouts';
import {getAdminAccount} from '@/support/env';
const authenticator = require('authenticator');
describe('Settings', () => {
let mfaUser;
let samlUser;
const ldapUser = ldapUsers['test-1'];
before(() => {
cy.apiInitSetup().then(({user}) => {
mfaUser = user;
cy.apiUpdateConfig({
ServiceSettings: {
EnableMultifactorAuthentication: true,
},
});
// * Check if server has license for LDAP
cy.apiRequireLicenseForFeature('LDAP');
return cy.apiSyncLDAPUser({ldapUser});
}).then(() => {
return cy.apiCreateUser();
}).then(({user: user2}) => {
// # Create SAML user
samlUser = user2;
const body = {
from: 'email',
auto: false,
};
body.matches = {};
body.matches[user2.email] = user2.username;
return migrateAuthToSAML(body);
}).then(() => {
return cy.apiGenerateMfaSecret(mfaUser.id);
}).then((res) => {
// # Create MFA user
const token = authenticator.generateToken(res.code.secret);
return cy.apiActivateUserMFA(mfaUser.id, true, token);
});
});
it('MM-T953 Verify correct authentication method', () => {
cy.visit('/admin_console/user_management/users');
const adminUsername = getAdminAccount().username;
// # Type sysadmin
cy.findByPlaceholderText('Search users').clear().type(adminUsername).wait(TIMEOUTS.TWO_SEC);
// * Verify sign-in method from user profile
cy.get('.displayName').click();
cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'Email');
cy.get('a.back').click();
// # Type saml user
cy.findByPlaceholderText('Search users').clear().type(samlUser.username).wait(TIMEOUTS.TWO_SEC);
// * Verify sign-in method from user profile
cy.get('.displayName').click();
cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'SAML');
cy.get('a.back').click();
// # Type ldap user
cy.findByPlaceholderText('Search users').clear().type(ldapUser.username).wait(TIMEOUTS.TWO_SEC);
// * Verify sign-in method from user profile
cy.get('.displayName').click();
cy.get('label').contains('Authentication Method').find('span').last().should('have.text', 'LDAP');
cy.get('a.back').click();
// # Type mfa user
cy.findByPlaceholderText('Search users').clear().type(mfaUser.username).wait(TIMEOUTS.TWO_SEC);
// * Verify sign-in method from user profile
cy.get('.displayName').click();
cy.get('label').contains('Authentication Method').find('span').last().should('contain.text', 'MFA');
cy.get('a.back').click();
});
});
function migrateAuthToSAML(body) {
return cy.request({
headers: {'X-Requested-With': 'XMLHttpRequest'},
url: '/api/v4/users/migrate_auth/saml',
method: 'POST',
body,
timeout: TIMEOUTS.ONE_MIN,
}).then((response) => {
expect(response.status).to.equal(200);
return cy.wrap(response);
});
}
@@ -45,7 +45,10 @@ describe('Incoming webhook', () => {
});
it('MM-T622 Disallow override of username and profile picture', () => {
const iconUrl = 'https://mattermost.com/wp-content/uploads/2022/02/icon_WS.png';
// The mattermost.com marketing asset this used to point at was removed (404), which the
// server can't fetch/proxy — points at a fixture already committed to this repo instead,
// so the URL doesn't rot again.
const iconUrl = 'https://raw.githubusercontent.com/mattermost/mattermost/master/e2e-tests/cypress/tests/fixtures/mattermost-icon.png';
// # Enable username and icon override
cy.apiAdminLogin();
@@ -14,10 +14,27 @@ export function enableUsernameAndIconOverride(enable) {
export function enableUsernameAndIconOverrideInt(enableUsername, enableIcon) {
// # Visit integration management at system console and change override values
cy.visit('/admin_console/integrations/integration_management');
cy.findByTestId('ServiceSettings.EnablePostUsernameOverride' + enableUsername).check({force: true});
cy.findByTestId('ServiceSettings.EnablePostIconOverride' + enableIcon).check({force: true});
// # Save the settings
cy.get('#saveSetting').should('be.enabled').click({force: true});
cy.get('#saveSetting').should('be.disabled');
// Each option is a radio pair (testid suffixed 'true'/'false'). Only click the ones not
// already in the desired state: a sibling spec can leave this setting already matching,
// and clicking an already-checked radio doesn't mark the form dirty, so unconditionally
// waiting for Save to become enabled would time out.
checkIfNotAlreadyChecked('ServiceSettings.EnablePostUsernameOverride' + enableUsername);
checkIfNotAlreadyChecked('ServiceSettings.EnablePostIconOverride' + enableIcon);
// # Save the settings only if something actually changed
cy.get('body').then(($body) => {
if ($body.find('#saveSetting:not(:disabled)').length > 0) {
cy.get('#saveSetting').click({force: true});
cy.get('#saveSetting').should('be.disabled');
}
});
}
function checkIfNotAlreadyChecked(testId) {
cy.findByTestId(testId).then(($el) => {
if (!$el.prop('checked')) {
cy.wrap($el).check({force: true});
}
});
}
@@ -95,6 +95,8 @@ const onPremServerConfig = (): Partial<TestAdminConfig> => {
EnableOnboardingFlow: false,
EnableSecurityFixAlert: false,
GiphySdkKey: 's0glxvzVg9azvPipKxcPLpXV0q1x1fVP',
EnableMultifactorAuthentication: false,
EnforceMultifactorAuthentication: false,
},
TeamSettings: {
EnableOpenServer: true,
+1 -1
View File
@@ -79,7 +79,7 @@ export {ensureLocalFile, listMattermostDataFiles} from './filestore';
export {ensurePostgresSearch} from './postgres_search';
export {ensureFeatureFlag} from './feature_flags';
export {ensureServerEnv, ensureSiteUrl} from './server_env';
export {generateTotp, enableUserMfa} from './mfa';
export {generateTotp, enableUserMfa, disableMfa} from './mfa';
export {runMmctl, runMmctlLocal, ensureMmctl} from './mmctl';
export type {MmctlResult} from './mmctl';
export {upgradeServerImage} from './version';
@@ -5,6 +5,10 @@ import {createHmac} from 'node:crypto';
import type {Client4} from '@mattermost/client';
import {runMmctlLocal} from './mmctl';
import {testConfig} from '@/test_config';
function base32Decode(secret: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const cleaned = secret.toUpperCase().replace(/[=+\s]/g, '');
@@ -44,3 +48,40 @@ export async function enableUserMfa(adminClient: Client4, userId: string) {
const {secret} = await adminClient.generateMfaSecret(userId);
await adminClient.updateUserMfa(userId, true, generateTotp(secret));
}
/** Turns MFA off without an admin API session, since enforced MFA can lock unenrolled admins out of patchConfig. */
export async function disableMfa(adminClient?: Client4): Promise<void> {
if (testConfig.mattermostContainerId) {
const enable = await runMmctlLocal([
'config',
'set',
'ServiceSettings.EnableMultifactorAuthentication',
'false',
]);
const enforce = await runMmctlLocal([
'config',
'set',
'ServiceSettings.EnforceMultifactorAuthentication',
'false',
]);
if (enable.exitCode !== 0 || enforce.exitCode !== 0) {
throw new Error(`Failed to disable MFA: ${enable.output} ${enforce.output}`);
}
return;
}
if (!adminClient) {
throw new Error('disableMfa requires a Mattermost container or an admin client.');
}
try {
await adminClient.patchConfig({
ServiceSettings: {EnableMultifactorAuthentication: false, EnforceMultifactorAuthentication: false},
});
} catch (error) {
throw new Error(
'disableMfa could not restore MFA via the admin API. Enforcing MFA invalidates unenrolled admin sessions, so patchConfig returns 403. Run against Testcontainers Mattermost so mmctl --local can disable MFA, or enroll the admin in MFA before enforcing it. Original error: ' +
String(error),
);
}
}
@@ -32,6 +32,7 @@ import {
createUserWithAttributes,
deleteKeycloakUser,
deleteLdapUser,
disableMfa,
elasticsearchServerConfig,
enableAIBridgeTestMode,
ensureAzurite,
@@ -167,6 +168,7 @@ export class PlaywrightExtended {
readonly ensureOpenldap;
readonly ensureOpensearch;
readonly ensurePostgresSearch;
readonly disableMfa;
readonly enableUserMfa;
readonly ensureServerEnv;
readonly ensureSiteUrl;
@@ -231,8 +233,13 @@ export class PlaywrightExtended {
readonly signupPage;
readonly selectTeamPage;
readonly verifyEmailPage;
readonly mfaSetupPage;
readonly resetPasswordPage;
readonly errorPage;
readonly emailToOAuthPage;
readonly oauthToEmailPage;
readonly emailToLdapPage;
readonly ldapToEmailPage;
// Same default page as above, post-login, for specs that authenticate it directly.
readonly channelsPage;
@@ -288,6 +295,7 @@ export class PlaywrightExtended {
this.ensureOpenldap = ensureOpenldap;
this.ensureOpensearch = ensureOpensearch;
this.ensurePostgresSearch = ensurePostgresSearch;
this.disableMfa = disableMfa;
this.enableUserMfa = enableUserMfa;
this.ensureServerEnv = ensureServerEnv;
this.ensureSiteUrl = ensureSiteUrl;
@@ -324,8 +332,13 @@ export class PlaywrightExtended {
this.signupPage = new pages.SignupPage(page);
this.selectTeamPage = new pages.SelectTeamPage(page);
this.verifyEmailPage = new pages.VerifyEmailPage(page);
this.mfaSetupPage = new pages.MfaSetupPage(page);
this.resetPasswordPage = new pages.ResetPasswordPage(page);
this.errorPage = new pages.ErrorPage(page);
this.emailToOAuthPage = new pages.EmailToOAuthPage(page);
this.oauthToEmailPage = new pages.OAuthToEmailPage(page);
this.emailToLdapPage = new pages.EmailToLdapPage(page);
this.ldapToEmailPage = new pages.LdapToEmailPage(page);
// Same default page as above, post-login
this.channelsPage = new pages.ChannelsPage(page);
@@ -165,12 +165,45 @@ class ProfileSettingsTab {
class SecurityTab {
readonly container: Locator;
readonly mfaHeading: Locator;
readonly signInHeading: Locator;
readonly switchToOpenId: Locator;
readonly switchToSaml: Locator;
readonly switchToLdap: Locator;
readonly switchToEmail: Locator;
readonly editSignInMethod: Locator;
constructor(container: Locator) {
this.container = container;
this.mfaHeading = container.getByText('Multi-factor Authentication', {exact: true});
this.signInHeading = container.getByText('Sign-in Method', {exact: true});
this.editSignInMethod = container.locator('#signinEdit');
this.switchToOpenId = container.getByRole('link', {name: 'Switch to Using OpenID SSO'});
this.switchToSaml = container.getByRole('link', {name: 'Switch to Using SAML SSO'});
this.switchToLdap = container.getByRole('link', {name: 'Switch to Using AD/LDAP'});
this.switchToEmail = container.getByRole('link', {name: 'Switch to Using Email and Password'});
}
async toBeVisible() {
await expect(this.container).toBeVisible();
}
async openSignInMethod() {
await this.editSignInMethod.click();
}
async clickSwitchToOpenId() {
await this.openSignInMethod();
await this.switchToOpenId.click();
}
async clickSwitchToLdap() {
await this.openSignInMethod();
await this.switchToLdap.click();
}
async clickSwitchToEmail() {
await this.openSignInMethod();
await this.switchToEmail.click();
}
}
@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Locator} from '@playwright/test';
import {expect} from '@playwright/test';
/**
* System Console -> Authentication -> MFA
*/
export default class MfaSettings {
readonly container: Locator;
readonly header: Locator;
readonly enableTrue: Locator;
readonly enableFalse: Locator;
readonly enforceTrue: Locator;
readonly enforceFalse: Locator;
readonly saveButton: Locator;
constructor(container: Locator) {
this.container = container;
this.header = container.getByText('Multi-factor Authentication', {exact: true});
this.enableTrue = container.getByTestId('ServiceSettings.EnableMultifactorAuthenticationtrue');
this.enableFalse = container.getByTestId('ServiceSettings.EnableMultifactorAuthenticationfalse');
this.enforceTrue = container.getByTestId('ServiceSettings.EnforceMultifactorAuthenticationtrue');
this.enforceFalse = container.getByTestId('ServiceSettings.EnforceMultifactorAuthenticationfalse');
this.saveButton = container.getByRole('button', {name: 'Save'});
}
async toBeVisible() {
await expect(this.header).toBeVisible();
}
async save() {
await expect(this.saveButton).toBeEnabled();
await this.saveButton.click();
await expect(this.saveButton).toBeDisabled();
}
}
@@ -44,4 +44,9 @@ export default class PasswordSettings {
await expect(this.saveButton).toBeEnabled();
await this.saveButton.click();
}
async reload() {
await this.container.page().reload();
await this.toBeVisible();
}
}
@@ -169,6 +169,10 @@ class AdminUserCard {
await expect(this.container).toBeVisible();
}
async expectAuthenticationMethod(text: string | RegExp) {
await expect(this.authenticationMethod).toHaveText(text);
}
/**
* Get the field-column container for a field by its exact label text.
*/
@@ -118,13 +118,19 @@ export default class Users {
* Search for users by typing in the search input
*/
async searchUsers(searchTerm: string) {
const responsePromise = waitForUsersReportResponse(this.page);
const responsePromise = waitForUsersReportResponse(this.page, searchTerm);
await this.searchInput.fill(searchTerm);
// SystemUsersSearch debounces dispatch by 500ms before fetching.
await responsePromise;
await expect(this.usersTable.getColumnHeader('User details')).toBeEnabled();
}
async openUserActions(searchTerm: string) {
await this.searchUsers(searchTerm);
const row = await this.usersTable.getRowByUsername(searchTerm);
return row.openActionMenu();
}
/**
* Clear the search input
*/
@@ -60,4 +60,12 @@ export class UserActionMenu {
async clickRevokeSessions() {
await this.clickMenuItem('Revoke sessions');
}
async clickRemoveMfa() {
await this.clickMenuItem('Remove MFA');
}
async expectRemoveMfaHidden() {
await expect(this.getMenuItem('Remove MFA')).toHaveCount(0);
}
}
@@ -13,13 +13,22 @@ const USERS_REPORT_RESPONSE_TIMEOUT_MS = 10_000;
* action that triggers the fetch (click, search, etc.) so Playwright does not sit
* on the timeout when the response already finished.
*/
export function waitForUsersReportResponse(page: Page) {
export function waitForUsersReportResponse(page: Page, searchTerm?: string) {
return page.waitForResponse(
(response) =>
response.url().includes('/api/v4/reports/users') &&
!response.url().includes('/count') &&
response.request().method() === 'GET' &&
response.ok(),
(response) => {
if (
!response.url().includes('/api/v4/reports/users') ||
response.url().includes('/count') ||
response.request().method() !== 'GET' ||
!response.ok()
) {
return false;
}
if (searchTerm === undefined) {
return true;
}
return new URL(response.url()).searchParams.get('search_term') === searchTerm;
},
{timeout: USERS_REPORT_RESPONSE_TIMEOUT_MS},
);
}
@@ -80,6 +89,22 @@ export class UsersTable {
return new UserRow(this.bodyRows.nth(index), index);
}
/**
* Get a user row by exact username, since the search is a partial LIKE match and can
* return more than one row.
*/
async getRowByUsername(username: string): Promise<UserRow> {
const count = await this.bodyRows.count();
for (let index = 0; index < count; index++) {
const row = this.getRowByIndex(index);
const text = (await row.userName.textContent())?.trim();
if (text === username) {
return row;
}
}
throw new Error(`No user row found for username "${username}"`);
}
/**
* Get a column header by display name
*/
@@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect} from '@playwright/test';
export default class EmailToLdapPage {
readonly page: Page;
readonly title;
readonly emailPasswordInput;
readonly ldapIdInput;
readonly ldapPasswordInput;
readonly submitButton;
readonly invalidPasswordError;
constructor(page: Page) {
this.page = page;
this.title = page.getByRole('heading', {name: 'Switch Email/Password Account to AD/LDAP'});
this.emailPasswordInput = page.locator('#claim input[name="emailPassword"]');
this.ldapIdInput = page.locator('#claim input[name="ldapId"]');
this.ldapPasswordInput = page.locator('#claim input[name="ldapPassword"]');
this.submitButton = page.getByRole('button', {name: 'Switch Account to AD/LDAP'});
this.invalidPasswordError = page.getByText('Login failed because of invalid password.');
}
async toBeVisible() {
await expect(this.title).toBeVisible();
await expect(this.emailPasswordInput).toBeVisible();
}
async submit(emailPassword: string, ldapId: string, ldapPassword: string) {
await this.emailPasswordInput.fill(emailPassword);
await this.ldapIdInput.fill(ldapId);
await this.ldapPasswordInput.fill(ldapPassword);
await this.submitButton.click();
}
}
@@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect} from '@playwright/test';
export default class EmailToOAuthPage {
readonly page: Page;
readonly title;
readonly passwordInput;
readonly submitButton;
readonly invalidPasswordError;
constructor(page: Page) {
this.page = page;
this.title = page.getByRole('heading', {name: /Switch Email\/Password Account to .+ SSO/});
this.passwordInput = page.locator('#claim input[name="password"]');
this.submitButton = page.getByRole('button', {name: /Switch Account to .+ SSO/});
this.invalidPasswordError = page.getByText('Login failed because of invalid password.');
}
async toBeVisible() {
await expect(this.title).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async submit(password: string) {
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
@@ -10,6 +10,11 @@ import ResetPasswordPage from './reset_password';
import SignupPage from './signup';
import SelectTeamPage from './select_team';
import VerifyEmailPage from './verify_email';
import MfaSetupPage from './mfa_setup';
import EmailToOAuthPage from './email_to_oauth';
import OAuthToEmailPage from './oauth_to_email';
import EmailToLdapPage from './email_to_ldap';
import LdapToEmailPage from './ldap_to_email';
import SystemConsolePage from './system_console';
import ScheduledPostsPage from './scheduled_posts';
import DraftsPage from './drafts';
@@ -27,6 +32,11 @@ const pages = {
SignupPage,
SelectTeamPage,
VerifyEmailPage,
MfaSetupPage,
EmailToOAuthPage,
OAuthToEmailPage,
EmailToLdapPage,
LdapToEmailPage,
ScheduledPostsPage,
ContentReviewPage,
SystemConsolePage,
@@ -49,6 +59,11 @@ export {
SignupPage,
SelectTeamPage,
VerifyEmailPage,
MfaSetupPage,
EmailToOAuthPage,
OAuthToEmailPage,
EmailToLdapPage,
LdapToEmailPage,
ScheduledPostsPage,
SystemConsolePage,
};
@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect} from '@playwright/test';
export default class LdapToEmailPage {
readonly page: Page;
readonly title;
readonly ldapPasswordInput;
readonly passwordInput;
readonly confirmPasswordInput;
readonly submitButton;
readonly mfaTokenInput;
readonly mfaSubmitButton;
constructor(page: Page) {
this.page = page;
this.title = page.getByRole('heading', {name: 'Switch AD/LDAP Account to Email/Password'});
this.ldapPasswordInput = page.locator('#claim input[name="ldapPassword"]');
this.passwordInput = page.locator('#claim input[name="password"]');
this.confirmPasswordInput = page.locator('#claim input[name="passwordconfirm"]');
this.submitButton = page.getByRole('button', {name: 'Switch account to email/password'});
this.mfaTokenInput = page.getByRole('textbox', {name: 'MFA Token'});
this.mfaSubmitButton = page.getByRole('button', {name: 'Submit'});
}
async toBeVisible() {
await expect(this.title).toBeVisible();
await expect(this.ldapPasswordInput.or(this.mfaTokenInput)).toBeVisible();
// The page currently opens on the MFA interstitial; a dummy token reveals the form.
if (await this.mfaTokenInput.isVisible()) {
await this.mfaTokenInput.fill('000000');
await this.mfaSubmitButton.click();
await expect(this.ldapPasswordInput).toBeVisible();
}
}
async submit(ldapPassword: string, newPassword: string) {
await this.ldapPasswordInput.fill(ldapPassword);
await this.passwordInput.fill(newPassword);
await this.confirmPasswordInput.fill(newPassword);
await this.submitButton.click();
}
}
@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect} from '@playwright/test';
export default class MfaSetupPage {
readonly page: Page;
readonly title: ReturnType<Page['getByText']>;
constructor(page: Page) {
this.page = page;
this.title = page.getByText('Multi-factor Authentication Setup', {exact: true});
}
async toBeVisible() {
await expect(this.title).toBeVisible();
}
async toBeHidden() {
await expect(this.title).toBeHidden();
}
}
@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect} from '@playwright/test';
export default class OAuthToEmailPage {
readonly page: Page;
readonly title;
readonly passwordInput;
readonly confirmPasswordInput;
readonly submitButton;
constructor(page: Page) {
this.page = page;
this.title = page.getByRole('heading', {name: /Switch .+ Account to Email/});
this.passwordInput = page.locator('#claim input[name="password"]');
this.confirmPasswordInput = page.locator('#claim input[name="passwordconfirm"]');
this.submitButton = page.getByRole('button', {name: /Switch .+ to Email and Password/});
}
async toBeVisible() {
await expect(this.title).toBeVisible();
await expect(this.passwordInput).toBeVisible();
}
async submit(password: string) {
await this.passwordInput.fill(password);
await this.confirmPasswordInput.fill(password);
await this.submitButton.click();
}
}
@@ -25,6 +25,8 @@ export default class SignupPage {
readonly emailError;
readonly usernameError;
readonly passwordError;
readonly passwordLengthError;
readonly passwordComplexityError;
readonly domainRestrictionError;
readonly noSignInMethods;
readonly createAccountWithFollowing;
@@ -54,6 +56,10 @@ export default class SignupPage {
'Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.',
);
this.passwordError = page.getByText(/Must be \d+-72 characters long\./);
this.passwordLengthError = page.getByText(/Your password must be \d+-72 characters long\.$/);
this.passwordComplexityError = page.getByText(
/Your password must be \d+-72 characters long and include both lowercase and uppercase letters, numbers, and special characters\./,
);
this.domainRestrictionError = page.getByText(
'The email you provided does not belong to an accepted domain. Please contact your administrator or sign up with a different email.',
);
@@ -26,6 +26,7 @@ import PluginManagement from '@/ui/components/system_console/sections/plugins/pl
import OpenIdConnect from '@/ui/components/system_console/sections/authentication/openid_connect';
import AdLdap from '@/ui/components/system_console/sections/authentication/ad_ldap';
import PasswordSettings from '@/ui/components/system_console/sections/authentication/password';
import MfaSettings from '@/ui/components/system_console/sections/authentication/mfa';
import PublicLinks from '@/ui/components/system_console/sections/site_configuration/public_links';
import {testConfig} from '@/test_config';
@@ -72,6 +73,7 @@ export default class SystemConsolePage {
readonly openIdConnect: OpenIdConnect;
readonly adLdap: AdLdap;
readonly passwordSettings: PasswordSettings;
readonly mfaSettings: MfaSettings;
readonly publicLinks: PublicLinks;
// Same page after logging out of the System Console
@@ -122,6 +124,7 @@ export default class SystemConsolePage {
this.openIdConnect = new OpenIdConnect(adminConsoleWrapper);
this.adLdap = new AdLdap(adminConsoleWrapper);
this.passwordSettings = new PasswordSettings(adminConsoleWrapper);
this.mfaSettings = new MfaSettings(adminConsoleWrapper);
this.publicLinks = new PublicLinks(adminConsoleWrapper);
this.loginPage = new LoginPage(page);
@@ -162,6 +165,11 @@ export default class SystemConsolePage {
await this.users.userDetail.toBeVisible();
}
async gotoUsers() {
await this.page.goto(new URL('/admin_console/user_management/users', testConfig.baseURL).href);
await this.users.toBeVisible();
}
async gotoAdLdap() {
await this.page.goto(new URL('/admin_console/authentication/ldap', testConfig.baseURL).href);
await this.adLdap.toBeVisible();
@@ -172,6 +180,11 @@ export default class SystemConsolePage {
await this.passwordSettings.toBeVisible();
}
async gotoMfaSettings() {
await this.page.goto(new URL('/admin_console/authentication/mfa', testConfig.baseURL).href);
await this.mfaSettings.toBeVisible();
}
async gotoPublicLinks() {
await this.page.goto(new URL('/admin_console/site_config/public_links', testConfig.baseURL).href);
await this.publicLinks.toBeVisible();
@@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify an email/password user can switch their sign-in method to AD/LDAP.
*
* @precondition
* A licensed server with OpenLDAP configured and authentication transfer enabled.
*/
test('switches an email account to AD/LDAP', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureOpenldap();
const ldapUser = {
username: `ldap${pw.random.id()}`,
password: 'Password1',
email: user.email,
firstname: user.first_name || 'First',
lastname: user.last_name || 'Last',
};
try {
await pw.createLdapUser(ldapUser);
// # Log in as the email user and open Switch to AD/LDAP
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToLdap();
await pw.emailToLdapPage.toBeVisible();
// # Confirm the email password and the matching LDAP credentials
await pw.emailToLdapPage.submit(user.password, ldapUser.username, ldapUser.password);
// * Verify the account now authenticates through LDAP
await pw.loginPage.expectOnLoginPage();
const switched = await adminClient.getUser(user.id);
expect(switched.auth_service).toBe('ldap');
// # Log in with the LDAP username and password
await pw.loginPage.submitCredentials(ldapUser.username, ldapUser.password);
// * Verify LDAP login succeeds
await pw.loginPage.expectNotOnLoginPage();
} finally {
await pw.deleteLdapUser(ldapUser.username).catch(() => undefined);
await adminClient.patchConfig({LdapSettings: {Enable: false}});
}
});
@@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify an email/password user can switch their sign-in method to OpenID SSO.
*
* @precondition
* A licensed server with Keycloak OpenID configured and authentication transfer enabled.
*/
test('switches an email account to OpenID SSO', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureSiteUrl();
await pw.ensureKeycloakOpenId();
const keycloakUser = {
username: `kc${user.username}`,
email: user.email,
firstName: user.first_name || 'First',
lastName: user.last_name || 'Last',
password: 'Password1',
};
await pw.createKeycloakUser(keycloakUser);
try {
// # Log in as the email user and open Switch to OpenID
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToOpenId();
await pw.emailToOAuthPage.toBeVisible();
// # Confirm the current password and complete Keycloak login
await pw.emailToOAuthPage.submit(user.password);
await pw.keycloakLoginPage.login(keycloakUser.username, keycloakUser.password);
// * Verify the account now authenticates through OpenID
await pw.loginPage.expectOnLoginPage();
const switched = await adminClient.getUser(user.id);
expect(switched.auth_service).toBe('openid');
} finally {
await adminClient.patchConfig({OpenIdSettings: {Enable: false}});
}
});
@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify an AD/LDAP user can switch their sign-in method to email and password.
*
* @precondition
* A licensed server with OpenLDAP configured and authentication transfer enabled.
*/
test('switches an AD/LDAP account to email and password', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await pw.ensureOpenldap();
const ldapUser = pw.generateLdapUser('claimldap');
const newPassword = pw.newTestPassword();
try {
await pw.createLdapUser(ldapUser);
// # Provision the user through LDAP login
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.submitCredentials(ldapUser.username, ldapUser.password);
await pw.loginPage.expectNotOnLoginPage();
const provisionedUser = await adminClient.getUserByUsername(ldapUser.username);
await adminClient.addToTeam(team.id, provisionedUser.id);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
// # Open Switch to Email and Password and set a new password
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToEmail();
await pw.ldapToEmailPage.toBeVisible();
await pw.ldapToEmailPage.submit(ldapUser.password, newPassword);
// * Verify the account now authenticates with email/password
await pw.loginPage.expectOnLoginPage();
const switched = await adminClient.getUser(provisionedUser.id);
expect(switched.auth_service).toBe('');
// # Log in with the new email password
await pw.loginPage.submitCredentials(provisionedUser.email, newPassword);
// * Verify email login succeeds
await pw.loginPage.expectNotOnLoginPage();
} finally {
await pw.deleteLdapUser(ldapUser.username).catch(() => undefined);
await adminClient.patchConfig({LdapSettings: {Enable: false}});
}
});
@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify an OpenID SSO user can switch their sign-in method to email and password.
*
* @precondition
* A licensed server with Keycloak OpenID configured and authentication transfer enabled.
*/
test('switches an OpenID account to email and password', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
await pw.ensureSiteUrl();
await pw.ensureKeycloakOpenId();
const keycloakUser = pw.generateKeycloakUser('openidclaim');
await pw.createKeycloakUser(keycloakUser);
const newPassword = pw.newTestPassword();
try {
// # Provision the user through OpenID SSO
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.openIdLoginButton.click();
await pw.keycloakLoginPage.login(keycloakUser.username, keycloakUser.password);
await pw.loginPage.expectNotOnLoginPage();
const provisionedUser = await adminClient.getUserByUsername(keycloakUser.username);
await adminClient.addToTeam(team.id, provisionedUser.id);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
// # Open Switch to Email and Password and set a new password
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToEmail();
await pw.oauthToEmailPage.toBeVisible();
await pw.oauthToEmailPage.submit(newPassword);
// * Verify the account now authenticates with email/password
await pw.loginPage.expectOnLoginPage();
const switched = await adminClient.getUser(provisionedUser.id);
expect(switched.auth_service).toBe('');
// # Log in with the new email password
await pw.loginPage.submitCredentials(provisionedUser.email, newPassword);
// * Verify email login succeeds
await pw.loginPage.expectNotOnLoginPage();
} finally {
await adminClient.patchConfig({OpenIdSettings: {Enable: false}});
}
});
@@ -0,0 +1,72 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify an email account can switch to OpenID and back to email in one session.
*
* @precondition
* A licensed server with Keycloak OpenID configured and authentication transfer enabled.
*/
test('round-trips an email account through OpenID and back to email', {tag: '@authentication'}, async ({pw}) => {
test.setTimeout(pw.duration.two_min);
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureSiteUrl();
await pw.ensureKeycloakOpenId();
const keycloakUser = {
username: `kc${user.username}`,
email: user.email,
firstName: user.first_name || 'First',
lastName: user.last_name || 'Last',
password: 'Password1',
};
await pw.createKeycloakUser(keycloakUser);
const newPassword = pw.newTestPassword();
try {
// # Log in as the email user and switch to OpenID
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
let profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToOpenId();
await pw.emailToOAuthPage.toBeVisible();
await pw.emailToOAuthPage.submit(user.password);
await pw.keycloakLoginPage.login(keycloakUser.username, keycloakUser.password);
await pw.loginPage.expectOnLoginPage();
const afterOpenId = await adminClient.getUser(user.id);
expect(afterOpenId.auth_service).toBe('openid');
// # Log in through OpenID and switch back to email
await pw.loginPage.openIdLoginButton.click();
await pw.keycloakLoginPage.loginIfFormShown(keycloakUser.username, keycloakUser.password);
await pw.loginPage.expectNotOnLoginPage();
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToEmail();
await pw.oauthToEmailPage.toBeVisible();
await pw.oauthToEmailPage.submit(newPassword);
await pw.loginPage.expectOnLoginPage();
// * Verify the account authenticates with the new email password
const afterEmail = await adminClient.getUser(user.id);
expect(afterEmail.auth_service).toBe('');
await pw.loginPage.submitCredentials(user.email, newPassword);
await pw.loginPage.expectNotOnLoginPage();
} finally {
await adminClient.patchConfig({OpenIdSettings: {Enable: false}});
}
});
@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify Sign-in Method is hidden when authentication transfer is disabled.
*
* @precondition
* A licensed server with OpenID enabled so a switch option would otherwise be shown.
*/
test('hides Sign-in Method when authentication transfer is disabled', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureSiteUrl();
await pw.ensureKeycloakOpenId();
try {
// # Disable authentication transfer while another sign-in method is available
await adminClient.patchConfig({
ServiceSettings: {ExperimentalEnableAuthenticationTransfer: false},
});
// # Log in and open Profile -> Security
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
// * Verify the Sign-in Method section is not offered
await expect(profileModal.securityTab.signInHeading).toBeHidden();
await expect(profileModal.securityTab.editSignInMethod).toBeHidden();
} finally {
await adminClient.patchConfig({
OpenIdSettings: {Enable: false},
ServiceSettings: {ExperimentalEnableAuthenticationTransfer: true},
});
}
});
@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify switching from email to OpenID is rejected when the current password is wrong.
*
* @precondition
* A licensed server with Keycloak OpenID configured and authentication transfer enabled.
*/
test('rejects email-to-OpenID when the current password is wrong', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureSiteUrl();
await pw.ensureKeycloakOpenId();
try {
// # Log in as the email user and open Switch to OpenID
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToOpenId();
await pw.emailToOAuthPage.toBeVisible();
// # Submit the wrong current password
await pw.emailToOAuthPage.submit('WrongPassword1');
// * Verify the switch is rejected and the auth method is unchanged
await expect(pw.emailToOAuthPage.invalidPasswordError).toBeVisible();
const unchanged = await adminClient.getUser(user.id);
expect(unchanged.auth_service).toBe('');
} finally {
await adminClient.patchConfig({OpenIdSettings: {Enable: false}});
}
});
/**
* @objective Verify switching from email to AD/LDAP is rejected when the current password is wrong.
*
* @precondition
* A licensed server with OpenLDAP configured and authentication transfer enabled.
*/
test('rejects email-to-LDAP when the current password is wrong', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
await pw.ensureOpenldap();
const ldapUser = pw.generateLdapUser('claimbad');
try {
await pw.createLdapUser(ldapUser);
// # Log in as the email user and open Switch to AD/LDAP
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
await pw.channelsPage.goto(team.name);
await pw.channelsPage.toBeVisible();
const profileModal = await pw.channelsPage.openProfileModal();
await profileModal.openSecurityTab();
await profileModal.securityTab.clickSwitchToLdap();
await pw.emailToLdapPage.toBeVisible();
// # Submit the wrong email password with valid LDAP credentials
await pw.emailToLdapPage.submit('WrongPassword1', ldapUser.username, ldapUser.password);
// * Verify the switch is rejected and the auth method is unchanged
await expect(pw.emailToLdapPage.invalidPasswordError).toBeVisible();
const unchanged = await adminClient.getUser(user.id);
expect(unchanged.auth_service).toBe('');
} finally {
await pw.deleteLdapUser(ldapUser.username).catch(() => undefined);
await adminClient.patchConfig({LdapSettings: {Enable: false}});
}
});
@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test, testConfig} from '@mattermost/playwright-lib';
/**
* @objective Verify an unenrolled user is sent to MFA setup when MFA is enabled and enforced.
*
* @precondition
* A licensed server with MFA available.
*/
test('MM-T1778 sends an unenrolled user to MFA setup when MFA is enforced', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Cleanup needs mmctl --local to disable enforced MFA for an unenrolled admin session.
test.skip(!testConfig.mattermostContainerId, 'Skipping test - requires PW_USE_TESTCONTAINERS=true');
const {adminUser, adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
try {
// # Enable and enforce MFA in the System Console
await systemConsolePage.gotoMfaSettings();
await systemConsolePage.mfaSettings.enableTrue.check();
await systemConsolePage.mfaSettings.enforceTrue.check();
await systemConsolePage.mfaSettings.save();
// # Log in as a user who has not enrolled in MFA
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.submitCredentials(user.username, user.password);
// * Verify the MFA setup page is shown
await pw.mfaSetupPage.toBeVisible();
} finally {
await pw.disableMfa(adminClient);
}
});
/**
* @objective Verify a system admin can remove MFA from a user who has enrolled.
*
* @precondition
* A licensed server with MFA available.
*/
test('MM-T1781 lets an admin remove MFA from an enrolled user', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
try {
// # Enable MFA and enroll the user
await adminClient.patchConfig({
ServiceSettings: {EnableMultifactorAuthentication: true, EnforceMultifactorAuthentication: false},
});
await pw.enableUserMfa(adminClient, user.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the user's action menu and remove MFA
await systemConsolePage.gotoUsers();
const actions = await systemConsolePage.users.openUserActions(user.username);
await actions.clickRemoveMfa();
// # Log in as that user after MFA is removed
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
// * Verify login succeeds without the MFA setup page
await pw.mfaSetupPage.toBeHidden();
await pw.selectTeamPage.toBeVisible();
} finally {
await pw.disableMfa(adminClient);
}
});
/**
* @objective Verify Remove MFA is hidden for a user who has not enrolled in MFA.
*
* @precondition
* A licensed server with MFA available.
*/
test('MM-T1782 hides Remove MFA for a user who has not enrolled', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
try {
// # Enable MFA without enrolling the user
await adminClient.patchConfig({
ServiceSettings: {EnableMultifactorAuthentication: true, EnforceMultifactorAuthentication: false},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the user's action menu
await systemConsolePage.gotoUsers();
const actions = await systemConsolePage.users.openUserActions(user.username);
// * Verify Remove MFA is not offered
await actions.expectRemoveMfaHidden();
} finally {
await pw.disableMfa(adminClient);
}
});
@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify Multi-factor Authentication is hidden in Profile when MFA is disabled.
*
* @precondition
* A licensed server with MFA available.
*/
test(
'MM-T1777 hides MFA in Profile when multifactor authentication is disabled',
{tag: '@authentication'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {user, adminClient, team} = await pw.initSetup();
try {
// # Disable MFA and open Profile -> Security
await pw.disableMfa(adminClient);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
await profileModal.openSecurityTab();
// * Verify the MFA section is not shown
await expect(profileModal.securityTab.mfaHeading).toBeHidden();
} finally {
await pw.disableMfa(adminClient);
}
},
);
/**
* @objective Verify Multi-factor Authentication appears in Profile when MFA is enabled.
*
* @precondition
* A licensed server with MFA available.
*/
test(
'MM-T1779 shows MFA in Profile when multifactor authentication is enabled',
{tag: '@authentication'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {user, adminClient, team} = await pw.initSetup();
try {
// # Enable MFA and open Profile -> Security
await adminClient.patchConfig({
ServiceSettings: {EnableMultifactorAuthentication: true, EnforceMultifactorAuthentication: false},
});
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const profileModal = await channelsPage.openProfileModal();
await profileModal.openSecurityTab();
// * Verify the MFA section is shown
await expect(profileModal.securityTab.mfaHeading).toBeVisible();
} finally {
await pw.disableMfa(adminClient);
}
},
);
/**
* @objective Verify a user can log in without an MFA prompt when MFA is enabled but not enforced.
*
* @precondition
* A licensed server with MFA available.
*/
test('MM-T1780 logs in without an MFA prompt when MFA is not enforced', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
try {
// # Enable MFA without enforcing it
await adminClient.patchConfig({
ServiceSettings: {EnableMultifactorAuthentication: true, EnforceMultifactorAuthentication: false},
});
// # Log in as a user who has not enrolled in MFA
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.login(user);
// * Verify login succeeds without the MFA setup page
await pw.mfaSetupPage.toBeHidden();
await pw.selectTeamPage.toBeVisible();
} finally {
await pw.disableMfa(adminClient);
}
});
@@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify Minimum password length rejects values outside 5-72 (or 14-72 on FIPS).
*/
test('MM-T1771 rejects a minimum password length outside the allowed range', {tag: '@authentication'}, async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const originalMinimumLength = (await adminClient.getConfig()).PasswordSettings.MinimumLength;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
try {
await systemConsolePage.gotoPasswordSettings();
// # Save a length above the maximum
await systemConsolePage.passwordSettings.minimumLength.fill('88');
await systemConsolePage.passwordSettings.save();
// * Verify the range error is shown
await expect(systemConsolePage.passwordSettings.lengthError).toBeVisible();
// # Save a length below the minimum
await systemConsolePage.passwordSettings.minimumLength.fill('3');
await systemConsolePage.passwordSettings.save();
// * Verify the range error is shown
await expect(systemConsolePage.passwordSettings.lengthError).toBeVisible();
} finally {
await adminClient.patchConfig({PasswordSettings: {MinimumLength: originalMinimumLength}});
}
});
/**
* @objective Verify changing minimum password length updates signup help text and validation.
*/
test('MM-T1772 applies a new minimum password length on signup', {tag: '@authentication'}, async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const originalMinimumLength = (await adminClient.getConfig()).PasswordSettings.MinimumLength;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
try {
await systemConsolePage.gotoPasswordSettings();
// # Set the minimum length to 15
await systemConsolePage.passwordSettings.minimumLength.fill('15');
await systemConsolePage.passwordSettings.save();
await expect(systemConsolePage.passwordSettings.sampleHelpText).toHaveText(
'Your password must be 15-72 characters long.',
);
await pw.hasSeenLandingPage();
await pw.signupPage.goto();
await pw.signupPage.toBeVisible();
await pw.signupPage.emailInput.fill(`test-${pw.random.id()}@example.com`);
await pw.signupPage.usernameInput.fill(`user${pw.random.id()}`);
await pw.signupPage.termsAndPrivacyCheckBox.check();
// # Submit a password that is too short
await pw.signupPage.passwordInput.fill('less');
await pw.signupPage.createAccountButton.click();
await expect(pw.signupPage.passwordLengthError).toBeVisible();
// # Submit a password that meets the new minimum
await pw.signupPage.passwordInput.fill('GreaterThan15Chr!');
await pw.signupPage.termsAndPrivacyCheckBox.check();
await pw.signupPage.createAccountButton.click();
// * Verify signup succeeds
await pw.selectTeamPage.toBeVisible();
} finally {
await adminClient.patchConfig({PasswordSettings: {MinimumLength: originalMinimumLength}});
}
});
/**
* @objective Verify clearing Minimum password length resets it to the server default.
*/
test(
'MM-T1773 resets Minimum password length to the default after clearing it',
{tag: '@authentication'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const originalMinimumLength = (await adminClient.getConfig()).PasswordSettings.MinimumLength;
const customLength = originalMinimumLength === 20 ? 21 : 20;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
try {
await systemConsolePage.gotoPasswordSettings();
// # Save a custom minimum length
await systemConsolePage.passwordSettings.minimumLength.fill(String(customLength));
await systemConsolePage.passwordSettings.save();
await systemConsolePage.passwordSettings.reload();
await expect(systemConsolePage.passwordSettings.minimumLength).toHaveValue(String(customLength));
// # Clear the field and save
await systemConsolePage.passwordSettings.minimumLength.clear();
await systemConsolePage.passwordSettings.save();
await systemConsolePage.passwordSettings.reload();
// * Verify the saved value resets to the compiled default, not the on-prem override
const resetLength = (await adminClient.getConfig()).PasswordSettings.MinimumLength;
expect(resetLength).toBe(5);
await expect(systemConsolePage.passwordSettings.minimumLength).toHaveValue(String(resetLength));
} finally {
await adminClient.patchConfig({PasswordSettings: {MinimumLength: originalMinimumLength}});
}
},
);
@@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify all password-complexity requirements are enforced on signup.
*/
test(
'MM-T1774 rejects signup passwords that miss a required character class',
{tag: '@authentication'},
async ({pw}) => {
const {adminClient} = await pw.initSetup();
try {
await adminClient.patchConfig({
PasswordSettings: {Lowercase: true, Number: true, Uppercase: true, Symbol: true},
});
await pw.hasSeenLandingPage();
await pw.signupPage.goto();
await pw.signupPage.toBeVisible();
await pw.signupPage.emailInput.fill(`test-${pw.random.id()}@example.com`);
await pw.signupPage.usernameInput.fill(`user${pw.random.id()}`);
await pw.signupPage.termsAndPrivacyCheckBox.check();
for (const password of ['NOLOWERCASE12345!', 'nouppercase12345!', 'NoNumberHere!!!', 'NoSymbol1234567']) {
// # Submit a password missing a required character class
await pw.signupPage.passwordInput.fill(password);
await pw.signupPage.createAccountButton.click();
// * Verify the complexity error is shown
await expect(pw.signupPage.passwordComplexityError).toBeVisible();
}
} finally {
await adminClient.patchConfig({
PasswordSettings: {Lowercase: false, Number: false, Uppercase: false, Symbol: false},
});
}
},
);
@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
/**
* @objective Verify a basic-auth user's System Console profile shows Authentication Method Email.
*
* @precondition
* A licensed server with the User Detail page available.
*/
test(
'MM-T953_1 shows Email as the authentication method for a basic-auth user',
{tag: '@authentication'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the user's System Console profile
await systemConsolePage.gotoUser(user.id);
// * Verify the authentication method is Email
await systemConsolePage.users.userDetail.userCard.expectAuthenticationMethod('Email');
},
);
/**
* @objective Verify an MFA-enrolled basic-auth user's System Console profile shows Email, MFA.
*
* @precondition
* A licensed server with MFA enabled.
*/
test('MM-T953_2 shows Email, MFA when multifactor authentication is active', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const user = await pw.createNewUserProfile(adminClient);
try {
// # Enable MFA and enroll the user
await adminClient.patchConfig({ServiceSettings: {EnableMultifactorAuthentication: true}});
await pw.enableUserMfa(adminClient, user.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the user's System Console profile
await systemConsolePage.gotoUser(user.id);
// * Verify the authentication method includes MFA
await systemConsolePage.users.userDetail.userCard.expectAuthenticationMethod('Email, MFA');
} finally {
await adminClient.patchConfig({ServiceSettings: {EnableMultifactorAuthentication: false}});
}
});
@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
/**
* @objective Verify an LDAP-provisioned user's System Console profile shows Authentication Method LDAP.
*
* @precondition
* An LDAP directory reachable at the configured LdapSettings.LdapServer/LdapPort.
*/
test(
'MM-T953_3 shows LDAP as the authentication method for a directory user',
{tag: '@authentication'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await pw.ensureOpenldap();
const ldapUser = pw.generateLdapUser();
await pw.createLdapUser(ldapUser);
// # Provision the directory user through the login form
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
await pw.loginPage.submitCredentials(ldapUser.username, ldapUser.password);
await pw.loginPage.expectNotOnLoginPage();
const provisionedUser = await adminClient.getUserByUsername(ldapUser.username);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the LDAP user's System Console profile
await systemConsolePage.gotoUser(provisionedUser.id);
// * Verify the authentication method is LDAP
await systemConsolePage.users.userDetail.userCard.expectAuthenticationMethod('LDAP');
},
);
/**
* @objective Verify a SAML user's System Console profile shows Authentication Method SAML.
*
* @precondition
* A licensed server with the User Detail page available.
*/
test('MM-T953_4 shows SAML as the authentication method for a SAML user', {tag: '@authentication'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient, user} = await pw.initSetup();
await adminClient.updateUserAuth(user.id, {auth_service: 'saml', auth_data: user.email});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Open the SAML user's System Console profile
await systemConsolePage.gotoUser(user.id);
// * Verify the authentication method is SAML
await systemConsolePage.users.userDetail.userCard.expectAuthenticationMethod('SAML');
});