diff --git a/.github/workflows/e2e-tests-override-status.yml b/.github/workflows/e2e-tests-override-status.yml index 278832cb939..8a9567d3d0c 100644 --- a/.github/workflows/e2e-tests-override-status.yml +++ b/.github/workflows/e2e-tests-override-status.yml @@ -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" diff --git a/.github/workflows/e2e-tests-playwright.yml b/.github/workflows/e2e-tests-playwright.yml index 83e27d81ade..0c0fabdebb0 100644 --- a/.github/workflows/e2e-tests-playwright.yml +++ b/.github/workflows/e2e-tests-playwright.yml @@ -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 }} diff --git a/.github/workflows/e2e-tests-verified-label.yml b/.github/workflows/e2e-tests-verified-label.yml index 7ae49e97573..74cdc01bdad 100644 --- a/.github/workflows/e2e-tests-verified-label.yml +++ b/.github/workflows/e2e-tests-verified-label.yml @@ -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" diff --git a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts deleted file mode 100644 index a830a8b9b14..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.ts deleted file mode 100644 index d468817e834..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.ts +++ /dev/null @@ -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, - }, - }); - }); -}); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js deleted file mode 100644 index a0bf2b6523e..00000000000 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js +++ /dev/null @@ -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); - }); -} diff --git a/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/disallow_username_profile_override_spec.js b/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/disallow_username_profile_override_spec.js index 01ac54589b2..37d49e2eacd 100644 --- a/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/disallow_username_profile_override_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/disallow_username_profile_override_spec.js @@ -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(); diff --git a/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/helpers.js b/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/helpers.js index fd384adda17..dbd5b293720 100644 --- a/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/integrations/incoming_webhook/helpers.js @@ -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}); + } + }); } diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index e3f2ea62368..67cce29005e 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -95,6 +95,8 @@ const onPremServerConfig = (): Partial => { EnableOnboardingFlow: false, EnableSecurityFixAlert: false, GiphySdkKey: 's0glxvzVg9azvPipKxcPLpXV0q1x1fVP', + EnableMultifactorAuthentication: false, + EnforceMultifactorAuthentication: false, }, TeamSettings: { EnableOpenServer: true, diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index 3694803a7ca..9e8fd506d4d 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -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'; diff --git a/e2e-tests/playwright/lib/src/server/mfa.ts b/e2e-tests/playwright/lib/src/server/mfa.ts index abb292d3af2..29c47a306ae 100644 --- a/e2e-tests/playwright/lib/src/server/mfa.ts +++ b/e2e-tests/playwright/lib/src/server/mfa.ts @@ -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 { + 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), + ); + } +} diff --git a/e2e-tests/playwright/lib/src/test_fixture.ts b/e2e-tests/playwright/lib/src/test_fixture.ts index 28a7662722f..e369aa41361 100644 --- a/e2e-tests/playwright/lib/src/test_fixture.ts +++ b/e2e-tests/playwright/lib/src/test_fixture.ts @@ -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); diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts index 3bdb2a9550b..63f28c225ca 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/profile_modal.ts @@ -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(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/mfa.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/mfa.ts new file mode 100644 index 00000000000..978c0de1a90 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/mfa.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/password.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/password.ts index 27af83e443b..9ac0372b903 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/password.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/authentication/password.ts @@ -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(); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts index d33bcca4453..e66a98ed274 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/user_detail.ts @@ -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. */ diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/index.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/index.ts index 20a7b84980c..85ef44f4aa1 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/index.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/index.ts @@ -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 */ diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/user_action_menu.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/user_action_menu.ts index ae2452c3f8e..6a30e8fc90a 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/user_action_menu.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/user_action_menu.ts @@ -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); + } } diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts index b9411cae0a2..f95d61b39c1 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/users_table.ts @@ -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 { + 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 */ diff --git a/e2e-tests/playwright/lib/src/ui/pages/email_to_ldap.ts b/e2e-tests/playwright/lib/src/ui/pages/email_to_ldap.ts new file mode 100644 index 00000000000..e9c5c8782d6 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/email_to_ldap.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/email_to_oauth.ts b/e2e-tests/playwright/lib/src/ui/pages/email_to_oauth.ts new file mode 100644 index 00000000000..6f9b5fe9d46 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/email_to_oauth.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/index.ts b/e2e-tests/playwright/lib/src/ui/pages/index.ts index 6a53af1e781..807bc32f8eb 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/index.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/index.ts @@ -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, }; diff --git a/e2e-tests/playwright/lib/src/ui/pages/ldap_to_email.ts b/e2e-tests/playwright/lib/src/ui/pages/ldap_to_email.ts new file mode 100644 index 00000000000..6cb9f9b7bf8 --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/ldap_to_email.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/mfa_setup.ts b/e2e-tests/playwright/lib/src/ui/pages/mfa_setup.ts new file mode 100644 index 00000000000..9fd58a76b9f --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/mfa_setup.ts @@ -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; + + 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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/oauth_to_email.ts b/e2e-tests/playwright/lib/src/ui/pages/oauth_to_email.ts new file mode 100644 index 00000000000..d2c9f8bd42d --- /dev/null +++ b/e2e-tests/playwright/lib/src/ui/pages/oauth_to_email.ts @@ -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(); + } +} diff --git a/e2e-tests/playwright/lib/src/ui/pages/signup.ts b/e2e-tests/playwright/lib/src/ui/pages/signup.ts index 6fd1f3cb569..e7eef1d0297 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/signup.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/signup.ts @@ -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.', ); diff --git a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts index dcdb49f10d2..e9b83ece903 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/system_console.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/system_console.ts @@ -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(); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/email_to_ldap.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/email_to_ldap.spec.ts new file mode 100644 index 00000000000..c6a5fe61116 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/email_to_ldap.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/email_to_openid.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/email_to_openid.spec.ts new file mode 100644 index 00000000000..ab8db6815fd --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/email_to_openid.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/ldap_to_email.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/ldap_to_email.spec.ts new file mode 100644 index 00000000000..b46da45c65c --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/ldap_to_email.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/openid_to_email.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/openid_to_email.spec.ts new file mode 100644 index 00000000000..8519574794a --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/openid_to_email.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/roundtrip.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/roundtrip.spec.ts new file mode 100644 index 00000000000..09d20331b02 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/roundtrip.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/transfer_disabled.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/transfer_disabled.spec.ts new file mode 100644 index 00000000000..fe06b1d2573 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/transfer_disabled.spec.ts @@ -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}, + }); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/claim/wrong_password.spec.ts b/e2e-tests/playwright/specs/functional/auth/claim/wrong_password.spec.ts new file mode 100644 index 00000000000..4189f77a377 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/claim/wrong_password.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/mfa_enforced.spec.ts b/e2e-tests/playwright/specs/functional/auth/mfa_enforced.spec.ts new file mode 100644 index 00000000000..8148db510a5 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/mfa_enforced.spec.ts @@ -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); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/mfa_visibility.spec.ts b/e2e-tests/playwright/specs/functional/auth/mfa_visibility.spec.ts new file mode 100644 index 00000000000..b89f71032e4 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/mfa_visibility.spec.ts @@ -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); + } +}); diff --git a/e2e-tests/playwright/specs/functional/auth/password_length.spec.ts b/e2e-tests/playwright/specs/functional/auth/password_length.spec.ts new file mode 100644 index 00000000000..95ef07456db --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/password_length.spec.ts @@ -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}}); + } + }, +); diff --git a/e2e-tests/playwright/specs/functional/auth/password_requirements.spec.ts b/e2e-tests/playwright/specs/functional/auth/password_requirements.spec.ts new file mode 100644 index 00000000000..5171a706439 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/auth/password_requirements.spec.ts @@ -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}, + }); + } + }, +); diff --git a/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method.spec.ts b/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method.spec.ts new file mode 100644 index 00000000000..658c1191efb --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method.spec.ts @@ -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}}); + } +}); diff --git a/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method_sso.spec.ts b/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method_sso.spec.ts new file mode 100644 index 00000000000..5d087a7ad74 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/authentication/authentication_method_sso.spec.ts @@ -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'); +});