snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {ensureUserAttributes} from '../support';
/**
* Check whether the PermissionPolicies feature flag is enabled at runtime.
* Returns true when the server exposes the permission_policies route.
*/
async function isPermissionPoliciesEnabled(adminClient: any): Promise<boolean> {
const config = await adminClient.getConfig();
return config.FeatureFlags?.PermissionPolicies === true || config.FeatureFlags?.PermissionPolicies === 'true';
}
/**
* ABAC Basic Operations - Enable/Disable
* Tests basic ABAC system-wide enable/disable functionality
*/
test('MM-T5782 System admin can enable or disable system-wide ABAC', async ({pw}) => {
test.setTimeout(120000);
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// # Set up admin user and login
const {adminUser, adminClient} = await pw.initSetup();
// # Ensure user attributes exist BEFORE logging in
await ensureUserAttributes(adminClient);
// # Reset ABAC to disabled via API before testing the UI toggle.
// Parallel tests may have already enabled it, which would leave the radio
// pre-selected and the Save button permanently disabled (no dirty state).
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
},
} as any);
// # Now login - this ensures the UI will have the attributes loaded
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Navigate to ABAC page
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.systemAttributes.attributeBasedAccess.click();
// Re-apply the ABAC=false reset right before UI interaction: a concurrent
// initSetup() on another shard may have re-enabled ABAC between the initial
// patchConfig call above and here. If it's already enabled when we click
// enableRadio the radio is a no-op and Save stays disabled.
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
},
} as any);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === false;
});
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify we're on the correct page
const abacSection = systemConsolePage.page.getByTestId('sysconsole_section_AttributeBasedAccessControl');
await expect(abacSection).toBeVisible();
const enableRadio = systemConsolePage.page.locator(
'#AccessControlSettings\\.EnableAttributeBasedAccessControltrue',
);
const disableRadio = systemConsolePage.page.locator(
'#AccessControlSettings\\.EnableAttributeBasedAccessControlfalse',
);
const saveButton = systemConsolePage.page.getByRole('button', {name: 'Save'});
// # Test enable ABAC
await enableRadio.click();
await expect(enableRadio).toBeChecked();
await saveButton.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the Attribute-Based Access page only has the toggle — no policy management here
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
// * Verify Membership Policies page shows "Add policy" when ABAC is enabled
// Re-apply enable guard: a concurrent shard may have disabled ABAC between the
// save above and this navigation, which would cause a redirect to the license page.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
// * Verify Permission Policies page shows "Add policy" when ABAC is enabled
// This section is only testable when the PermissionPolicies feature flag is on.
if (await isPermissionPoliciesEnabled(adminClient)) {
await systemConsolePage.page.goto('/admin_console/system_attributes/permission_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
}
// # Navigate back to Attribute-Based Access to test disable
await systemConsolePage.page.goto('/admin_console/system_attributes/attribute_based_access_control');
await systemConsolePage.page.waitForLoadState('networkidle');
// # Test disable ABAC
await disableRadio.click();
await expect(disableRadio).toBeChecked();
await saveButton.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify Membership Policies no longer shows "Add policy" when ABAC is disabled
await systemConsolePage.page.goto('/admin_console/system_attributes/membership_policies');
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).not.toBeVisible();
// # Re-enable ABAC for subsequent tests via API — avoids the race where a concurrent
// shard's initSetup() re-enables ABAC between the disable save and here, leaving the
// radio already checked so the UI save button stays disabled.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
});

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, expectFilesRedacted, getRandomId, testConfig} from '@mattermost/playwright-lib';
import {createPermissionPolicy, deletePermissionPolicyByName, navigateToPermissionPoliciesPage} from '../support';
import {ensureABACEnabled, setupUserAndChannel, waitForPolicy} from './helpers';
// Each surface renders attachments through a different React tree, so one that forgets to
// honour RedactedFileCount is invisible to server-side coverage.
//
// Denies via a comparison no test user satisfies: policy creation rejects a bare `false`
// literal, and these users have no Department value.
const DENY_ALL_CEL = "user.attributes.Department == 'no-such-value-deny-all'";
const THREAD_ROOT_MESSAGE = 'thread root with file';
test.describe('ABAC file permissions - redaction across surfaces', () => {
let lastPolicyName = '';
let savedAdminClient: any = null;
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
});
test.afterEach(async () => {
if (lastPolicyName && savedAdminClient) {
await deletePermissionPolicyByName(savedAdminClient, lastPolicyName);
lastPolicyName = '';
savedAdminClient = null;
}
});
/**
* @objective Verify a Burn-on-Read message's attachment is redacted after the recipient
* reveals it, so the reveal cannot be used to sidestep the download policy.
*/
test(
'MM-T5827 redacts the attachment of a revealed Burn-on-Read message',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin sends a Burn-on-Read message carrying a file
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.toggleBurnOnRead();
await adminChannelsPage.centerView.postCreate.postMessage('BOR with file', ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `BOR Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the channel and reveals the concealed message
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const post = await channelsPage.centerView.getLastPost();
await post.concealedPlaceholder.toBeVisible();
await post.concealedPlaceholder.clickToReveal();
await post.concealedPlaceholder.waitForReveal(pw.duration.ten_sec);
// * Verify the revealed message's file is redacted
await post.toHaveFilesRedacted();
},
);
/**
* @objective Verify the file inside an embedded permalink preview is redacted, not just the
* file on the original post — the preview is built from a separately fetched post.
*/
test(
'MM-T5828 redacts the attachment inside an embedded permalink preview',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName, channelId} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file, then posts a permalink to it in the same channel
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage('original with file', ['sample_text_file.txt']);
const originalPosts = await adminClient.getPosts(channelId, 0, 1);
const originalPostId = originalPosts.order[0];
// Use testConfig.internalBaseURL rather than the admin client's host-mapped route:
// the server must recognize this URL as its own SiteURL to embed it through an
// internal permalink lookup, instead of fetching it back over HTTP as a link
// (which fails under testcontainers).
const permalinkUrl = `${testConfig.internalBaseURL}/${team.name}/pl/${originalPostId}`;
await adminChannelsPage.centerView.postCreate.postMessage(permalinkUrl);
// Both posts are addressed by ID: the embed repeats the original's text, so filtering
// by text cannot tell the two of them apart.
const permalinkPosts = await adminClient.getPosts(channelId, 0, 1);
const permalinkPostId = permalinkPosts.order[0];
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Permalink Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the channel
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the standalone original post is redacted
const originalPost = await channelsPage.centerView.getPostById(originalPostId);
await originalPost.toHaveFilesRedacted();
// * Verify the embedded preview is redacted too, and not merely file-less
const permalinkPost = await channelsPage.centerView.getPostById(permalinkPostId);
await expect(permalinkPost.postPreview).toBeVisible();
await permalinkPost.toHaveFilesRedacted(permalinkPost.postPreview);
},
);
/**
* @objective Verify a denied user sees the redacted placeholder when the same post is opened
* in a thread in the right-hand sidebar, which renders through its own post list.
*/
test(
'redacts the attachment when the post is opened in a thread',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(THREAD_ROOT_MESSAGE, ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Thread Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user opens the post in a thread
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const centerPost = await channelsPage.centerView.getPostByText(THREAD_ROOT_MESSAGE);
await centerPost.toHaveFilesRedacted();
await centerPost.openAThread();
await channelsPage.sidebarRight.toBeVisible();
// * Verify the thread's copy of the post is redacted as well
const rhsPost = await channelsPage.sidebarRight.getFirstPost();
await rhsPost.toHaveFilesRedacted();
},
);
/**
* @objective Verify a denied user sees the redacted placeholder in search results, which
* render posts through the search panel rather than a channel post list.
*/
test('redacts the attachment in search results', {tag: '@abac_file_permissions'}, async ({pw}) => {
test.setTimeout(pw.duration.four_min);
const {adminUser, adminClient, team} = await pw.initSetup();
savedAdminClient = adminClient;
const {testUser: deniedUser, channelName} = await setupUserAndChannel(adminClient, team);
// # Admin posts a file with a searchable, unique term
const searchTerm = `redactme${getRandomId()}`;
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(searchTerm, ['sample_text_file.txt']);
// # Enable ABAC and deny download for everyone
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
lastPolicyName = `Search Download Deny ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: lastPolicyName,
celExpression: DENY_ALL_CEL,
permissions: ['Download Files'],
adminClient,
});
await waitForPolicy(adminClient, lastPolicyName);
await ensureABACEnabled(adminClient);
// # The denied user searches for the post
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
await channelsPage.searchFor(searchTerm);
// * Verify the search result shows the redacted placeholder, not the file
const result = channelsPage.searchResultsPanel.getResultByText(searchTerm).first();
await expect(result).toBeVisible();
await expectFilesRedacted(result);
});
});

View File

@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, test, enableABAC, getAdminClient, getRandomId, TestBrowser} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValuesForUser,
} from '../../../channels/custom_profile_attributes/helpers';
import {
createPermissionPolicy,
createPrivateChannelForABAC,
createUserForABAC,
deletePermissionPolicyByName,
enableUserManagedAttributes,
navigateToPermissionPoliciesPage,
updatePermissionPolicyExpression,
} from '../support';
import {ensureABACEnabled, waitForPolicy} from './helpers';
// Each test adds a member, and each addition appends a join system message after the fixture
// post, so the post under test is never the last one.
const FIXTURE_MESSAGE = 'File for live tests';
const GRANTING_EXPRESSION = 'user.attributes.Department == "Engineering"';
const REVOKING_EXPRESSION = 'user.attributes.Department == "Finance"';
// Access changes reaching an already-open page. Two triggers, two websocket events: an
// attribute change sends custom_profile_attributes_values_updated, a policy edit sends
// permission_policy_updated. Each test creates its own user so a revoke cannot leak.
test.describe('ABAC file permissions - live updates', () => {
let sharedAdminClient: Client4;
let policyName = '';
let team: any;
let channelName = '';
let channelId = '';
let attributeFieldsMap: Record<string, any>;
let licensed = true;
let sharedBrowser: TestBrowser | null = null;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found — cannot proceed with ABAC file-access tests');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const departmentAttr: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttr);
const suffix = getRandomId();
team = await adminClient.createTeam({
name: `abac-live-${suffix}`,
display_name: `ABAC Live ${suffix}`,
type: 'O',
} as any);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
channelName = channel.name;
channelId = channel.id;
sharedBrowser = new TestBrowser(browser);
// ABAC off while the admin posts the fixture file, so a policy left behind by an
// earlier spec cannot block the upload this whole describe depends on.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
} as any);
const {channelsPage: adminChannelsPage} = await sharedBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(FIXTURE_MESSAGE, ['sample_text_file.txt']);
const {systemConsolePage} = await sharedBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
policyName = `Dept Live Policy ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: GRANTING_EXPRESSION,
permissions: ['Upload Files', 'Download Files'],
adminClient,
});
await waitForPolicy(adminClient, policyName);
});
test.beforeEach(async () => {
test.skip(!licensed, 'No ABAC license');
await ensureABACEnabled(sharedAdminClient);
});
test.afterAll(async () => {
if (policyName && sharedAdminClient) {
await deletePermissionPolicyByName(sharedAdminClient, policyName).catch(() => {});
}
await sharedBrowser?.close().catch(() => {});
});
/**
* Create a user who currently satisfies the policy and drop them into the fixture channel.
* Each test gets its own so that revoking access is not visible to the others.
*/
async function createAllowedMember() {
const user = await createUserForABAC(sharedAdminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
await sharedAdminClient.addToTeam(team.id, user.id);
await sharedAdminClient.addToChannel(user.id, channelId);
return user;
}
/** Move a user out of the department the policy grants access to. */
async function revokeByAttribute(userId: string) {
await setupCustomProfileAttributeValuesForUser(
sharedAdminClient,
[{name: 'Department', type: 'text', value: 'Sales'}],
attributeFieldsMap,
userId,
);
}
/**
* @objective Verify the upload control flips from enabled to disabled on an open page when
* the user's attributes stop satisfying the policy, without a reload.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'disables the upload control on an open page when the user attribute changes',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify upload starts out available
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Admin moves the user out of the granted department
await revokeByAttribute(user.id);
// * Verify the control becomes unavailable without the page being reloaded
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify file attachments already rendered on screen are replaced by the redacted
* placeholder when the user's attributes stop satisfying the policy, without a reload.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'redacts files already on screen when the user attribute changes',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file starts out visible
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
// # Admin moves the user out of the granted department
await revokeByAttribute(user.id);
// * Verify the already-loaded post is re-rendered as redacted, with no reload
await post.toHaveFilesRedacted();
},
);
/**
* @objective Verify a revoked user stays revoked across a reload — the post-list ETag epoch
* moved, so the refetch cannot be served from a cache populated while access was allowed.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'keeps files redacted and upload blocked after a page reload',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who currently matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage, page} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify access starts out granted
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
// # Admin revokes access, then the user reloads
await revokeByAttribute(user.id);
await page.reload();
await channelsPage.toBeVisible();
// * Verify the fresh fetch is sanitized rather than served from the stale cache
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
const reloadedPost = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await reloadedPost.toHaveFilesRedacted();
},
);
/**
* @objective Verify editing the policy itself — rather than the user's attributes — reaches
* an already-open page and withdraws the upload affordance.
*
* Distinct path from the tests above: a policy edit is broadcast system-wide as
* permission_policy_updated, with no per-user event to key off.
*
* Revokes by narrowing the rule's expression, not by removing the action. An action that no
* rule grants is implicitly allowed, so dropping it from the policy would widen access.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'disables the upload control on an open page when the policy stops matching the user',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # A user who matches the policy opens the channel
const user = await createAllowedMember();
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify upload starts out available
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Restore the fixture even if the assertion below fails, so the shared policy does
// not leak a revoking expression into the tests that run after this one.
try {
// # Admin repoints the policy at a department the user is not in, leaving the
// user's own attributes untouched
await updatePermissionPolicyExpression(sharedAdminClient, policyName, REVOKING_EXPRESSION);
// * Verify the upload affordance is withdrawn without the page being reloaded
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
} finally {
await updatePermissionPolicyExpression(sharedAdminClient, policyName, GRANTING_EXPRESSION);
}
},
);
});

View File

@@ -0,0 +1,365 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, test, enableABAC, getAdminClient, getRandomId, TestBrowser} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
cleanupAllPermissionPolicies,
createPermissionPolicy,
createPrivateChannelForABAC,
createUserForABAC,
deletePermissionPolicyByName,
enableUserManagedAttributes,
navigateToPermissionPoliciesPage,
} from '../support';
import {ensureABACEnabled, setupUserAndChannel, waitForPolicy} from './helpers';
// Targeted by text, not position: joining a channel appends a system message and later tests
// append their own posts, so the fixture post is not the last one.
const FIXTURE_MESSAGE = 'File for render tests';
const NO_POLICY_MESSAGE = 'File attachment post';
// Load-time assertions only. Changes to an already-open page live in
// file_permissions_live_updates.spec.ts; non-center-channel surfaces in
// file_permissions_edge_cases.spec.ts.
test.describe('ABAC file permissions - attribute-based policy', () => {
let sharedAdminClient: Client4;
let policyName = '';
let team: any;
let channelName = '';
let allowedUser: any;
let deniedUser: any;
let licensed = true;
let sharedBrowser: TestBrowser | null = null;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found — cannot proceed with ABAC file-access tests');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const departmentAttr: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttr);
allowedUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
deniedUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
const suffix = getRandomId();
team = await adminClient.createTeam({
name: `abac-render-${suffix}`,
display_name: `ABAC Render ${suffix}`,
type: 'O',
} as any);
await adminClient.addToTeam(team.id, allowedUser.id);
await adminClient.addToTeam(team.id, deniedUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
channelName = channel.name;
await adminClient.addToChannel(allowedUser.id, channel.id);
await adminClient.addToChannel(deniedUser.id, channel.id);
sharedBrowser = new TestBrowser(browser);
// Turn ABAC off while the admin posts the fixture file, so a policy left behind by an
// earlier spec cannot block the upload this whole describe depends on.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
} as any);
const {channelsPage: adminChannelsPage} = await sharedBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(FIXTURE_MESSAGE, ['sample_text_file.txt']);
const {systemConsolePage} = await sharedBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
policyName = `Dept Render Policy ${getRandomId()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering"',
permissions: ['Upload Files', 'Download Files'],
adminClient,
});
await waitForPolicy(adminClient, policyName);
});
test.beforeEach(async () => {
test.skip(!licensed, 'No ABAC license');
await ensureABACEnabled(sharedAdminClient);
});
test.afterAll(async () => {
if (policyName && sharedAdminClient) {
await deletePermissionPolicyByName(sharedAdminClient, policyName).catch(() => {});
}
await sharedBrowser?.close().catch(() => {});
});
/**
* @objective Verify a user whose attributes satisfy the policy sees real file attachments.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'MM-T5826_b shows file attachments to a user whose attributes match the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the post renders its real attachments, not the redacted placeholder
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesVisible();
},
);
/**
* @objective Verify a user whose attributes fail the policy sees the redacted placeholder
* instead of the file.
*
* @precondition
* A licensed server with a permission policy granting Download Files to Department == "Engineering"
*/
test(
'MM-T5826_a redacts file attachments for a user whose attributes fail the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file is replaced by the redacted placeholder
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesRedacted();
await expect(post.redactedFilesPlaceholder).toContainText('Files not available');
},
);
/**
* @objective Verify the upload affordance is offered to a user whose attributes satisfy the policy.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'enables the upload control for a user whose attributes match the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the attachment control is offered
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify the upload affordance is disabled rather than hidden for a user whose
* attributes fail the policy, so the restriction is visible instead of surfacing as a
* server error after the fact.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'MM-T5822 disables the upload control for a user whose attributes fail the policy',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the attachment control stays visible but is not actionable
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeVisible({
timeout: pw.duration.half_min,
});
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled();
},
);
/**
* @objective Verify a single denied user is restricted on both file actions at once — files
* redacted and upload blocked in the same view.
*
* @precondition
* A licensed server with a permission policy granting both file permissions to Department == "Engineering"
*/
test(
'MM-T5824 redacts files and blocks upload together for a denied user',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.one_min);
// # Log in as the user whose Department does not match the policy
const {channelsPage} = await pw.testBrowser.login(deniedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify existing files are redacted
const post = await channelsPage.centerView.getPostByText(FIXTURE_MESSAGE);
await post.toHaveFilesRedacted();
// * Verify new uploads are not offered
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeDisabled({
timeout: pw.duration.half_min,
});
},
);
/**
* @objective Verify a rendered "allowed" is backed by live enforcement — the user the UI
* offers upload to can actually complete one, so the affordance and the server agree.
*
* @precondition
* A licensed server with a permission policy granting Upload Files to Department == "Engineering"
*/
test(
'accepts a real upload from a user the render decision allows',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Log in as the user whose Department matches the policy
const {channelsPage} = await pw.testBrowser.login(allowedUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
await expect(channelsPage.centerView.postCreate.attachmentButton).toBeEnabled({
timeout: pw.duration.half_min,
});
// # Upload a file
const message = `Upload from allowed user ${getRandomId()}`;
await channelsPage.centerView.postCreate.postMessage(message, ['sample_text_file.txt']);
// * Verify the post was accepted with its attachment intact
const post = await channelsPage.centerView.getPostByText(message);
await post.toContainText(message);
await post.toHaveFilesVisible();
},
);
});
/**
* With ABAC enabled but no permission policy in existence, every file action is implicitly
* allowed. Separate fixture from the describe above because it needs the policy table empty.
*/
test.describe('ABAC file permissions - implicit allow with no policy', () => {
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
});
/**
* @objective Verify existing file attachments render normally when no permission policy
* restricts the user.
*/
test(
'MM-T5821 shows file attachments when no permission policy exists',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Set up a team, a private channel, and a member of it
const {adminUser, adminClient, team} = await pw.initSetup();
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
await cleanupAllPermissionPolicies(adminClient);
// # Admin posts a file into the channel
const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser);
await adminChannelsPage.goto(team.name, channelName);
await adminChannelsPage.toBeVisible();
await adminChannelsPage.centerView.postCreate.postMessage(NO_POLICY_MESSAGE, ['sample_text_file.txt']);
// # Enable ABAC without creating any policy
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await ensureABACEnabled(adminClient);
// # The member opens the channel
const {channelsPage} = await pw.testBrowser.login(testUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
// * Verify the file renders rather than being redacted
const post = await channelsPage.centerView.getPostByText(NO_POLICY_MESSAGE);
await post.toHaveFilesVisible();
},
);
/**
* @objective Verify a user can attach and send a file when no permission policy restricts
* uploads.
*/
test(
'MM-T5823 accepts a file upload when no permission policy exists',
{tag: '@abac_file_permissions'},
async ({pw}) => {
test.setTimeout(pw.duration.two_min);
// # Set up a team, a private channel, and a member of it
const {adminUser, adminClient, team} = await pw.initSetup();
const {testUser, channelName} = await setupUserAndChannel(adminClient, team);
await cleanupAllPermissionPolicies(adminClient);
// # Enable ABAC without creating any policy
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await ensureABACEnabled(adminClient);
// # The member uploads a file
const {channelsPage} = await pw.testBrowser.login(testUser);
await channelsPage.goto(team.name, channelName);
await channelsPage.toBeVisible();
const message = `Upload test ${getRandomId()}`;
await channelsPage.centerView.postCreate.postMessage(message, ['sample_text_file.txt']);
// * Verify the post was accepted with its attachment intact
const post = await channelsPage.centerView.getPostByText(message);
await post.toContainText(message);
await post.toHaveFilesVisible();
},
);
});

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, getRandomId, newTestPassword} from '@mattermost/playwright-lib';
import {createPrivateChannelForABAC, ensureUserAttributes, getPolicyIdByName} from '../support';
export async function setupUserAndChannel(
adminClient: any,
team: any,
): Promise<{
testUser: any;
channelName: string;
channelId: string;
}> {
// Ensure at least one user attribute field exists so the permission policy
// CEL editor's "Switch to Advanced Mode" button is enabled in the UI.
await ensureUserAttributes(adminClient, ['Department']);
const username = `user${getRandomId()}`;
const password = newTestPassword();
const testUser = await adminClient.createUser(
{email: `${username}@example.com`, username, password} as any,
'',
'',
);
(testUser as any).password = password;
await adminClient.addToTeam(team.id, testUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(testUser.id, channel.id);
return {testUser, channelName: channel.name, channelId: channel.id};
}
/**
* Every initSetup() rewrites the shared server config, so an earlier spec can leave ABAC off.
* Without this the policy is created but never enforced, and the allow-side tests pass blindly.
*/
export async function ensureABACEnabled(adminClient: Client4): Promise<void> {
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
await expect
.poll(
async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
},
{timeout: 15000, intervals: [500, 1000, 2000]},
)
.toBe(true);
}
/** Block until a just-created policy is readable through the search API. */
export async function waitForPolicy(adminClient: Client4, policyName: string): Promise<void> {
await expect
.poll(async () => Boolean(await getPolicyIdByName(adminClient, policyName, 1)), {
timeout: 30000,
intervals: [500, 1000, 2000],
})
.toBe(true);
}

View File

@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5797a: LDAP sync - User auto-added with `= is` operator (auto-add true)
*
* 1. Policy with Department == Engineering, auto-add=true
* 2. User has non-qualifying attribute (Sales) → not added on first sync
* 3. Attribute updated to Engineering (simulating LDAP sync)
* 4. Next sync auto-adds the user
*/
test('MM-T5797a LDAP sync - User auto-added with == operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `LDAP AutoAdd Equals ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, policyId);
// Initial sync — user has non-qualifying attribute, should not be added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5797a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797a1);
// Poll: sync job marks itself success before channel_members write is committed.
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should NOT be in channel after first sync (Department=Sales)',
})
.toBe(false);
// Simulate LDAP sync: update attribute to qualifying value.
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Sync again — user now qualifies and should be auto-added.
const syncJob5797a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797a2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should be in channel after second sync (Department=Engineering)',
})
.toBe(true);
});
/**
* MM-T5797b: LDAP sync - User auto-added with `contains` operator (auto-add true)
*
* 1. Policy with Department.contains("Eng"), auto-add=true
* 2. User has non-qualifying attribute (Sales) → not added on first sync
* 3. Attribute updated to Engineering (simulating LDAP sync)
* 4. Next sync auto-adds the user
*/
test('MM-T5797b LDAP sync - User auto-added with contains operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `LDAP AutoAdd Contains ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department.contains("Eng")',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, policyId);
// Initial sync — user has non-qualifying attribute, should not be added.
const syncJob5797b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797b1);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should NOT be in channel after first sync (Department=Sales)',
})
.toBe(false);
// Simulate LDAP sync: update Department to value containing "Eng".
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Sync again — user now qualifies and should be auto-added.
const syncJob5797b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5797b2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should be in channel after second sync (Department=Engineering)',
})
.toBe(true);
});

View File

@@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* ABAC LDAP Integration - Sync
* Tests for LDAP sync behavior with ABAC policies
*/
test.describe('ABAC LDAP Integration - Sync', () => {
/**
* MM-T5798: LDAP sync - User can be added to channel by admin after editing qualifying attribute (auto-add false)
*
* Step 1: Using `= is` operator
* 1. Policy with auto-add=false exists and is applied to a channel
* 2. User has wrong attribute value (non-qualifying)
* 3. Simulate LDAP sync by updating user's attribute to qualifying value
* 4. Run ABAC sync job (updates qualification state but doesn't auto-add due to auto-add=false)
* 5. Verify user NOT auto-added
* 6. Admin manually adds user to channel
*
* Step 2: Using `∈ in` operator
* 1. Policy with `in` operator exists
* 2. User has attribute but not a qualifying value
* 3. Simulate LDAP sync by updating to qualifying value
* 4. Admin adds user to channel
*
* Expected:
* - User who satisfies policy can be added by admin
* - `User added` message posted in channel
*
* NOTE: This test simulates LDAP attribute sync behavior via API.
* In production, attributes would be synced from LDAP server.
*/
test('MM-T5798 User added by admin after LDAP attribute sync (auto-add false)', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
// ============================================================
// STEP 1: Test with `= is` operator
// ============================================================
// Create user with NON-qualifying attribute (simulating LDAP user before sync)
const user1 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user1.id);
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `LDAP Sync Equals ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policy1Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [channel1.display_name],
});
// Get policy ID via API (no DOM scraping, no page reload needed)
const policyId1 = (await getPolicyIdByName(adminClient, policy1Name))!;
await activatePolicy(adminClient, policyId1);
// Initial sync — user has non-qualifying attribute, should not be added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5798a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798a1);
const user1InitialCheck = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1InitialCheck).toBe(false);
// Simulate LDAP sync: update attribute to qualifying value
await updateUserAttributes(adminClient, user1.id, {Department: 'Engineering'});
// Sync again — auto-add=false, so user is NOT auto-added even when qualifying
const syncJob5798a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798a2);
const user1AfterSync = await verifyUserInChannel(adminClient, user1.id, channel1.id);
if (!user1AfterSync) {
// Expected: admin must manually add qualifying user when auto-add=false
await adminClient.addToChannel(user1.id, channel1.id);
const user1AfterAdminAdd = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1AfterAdminAdd).toBe(true);
}
const user1Final = await verifyUserInChannel(adminClient, user1.id, channel1.id);
expect(user1Final).toBe(true);
// ============================================================
// STEP 2: Test with `∈ in` operator
// ============================================================
const user2 = await createUserWithAttributes(adminClient, {Department: 'Marketing'});
await adminClient.addToTeam(team.id, user2.id);
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
await navigateToABACPage(systemConsolePage.page);
const policy2Name = `LDAP Sync In ${pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policy2Name,
celExpression: 'user.attributes.Department in ["Engineering", "Product"]',
autoSync: false,
channels: [channel2.display_name],
});
const policyId2 = (await getPolicyIdByName(adminClient, policy2Name))!;
await activatePolicy(adminClient, policyId2);
// Initial sync — non-qualifying, should not be added
const syncJob5798b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798b1);
const user2InitialCheck = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2InitialCheck).toBe(false);
// Simulate LDAP sync: update to qualifying value
await updateUserAttributes(adminClient, user2.id, {Department: 'Product'});
// Sync again — auto-add=false, admin must manually add
const syncJob5798b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5798b2);
const user2AfterSync = await verifyUserInChannel(adminClient, user2.id, channel2.id);
if (!user2AfterSync) {
await adminClient.addToChannel(user2.id, channel2.id);
const user2AfterAdminAdd = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2AfterAdminAdd).toBe(true);
}
const user2Final = await verifyUserInChannel(adminClient, user2.id, channel2.id);
expect(user2Final).toBe(true);
});
});

View File

@@ -0,0 +1,121 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC LDAP Integration - Sync
* Tests for LDAP sync behavior with ABAC policies
*/
test.describe('ABAC LDAP Integration - Sync', () => {
/**
* MM-T5800: Policy enforcement after attribute change
* @objective Verify that policy enforcement updates when user attributes change
*
* This test is similar to MM-T5794 but focuses on the bidirectional nature:
* - User starts with non-qualifying attribute → NOT in channel
* - Attribute changed to qualifying value → User auto-added
* - Attribute changed back to non-qualifying → User auto-removed
*/
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
// 4 x waitForLatestSyncJob at up to 180 s each, plus policy creation and browser
// navigation — CI LDAP sync jobs can take significantly longer than the default 90 s.
test.setTimeout(300000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
// Create user with Sales department (non-qualifying)
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// Create policy for Engineering with auto-add
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Dynamic Policy ${pw.random.id()}`;
const createJobId = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [privateChannel.display_name],
});
// Activate policy
await waitForLatestSyncJob(systemConsolePage.page, undefined, createJobId, 180_000);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// ============================================================
// PHASE 1: User should NOT be added (Department=Sales)
// ============================================================
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1, 180_000);
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase1InChannel).toBe(false);
// ============================================================
// PHASE 2: Change attribute to qualifying value → User auto-added
// ============================================================
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2, 180_000);
const phase2InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase2InChannel).toBe(true);
// ============================================================
// PHASE 3: Change attribute back → User auto-removed
// ============================================================
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3, 180_000);
const phase3InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase3InChannel).toBe(false);
});
});

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5800: Policy enforcement after attribute change (bidirectional)
*/
test('MM-T5800 Policy enforcement after attribute change (bidirectional)', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure the "Department" custom profile attribute exists before creating users.
// A concurrent test shard may not yet have run the global setup, or the attribute
// may have been recreated under a different ID — ensureUserAttributes is idempotent.
await ensureUserAttributes(adminClient);
// User starts with non-qualifying attribute.
const user = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const policyName = `Dynamic Policy ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [privateChannel.display_name],
});
const t5800PolicyId = (await getPolicyIdByName(adminClient, policyName))!;
await activatePolicy(adminClient, t5800PolicyId);
// PHASE 1: User has non-qualifying attribute — not in channel without a sync.
const phase1InChannel = await verifyUserInChannel(adminClient, user.id, privateChannel.id);
expect(phase1InChannel).toBe(false);
// PHASE 2: Change to qualifying → User auto-added.
await updateUserAttributes(adminClient, user.id, {Department: 'Engineering'});
// Capture exact job ID so waitForLatestSyncJob polls the right job, not
// the most-recent row (which may belong to a concurrent shard's sync job).
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after qualifying sync',
})
.toBe(true);
// PHASE 3: Change back to non-qualifying → User auto-removed.
await updateUserAttributes(adminClient, user.id, {Department: 'Marketing'});
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3);
await expect
.poll(() => verifyUserInChannel(adminClient, user.id, privateChannel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after non-qualifying sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5799b: LDAP sync - User removed after attribute removed (== operator, auto-add true)
*/
test('MM-T5799b LDAP sync - User removed with == operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient, ['Department']);
// User starts WITH qualifying attribute.
const user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
await adminClient.addToTeam(team.id, user2.id);
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy2Name = `LDAP Remove TwoAttr ${await pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policy2Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true,
channels: [channel2.display_name],
});
const t5799Policy2Id = (await getPolicyIdByName(adminClient, policy2Name))!;
await activatePolicy(adminClient, t5799Policy2Id);
// Sync: user has qualifying attribute → gets auto-added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5799b1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799b1);
await expect
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after first sync',
})
.toBe(true);
// Change Department to non-qualifying value.
await updateUserAttributes(adminClient, user2.id, {Department: 'Sales'});
// Sync: user no longer qualifies → gets removed.
const syncJob5799b2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799b2);
await expect
.poll(() => verifyUserInChannel(adminClient, user2.id, channel2.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after second sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5799a: LDAP sync - User removed after attribute removed (startsWith operator, auto-add true)
*/
test('MM-T5799a LDAP sync - User removed with startsWith operator (auto-add true)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure the "Department" attribute exists — may not be present if global
// setup hasn't run yet or ran on a different shard.
await ensureUserAttributes(adminClient, ['Department']);
// User starts WITH qualifying attribute (Department starts with "Eng").
const user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
await adminClient.addToTeam(team.id, user1.id);
const channel1 = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `LDAP Remove StartsWith ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policy1Name,
celExpression: 'user.attributes.Department.startsWith("Eng")',
autoSync: true,
channels: [channel1.display_name],
});
const t5799Policy1Id = (await getPolicyIdByName(adminClient, policy1Name))!;
// Activate immediately using the UUID — no creation-sync wait needed.
await activatePolicy(adminClient, t5799Policy1Id);
// Sync: user has qualifying attribute → gets auto-added.
// Capture exact job ID so we poll the right job, not the most-recent row
// (which may belong to a concurrent shard's sync job under PW_WORKERS >= 2).
const syncJob5799a1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799a1);
// Poll: the sync job marks itself success before the channel_members write
// is fully committed. Give the server up to 15 s to catch up.
await expect
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been added to channel after first sync',
})
.toBe(true);
// Simulate LDAP sync: change Department to non-qualifying value.
await updateUserAttributes(adminClient, user1.id, {Department: 'Sales'});
// Sync: user no longer qualifies → gets removed.
const syncJob5799a2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob5799a2);
await expect
.poll(() => verifyUserInChannel(adminClient, user1.id, channel1.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'User should have been removed from channel after second sync',
})
.toBe(false);
});

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ChannelsPage, expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {
assignChannelsToPolicy,
createPrivateChannel,
createTeamAdmin,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {assignTeamsToPolicy} from '../teams/helpers';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
setUserAttribute,
} from './masking_helpers';
import {deleteFieldFromDB, purgeFieldsByPrefix, setFieldAsSharedOnly, setFieldAsSourceOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingAR';
test.describe('Attribute-Value Masking - Admin Roles', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-18: Team admin cannot delete a policy with masked values even after removing all channels', async ({
pw,
}) => {
// Validates that the masked-values block applies to the team settings modal:
// the Delete button stays disabled even after a team admin removes all assigned
// channels from the policy, as long as masked values are present.
// The server also returns HTTP 403 for a direct DELETE request.
test.setTimeout(150000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha"; policy has ["Alpha", "Bravo"] — Bravo is masked
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sysPage = systemConsolePage.page;
await navigateToABACPage(sysPage);
await enableABAC(sysPage);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
sysPage,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo"]`,
);
policyIds.push(policyId);
// Assign channel first so the attribute view can be primed before
// we flip the field to shared_only (self-inclusion filter in
// SearchTeamAccessPolicies requires the view to be current).
await adminClient.addToTeam(team.id, adminUser.id);
const channel = await createPrivateChannel(adminClient, team.id);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await waitForAttributeViewToInclude(adminClient, `user.attributes.${fieldName} in ["Alpha", "Bravo"]`, [
adminUser.id,
]);
await setFieldAsSharedOnly(fieldId);
await assignTeamsToPolicy(adminClient, policyId, [team.id]);
const {page} = await pw.testBrowser.login(adminUser);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessPoliciesTab();
const policyRow = teamSettings.container.getByText(policyName).first();
await expect(policyRow).toBeVisible({timeout: 10000});
await policyRow.click();
await page.waitForTimeout(500);
const deleteBtn = teamSettings.container
.locator('.TeamPolicyEditor__section--delete button')
.filter({hasText: 'Delete'});
await expect(deleteBtn).toBeVisible({timeout: 3000});
await expect(deleteBtn).toBeDisabled();
// Remove the channel — button must STAY disabled due to masked values
const removeLink = teamSettings.container.getByText('Remove').first();
await expect(removeLink).toBeVisible({timeout: 5000});
await removeLink.click();
await page.waitForTimeout(300);
await expect(deleteBtn).toBeDisabled();
await teamSettings.close();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
// Server: direct DELETE must return HTTP 403 regardless of UI state
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE /api/v4/access_control_policies/${policyId} returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-20: Team admin (non-sysadmin) sees the same masking as a system admin in team settings', async ({
pw,
}) => {
// Role-neutrality across roles: a delegated team admin (granted
// PermissionManageTeamAccessRules by their team_admin role, but NOT
// PermissionManageSystem) must see masking in the team-settings access
// policy editor. The masked-values guard MUST apply at this surface too:
// controls locked, Delete disabled, server 403 on direct DELETE.
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const teamAdmin = await createTeamAdmin(adminClient, team.id);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, teamAdmin.id, fieldId, 'Alpha');
const channel = await createPrivateChannel(adminClient, team.id);
await adminClient.addToChannel(teamAdmin.id, channel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sysPage = systemConsolePage.page;
await navigateToABACPage(sysPage);
await enableABAC(sysPage);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyExpression = `user.attributes.${fieldName} in ["Alpha", "Bravo"]`;
const policyResp = await (adminClient as any).doFetch(
`${(adminClient as any).getBaseRoute()}/access_control_policies`,
{
method: 'PUT',
body: JSON.stringify({
name: policyName,
type: 'parent',
version: 'v0.3',
revision: 1,
rules: [
{
actions: ['membership'],
expression: policyExpression,
},
],
}),
},
);
const policyId = policyResp.id as string;
policyIds.push(policyId);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await waitForAttributeViewToInclude(adminClient, policyExpression, [teamAdmin.id]);
await setFieldAsSharedOnly(fieldId);
// Log in AS THE TEAM ADMIN (not the sysadmin)
const {page} = await pw.testBrowser.login(teamAdmin);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const teamSettings = await channelsPage.openTeamSettings();
await teamSettings.openAccessPoliciesTab();
const searchInput = teamSettings.container.locator('[data-testid="searchInput"]').first();
await expect(searchInput).toBeVisible({timeout: 10000});
const searchResponse = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies\/search$/.test(resp.url()) &&
resp.request().method() === 'POST' &&
Boolean(resp.request().postData()?.includes(policyName)) &&
resp.ok(),
{timeout: 15000},
);
await searchInput.fill(policyName);
await searchResponse.catch(() => {
// debounced search can occasionally settle from cached data
});
await page.waitForLoadState('networkidle');
const policyRow = teamSettings.container.getByText(policyName).first();
await expect(policyRow).toBeVisible({timeout: 10000});
await policyRow.click();
await page.waitForTimeout(500);
// Masking surfaces in the team-policy editor exactly as in the system console
await expect(teamSettings.container.locator('.select__multi-value--masked').first()).toBeVisible({
timeout: 5000,
});
const deleteBtn = teamSettings.container
.locator('.TeamPolicyEditor__section--delete button')
.filter({hasText: 'Delete'});
await expect(deleteBtn).toBeVisible({timeout: 5000});
await expect(deleteBtn).toBeDisabled();
await teamSettings.close();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
const status = await page.evaluate(
async ({id, teamId}: {id: string; teamId: string}) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}?team_id=${teamId}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
},
{id: policyId, teamId: team.id},
);
expect(status, `DELETE as team admin returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-21: Channel admin (non-sysadmin) sees the same masking as a system admin in channel settings', async ({
pw,
}) => {
// Role-neutrality for the channel-admin surface: a user with
// PermissionManageChannelAccessRules (via channel_admin role) on a
// private channel must see masking inside the Membership Policy tab of
// the channel settings modal.
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminClient, user, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `mp-${pw.random.id()}`.toLowerCase(),
display_name: `Masked Policy Channel ${pw.random.id()}`,
type: 'P',
purpose: '',
header: '',
} as any);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.updateChannelMemberRoles(channel.id, user.id, 'channel_user channel_admin');
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, user.id, fieldId, 'Alpha');
// Sysadmin authors a CHANNEL-level policy directly
const channelPolicyResp = await (adminClient as any).doFetch(
`${(adminClient as any).getBaseRoute()}/access_control_policies`,
{
method: 'PUT',
body: JSON.stringify({
id: channel.id,
type: 'channel',
version: 'v0.3',
revision: 1,
rules: [
{actions: ['membership'], expression: `user.attributes.${fieldName} in ["Alpha", "Bravo"]`},
],
}),
},
);
const policyId = (channelPolicyResp?.id ?? channel.id) as string;
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
// Log in AS THE CHANNEL ADMIN (not the sysadmin)
const {page} = await pw.testBrowser.login(user);
const channelsPage = new ChannelsPage(page);
await page.goto(`/${team.name}/channels/${channel.name}`);
await channelsPage.toBeVisible();
const channelSettings = await channelsPage.openChannelSettings();
const membershipPolicyTab = channelSettings.container.getByRole('tab', {name: /membership policy/i});
await membershipPolicyTab.waitFor({state: 'visible', timeout: 10000});
await membershipPolicyTab.click();
await page.waitForTimeout(1500);
await expect(channelSettings.container.locator('.select__multi-value--masked').first()).toBeVisible({
timeout: 10000,
});
// Server-side guard: direct DELETE by the channel admin must 403
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE as channel admin returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-23: source_only and shared_only fields are filtered from the channel members RHS attribute tags', async ({
pw,
}) => {
// Validates that the /attributes endpoint strips source_only and shared_only
// fields before they reach the channel members RHS panel. A public field in
// the same policy must still appear so we confirm the filter is selective.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const id = pw.random.id();
const publicFieldName = `${fieldPrefix}Pub_${id}`;
const sharedFieldName = `${fieldPrefix}Sh_${id}`;
const sourceFieldName = `${fieldPrefix}Src_${id}`;
// Create all three fields as public first — the API rejects protected
// access modes without a source_plugin_id, so we flip them via DB after creation.
const publicFieldId = await createMaskingTextField(adminClient, publicFieldName);
const sharedFieldId = await createMaskingTextField(adminClient, sharedFieldName);
const sourceFieldId = await createMaskingTextField(adminClient, sourceFieldName);
fieldIds.push(publicFieldId, sharedFieldId, sourceFieldId);
await setUserAttribute(adminClient, adminUser.id, publicFieldId, 'Alpha');
await setUserAttribute(adminClient, adminUser.id, sharedFieldId, 'Beta');
await setUserAttribute(adminClient, adminUser.id, sourceFieldId, 'Gamma');
const {channelsPage, page} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${publicFieldName} in ["Alpha"] && user.attributes.${sharedFieldName} in ["Beta"] && user.attributes.${sourceFieldName} in ["Gamma"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(sharedFieldId);
await setFieldAsSourceOnly(sourceFieldId);
const channel = await createPrivateChannel(adminClient, team.id);
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The enforcement cache is cold on the first request — retry until the
// public-field tag is visible (up to 6 attempts with reload).
const alertContainer = page.locator('.channel-members-rhs__alert-container.policy-enforced');
let publicTagVisible = false;
for (let attempt = 0; attempt < 6; attempt++) {
if (attempt > 0) {
await page.keyboard.press('Escape');
await page.waitForTimeout(3000);
await page.reload();
await channelsPage.toBeVisible();
}
await channelsPage.centerView.header.openChannelMenu();
await page.locator('#channelMembers').click();
await channelsPage.sidebarRight.toBeVisible();
try {
await alertContainer.waitFor({state: 'visible', timeout: 10000});
publicTagVisible = await alertContainer.getByText(/:\s*Alpha/).isVisible();
if (publicTagVisible) {
break;
}
} catch {
// alert container not yet visible, retry
}
}
// Public field (value "Alpha") MUST be visible
await expect(alertContainer.getByText(/:\s*Alpha/)).toBeVisible({timeout: 5000});
// shared_only (value "Beta") and source_only (value "Gamma") must NOT appear
await expect(alertContainer.getByText(/:\s*Beta/)).not.toBeVisible();
await expect(alertContainer.getByText(/:\s*Gamma/)).not.toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {
// Protected/shared-only/source-only fields reject the API delete; fall back to DB.
await deleteFieldFromDB(id).catch(() => {});
}
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
getPolicyIdFromURL,
getRawPolicyExpression,
openExistingPolicy,
resubmitPolicyExpression,
searchPoliciesExpression,
setUserAttribute,
} from './masking_helpers';
import {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingAD';
test.describe('Attribute-Value Masking - API Redaction and Delete', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-12: GET /policies/{id} does not leak raw CEL when values are masked', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
expect(storedPolicyId).toBeTruthy();
// GET policy as the logged-in user (holds "Alpha" only). Hidden values
// must be replaced with the masked-token sentinel.
const expression = await getRawPolicyExpression(page, storedPolicyId);
expect(expression).toContain('Alpha');
expect(expression).toContain('--------');
expect(expression).not.toContain('Bravo');
expect(expression).not.toContain('Charlie');
// Direct DB read bypasses the API masking pipeline — stored expression
// must still contain the originals.
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-13: POST /policies/search does not leak raw CEL when values are masked', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
// Search as the logged-in (masked) user — the response must contain the
// masked-token sentinel for any hidden values, never the raw originals.
const maskedExpression = await searchPoliciesExpression(page, policyName);
expect(maskedExpression).toContain('--------');
expect(maskedExpression).not.toContain('Bravo');
expect(maskedExpression).not.toContain('Charlie');
// Verify the stored policy still contains the originals — direct DB read.
const rawExpression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-14: Warning banner visible in editor when policy has masked values', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy with masked values (admin holds Alpha; Bravo/Charlie are masked)
const maskedPolicyName = `MaskingPolicy ${pw.random.id()}`;
const maskedPolicyId = await createPolicyWithCEL(
page,
maskedPolicyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(maskedPolicyId);
// Policy with NO masked values (admin holds the only value in the condition)
const cleanPolicyName = `CleanPolicy ${pw.random.id()}`;
const cleanPolicyId = await createPolicyWithCEL(
page,
cleanPolicyName,
`user.attributes.${fieldName} in ["Alpha"]`,
);
policyIds.push(cleanPolicyId);
// shared_only must flip AFTER both policy saves
await setFieldAsSharedOnly(fieldId);
// Open masked policy — warning banner must be present
await openExistingPolicy(page, maskedPolicyName);
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible({timeout: 5000});
// Open clean policy — warning banner must NOT be present
await openExistingPolicy(page, cleanPolicyName);
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-15: Delete button is disabled on masked policies; clean policies open the standard confirmation modal', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy WITH masked values
const maskedPolicyName = `MaskingPolicy ${pw.random.id()}`;
const maskedPolicyId = await createPolicyWithCEL(
page,
maskedPolicyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(maskedPolicyId);
// Policy WITHOUT masked values
const cleanPolicyName = `CleanPolicy ${pw.random.id()}`;
const cleanPolicyId = await createPolicyWithCEL(
page,
cleanPolicyName,
`user.attributes.${fieldName} in ["Alpha"]`,
);
policyIds.push(cleanPolicyId);
await setFieldAsSharedOnly(fieldId);
// --- Masked policy: Delete button must be disabled (no modal flow) ---
await openExistingPolicy(page, maskedPolicyName);
const deleteBtn = page.getByRole('button', {name: /delete policy|delete/i}).last();
await deleteBtn.scrollIntoViewIfNeeded();
await expect(deleteBtn).toBeDisabled();
// --- Clean policy: Delete button must be enabled and open a normal
// confirmation modal without the "restricted values" warning ---
await openExistingPolicy(page, cleanPolicyName);
const cleanDeleteBtn = page.getByRole('button', {name: /delete policy|delete/i}).last();
await cleanDeleteBtn.scrollIntoViewIfNeeded();
await expect(cleanDeleteBtn).toBeEnabled();
await cleanDeleteBtn.click();
await page.waitForTimeout(500);
const cleanModal = page.locator('[role="dialog"]').filter({hasText: /confirm|delete/i});
await cleanModal.waitFor({state: 'visible', timeout: 5000});
await expect(cleanModal.locator('text=/restricted values/i')).not.toBeVisible();
await cleanModal.getByRole('button', {name: /cancel/i}).click();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-16: Delete Policy is blocked (UI and server) when caller has masked values', async ({pw}) => {
// Validates that the read-only-when-masked invariant covers deletion:
// - Delete Policy button in the UI is disabled when hasMaskedRows is true
// - Server returns HTTP 403 for direct DELETE requests when caller has masked values
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// UI: Delete Policy button must be disabled when masked values present
const deleteBtn = page.getByRole('button', {name: /^delete$/i}).last();
await expect(deleteBtn).toBeVisible({timeout: 5000});
await expect(deleteBtn).toBeDisabled();
expect(policyId).toMatch(/^[A-Za-z0-9]{26}$/);
// Server: direct DELETE must return HTTP 403
const status = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
method: 'DELETE',
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.status;
}, policyId);
expect(status, `DELETE /api/v4/access_control_policies/${policyId} returned ${status}`).toBe(403);
// Verify policy still exists via DB
const expression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(expression).toContain('Alpha');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-17: Multi-condition save preserves all hidden values; deleting masked row is blocked', async ({
pw,
}) => {
// Validates merge-on-save for a multi-condition policy. The caller (holds Alpha
// in programField, nothing in clearanceField) can save — both conditions survive
// with their hidden values intact. The server blocks deletion of masked conditions.
test.setTimeout(150000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const programFieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const clearanceFieldName = `${fieldPrefix}Clear_${pw.random.id()}`;
const programFieldId = await createMaskingTextField(adminClient, programFieldName);
const clearanceFieldId = await createMaskingTextField(adminClient, clearanceFieldName);
fieldIds.push(programFieldId, clearanceFieldId);
await setUserAttribute(adminClient, adminUser.id, programFieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingRegressionPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${programFieldName} in ["Alpha", "Bravo", "Charlie"] && user.attributes.${clearanceFieldName} in ["Secret", "TopSecret"]`,
);
policyIds.push(policyId);
// shared_only must flip AFTER the policy save for both fields
await setFieldAsSharedOnly(programFieldId);
await setFieldAsSharedOnly(clearanceFieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
// Both rows are masked — banner visible
await expect(page.locator('.select__multi-value--masked').first()).toBeVisible();
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Trash buttons on both masked rows must be DISABLED
const trashButtons = page.locator('button[aria-label="Remove row"]');
const firstTrash = trashButtons.first();
if (await firstTrash.isVisible({timeout: 3000})) {
await expect(firstTrash).toBeDisabled();
}
// Dirty the form via the policy name so Save enables
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.fill(policyName + ' (edited)');
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForLoadState('networkidle');
// Verify the stored policy directly — all hidden values must survive merge-on-save
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain(programFieldId);
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
expect(rawExpression).toContain(clearanceFieldId);
expect(rawExpression).toContain('Secret');
expect(rawExpression).toContain('TopSecret');
// Server blocks a direct API attempt to remove a masked condition
const status = await page.evaluate(
async ({policyId: id, fn}: {policyId: string; fn: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
body: JSON.stringify({
id,
name: 'Modified',
type: 'parent',
rules: [{expression: `user.attributes.${fn} in ["Alpha"]`}],
}),
});
return resp.status;
},
{policyId, fn: programFieldName},
);
expect(status, `PUT /api/v4/access_control_policies (id=${policyId}) returned ${status}`).toBe(403);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-19: Mode toggle Simple → Advanced → Simple preserves all masked-row restrictions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// --- Initial Simple mode: restrictions in place ---
const maskedChip = page.locator('.select__multi-value--masked');
const banner = page.locator('text="This policy contains restricted values"');
const deleteBtn = page.getByRole('button', {name: /^delete$/i}).last();
await expect(maskedChip.first()).toBeVisible();
await expect(banner).toBeVisible();
await expect(deleteBtn).toBeDisabled();
// --- Switch to Advanced mode ---
const toAdvanced = page.getByRole('button', {name: /switch to advanced mode/i});
await toAdvanced.click();
await page.waitForTimeout(500);
// Banner must persist across the toggle
await expect(banner).toBeVisible();
await expect(page.locator('.monaco-editor').first()).toBeVisible();
// --- Switch back to Simple mode ---
const toSimple = page.getByRole('button', {name: /switch to simple mode/i});
await toSimple.click();
await page.waitForTimeout(1500);
// All restrictions must STILL be in place after toggling back
await expect(banner).toBeVisible();
await expect(maskedChip.first()).toBeVisible();
await expect(deleteBtn).toBeDisabled();
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
if (await valueSelector.isVisible({timeout: 2000})) {
await expect(valueSelector).toBeDisabled();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-22: PUT re-submitting "false" over a rule with hidden values is rejected (403), stored policy untouched', async ({
pw,
}) => {
// The genuinely dangerous scenario: a caller re-submits the deny-all
// sentinel "false" over a stored rule whose values they cannot see, which
// would silently wipe a masked rule. The canonical merge can't pair the
// bare "false" with the stored masked node, so it must fail closed (403)
// and leave the stored expression untouched. The merge is the actual line
// of defence — rejectMaskedTokens' former "== false" check was only a
// redundant (and overbroad) extra layer on top of it. This is the
// end-to-end proof of that protection.
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
// Flip to shared_only AFTER the save: the admin now holds only "Alpha",
// so "Bravo"/"Charlie" become hidden from this caller.
await setFieldAsSharedOnly(fieldId);
// Sanity: the caller's GET view is masked.
const masked = await getRawPolicyExpression(page, policyId);
expect(masked).toContain('Alpha');
expect(masked).toContain('--------');
expect(masked).not.toContain('Bravo');
expect(masked).not.toContain('Charlie');
// Attempt to overwrite the masked rule with the bare deny-all sentinel.
const {status, body} = await resubmitPolicyExpression(page, policyId, 'false');
expect(status, `PUT re-submitting "false" returned ${status}`).toBe(403);
expect(body?.id).toBe('app.pap.save_policy.masked_condition_deleted');
// The stored expression must be untouched — direct DB read still has originals.
const rawExpression = (await getStoredPolicyRuleExpressions(policyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
expect(rawExpression).not.toBe('false');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* masking_db_setup.ts — direct DB helpers for the masking E2E suite.
*
* Why direct DB access:
* - access_mode=shared_only requires protected=true, which requires
* source_plugin_id (plugin-only). There is no admin/sysadmin bypass via API.
* - The masking feature flag is loaded at server boot and cannot be flipped at
* runtime, so going through the API would return the masked view and we'd
* have no way to verify what was actually persisted.
*
* Helpers use a one-shot `pg.Client` so we don't keep a connection pool open
* for the lifetime of the test run, and use parameterized queries throughout.
*
* Connects via testConfig.postgresUrl, which resolves to the fixed localhost:5432 default in
* `external` mode (docker-compose's mapped port), or the Testcontainers-assigned host port in
* `testcontainers` mode.
*/
import {Client} from 'pg';
import {testConfig} from '@mattermost/playwright-lib';
async function runQuery<T = unknown>(sql: string, params: unknown[] = []): Promise<T[]> {
const client = new Client({connectionString: testConfig.postgresUrl});
await client.connect();
try {
const result = await client.query(sql, params);
return result.rows as T[];
} finally {
await client.end();
}
}
/**
* Set a CPA field's access_mode to 'shared_only' directly in the DB.
* Bypasses API validation (which requires source_plugin_id for protected fields).
*/
export async function setFieldAsSharedOnly(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, 'shared_only');
}
/**
* Set a CPA field's access_mode to 'source_only' directly in the DB.
* Bypasses API validation (which requires source_plugin_id for protected fields).
*/
export async function setFieldAsSourceOnly(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, 'source_only');
}
/**
* Set the field back to public (removes the access_mode attr).
*/
export async function setFieldAsPublic(fieldId: string): Promise<void> {
await setFieldAccessMode(fieldId, '');
}
/**
* Read a policy's rule expressions straight from the AccessControlPolicies table.
* Bypasses the API masking pipeline entirely — what you get is what's persisted.
* Returns an array of rule expressions in storage order.
*/
export async function getStoredPolicyRuleExpressions(policyId: string): Promise<string[]> {
if (!/^[a-z0-9]{26}$/.test(policyId)) {
throw new Error(
`getStoredPolicyRuleExpressions: refusing to use untrusted policy id ${JSON.stringify(policyId)}`,
);
}
const rows = await runQuery<{expression: string | null}>(
`SELECT rule->>'expression' AS expression
FROM AccessControlPolicies, jsonb_array_elements(Data->'rules') AS rule
WHERE ID = $1`,
[policyId],
);
return rows.map((r) => (r.expression ?? '').trim()).filter((s) => s.length > 0);
}
/**
* Hard-delete a CPA field directly in the DB by setting deleteat.
* Use this instead of the API for fields that were flipped to protected=true
* via setFieldAsSharedOnly / setFieldAsSourceOnly — the API rejects deletes
* for protected fields (403), so calling it from a finally block silently
* leaves the field behind and the 200-field global limit fills up over time.
*/
export async function deleteFieldFromDB(fieldId: string): Promise<void> {
if (!/^[a-z0-9]{26}$/.test(fieldId)) {
throw new Error(`deleteFieldFromDB: refusing to use untrusted field id ${JSON.stringify(fieldId)}`);
}
await runQuery(
`UPDATE propertyfields
SET deleteat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId],
);
}
/**
* Soft-delete all CPA fields whose name starts with the given prefix.
* Used in beforeAll to purge orphaned test fields from previous failed runs,
* including protected ones that the API cannot delete.
*/
export async function purgeFieldsByPrefix(prefix: string): Promise<void> {
if (!/^[A-Za-z0-9_-]+$/.test(prefix)) {
throw new Error(`purgeFieldsByPrefix: refusing untrusted prefix ${JSON.stringify(prefix)}`);
}
await runQuery(
`UPDATE propertyfields
SET deleteat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE name LIKE $1
AND deleteat = 0`,
[`${prefix}%`],
);
}
async function setFieldAccessMode(fieldId: string, accessMode: string): Promise<void> {
if (!/^[a-z0-9]{26}$/.test(fieldId)) {
throw new Error(`setFieldAccessMode: refusing to use untrusted field id ${JSON.stringify(fieldId)}`);
}
if (!/^[a-z_]*$/.test(accessMode)) {
throw new Error(`setFieldAccessMode: refusing untrusted access mode ${JSON.stringify(accessMode)}`);
}
if (accessMode === '') {
// Public = remove both access_mode and protected keys, and clear the protected column.
await runQuery(
`UPDATE propertyfields
SET attrs = (COALESCE(attrs, '{}'::jsonb) - 'access_mode' - 'protected'),
protected = false,
updateat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId],
);
return;
}
await runQuery(
`UPDATE propertyfields
SET attrs = jsonb_set(
jsonb_set(COALESCE(attrs, '{}'::jsonb), '{access_mode}', to_jsonb($2::text)),
'{protected}',
'true'::jsonb
),
protected = true,
updateat = EXTRACT(EPOCH FROM NOW())::bigint * 1000
WHERE id = $1`,
[fieldId, accessMode],
);
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import {deleteFieldFromDB} from './masking_db_setup';
export async function enableMaskingFlag(client: Client4): Promise<void> {
const config = await client.getConfig();
config.FeatureFlags = config.FeatureFlags || {};
(config.FeatureFlags as any).AttributeValueMasking = true;
await client.updateConfig(config);
}
export async function disableMaskingFlag(client: Client4): Promise<void> {
const config = await client.getConfig();
config.FeatureFlags = config.FeatureFlags || {};
(config.FeatureFlags as any).AttributeValueMasking = false;
await client.updateConfig(config);
}
export async function createMaskingTextField(client: Client4, fieldName: string): Promise<string> {
const url = `${client.getBaseRoute()}/custom_profile_attributes/fields`;
const created = await (client as any).doFetch(url, {
method: 'POST',
body: JSON.stringify({
name: fieldName,
type: 'text',
attrs: {
sort_order: 99,
managed: 'admin',
visibility: 'when_set',
},
}),
});
return created.id as string;
}
export async function createMaskingMultiselectField(
client: Client4,
fieldName: string,
options: string[],
): Promise<string> {
const url = `${client.getBaseRoute()}/custom_profile_attributes/fields`;
const created = await (client as any).doFetch(url, {
method: 'POST',
body: JSON.stringify({
name: fieldName,
type: 'multiselect',
attrs: {
sort_order: 99,
managed: 'admin',
visibility: 'when_set',
options: options.map((name) => ({name, color: ''})),
},
}),
});
return created.id as string;
}
/**
* Delete a CPA field by ID. Tries the API first; falls back to a direct DB
* soft-delete for fields that were flipped to protected=true via
* setFieldAsSharedOnly / setFieldAsSourceOnly (the API returns 403 for those).
* Never throws.
*/
export async function deleteCPAField(client: Client4, fieldId: string): Promise<void> {
if (!fieldId) {
return;
}
try {
await (client as any).doFetch(`${client.getBaseRoute()}/custom_profile_attributes/fields/${fieldId}`, {
method: 'DELETE',
});
} catch {
try {
await deleteFieldFromDB(fieldId);
} catch {
// best-effort
}
}
}
export async function deletePolicy(client: Client4, policyId: string): Promise<void> {
if (!policyId) {
return;
}
try {
await (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies/${policyId}`, {
method: 'DELETE',
});
} catch {
// best-effort
}
}
export async function setUserAttribute(client: Client4, userId: string, fieldId: string, value: string): Promise<void> {
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
}
/**
* Create a membership policy using the Advanced (CEL) editor in the UI.
* Does NOT add channels so there is no "Apply policy" gate to click through.
* Returns the policy ID extracted from the URL after saving.
*/
export async function createPolicyWithCEL(page: Page, name: string, celExpression: string): Promise<string> {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const addPolicyBtn = page.getByRole('button', {name: 'Add policy'});
await addPolicyBtn.waitFor({state: 'visible', timeout: 15000});
await addPolicyBtn.click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(name);
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await advancedBtn.waitFor({state: 'visible', timeout: 5000});
await advancedBtn.click();
await page.waitForTimeout(1000);
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.waitFor({state: 'visible', timeout: 5000});
await editorLines.click({force: true});
await page.waitForTimeout(300);
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.keyboard.type(celExpression, {delay: 10});
await page.waitForTimeout(1000);
const saveBtn = page.getByRole('button', {name: 'Save'});
await saveBtn.waitFor({state: 'visible', timeout: 5000});
const savePromise = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies(\/[A-Za-z0-9]+)?$/.test(resp.url()) &&
resp.request().method() === 'PUT' &&
resp.ok(),
{timeout: 15000},
);
await saveBtn.click();
const saveResp = await savePromise;
const saved = await saveResp.json();
await page.waitForLoadState('networkidle');
const id = (saved?.id ?? saved?.ID ?? '') as string;
if (!/^[A-Za-z0-9]{26}$/.test(id)) {
throw new Error(
`createPolicyWithCEL: save response did not include a valid policy id (got ${JSON.stringify(id)})`,
);
}
return id;
}
/**
* Navigate to the membership-policies list and open the editor for the named policy.
*/
export async function openExistingPolicy(page: Page, policyName: string): Promise<void> {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const searchInput = page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 10000});
const searchResponse = page.waitForResponse(
(resp) =>
/\/api\/v4\/access_control_policies\/search$/.test(resp.url()) &&
resp.request().method() === 'POST' &&
resp.ok(),
{timeout: 15000},
);
await searchInput.fill(policyName);
await searchResponse.catch(() => {
// debounced search may settle from cache
});
await page.waitForLoadState('networkidle');
const policyRow = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRow.waitFor({state: 'visible', timeout: 20000});
await policyRow.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500);
}
export async function getRawPolicyExpression(page: Page, policyId: string): Promise<string> {
const data = await page.evaluate(async (id: string) => {
const resp = await fetch(`/api/v4/access_control_policies/${id}`, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
return resp.json();
}, policyId);
return (data?.rules?.[0]?.expression ?? '') as string;
}
/**
* Re-submit an existing policy with rule[0].expression overwritten by
* newExpression, using the logged-in caller's own browser session (so per-caller
* masking applies). Performs the GET -> modify -> PUT round-trip a real client
* would: fetch the (possibly masked) policy, change one rule's expression, and
* save it back. Returns the HTTP status and parsed body so callers can assert
* the server's response (e.g. a fail-closed 403).
*/
export async function resubmitPolicyExpression(
page: Page,
policyId: string,
newExpression: string,
): Promise<{status: number; body: any}> {
return page.evaluate(
async ({id, expr}: {id: string; expr: string}) => {
const getResp = await fetch(`/api/v4/access_control_policies/${id}`, {
headers: {'X-Requested-With': 'XMLHttpRequest'},
});
const policy = await getResp.json();
if (Array.isArray(policy?.rules) && policy.rules.length > 0) {
policy.rules[0].expression = expr;
}
const putResp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(policy),
});
const body = await putResp.json().catch(() => ({}));
return {status: putResp.status, body};
},
{id: policyId, expr: newExpression},
);
}
export async function searchPoliciesExpression(page: Page, term: string): Promise<string> {
const data = await page.evaluate(async (t: string) => {
const resp = await fetch('/api/v4/access_control_policies/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({term: t}),
});
return resp.json();
}, term);
const policies = data?.policies ?? (Array.isArray(data) ? data : []);
return (policies[0]?.rules?.[0]?.expression ?? '') as string;
}
/**
* Extract the policy ID from the current URL after the editor has opened.
* The route is `/admin_console/system_attributes/membership_policies/edit_policy/{id}`
*/
export async function getPolicyIdFromURL(page: Page): Promise<string> {
const url = page.url();
const editMatch = url.match(/edit_policy\/([A-Za-z0-9]{26})(?:[/?#]|$)/);
if (editMatch) {
return editMatch[1];
}
const fallback = url.match(/membership_policies\/([A-Za-z0-9]{26})/);
if (fallback) {
return fallback[1];
}
throw new Error(`getPolicyIdFromURL: could not extract policy id from URL ${JSON.stringify(url)}`);
}

View File

@@ -0,0 +1,485 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
getPolicyIdFromURL,
openExistingPolicy,
setUserAttribute,
} from './masking_helpers';
import {getStoredPolicyRuleExpressions, purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingTE';
test.describe('Attribute-Value Masking - Table Editor', {tag: ['@abac', '@abac_masking']}, () => {
// Purge any orphaned MaskingTE* CPA fields left by previous failed runs so we
// don't hit the 200-field global limit mid-suite.
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-1: Full masking round-trip in Simple editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha" — Bravo and Charlie will be masked for them
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
// shared_only must flip AFTER the policy save: validatePolicyExpressionValues would
// otherwise reject values the caller does not hold. Flipping now means the policy
// is created against a public field, then masking applies on the next load.
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha chip must be visible (caller holds it)
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
// Masked chip must be visible (Bravo + Charlie are hidden)
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Bravo and Charlie chips must NOT appear in plain text
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Charlie'})).not.toBeVisible();
// Warning banner must appear
await expect(page.locator('text="This policy contains restricted values"')).toBeVisible();
// Attribute selector on the row must be locked (has 'disabled' class)
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).toHaveClass(/disabled/);
// Test-access-rule button must be disabled when policy has masked values
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
if (await testRulesBtn.isVisible({timeout: 3000})) {
await expect(testRulesBtn).toBeDisabled();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-2: Caller with masked values can save; hidden values are preserved by merge', async ({pw}) => {
// Validates that callers with masked values CAN save changes. Merge-on-save
// re-injects hidden values so Bravo and Charlie survive even though the caller
// only sees and submits Alpha. Save button is enabled (not gated on masked state).
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
const storedPolicyId = await getPolicyIdFromURL(page);
// Alpha visible, Bravo+Charlie masked
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Dirty the form via the policy name field. Masked rows are read-only so
// we can't remove/add chips. The merge-on-save invariant we're testing
// doesn't depend on how the form is dirtied.
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.fill(policyName + ' (edited)');
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForLoadState('networkidle');
// Verify via DB: Bravo + Charlie preserved by merge-on-save
const rawExpression = (await getStoredPolicyRuleExpressions(storedPolicyId))[0] ?? '';
expect(rawExpression).toContain('Alpha');
expect(rawExpression).toContain('Bravo');
expect(rawExpression).toContain('Charlie');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-3: Row-remove button is disabled on masked rows', async ({pw}) => {
// The trash/remove button on a masked row is disabled — a caller with
// masked values cannot delete individual rows, matching the Save/Delete
// buttons which are also disabled when masked values are present.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Row-remove (trash) button must be disabled on the masked row
const removeRowBtn = page
.locator('button[aria-label="Remove row"], button.table-editor__row-remove')
.first();
await removeRowBtn.waitFor({state: 'visible', timeout: 5000});
await expect(removeRowBtn).toBeDisabled();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-4: Self-inclusion failure blocks save (caller has full visibility)', async ({pw}) => {
// Self-inclusion is only checked when the caller holds ALL values in the policy
// (no masked values). If masked values are present the 403 block fires first
// and the Save button is disabled. This test uses a single-value policy so the
// caller has full visibility, then removes their own satisfying value.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha"; policy has ONLY ["Alpha"] — no masked values
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
await adminClient.addToTeam(team.id, adminUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(page, policyName, `user.attributes.${fieldName} in ["Alpha"]`);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha visible, no masked chip — caller has full visibility
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
const saveBtn = page.getByRole('button', {name: 'Save'});
// Remove Alpha — now the condition has no values (empty)
const alphaChip = page.locator('.select__multi-value').filter({hasText: 'Alpha'});
await alphaChip.locator('.select__multi-value__remove').click();
await page.waitForTimeout(300);
await saveBtn.click();
await page.waitForTimeout(2000);
// An error message about self-inclusion should appear
const errorMsg = page.locator('text=/do not satisfy|self.inclusion|condition/i').first();
await expect(errorMsg).toBeVisible({timeout: 8000});
// Reload — Alpha should still be in the stored policy (save was blocked)
await page.reload();
await page.waitForLoadState('networkidle');
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-5: Non-held value rejected via direct API', async ({pw}) => {
test.setTimeout(60000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Try to create a policy containing a non-held value ("Delta") via direct API
const statusWithDelta = await page.evaluate(
async ({fieldName: fn}: {fieldName: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
name: `Illegal ${Date.now()}`,
type: 'member',
rules: [{expression: `user.attributes.${fn} in ["Alpha", "Delta"]`}],
}),
});
return resp.status;
},
{fieldName},
);
// Server must reject with 400 — "Delta" is not a held value
expect(statusWithDelta).toBe(400);
// Also verify that the masked placeholder literal is rejected
const statusWithMasked = await page.evaluate(
async ({fieldName: fn}: {fieldName: string}) => {
const resp = await fetch('/api/v4/access_control_policies', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({
name: `Illegal ${Date.now()}`,
type: 'member',
rules: [{expression: `user.attributes.${fn} in ["Alpha", "--------"]`}],
}),
});
return resp.status;
},
{fieldName},
);
expect(statusWithMasked).toBe(400);
} finally {
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-6: CEL editor is read-only when policy has masked values', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Switch to Advanced (CEL) mode
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await advancedBtn.waitFor({state: 'visible', timeout: 5000});
await advancedBtn.click();
await page.waitForTimeout(1000);
// Monaco editor must be read-only — verify functionally: capture the current text,
// attempt to type, and assert the content is unchanged.
const monacoEditor = page.locator('.monaco-editor').first();
await monacoEditor.waitFor({state: 'visible', timeout: 5000});
const viewLines = monacoEditor.locator('.view-lines').first();
const before = (await viewLines.textContent()) ?? '';
await monacoEditor.locator('textarea.inputarea').first().focus();
await page.keyboard.press('End');
await page.keyboard.type('xyz');
await page.waitForTimeout(300);
const after = (await viewLines.textContent()) ?? '';
expect(after).toBe(before);
// There should be a notice/banner about restricted values in CEL mode
const celNotice = page.locator('text=/restricted values|read.only/i').first();
await expect(celNotice).toBeVisible({timeout: 5000});
// Test-access-rule button must be disabled in CEL mode with masked values
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
if (await testRulesBtn.isVisible({timeout: 3000})) {
await expect(testRulesBtn).toBeDisabled();
}
// Switch back to Simple mode — masked chip is still present
const simpleBtn = page.getByRole('button', {name: /simple/i});
if (await simpleBtn.isVisible({timeout: 3000})) {
await simpleBtn.click();
await page.waitForTimeout(500);
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
}
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,409 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {
createMaskingMultiselectField,
createMaskingTextField,
createPolicyWithCEL,
deleteCPAField,
deletePolicy,
disableMaskingFlag,
enableMaskingFlag,
openExistingPolicy,
setUserAttribute,
} from './masking_helpers';
import {purgeFieldsByPrefix, setFieldAsSharedOnly} from './masking_db_setup';
const fieldPrefix = 'MaskingVT';
test.describe('Attribute-Value Masking - Visibility and Text Fields', {tag: ['@abac', '@abac_masking']}, () => {
test.beforeAll(async () => {
await purgeFieldsByPrefix(fieldPrefix);
});
test('MM-68508-7: Caller holding all policy values sees them all unmasked', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
// adminUser holds "Alpha" and the policy contains ONLY "Alpha"
// → caller holds ALL values in the condition → nothing is masked
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(page, policyName, `user.attributes.${fieldName} in ["Alpha"]`);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Alpha visible
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
// No masked chip — caller holds all values
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
// No warning banner
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
// Attribute selector is NOT locked
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).not.toHaveClass(/disabled/);
// Test access rule button should be enabled
const testRulesBtn = page.locator('button').filter({hasText: 'Test access rule'});
await expect(testRulesBtn).toBeVisible();
await expect(testRulesBtn).toBeEnabled();
// CEL mode is editable (no read-only)
const advancedBtn = page.getByRole('button', {name: /advanced/i});
await expect(advancedBtn).toBeVisible();
await advancedBtn.click();
await page.waitForTimeout(1000);
const monacoEditor = page.locator('.monaco-editor').first();
await expect(monacoEditor).toBeVisible();
const ariaReadOnly = await monacoEditor.getAttribute('aria-readonly');
expect(ariaReadOnly).not.toBe('true');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-8: New policy creation has no masking', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Navigate to New Policy form
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
// A fresh editor must show no masked chip and no warning banner
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
await expect(page.locator('text="This policy contains restricted values"')).not.toBeVisible();
// Add a rule row
const addAttributeBtn = page.getByRole('button', {name: /add attribute/i});
await expect(addAttributeBtn).toBeVisible();
await expect(addAttributeBtn).toBeEnabled();
await addAttributeBtn.click();
await page.waitForTimeout(500);
// Still no masked chip after adding a blank row
await expect(page.locator('.select__multi-value--masked')).not.toBeVisible();
// Attribute selector is NOT locked on a new row
const attributeSelector = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await expect(attributeSelector).not.toHaveClass(/disabled/);
} finally {
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-9: Masked row is fully read-only', async ({pw}) => {
// The masked row's value selector is locked — callers cannot add or remove values
// through it. This is intentional: any direct modification could silently drop
// hidden values, and the merge logic gates write-path edits on shared_only fields.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Masked state visible
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Value selector on the masked row must be disabled
const valueSelector = page.locator('[data-testid="valueSelectorMenuButton"]').first();
await expect(valueSelector).toBeVisible({timeout: 3000});
await expect(valueSelector).toBeDisabled();
// Attribute selector locked
await expect(page.locator('[data-testid="attributeSelectorMenuButton"]').first()).toHaveClass(/disabled/);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-10: Text field masking with "in" operator', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Prog_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Alpha');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} in ["Alpha", "Bravo", "Charlie"]`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// "Alpha" chip visible (held); "Bravo" and "Charlie" are masked
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Charlie'})).not.toBeVisible();
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
// Attribute selector on the masked row is locked
await expect(page.locator('[data-testid="attributeSelectorMenuButton"]').first()).toHaveClass(/disabled/);
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-11: Text field masking with single-value operator (value not held)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
// adminUser holds "Building 1"; policy value is "Building 7" (not held)
const fieldName = `${fieldPrefix}Loc_${pw.random.id()}`;
const fieldId = await createMaskingTextField(adminClient, fieldName);
fieldIds.push(fieldId);
await setUserAttribute(adminClient, adminUser.id, fieldId, 'Building 1');
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy: Location != "Building 7"
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`user.attributes.${fieldName} != "Building 7"`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// "Building 7" is not held by the admin → it should be masked in some form
await expect(page.locator('text="Building 7"')).not.toBeVisible();
// The row should still show the masked state: either masked chip or read-only input
const maskedState = page.locator(
'.select__multi-value--masked, input[disabled], .values-editor__simple-input[disabled]',
);
await expect(maskedState).toBeVisible({timeout: 5000});
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
test('MM-68508-22: Fully-masked hasAnyOf row displays correct operator', async ({pw}) => {
// Regression test for: when a caller holds none of the values in a
// hasAnyOf condition, all values are replaced by a single masked-token
// sentinel. The masked expression re-parses to a standalone "tok in attr"
// which mergeMultiselectConditions promotes to hasAllOf — showing the wrong
// operator in the table editor. The fix emits a duplicate-token OR to
// preserve hasAnyOf semantics through the re-parse cycle.
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const fieldIds: string[] = [];
const policyIds: string[] = [];
try {
await enableUserManagedAttributes(adminClient);
await enableMaskingFlag(adminClient);
const fieldName = `${fieldPrefix}Team_${pw.random.id()}`;
const fieldId = await createMaskingMultiselectField(adminClient, fieldName, ['Alpha', 'Bravo']);
fieldIds.push(fieldId);
// adminUser holds NONE of the values — the entire condition is fully masked.
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Policy uses hasAnyOf: ("Alpha" in attr || "Bravo" in attr)
const policyName = `MaskingPolicy ${pw.random.id()}`;
const policyId = await createPolicyWithCEL(
page,
policyName,
`("Alpha" in user.attributes.${fieldName} || "Bravo" in user.attributes.${fieldName})`,
);
policyIds.push(policyId);
await setFieldAsSharedOnly(fieldId);
await openExistingPolicy(page, policyName);
// Only the masked chip is visible — caller holds no values.
await expect(page.locator('.select__multi-value--masked')).toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Alpha'})).not.toBeVisible();
await expect(page.locator('.select__multi-value').filter({hasText: 'Bravo'})).not.toBeVisible();
// The operator selector on the masked row must show "has any of", NOT "has all of".
const operatorBtn = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await operatorBtn.waitFor({state: 'visible', timeout: 10000});
await expect(operatorBtn).toContainText('has any of');
await expect(operatorBtn).not.toContainText('has all of');
} finally {
for (const id of policyIds) {
try {
await deletePolicy(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
for (const id of fieldIds) {
try {
await deleteCPAField(adminClient, id);
} catch {} // eslint-disable-line no-empty
}
try {
await disableMaskingFlag(adminClient);
} catch {} // eslint-disable-line no-empty
}
});
});

View File

@@ -0,0 +1,486 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
createUserWithAttributes,
getAdminClient,
TestBrowser,
getRandomId,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
ensureUserAttributes,
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForLatestSyncJob,
waitForPolicySyncJob,
getJobDetailsFromRecentJobs,
enableUserManagedAttributes,
assertAccessControlAutocompleteContains,
} from '../support';
/**
* ABAC Policies - Advanced Policies
*
* The previous monolithic MM-T5785 and MM-T5786 tests each did 3–5 independent
* assertions wrapped around one very-expensive sync-job cycle. They are now
* split into separate `test(...)` blocks — either sharing a single beforeAll
* (MM-T5785) or split into one-operator-per-test (MM-T5786) so the whole
* test-file parallelises cleanly under sharding.
*/
test.describe('ABAC Policies - Advanced Policies - MM-T5785 all attribute types (auto-add)', () => {
let sharedAdminClient: any;
let user1: Awaited<ReturnType<typeof createUserWithAttributes>>; // qualifying, NOT in channel
let user2: Awaited<ReturnType<typeof createUserWithAttributes>>; // qualifying, IN channel
let user3: Awaited<ReturnType<typeof createUserWithAttributes>>; // non-qualifying, IN channel
let privateChannel: any;
let licensed = true;
test.beforeAll(async ({browser}) => {
test.setTimeout(240000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
sharedAdminClient = adminClient;
// License gate
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await ensureUserAttributes(adminClient);
const suffix = getRandomId();
const team = await adminClient.createTeam({
name: `abac-${suffix}`,
display_name: `ABAC ${suffix}`,
type: 'O',
} as any);
// Three users with different attribute combinations
user1 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
user2 = await createUserWithAttributes(adminClient, {Department: 'Engineering'});
user3 = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, user1.id);
await adminClient.addToTeam(team.id, user2.id);
await adminClient.addToTeam(team.id, user3.id);
// Attribute indexing settle
await new Promise((resolve) => setTimeout(resolve, 2000));
privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(user2.id, privateChannel.id);
await adminClient.addToChannel(user3.id, privateChannel.id);
// Drive policy creation via the system-console UI exactly once.
const tb = new TestBrowser(browser);
try {
const {systemConsolePage} = await tb.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Multi-Attr Policy ${getRandomId()}`;
const celExpression = 'user.attributes.Department == "Engineering"';
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression,
autoSync: true,
channels: [privateChannel.display_name],
});
await systemConsolePage.page.waitForTimeout(1000);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (!policyId) {
throw new Error('Could not get policy ID');
}
await searchInput.clear();
await activatePolicy(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
const jobId1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 10, jobId1);
try {
await getJobDetailsFromRecentJobs(systemConsolePage.page, privateChannel.display_name);
} catch {
// non-fatal
}
// Optional extra sync if user1 not added yet
const added = await verifyUserInChannel(adminClient, user1.id, privateChannel.id);
if (!added) {
const jobId2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 10, jobId2);
await systemConsolePage.page.waitForTimeout(2000);
}
} finally {
await tb.close().catch(() => {});
}
});
test('MM-T5785_a auto-adds qualifying user who was not in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user1.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user1 should be auto-added to channel',
})
.toBe(true);
});
test('MM-T5785_b keeps qualifying user who was already in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user2.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user2 should stay in channel',
})
.toBe(true);
});
test('MM-T5785_c auto-removes non-qualifying user who was in channel', async () => {
test.setTimeout(60000);
test.skip(!licensed, 'No ABAC license');
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, user3.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'user3 should be auto-removed from channel',
})
.toBe(false);
});
});
/**
* MM-T5786: Attribute-based access policy using operator variations in Simple mode.
*
* Splits the original test's 5 operator sequences into 5 independent tests
* that share a single beforeAll (which creates the shared team, attributes,
* engineer/sales users, and admin-logged-in system-console page). Each
* operator then adds its own channel + policy and verifies independently.
*
* Tests run serially within the same worker for this file, so mutations on
* the shared page don't race.
*/
test.describe('ABAC Policies - Advanced Policies - MM-T5786 operator variants', () => {
test.describe.configure({mode: 'serial'});
let sharedAdminClient: any;
let sharedTeamId: string;
let engineerUser: Awaited<ReturnType<typeof createUserForABAC>>;
let salesUser: Awaited<ReturnType<typeof createUserForABAC>>;
let deptFieldName: string;
let systemConsolePage: {page: Page};
let sharedTestBrowser: TestBrowser | null = null;
let licensed = true;
test.beforeAll(async ({browser}) => {
test.setTimeout(180000);
const {adminClient, adminUser} = await getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
sharedAdminClient = adminClient;
try {
const lic = await adminClient.getClientLicenseOld();
if (!lic || lic.IsLicensed !== 'true') {
licensed = false;
return;
}
} catch {
licensed = false;
return;
}
await enableUserManagedAttributes(adminClient);
const suffix = getRandomId();
deptFieldName = `MM5786_Dept_${suffix}`;
const attributeFields: CustomProfileAttribute[] = [
{name: deptFieldName, type: 'text', value: '', attrs: {managed: 'admin', visibility: 'when_set'}},
];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
await assertAccessControlAutocompleteContains(adminClient, [deptFieldName]);
engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, type: 'text', value: 'Engineering'},
]);
salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, type: 'text', value: 'Sales'},
]);
const teamSuffix = getRandomId();
const team = await adminClient.createTeam({
name: `abac-ops-${teamSuffix}`,
display_name: `ABAC-Ops ${teamSuffix}`,
type: 'O',
} as any);
sharedTeamId = team.id;
await adminClient.addToTeam(sharedTeamId, engineerUser.id);
await adminClient.addToTeam(sharedTeamId, salesUser.id);
sharedTestBrowser = new TestBrowser(browser);
const loggedIn = await sharedTestBrowser.login(adminUser);
systemConsolePage = loggedIn.systemConsolePage;
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
});
test.afterAll(async () => {
await sharedTestBrowser?.close().catch(() => {});
});
async function runOperatorCase(celExpression: string, namePrefix: string, searchTerm: string) {
const channel = await createPrivateChannelForABAC(sharedAdminClient, sharedTeamId);
await sharedAdminClient.addToChannel(salesUser.id, channel.id);
await navigateToABACPage(systemConsolePage.page);
const policyName = `${namePrefix} Policy ${getRandomId()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: celExpression.replaceAll('user.attributes.Department', `user.attributes.${deptFieldName}`),
autoSync: true,
channels: [channel.display_name],
});
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.fill(searchTerm);
// Wait for the exact policy row to appear instead of grabbing .first() blindly.
// Under parallel load the grid update may be delayed, and .first() can return a
// policy created by another concurrent test.
const policyRow = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
await expect
.poll(() => policyRow.isVisible(), {
timeout: 45_000,
intervals: [200, 500, 1000, 2000],
message: `policy row for "${policyName}" should appear in search results`,
})
.toBe(true);
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(sharedAdminClient, policyId);
const jobId3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 15, jobId3);
}
await searchInput.clear();
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, engineerUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'engineerUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(sharedAdminClient, salesUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesUser should not be in channel',
})
.toBe(false);
}
test('MM-T5786_a is-not (!=) operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department != "Sales"', 'IsNot', 'IsNot');
});
test('MM-T5786_b in operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department in ["Engineering", "DevOps"]', 'In', 'In Policy');
});
test('MM-T5786_c starts-with operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.startsWith("Eng")', 'StartsWith', 'StartsWith');
});
test('MM-T5786_d ends-with operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.endsWith("ing")', 'EndsWith', 'EndsWith');
});
test('MM-T5786_e contains operator', async () => {
test.setTimeout(90000);
test.skip(!licensed, 'No ABAC license');
await runOperatorCase('user.attributes.Department.contains("gineer")', 'Contains', 'Contains');
});
});
/**
* MM-T5787: Complex CEL expressions with || and grouping ().
* Kept as a single test because its three verifications are cheap once
* the single sync-job cycle completes.
*/
test.describe('ABAC Policies - Advanced Policies', () => {
test('MM-T5787 Test policy with complex rules in Advanced Mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const idSuffix = pw.random.id();
const deptFieldName = `MM5787_Dept_${idSuffix}`;
const locationFieldName = `MM5787_Loc_${idSuffix}`;
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{
name: deptFieldName,
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set'},
},
{
name: locationFieldName,
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set'},
},
]);
await assertAccessControlAutocompleteContains(adminClient, [deptFieldName, locationFieldName]);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Engineering', type: 'text'},
{name: locationFieldName, value: 'Office', type: 'text'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Sales', type: 'text'},
{name: locationFieldName, value: 'Remote', type: 'text'},
]);
const salesOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: deptFieldName, value: 'Sales', type: 'text'},
{name: locationFieldName, value: 'Office', type: 'text'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
await adminClient.addToTeam(team.id, salesOfficeUser.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesOfficeUser.id, channel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
const policyName = `Complex Policy ${pw.random.id()}`;
const complexExpression = `user.attributes.${deptFieldName} == "Engineering" || (user.attributes.${deptFieldName} == "Sales" && user.attributes.${locationFieldName} == "Remote")`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: complexExpression,
autoSync: true,
channels: [channel.display_name],
});
await navigateToABACPage(systemConsolePage.page);
await systemConsolePage.page.waitForTimeout(500);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
const policyIdMatch = policyName.match(/([a-z0-9]+)$/i);
const searchTerm = policyIdMatch ? policyIdMatch[1] : policyName;
await searchInput.fill(searchTerm);
await systemConsolePage.page.waitForTimeout(500);
const foundPolicy = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
await expect
.poll(() => foundPolicy.isVisible(), {
timeout: 15_000,
message: `policy "${policyName}" should appear after search`,
})
.toBe(true);
const policyId = (await foundPolicy.getAttribute('id'))?.replace('customDescription-', '');
expect(policyId, 'policy row should expose id').toBeTruthy();
await activatePolicy(adminClient, policyId!);
const jobId4 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 5, jobId4);
await searchInput.clear();
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'engineerUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesRemoteUser should be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesOfficeUser.id, channel.id), {
timeout: 90_000,
intervals: [500, 1000, 2000, 4000],
message: 'salesOfficeUser should not be in channel',
})
.toBe(false);
});
});

View File

@@ -0,0 +1,218 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, navigateToABACPage, runSyncJob, verifyUserInChannel} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {
ensureUserAttributes,
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createAdvancedPolicy,
activatePolicy,
waitForPolicySyncJob,
getPolicyIdByName,
} from '../support';
/**
* MM-T5786 (1/5): "is not" (!=) operator — Department != "Sales" with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "is not" (!=) operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `IsNot Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department != "Sales"',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department != "Sales"', [engineerUser.id]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});
/**
* MM-T5786 (2/5): "in" operator — Department in ["Engineering", "DevOps"] with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "in" operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `In Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department in ["Engineering", "DevOps"]',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department in ["Engineering", "DevOps"]', [
engineerUser.id,
]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});
/**
* MM-T5786 (3/5): "starts with" operator — Department.startsWith("Eng") with auto-add
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5786.md
*/
test('MM-T5786 Test "starts with" operator in Simple mode', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesUser.id, channel.id);
await ensureUserAttributes(adminClient);
const policyName = `StartsWith Policy ${await pw.random.id()}`;
await createAdvancedPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department.startsWith("Eng")',
autoSync: true,
channels: [channel.display_name],
});
const policyId = (await getPolicyIdByName(adminClient, policyName))!;
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [engineerUser.username],
expectedNonMatchingUsers: [salesUser.username],
});
await navigateToABACPage(systemConsolePage.page);
}
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department.startsWith("Eng")', [engineerUser.id]);
await activatePolicy(adminClient, policyId);
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
await expect
.poll(() => verifyUserInChannel(adminClient, engineerUser.id, channel.id), {
timeout: 15_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be in channel after sync',
})
.toBe(true);
expect(await verifyUserInChannel(adminClient, salesUser.id, channel.id)).toBe(false);
});

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for MM-64357 in the Membership Policy editor.
*
* A rule value containing a quote character (e.g. the apostrophe in
* "Matt's Department") must still be recognized as a simple expression. Before
* the fix the quoted-value matcher forbade any inner quote, so such a rule was
* misclassified as complex: the "Switch to Simple Mode" toggle became disabled
* and the admin was trapped in Advanced mode after a save/reopen.
*
* These specs drive the rendered UI (button enabled/disabled state and the
* save -> reopen round-trip) rather than the helper functions directly, which
* is the coverage gap flagged in review.
*/
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
import {deleteCustomProfileAttributes} from '../../../channels/custom_profile_attributes/helpers';
const APOSTROPHE_VALUE = "Matt's Department";
test.describe('System Console - Membership Policy apostrophe values (MM-64357)', () => {
let adminClient: Client4;
let adminUser: UserProfile;
let field: UserPropertyField | undefined;
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const clientInfo = await pw.getAdminClient();
adminClient = clientInfo.adminClient;
adminUser = clientInfo.adminUser!;
// # Create an admin-managed text attribute (admin-managed → usable in ABAC)
field = await adminClient.createCustomProfileAttributeField({
name: `department_${getRandomId()}`,
type: 'text',
attrs: {
sort_order: 0,
managed: 'admin',
},
} as any);
// # Make sure ABAC stays enabled (a concurrent initSetup can reset config)
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
});
test.afterEach(async () => {
if (field) {
await deleteCustomProfileAttributes(adminClient, {
[field.id]: field,
__ownedIds: new Set([field.id]),
} as any);
field = undefined;
}
});
/**
* @objective A rule whose value contains an apostrophe survives a
* save/reopen round-trip: the stored expression rehydrates as a simple
* expression so the "Switch to Simple Mode" toggle stays enabled (the admin
* is not trapped in Advanced mode) and the table editor re-renders the same
* value.
*
* @precondition
* An admin-managed text attribute exists.
*/
test('round-trips an apostrophe "is" value and keeps the mode toggle switchable', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
const policyName = `Apostrophe RT ${getRandomId()}`;
let policyId: string | null = null;
try {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Create a policy and add a text attribute row
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(policyName);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
// # Select the text attribute by name
if (!(await page.locator('[id^="attribute-selector-menu"]').isVisible({timeout: 2000}))) {
await page.locator('[data-testid="attributeSelectorMenuButton"]').first().click();
}
await page
.locator(`[id^="attribute-selector-menu"] li:has-text("${field!.name}")`)
.first()
.click({force: true});
// # Dismiss the attribute menu
await page.keyboard.press('Escape');
// * Verify the attribute menu is hidden
await expect(page.locator('[id^="attribute-selector-menu"]')).toBeHidden();
// * A text attribute defaults to the "is" operator
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText('is');
// # Type a value containing an apostrophe
const valueInput = page.locator('.values-editor__simple-input, input[placeholder*="Add value" i]').first();
await valueInput.waitFor({state: 'visible', timeout: 10000});
await valueInput.fill(APOSTROPHE_VALUE);
await expect(valueInput).toHaveValue(APOSTROPHE_VALUE);
// # Save the policy
await page.getByRole('button', {name: 'Save'}).last().click();
await page.waitForLoadState('networkidle');
// # Reopen the policy from the list
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).first();
await expect(policyRow).toBeVisible({timeout: 10000});
const rowId = await policyRow.getAttribute('id');
policyId = rowId?.replace('customDescription-', '') ?? null;
await policyRow.click();
await page.waitForLoadState('networkidle');
// # Switch to Advanced mode so the "Switch to Simple Mode" toggle is present.
const toAdvanced = page.getByRole('button', {name: 'Switch to Advanced Mode'});
if (await toAdvanced.isVisible({timeout: 5000}).catch(() => false)) {
await expect(toAdvanced).toBeEnabled({timeout: 60_000});
await toAdvanced.click();
}
// * The stored apostrophe expression classifies as simple, so the
// toggle back to Simple mode stays enabled (before the fix it was
// disabled, trapping the admin in Advanced mode).
const toSimpleMode = page.getByRole('button', {name: 'Switch to Simple Mode'});
await expect(toSimpleMode).toBeVisible({timeout: 10_000});
await expect(toSimpleMode).toBeEnabled();
// # Return to Simple mode and confirm the value round-tripped intact
await toSimpleMode.click();
const reopenedValue = page
.locator('.values-editor__simple-input, input[placeholder*="Add value" i]')
.first();
await expect(reopenedValue).toHaveValue(APOSTROPHE_VALUE, {timeout: 10_000});
} finally {
// Clean up the policy. If we didn't capture the ID from the DOM (e.g. because
// save succeeded but navigation/assertion failed), search for it by name.
let idToDelete = policyId;
if (!idToDelete) {
try {
const searchResult = await adminClient.searchAccessControlPolicies(policyName, 'parent', '', 10);
const foundPolicy = searchResult.policies?.find((p) => p.name === policyName);
if (foundPolicy) {
idToDelete = foundPolicy.id;
}
} catch {
// Search failed; skip cleanup
}
}
if (idToDelete) {
await adminClient.deleteAccessControlPolicy(idToDelete).catch(() => {});
}
}
});
});

View File

@@ -0,0 +1,406 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
ensureUserAttributes,
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC Policies - Channel Integration
* Tests for managing ABAC policies through Channel Configuration
*/
test.describe('ABAC Policies - Channel Integration', () => {
/**
* MM-T5788: Add attribute-based policy to a channel from Channel Configuration page
* @objective Verify that a policy can be added to a channel from the Channel Configuration page
*
* Steps:
* 1. As admin go to System Console > User Management > Channels
*/
test('MM-T5788 Add policy to channel from Channel Configuration page', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users, channel, and policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Create satisfying user (Department=Engineering) - NOT in channel initially
const satisfyingUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
await adminClient.addToTeam(team.id, satisfyingUser.id);
// Create non-satisfying user (Department=Sales) - will be IN channel initially
const nonSatisfyingUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
await adminClient.addToTeam(team.id, nonSatisfyingUser.id);
// Create private channel and add non-satisfying user
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(nonSatisfyingUser.id, channel.id);
// Login and setup ABAC
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Create policy without channels (we'll link via Channel Config)
const policyName = `Channel Config Policy ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
});
// Get search term for policy
const policyIdMatch = policyName.match(/([a-z0-9]+)$/i);
const searchTerm = policyIdMatch ? policyIdMatch[1] : policyName;
// ============================================================
// STEP 1-2: Navigate to Channel Configuration
// ============================================================
await systemConsolePage.page.goto('/admin_console/user_management/channels');
await systemConsolePage.page.waitForLoadState('networkidle');
// Search and find our channel
const channelSearchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await channelSearchInput.fill(channel.display_name);
await systemConsolePage.page.waitForTimeout(1000);
// Verify channel shows "Manual Invites" management
const channelRow = systemConsolePage.page
.locator('.DataGrid_row')
.filter({hasText: channel.display_name})
.first();
// const managementText = await channelRow.textContent();
// Click Edit
await channelRow.getByText('Edit').click();
await systemConsolePage.page.waitForLoadState('networkidle');
// ============================================================
// STEP 3: Toggle on "Enable attribute based channel access"
// ============================================================
const abacToggle = systemConsolePage.page.locator('[data-testid="policy-enforce-toggle-button"]');
await abacToggle.waitFor({state: 'visible', timeout: 5000});
const isEnabled = await abacToggle.getAttribute('aria-pressed');
if (isEnabled !== 'true') {
await abacToggle.click();
}
await systemConsolePage.page.waitForTimeout(500);
// ============================================================
// STEP 4: Link to policy and Save
// ============================================================
// Click "Link to a policy"
const linkButton = systemConsolePage.page.locator('[data-testid="link-to-a-policy"]');
await linkButton.waitFor({state: 'visible', timeout: 5000});
await linkButton.click();
await systemConsolePage.page.waitForTimeout(500);
// Select policy in modal
const modal = systemConsolePage.page.locator('[role="dialog"]').filter({hasText: 'Select a Membership Policy'});
await modal.waitFor({state: 'visible', timeout: 5000});
const modalSearch = modal.locator('[data-testid="searchInput"]');
await modalSearch.fill(searchTerm);
await systemConsolePage.page.waitForTimeout(1000);
const policyOption = modal.locator('.DataGrid_row').filter({hasText: policyName}).first();
await policyOption.click();
await systemConsolePage.page.waitForTimeout(500);
// Save
await systemConsolePage.page.getByRole('button', {name: 'Save'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// ============================================================
// Run sync to apply policy
// ============================================================
await systemConsolePage.page.waitForTimeout(2000);
await navigateToABACPage(systemConsolePage.page);
const jobId1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, 5, jobId1);
// ============================================================
// VERIFY: Channel membership
// ============================================================
// 1. Non-satisfying user should be REMOVED
const nonSatisfyingInChannel = await verifyUserInChannel(adminClient, nonSatisfyingUser.id, channel.id);
expect(nonSatisfyingInChannel).toBe(false);
// 2. Satisfying user should NOT be auto-added (per requirement)
const satisfyingInChannel = await verifyUserInChannel(adminClient, satisfyingUser.id, channel.id);
// Note: If implementation auto-adds, this will fail. Adjust if needed.
expect(satisfyingInChannel).toBe(false);
// 3. Satisfying user CAN be manually added
await adminClient.addToChannel(satisfyingUser.id, channel.id);
const afterManualAdd = await verifyUserInChannel(adminClient, satisfyingUser.id, channel.id);
expect(afterManualAdd).toBe(true);
// 4. Non-satisfying user CANNOT be added (blocked by policy)
let blocked = false;
try {
await adminClient.addToChannel(nonSatisfyingUser.id, channel.id);
} catch {
// Expected to fail - policy blocks non-qualifying users
blocked = true;
}
expect(blocked).toBe(true);
});
/**
* MM-T5789: Channel cannot use attribute-based policies if already constrained by LDAP group sync
*
* Preconditions:
* - At least one policy exists on the server
* - At least one channel configured to be constrained by LDAP group sync
*
* Step 1:
* 1. As admin go to System Console > User Management > Channels
* 2. Click a channel with Management = "Group Sync" (private channel)
* 3. Observe "Enable attribute based channel access" is NOT available
* 4. Toggle off "Sync Group Members", observe ABAC becomes available
*
* Step 2:
* 1. Go to System Console > User Management > Attribute-Based Access
* 2. Click a policy to edit it, click Add channels
* 3. Select a channel constrained by LDAP group sync
*
* Expected: ABAC not available for channels using LDAP group sync
*
* Test Data Note: Current UI behavior is uncertain - may allow save but channel
* not added, or may show error. This test observes and documents actual behavior.
*
* Implementation Note: We mock Group Sync by setting group_constrained=true via API.
* This works without LDAP - the server accepts it and UI shows "Group Sync".
*/
test('MM-T5789 Channel with LDAP group sync cannot use ABAC', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
// ===========================================
// PRECONDITION 1: Create a policy first
// ===========================================
await navigateToABACPage(page);
await enableABAC(page);
const policyName = `ABAC-GroupSync-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
});
// ===========================================
// PRECONDITION 2: Create a Group Sync channel via API
// We mock Group Sync by setting group_constrained=true
// This works without LDAP configuration
// ===========================================
const groupSyncChannelName = `ABAC-GroupSync-${pw.random.id()}`;
const groupSyncChannel = await adminClient.createChannel({
team_id: team.id,
name: groupSyncChannelName.toLowerCase().replace(/[^a-z0-9]/g, ''),
display_name: groupSyncChannelName,
type: 'P', // Private
});
// Set group_constrained=true via API to mock Group Sync
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: true,
} as any);
// ===========================================
// STEP 1: Navigate to Group Sync channel config
// ===========================================
await page.goto('/admin_console/user_management/channels');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Search for our channel
const searchInput = page.locator('input[placeholder*="Search" i]').first();
if (await searchInput.isVisible({timeout: 3000})) {
await searchInput.fill(groupSyncChannelName);
await page.waitForTimeout(1000);
}
// Verify channel shows as "Group Sync"
const channelRow = page.locator('.DataGrid_row').filter({hasText: groupSyncChannelName}).first();
await channelRow.waitFor({state: 'visible', timeout: 10000});
const rowText = await channelRow.textContent();
expect(rowText).toContain('Group Sync');
// Click Edit to open channel configuration
await channelRow.getByText('Edit').click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// STEP 1.3: Verify ABAC toggle is NOT available when Group Sync is enabled
const abacToggle = page.locator('[data-testid="policy-enforce-toggle-button"]');
const abacVisibleWithGroupSync = await abacToggle.isVisible({timeout: 5000}).catch(() => false);
expect(abacVisibleWithGroupSync).toBe(false);
// STEP 1.4: Toggle off Group Sync and verify ABAC becomes available
// Disable Group Sync via API (more reliable than UI toggle)
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: false,
} as any);
// Reload page to see updated UI
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify ABAC toggle is now available
const abacToggleAfter = page.locator('[data-testid="policy-enforce-toggle-button"]');
const abacVisibleAfterDisable = await abacToggleAfter.isVisible({timeout: 5000}).catch(() => false);
expect(abacVisibleAfterDisable).toBe(true);
// Re-enable Group Sync for Step 2
await adminClient.patchChannel(groupSyncChannel.id, {
group_constrained: true,
} as any);
// ===========================================
// STEP 2: Try to add Group Sync channel to existing policy
// ===========================================
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Step 2.2: Click the policy to edit it
// Search for the policy first
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Click on the policy row (use text-based locator)
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Click Add channels button
const addChannelsButton = page.getByRole('button', {name: /add channel/i});
await addChannelsButton.waitFor({state: 'visible', timeout: 10000});
await addChannelsButton.click();
await page.waitForTimeout(1000);
// Step 2.3: Try to select the Group Sync channel
const channelModal = page.locator('[role="dialog"]').filter({hasText: /channel/i});
await channelModal.waitFor({state: 'visible', timeout: 5000});
// Search for the Group Sync channel
const modalSearchInput = channelModal.locator('[data-testid="searchInput"], input[type="text"]').first();
if (await modalSearchInput.isVisible({timeout: 3000})) {
await modalSearchInput.fill(groupSyncChannelName);
await page.waitForTimeout(1000);
}
// Document actual behavior (requirement notes uncertainty)
const channelRows = channelModal.locator('.DataGrid_row, .more-modal__row');
const rowCount = await channelRows.count();
if (rowCount === 0) {
// Group Sync channel is filtered out - good behavior
} else {
// Channel is shown - try to select it
const channelRowToSelect = channelRows.first();
await channelRowToSelect.textContent();
// Try to click/select the channel
await channelRowToSelect.click({timeout: 5000}).catch(() => {
// Ignore click errors
});
await page.waitForTimeout(500);
// Try to click Add button
const addButton = channelModal.getByRole('button', {name: 'Add'});
if (await addButton.isVisible({timeout: 3000})) {
const addButtonDisabled = await addButton.isDisabled();
if (addButtonDisabled) {
// Add button is disabled
} else {
await addButton.click();
await page.waitForTimeout(1000);
}
}
// Close modal
const closeButton = channelModal.getByRole('button', {name: /close|cancel|×/i});
if (await closeButton.isVisible({timeout: 2000})) {
await closeButton.click();
await page.waitForTimeout(500);
}
// Check if we're back on edit page - try to save
const saveButton = page.getByRole('button', {name: 'Save'});
if (await saveButton.isVisible({timeout: 3000})) {
const saveEnabled = await saveButton.isEnabled();
if (saveEnabled) {
await saveButton.click();
await page.waitForTimeout(2000);
// Check for error message
const errorMessage = page.locator('.error-message, [class*="error"], .alert-danger');
const hasError = await errorMessage.isVisible({timeout: 3000}).catch(() => false);
if (hasError) {
await errorMessage.textContent();
} else {
// Check if channel was actually added
}
}
}
}
});
});

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
waitForPolicySyncJob,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC Policies - Create Policies
* Tests for creating ABAC policies with different auto-add settings
*/
test.describe('ABAC Policies - Create Policies', () => {
test('MM-T5783 Create and test policy with auto-add disabled', async ({pw}) => {
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users and channel BEFORE creating policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Define and create the Department attribute field
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
// Create 3 users as per test case:
// 1. satisfyingUserNotInChannel - Department=Engineering, NOT in channel initially
// 2. satisfyingUserInChannel - Department=Engineering, IN channel initially
// 3. nonSatisfyingUserInChannel - Department=Sales, IN channel initially
const satisfyingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const satisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const nonSatisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add all users to the team
await adminClient.addToTeam(team.id, satisfyingUserNotInChannel.id);
await adminClient.addToTeam(team.id, satisfyingUserInChannel.id);
await adminClient.addToTeam(team.id, nonSatisfyingUserInChannel.id);
// Create private channel and add users 2 and 3 (but NOT user 1)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(satisfyingUserInChannel.id, privateChannel.id);
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
// Verify initial channel state
const initialUser1InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
const initialUser2InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserInChannel.id,
privateChannel.id,
);
const initialUser3InChannel = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(initialUser1InChannel).toBe(false);
expect(initialUser2InChannel).toBe(true);
expect(initialUser3InChannel).toBe(true);
// ============================================================
// STEP 1-4: Login, navigate to ABAC, create policy with rule and channel
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Use the working createBasicPolicy helper (same as MM-T5784)
const policyName = `Engineering Policy ${pw.random.id()}`;
const jobIdMM5783 = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add DISABLED for this test
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 3: Test Access Rule (navigate back to policy to test)
// ============================================================
// Navigate back to policy to test the access rule
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
const testResult = await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [satisfyingUserNotInChannel.username, satisfyingUserInChannel.username],
expectedNonMatchingUsers: [nonSatisfyingUserInChannel.username],
});
expect(testResult.expectedUsersMatch).toBe(true);
expect(testResult.unexpectedUsersMatch).toBe(false);
// Navigate back to ABAC page
await navigateToABACPage(systemConsolePage.page);
}
// Wait for sync job to complete (triggered by createBasicPolicy)
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobIdMM5783);
// ============================================================
// STEP 5-7: Verify channel membership after sync
// ============================================================
// Step 5: User who satisfies policy but NOT in channel → should NOT be auto-added
const user1AfterSync = await verifyUserInChannel(adminClient, satisfyingUserNotInChannel.id, privateChannel.id);
expect(user1AfterSync).toBe(false); // NOT auto-added because auto-add is FALSE
// Step 6: User who satisfies policy and IS in channel → no change (stays in channel)
const user2AfterSync = await verifyUserInChannel(adminClient, satisfyingUserInChannel.id, privateChannel.id);
expect(user2AfterSync).toBe(true); // Stays in channel
// Step 7: User who does NOT satisfy policy and IS in channel → auto-removed
const user3AfterSync = await verifyUserInChannel(adminClient, nonSatisfyingUserInChannel.id, privateChannel.id);
expect(user3AfterSync).toBe(false); // AUTO-REMOVED
// ============================================================
// STEP 8: Admin can manually add the satisfying user to channel
// Validate: satisfying user CAN be added, non-satisfying user CANNOT
// ============================================================
// 8a. Add user who SATISFIES the policy - should succeed
await adminClient.addToChannel(satisfyingUserNotInChannel.id, privateChannel.id);
const user1AfterManualAdd = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
expect(user1AfterManualAdd).toBe(true); // Successfully added by admin
// 8b. Try to add user who does NOT satisfy the policy - should FAIL
try {
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
} catch {
// Expected to fail - policy prevents non-compliant users
}
// Verify the non-satisfying user is NOT in the channel
const user3AfterAttempt = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(user3AfterAttempt).toBe(false); // Policy prevents non-compliant users
});
/**
* MM-T5784: Attribute-based access policy created in System Console controls access as specified
* (one attribute, = is, with auto-add)
*
* @reference https://github.com/mattermost/mattermost-test-management/blob/main/data/test-cases/channels/abac-attribute-based-access/abac-system-admin/MM-T5784.md
*
* Test Steps:
* 1. As system admin, go to ABAC page, click Add policy, enter name, set Auto-add = TRUE
* 2. Select policy values: Attribute, Operator, and Value (just one)
* 3. Click Test Access Rule, observe users who satisfy the policy are listed
* 4. Click Add channels and select a channel, then save
* 5. User who satisfies policy but NOT in channel → should be AUTO-ADDED
* 6. User who satisfies policy and IS in channel → no change (stays in channel)
* 7. User who does NOT satisfy policy and IS in channel → auto-removed
*
* Expected:
* - User who satisfies the policy is auto-added
* - User who does not satisfy the policy is auto-removed
*/
test('MM-T5784 Create and test policy with auto-add enabled', async ({pw}) => {
// Increase timeout for this complex test to prevent trace file race conditions
test.setTimeout(120000); // 2 minutes instead of default 1 minute
// # Skip test if no license for ABAC
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create users and channel BEFORE creating policy
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Define and create the Department attribute field
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
// Create 3 users as per test case:
// 1. satisfyingUserNotInChannel - Department=Engineering, NOT in channel initially
// 2. satisfyingUserInChannel - Department=Engineering, IN channel initially
// 3. nonSatisfyingUserInChannel - Department=Sales, IN channel initially
const satisfyingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const satisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
const nonSatisfyingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add all users to the team
await adminClient.addToTeam(team.id, satisfyingUserNotInChannel.id);
await adminClient.addToTeam(team.id, satisfyingUserInChannel.id);
await adminClient.addToTeam(team.id, nonSatisfyingUserInChannel.id);
// Wait for user attributes to be indexed before creating policy
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create private channel and add users 2 and 3 (but NOT user 1)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(satisfyingUserInChannel.id, privateChannel.id);
await adminClient.addToChannel(nonSatisfyingUserInChannel.id, privateChannel.id);
// Verify initial channel state
const initialUser1InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserNotInChannel.id,
privateChannel.id,
);
const initialUser2InChannel = await verifyUserInChannel(
adminClient,
satisfyingUserInChannel.id,
privateChannel.id,
);
const initialUser3InChannel = await verifyUserInChannel(
adminClient,
nonSatisfyingUserInChannel.id,
privateChannel.id,
);
expect(initialUser1InChannel).toBe(false);
expect(initialUser2InChannel).toBe(true);
expect(initialUser3InChannel).toBe(true);
// ============================================================
// STEP 1-4: Login, navigate to ABAC, create policy with auto-add TRUE
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
// Use createBasicPolicy with autoSync: true
const policyName = `Auto-Add Policy ${pw.random.id()}`;
const jobIdMM5784 = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add ENABLED for this test
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 3: Test Access Rule (navigate back to policy to test)
// ============================================================
await systemConsolePage.page.waitForTimeout(1000);
const policyRowForTest = systemConsolePage.page.locator('.policy-name').filter({hasText: policyName}).first();
if (await policyRowForTest.isVisible({timeout: 3000})) {
await policyRowForTest.click();
await systemConsolePage.page.waitForLoadState('networkidle');
const testResult = await testAccessRule(systemConsolePage.page, {
expectedMatchingUsers: [satisfyingUserNotInChannel.username, satisfyingUserInChannel.username],
expectedNonMatchingUsers: [nonSatisfyingUserInChannel.username],
});
expect(testResult.expectedUsersMatch).toBe(true);
expect(testResult.unexpectedUsersMatch).toBe(false);
await navigateToABACPage(systemConsolePage.page);
}
// Wait for initial sync job to complete
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobIdMM5784);
// Get policy ID and activate it for auto-add to work
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (!policyId) {
throw new Error('Could not get policy ID');
}
await searchInput.clear();
// Activate the policy so auto-add works
await activatePolicy(adminClient, policyId);
// Run sync job with active policy; poll by policyId to avoid picking up a
// concurrent shard's job completing first
await runSyncJob(systemConsolePage.page);
await waitForPolicySyncJob(adminClient, policyId);
// ============================================================
// STEP 5-7: Verify via API
// ============================================================
// Step 5: User who satisfies policy but NOT in channel → should be AUTO-ADDED
const user1AfterSync = await verifyUserInChannel(adminClient, satisfyingUserNotInChannel.id, privateChannel.id);
expect(user1AfterSync).toBe(true); // AUTO-ADDED because auto-add is TRUE
// Step 6: User who satisfies policy and IS in channel → no change (stays in channel)
const user2AfterSync = await verifyUserInChannel(adminClient, satisfyingUserInChannel.id, privateChannel.id);
expect(user2AfterSync).toBe(true); // Stays in channel
// Step 7: User who does NOT satisfy policy and IS in channel → auto-removed
const user3AfterSync = await verifyUserInChannel(adminClient, nonSatisfyingUserInChannel.id, privateChannel.id);
expect(user3AfterSync).toBe(false); // AUTO-REMOVED
});
/**
* MM-63848: Creating a policy with a name that already exists should show an error
*/
test('MM-63848 Should show error when creating policy with duplicate name', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and policy creation
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Create the first policy
const policyName = `Duplicate Test ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Navigate back and try to create another policy with the same name.
// Re-apply guards: a concurrent initSetup() may have reset EnableAttributeBasedAccessControl
// AND deleted custom profile attributes between the first createBasicPolicy call and now.
// Without the Department attribute the attributeSelectorMenuButton has no items and times out.
await navigateToABACPage(page);
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [],
});
// Verify error message is shown
const errorMessage = page.locator('.EditPolicy__error');
await expect(errorMessage).toBeVisible({timeout: 5000});
const errorText = await errorMessage.textContent();
expect(errorText).toContain('A policy with this name already exists');
});
});

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {ensureUserAttributes, navigateToPermissionPoliciesPage} from '../support';
/**
* Permission Policies - Create Policy form UI and validation (MM-64508)
*
* Covers the create-form UI elements (name input, role dropdown, CEL editor mode
* toggle, info banner) and inline validation (empty name / expression / permissions).
* See permission_policies_create_save.spec.ts for the save/cancel flows that
* actually persist policies.
*/
test.describe('Permission Policies - Create Policy', () => {
test('MM-T5804 admin can open the create permission policy form', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Detail page heading and name input visible
await expect(
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
).toBeVisible();
await expect(systemConsolePage.page.getByPlaceholder('Add a unique policy name')).toBeVisible();
});
test('MM-T5805 create policy form shows evaluation order info banner', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Banner explains that permission policies override system permission schemes
await expect(
systemConsolePage.page.getByText('The permissions defined in this policy override the', {exact: false}),
).toBeVisible();
await expect(systemConsolePage.page.getByText('system permission schemes', {exact: false})).toBeVisible();
await expect(systemConsolePage.page.getByText('Permissions evaluation order', {exact: false})).toBeVisible();
});
test('MM-T5806 create policy form shows role dropdown defaulting to Members', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByText('Who this policy applies to')).toBeVisible();
await expect(
systemConsolePage.page.getByText('Select a role from the predefined list of system roles'),
).toBeVisible();
// * The dropdown button is visible and shows the default role
// (system_user). The label was shortened from "Members and
// system administrators" to just "Members" in the UX pass;
// the "system admins fall back when no admin-specific rule
// exists" semantics moved into the role's description copy.
// Use an exact-text matcher so a regression to the longer
// "Members and system administrators" label fails the test
// instead of silently passing the substring check.
const roleButton = systemConsolePage.page.locator('#pp-role-selector-btn');
await expect(roleButton).toBeVisible();
await expect(roleButton).toHaveText('Members');
});
test('MM-T5807 admin can change role selection to System administrators via dropdown', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// # Open the role dropdown and select System administrators
await systemConsolePage.page.locator('#pp-role-selector-btn').click();
await systemConsolePage.page.locator('#pp-role-option-system_admin').click();
// * Dropdown button now shows the selected role
await expect(systemConsolePage.page.locator('#pp-role-selector-btn')).toContainText('System administrators');
});
test('MM-T5808 admin can toggle between Simple and Advanced CEL editor modes', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
const switchToAdvanced = systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'});
await expect(switchToAdvanced).toBeVisible();
await switchToAdvanced.click();
// * Button label flips to Simple Mode
const switchToSimple = systemConsolePage.page.getByRole('button', {name: 'Switch to Simple Mode'});
await expect(switchToSimple).toBeVisible();
await switchToSimple.click();
await expect(switchToAdvanced).toBeVisible();
});
test('MM-T5809 Save is blocked when policy name is empty', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
// # Type then clear the name to mark the form dirty, enabling the Save button
const nameInput = systemConsolePage.page.getByPlaceholder('Add a unique policy name');
await nameInput.fill('x');
await nameInput.clear();
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please add a name to the policy')).toBeVisible();
});
test('MM-T5810 Save is blocked when CEL expression is empty', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await systemConsolePage.page
.getByPlaceholder('Add a unique policy name')
.fill(`PP Expr Validate ${pw.random.id()}`);
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please add an expression to the policy')).toBeVisible();
});
test('MM-T5811 Save is blocked when no permission is selected', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await systemConsolePage.page
.getByPlaceholder('Add a unique policy name')
.fill(`PP Perm Validate ${pw.random.id()}`);
// # Enter a valid CEL expression but add no permissions
await systemConsolePage.page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
const monacoContainer = systemConsolePage.page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
const editorLines = systemConsolePage.page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await systemConsolePage.page.waitForTimeout(300);
const isMac = process.platform === 'darwin';
await systemConsolePage.page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await systemConsolePage.page.waitForTimeout(100);
await systemConsolePage.page.keyboard.type('user.attributes.Department == "Engineering"', {delay: 10});
await systemConsolePage.page.getByRole('button', {name: 'Save'}).last().click();
await expect(systemConsolePage.page.getByText('Please select at least one permission')).toBeVisible();
});
});

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPermissionPolicy,
deletePermissionPolicyByName,
navigateToPermissionPoliciesPage,
} from '../support';
/**
* Permission Policies - Create Policy save/cancel flows (MM-64508)
*
* Covers end-to-end creation flows that persist a policy (download-only,
* combined download+upload, guest role) plus the cancel path. Paired with
* permission_policies_create_form.spec.ts which covers form UI + validation.
*/
test.describe('Permission Policies - Create Policy', () => {
test('MM-T5812 admin can create a permission policy restricting file downloads', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
// Re-apply via API: a concurrent initSetup() on another shard may have
// disabled ABAC between the enableABAC UI call and the navigation to
// permission_policies, causing a redirect to the license page.
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP Download ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering"',
permissions: ['Download Files'],
});
// * List page shows the new policy with correct role and permissions
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow).toBeVisible();
// Role label was shortened from "Members and system administrators"
// to just "Members" in the UX pass; the "system admins fall back
// when no admin-specific rule exists" semantics moved into the
// role's description copy.
await expect(policyRow.getByText('Members', {exact: true})).toBeVisible();
await expect(policyRow.getByText('Download Files')).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5813 admin can create a permission policy with both Download and Upload permissions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP Both Perms ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Legal"',
permissions: ['Download Files', 'Upload Files'],
});
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow.getByText(/Download Files/)).toBeVisible();
await expect(policyRow.getByText(/Upload Files/)).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5814 created policy appears in list with correct name, role, and permissions', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
const policyName = `PP List Check ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Legal"',
permissions: ['Download Files'],
role: 'system_guest',
});
// * Row shows name, Guest role, and Download Files permission
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await expect(policyRow).toBeVisible();
await expect(policyRow.getByText('Guest users')).toBeVisible();
await expect(policyRow.getByText('Download Files')).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
test('MM-T5815 admin can cancel policy creation and return to list', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({AccessControlSettings: {EnableAttributeBasedAccessControl: true}});
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await systemConsolePage.page.getByRole('button', {name: 'Add policy'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(
systemConsolePage.page.getByText('Attribute Based Permission Policy', {exact: true}),
).toBeVisible();
// # Cancel navigates back to list without saving
await systemConsolePage.page.getByRole('link', {name: 'Cancel'}).click();
await systemConsolePage.page.waitForLoadState('networkidle');
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
});
});

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPermissionPolicy,
deletePermissionPolicyByName,
navigateToPermissionPoliciesPage,
} from '../support';
/**
* Permission Policies - System Console (MM-64508)
*
* Tests the Permission Policies page under System Attributes > Permission Policies.
* Requires Enterprise Advanced license and ABAC enabled.
*
* Sidebar items (Membership Policies, Permission Policies) are only rendered when
* ABAC is enabled — all tests call enableABAC() first.
*
* This file covers list-page UI and management (delete / search) of existing policies.
*/
test.describe('Permission Policies - List Page', () => {
test('MM-T5801 admin can navigate to Permission Policies page via sidebar', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Enable ABAC — sidebar items only appear when ABAC is on.
// enableABAC lands on /membership_policies so the sidebar is already expanded.
await enableABAC(systemConsolePage.page);
// # Click Permission Policies in the sidebar
await systemConsolePage.sidebar.systemAttributes.permissionPolicies.click();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Correct URL and heading
await expect(systemConsolePage.page).toHaveURL(/permission_policies/);
await expect(systemConsolePage.page.getByRole('heading', {name: 'Permission Policies'})).toBeVisible();
// * List columns are present
const section = systemConsolePage.page.getByTestId('sysconsole_section_PermissionPolicies');
await expect(section.getByText('Name')).toBeVisible();
await expect(section.getByText('Role')).toBeVisible();
await expect(section.getByText('Permissions', {exact: true})).toBeVisible();
});
test('MM-T5802 Permission Policies list page has Add policy button and search input', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await expect(systemConsolePage.page.getByRole('button', {name: 'Add policy'})).toBeVisible();
await expect(systemConsolePage.page.getByPlaceholder('Search')).toBeVisible();
});
test('MM-T5803 Permission Policies list page subtitle describes file permission scope', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
await navigateToPermissionPoliciesPage(systemConsolePage.page);
await expect(
systemConsolePage.page.getByText(
'Create policies to control file upload and download permissions based on user attributes',
{exact: false},
),
).toBeVisible();
});
});
test.describe('Permission Policies - Manage Existing Policies', () => {
test('MM-T5816 admin can delete a permission policy', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
const policyName = `PP Delete ${pw.random.id()}`;
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Delete"',
permissions: ['Download Files'],
});
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
// # Open the row's action menu and delete
const policyRow = systemConsolePage.page.locator('.DataGrid_row').filter({hasText: policyName});
await policyRow.locator('button[id*="policy-menu"], button[aria-label*="menu" i], button').last().click();
const deleteOption = systemConsolePage.page.getByRole('menuitem', {name: /delete/i});
await deleteOption.click();
// # Deletion from the list fires immediately — no confirmation modal
await systemConsolePage.page.waitForLoadState('networkidle');
// * Policy no longer in list
await expect(systemConsolePage.page.getByText(policyName)).not.toBeVisible();
// # Safety net: API cleanup in case UI deletion failed
await deletePermissionPolicyByName(adminClient, policyName);
});
test('MM-T5817 admin can search for a permission policy by name', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await ensureUserAttributes(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await enableABAC(systemConsolePage.page);
const policyName = `PP Search ${pw.random.id()}`;
try {
await createPermissionPolicy(systemConsolePage.page, {
name: policyName,
celExpression: 'user.attributes.Department == "Search"',
permissions: ['Download Files'],
});
// # Search by the exact name
await systemConsolePage.page.getByPlaceholder('Search').fill(policyName);
await systemConsolePage.page.waitForLoadState('networkidle');
// * Only the matching policy is visible
await expect(systemConsolePage.page.getByText(policyName)).toBeVisible();
} finally {
await deletePermissionPolicyByName(adminClient, policyName);
}
});
});

View File

@@ -0,0 +1,235 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for ranked comparison operators in the Membership Policy editor.
*
* When the selected attribute is of type `rank`, the simple-mode operator
* dropdown replaces the standard set with the ordinal comparison operators
* (is exactly, is not, is at least, is greater than, is at most, is less than).
*/
import type {Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
import {deleteCustomProfileAttributes} from '../../../channels/custom_profile_attributes/helpers';
import {assertAccessControlAutocompleteContains} from '../support';
// Filter + real click so an off-screen ranked field is selected; click the
// policy name to dismiss a leftover MUI overlay (the filter swallows Escape).
async function selectAttributeInEditor(page: Page, attributeName: string) {
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
if (!(await attributeMenu.isVisible({timeout: 2000}).catch(() => false))) {
await attributeButton.click();
}
await expect(attributeMenu).toBeVisible();
const filterInput = attributeMenu.locator('.attribute-selector-search input');
await filterInput.waitFor({state: 'visible'});
await filterInput.fill(attributeName);
const option = attributeMenu.getByRole('menuitemradio', {name: attributeName, exact: true});
await expect(option).toBeVisible();
await option.click();
await expect(attributeButton).toContainText(attributeName);
if (await attributeMenu.isVisible().catch(() => false)) {
await page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName').click();
}
await expect(attributeMenu).toBeHidden();
}
test.describe('System Console - Membership Policy ranked operators', () => {
let adminClient: Client4;
let adminUser: UserProfile;
let field: UserPropertyField | undefined;
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const clientInfo = await pw.getAdminClient();
adminClient = clientInfo.adminClient;
adminUser = clientInfo.adminUser!;
// # Create an admin-managed ranked attribute (admin-managed → usable in ABAC)
field = await adminClient.createCustomProfileAttributeField({
name: `clearance_${getRandomId()}`,
type: 'rank',
attrs: {
sort_order: 0,
managed: 'admin',
options: [
{name: 'Unclassified', rank: 1},
{name: 'Secret', rank: 2},
{name: 'TopSecret', rank: 3},
],
},
} as any);
// # Make sure ABAC stays enabled (a concurrent initSetup can reset config)
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as any);
await assertAccessControlAutocompleteContains(adminClient, [field.name]);
});
test.afterEach(async () => {
if (field) {
await deleteCustomProfileAttributes(adminClient, {
[field.id]: field,
ownedIds: new Set([field.id]),
} as any);
field = undefined;
}
});
/**
* @objective Selecting a ranked attribute in the policy editor surfaces the
* ordinal operators and removes the equality/string/list operators; the row
* defaults to "is at least".
*
* @precondition
* An admin-managed ranked attribute (Unclassified/Secret/TopSecret) exists.
*/
test('shows ranked comparison operators for a ranked attribute', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
// # ABAC is already enabled (see beforeEach) — go straight to Membership Policies
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Open the new-policy editor and name it
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(`Ranked Policy ${getRandomId()}`);
// # Add an attribute row (reload once if attributes haven't loaded yet)
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(`Ranked Policy ${getRandomId()}`);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
await selectAttributeInEditor(page, field!.name);
// * The row defaults to the canonical ranked operator, "is at least"
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await expect(operatorButton).toContainText('is at least');
// # Open the operator dropdown
await operatorButton.click();
// * All six ranked operators are offered (menu items auto-wait for the open menu)
for (const label of ['is exactly', 'is not', 'is at least', 'is greater than', 'is at most', 'is less than']) {
await expect(page.getByRole('menuitemradio', {name: label, exact: true})).toBeVisible();
}
// * The standard (non-ranked) operators are not offered
for (const label of ['is', 'in', 'has any of', 'has all of', 'starts with', 'ends with', 'contains']) {
await expect(page.getByRole('menuitemradio', {name: label, exact: true})).toHaveCount(0);
}
});
/**
* @objective An "is at least <option>" rule built in the editor survives a
* save/reopen round-trip: the stored marker form rehydrates back to the
* operator form and the table editor re-renders the same operator and value.
*
* @precondition
* An admin-managed ranked attribute (Unclassified/Secret/TopSecret) exists.
*/
test('round-trips an "is at least" ranked rule through save and reopen', {tag: '@abac'}, async ({pw}) => {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
const policyName = `Ranked RT ${getRandomId()}`;
let policyId: string | null = null;
try {
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Create a policy and add a ranked attribute row
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 10000});
if (await addAttributeButton.isDisabled()) {
await page.reload();
await page.waitForLoadState('networkidle');
await nameInput.fill(policyName);
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
}
await addAttributeButton.click();
// # Select the ranked attribute by name
await selectAttributeInEditor(page, field!.name);
// * Defaults to "is at least"
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText(
'is at least',
);
// # Pick the value "Secret"
await page.locator('[data-testid="valueSelectorMenuButton"]').first().click();
await page.getByRole('menuitemradio', {name: 'Secret', exact: true}).click();
await expect(page.locator('[data-testid="valueSelectorMenuButton"]').first()).toContainText('Secret');
// # Save the policy
await page.getByRole('button', {name: 'Save'}).last().click();
await page.waitForLoadState('networkidle');
// # Reopen the policy from the list
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
const policyRow = page.locator('.policy-name').filter({hasText: policyName}).first();
await expect(policyRow).toBeVisible({timeout: 10000});
const rowId = await policyRow.getAttribute('id');
policyId = rowId?.replace('customDescription-', '') ?? null;
await policyRow.click();
await page.waitForLoadState('networkidle');
// Existing policies open in Advanced mode. The "Switch to Simple Mode"
// toggle is enabled only when isSimpleExpression accepts the stored
// expression — proving the marker form rehydrated to `>= "Secret"`
// (a non-rehydrated `_rank_ge(...)` marker would disable the toggle).
const toSimpleMode = page.getByRole('button', {name: 'Switch to Simple Mode'});
if (await toSimpleMode.isVisible({timeout: 5000}).catch(() => false)) {
await expect(toSimpleMode).toBeEnabled();
await toSimpleMode.click();
}
// * The table editor re-parses the rehydrated rule to the same operator
// and value (no rank integer surfaced in the value chip)
await expect(page.locator('[data-testid="operatorSelectorMenuButton"]').first()).toContainText(
'is at least',
);
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
await expect(valueButton).toContainText('Secret');
await expect(valueButton).not.toContainText('2');
} finally {
if (policyId) {
await adminClient.deleteAccessControlPolicy(policyId).catch(() => {});
}
}
});
});

View File

@@ -0,0 +1,226 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {createPrivateChannelForABAC, createBasicPolicy, enableUserManagedAttributes} from '../support';
/**
* ABAC Policy Management - Delete Policies
* Tests for deleting ABAC policies
*/
test.describe('ABAC Policy Management - Delete Policies', () => {
/**
* MM-T5793: Attribute-based access policy cannot be deleted if it is applied to any channels
*
* Step 1:
* 1. As system admin, go to ABAC page and click three-dot menu on a policy APPLIED to a channel
* 2. Observe Delete is DISABLED
* 3. Click three-dot menu on a policy NOT applied to any channels
* 4. Click Delete
*
* Expected:
* - Policy applied to a channel CANNOT be deleted (Delete is disabled)
* - Policy NOT applied to any channels CAN be deleted
*/
test('MM-T5793 Policy with channels cannot be deleted, policy without channels can be deleted', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
// Enable user-managed attributes
await enableUserManagedAttributes(adminClient);
// Set up a basic attribute field
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, attributeFields);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// Create two policies:
// 1. policyWithChannel - has a channel assigned
// 2. policyWithoutChannel - has NO channels assigned
// ===========================================
const uniqueId = pw.random.id();
const policyWithChannelName = `ABAC-WithChannel-${uniqueId}`;
const policyWithoutChannelName = `ABAC-NoChannel-${uniqueId}`;
// Create a channel for the first policy
const team = (await adminClient.getMyTeams())[0];
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// Create policy WITH channel
await createBasicPolicy(page, {
name: policyWithChannelName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Navigate back to ABAC page
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Create policy WITHOUT channel using UI (Advanced mode)
// We'll create the policy, save it without channels, then remove channels via UI
// Click Add policy
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.click();
await page.waitForLoadState('networkidle');
// Fill policy name
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyWithoutChannelName);
// Switch to Advanced mode to create minimal policy
const advancedModeButton = page.getByRole('button', {name: /advanced/i});
if (await advancedModeButton.isVisible({timeout: 2000})) {
await advancedModeButton.click();
await page.waitForTimeout(1000);
}
// Fill CEL expression in Monaco editor
const monacoContainer = page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 5000});
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Type a simple expression
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(100);
await page.keyboard.type('user.attributes.Department == "Sales"', {delay: 10});
await page.waitForTimeout(1000);
// Save policy WITHOUT assigning any channels
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.click();
// The "Apply policy" modal should NOT appear since there are no channels
// The webapp will call handleSubmit() directly and navigate back automatically
// Wait for navigation to complete
await page.waitForURL('**/membership_policies', {timeout: 10000});
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1500);
// ===========================================
// STEP 1-2: Verify Delete is DISABLED for policy WITH channel
// ===========================================
// Clear any existing search and verify both policies exist
const searchInput = page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
await searchInput.clear();
await page.waitForTimeout(500);
// Verify both policies are visible
await page.locator('.policy-name, tr.clickable').count();
// Now search for the policy with channel
await searchInput.fill(policyWithChannelName);
await page.waitForTimeout(1000);
// Find and click the three-dot menu for the policy with channel
const policyWithChannelRow = page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithChannelName})
.first();
await policyWithChannelRow.waitFor({state: 'visible', timeout: 10000});
const menuButtonWithChannel = policyWithChannelRow
.locator('button[id*="policy-menu"], button[aria-label*="menu" i], .menu-button, button:has(svg)')
.first();
await menuButtonWithChannel.click();
await page.waitForTimeout(500);
// Check if Delete is disabled
const deleteMenuItemWithChannel = page.getByRole('menuitem', {name: /delete/i});
const isDeleteDisabled = await deleteMenuItemWithChannel.isDisabled();
// Close the menu
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
expect(isDeleteDisabled).toBe(true);
// ===========================================
// STEP 3-4: Verify Delete is ENABLED for policy WITHOUT channel and delete it
// ===========================================
// Clear search first to ensure we're seeing all policies
await searchInput.clear();
await page.waitForTimeout(500);
// Verify policy without channel exists in the list
const policyWithoutChannelExists = await page.locator('text=' + policyWithoutChannelName).count();
if (policyWithoutChannelExists === 0) {
// Try reloading if policy not visible
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
// Now search for the policy without channel
await searchInput.fill(policyWithoutChannelName);
await page.waitForTimeout(1000);
// Find and click the three-dot menu for the policy without channel
const policyWithoutChannelRow = page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithoutChannelName})
.first();
await policyWithoutChannelRow.waitFor({state: 'visible', timeout: 10000});
const menuButtonWithoutChannel = policyWithoutChannelRow
.locator('button[id*="policy-menu"], button[aria-label*="menu" i], .menu-button, button:has(svg)')
.first();
await menuButtonWithoutChannel.click();
await page.waitForTimeout(500);
// Check if Delete is enabled
const deleteMenuItemWithoutChannel = page.getByRole('menuitem', {name: /delete/i});
const isDeleteEnabled = !(await deleteMenuItemWithoutChannel.isDisabled());
expect(isDeleteEnabled).toBe(true);
// Click Delete
await deleteMenuItemWithoutChannel.click();
await page.waitForTimeout(500);
// Handle confirmation modal if it appears
const confirmDeleteButton = page.getByRole('button', {name: /delete|confirm/i});
if (await confirmDeleteButton.isVisible({timeout: 2000})) {
await confirmDeleteButton.click();
await page.waitForTimeout(1000);
}
await page.waitForLoadState('networkidle');
// Verify the policy was deleted
await searchInput.clear();
await searchInput.fill(policyWithoutChannelName);
await page.waitForTimeout(1000);
const policyStillExists = await page
.locator('tr.clickable, .DataGrid_row')
.filter({hasText: policyWithoutChannelName})
.isVisible({timeout: 3000});
expect(policyStillExists).toBe(false);
});
});

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import {createBasicPolicy, getPolicyIdByName} from '../support';
/**
* @objective E2E coverage for membership policy edit action navigation:
* - Clicking Edit in the policy row action menu navigates to the membership policy editor
*
* @reference MM-68958: Fix membership policy edit action navigation
*/
test.describe('ABAC Policy Management - Edit Action Navigation', () => {
/**
* MM-68958: Edit action in policy row menu navigates to membership policy editor
*
* Steps:
* 1. Enable ABAC and create a membership policy
* 2. Navigate to System Console > Membership Policies
* 3. Open a policy row's three-dot action menu
* 4. Click Edit
* 5. Verify the URL is /admin_console/system_attributes/membership_policies/edit_policy/<policy_id>
*/
test('MM-68958 Edit action navigates to membership policy editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Create a test channel for the policy
const channelName = `abac-edit-nav-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create a basic membership policy
const policyName = `Edit-Nav-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
// Get the policy ID from the backend
const policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
// Navigate to Membership Policies list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(1000);
// Search for the policy to ensure it's visible
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Find the policy row
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
// Open the three-dot action menu for the policy
const actionMenuButton = policyRowLocator
.locator('button[aria-label*="Actions" i], button:has(i.icon-dots-vertical)')
.first();
await actionMenuButton.waitFor({state: 'visible', timeout: 5000});
await actionMenuButton.click();
await page.waitForTimeout(500);
// Click the Edit menu item
const editMenuItem = page.locator(`[id*="policy-menu-edit-${policyId}"]`).first();
await editMenuItem.waitFor({state: 'visible', timeout: 5000});
// Click Edit and wait for navigation
await editMenuItem.click();
// Wait for the URL to change to the edit policy page
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
timeout: 10000,
});
// Verify we're on the edit policy page by checking the URL
const currentURL = page.url();
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
// Additional verification: Check that the policy editor is loaded
// The policy editor should have the policy name visible
const policyNameInput = page.locator('input[placeholder*="name" i], input[value*="Edit-Nav-Test"]').first();
await expect(policyNameInput).toBeVisible({timeout: 10000});
});
/**
* MM-68958: Row click also navigates to membership policy editor
*
* This test verifies that clicking the row (not just the Edit action) also navigates correctly.
* This behavior should have been working before, but we verify it still works after the fix.
*/
test('Row click navigates to membership policy editor', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Create a test channel for the policy
const channelName = `abac-row-click-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create a basic membership policy
const policyName = `Row-Click-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [privateChannel.display_name],
});
// Get the policy ID from the backend
const policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId, 'Policy should be created and have an ID').toBeTruthy();
// Navigate to Membership Policies list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(1000);
// Search for the policy to ensure it's visible
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
// Find the policy row
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
// Click the row (not the action menu)
await policyRowLocator.click();
// Wait for the URL to change to the edit policy page
await page.waitForURL(`**/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`, {
timeout: 10000,
});
// Verify we're on the edit policy page
const currentURL = page.url();
expect(currentURL).toContain(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
});
});

View File

@@ -0,0 +1,939 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
verifyUserInChannel,
verifyUserNotInChannel,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
waitForPolicySyncJob,
getPolicyIdByName,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC Policy Management - Edit Policies
* Tests for editing existing ABAC policies
*/
test.describe('ABAC Policy Management - Edit Policies', () => {
/**
* MM-T5790: Editing value of existing attribute-based access policy applies access control as specified (without auto-add)
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is False
* 2. Edit an existing policy rule to a different value (same attribute and operator)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEW policy but not in channel → NOT auto-added
*/
test('MM-T5790 Editing policy value applies access control without auto-add', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Enable user-managed attributes FIRST (same order as MM-T5783)
await enableUserManagedAttributes(adminClient);
// Set up the Department attribute field
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create users with proper CPA attributes
// User A: Department=Engineering (will satisfy ORIGINAL policy)
// User B: Department=Sales (will satisfy EDITED policy)
const engineerUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
await adminClient.addToTeam(team.id, engineerUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel - use direct API call for more control
const channelName = `abac-edit-test-${pw.random.id()}`;
const privateChannel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'P',
});
// Admin user is automatically added as channel creator, but let's add the test users
// Note: addToChannel(userId, channelId) - user first, then channel
await adminClient.addToChannel(engineerUser.id, privateChannel.id);
// Verify we can access the channel
// const channelCheck = await adminClient.getChannel(privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Check membership BEFORE policy creation
// Using library helper verifyUserInChannel(client, userId, channelId)
await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
// Debug: Show user attributes BEFORE policy creation
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/users/${engineerUser.id}/custom_profile_attributes`,
{method: 'GET'},
);
} catch {
// Ignore errors
}
// ===========================================
// SETUP: Create policy with ORIGINAL value (Engineering), Auto-add OFF
// ===========================================
const policyName = `ABAC-Edit-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add is OFF
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Check membership AFTER policy creation (before explicit sync)
await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
// Wait for the automatic sync (triggered by createBasicPolicy's "Apply Policy") to complete
await page.waitForTimeout(3000); // Give time for sync job to run
// Check membership AFTER automatic sync
const engineerAfterSync = await verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id);
const salesAfterSync = await verifyUserInChannel(adminClient, salesUser.id, privateChannel.id);
// Debug: Fetch user attributes to verify they're set
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/users/${engineerUser.id}/custom_profile_attributes`,
{method: 'GET'},
);
} catch {
// Ignore errors
}
expect(engineerAfterSync).toBe(true);
expect(salesAfterSync).toBe(false);
// ===========================================
// STEP 1-2: Edit policy to different value (Sales instead of Engineering)
// ===========================================
// Navigate to ABAC page and find the policy
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Search for and click the policy
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
await page.waitForTimeout(1000);
}
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await policyRowLocator.waitFor({state: 'visible', timeout: 10000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is OFF (check the header checkbox)
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (isChecked) {
// Uncheck it
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
} else {
// Checkbox not visible
}
// Edit the value: Change from "Engineering" to "Sales"
// Strategy 1: Try simple input field (for text attributes)
const simpleValueInput = page.locator('.values-editor__simple-input').first();
if (await simpleValueInput.isVisible({timeout: 3000})) {
// Clear and fill the new value
await simpleValueInput.click();
await simpleValueInput.fill('');
await simpleValueInput.fill('Sales');
// Press Tab or click elsewhere to confirm
await page.keyboard.press('Tab');
await page.waitForTimeout(500);
} else {
// Strategy 2: Try value selector menu button
const valueButton = page.locator('[data-testid="valueSelectorMenuButton"]').first();
if (await valueButton.isVisible({timeout: 3000})) {
await valueButton.click();
await page.waitForTimeout(500);
// Look for input in the dropdown
const menuInput = page
.locator('#value-selector-menu input[type="text"], .value-selector-menu input')
.first();
if (await menuInput.isVisible({timeout: 2000})) {
await menuInput.fill('Sales');
await page.waitForTimeout(500);
// Click on the option or press Enter
const salesOption = page.locator('#value-selector-menu').getByText('Sales', {exact: true}).first();
if (await salesOption.isVisible({timeout: 2000})) {
await salesOption.click();
} else {
await page.keyboard.press('Enter');
}
await page.waitForTimeout(500);
}
} else {
// Strategy 3: Use Advanced mode to edit CEL expression directly
const advancedModeButton = page.getByRole('button', {name: /advanced|switch to advanced/i});
if (await advancedModeButton.isVisible({timeout: 3000})) {
await advancedModeButton.click();
await page.waitForTimeout(1000);
// Find Monaco editor - use view-lines with force click to bypass overlay
const monacoContainer = page.locator('.monaco-editor').first();
if (await monacoContainer.isVisible({timeout: 3000})) {
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Platform-specific select all
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(100);
await page.keyboard.type('user.attributes.Department == "Sales"', {delay: 10});
await page.waitForTimeout(500);
}
}
}
}
// ===========================================
// STEP 3: Click Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Wait for sync to complete (race-safe: polls exact policy, not UI table)
await navigateToABACPage(page);
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// ===========================================
// STEP 5 & 6: Verify channel membership after policy edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may flip membership.
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should NOT be in channel (auto-add is off)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerUser should be REMOVED (no longer satisfies policy)',
})
.toBe(false);
// ===========================================
// STEP 7: Admin can manually add satisfying user
// ===========================================
try {
// Note: addToChannel(userId, channelId) - user first, then channel
await adminClient.addToChannel(salesUser.id, privateChannel.id);
// Verify the user was actually added
const salesInChannelAfterManualAdd = await verifyUserInChannel(
adminClient,
salesUser.id,
privateChannel.id,
);
expect(salesInChannelAfterManualAdd).toBe(true);
} catch (error) {
throw new Error(`Step 7 FAILED: Admin should be able to manually add satisfying user. Error: ${error}`);
}
});
/**
* MM-T5791: Editing existing access policy to add another attribute applies access control as specified (with auto-add)
*
* Precondition: At least one policy in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit an existing policy rule to add another attribute/value
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEWLY EDITED policy but not in channel → auto-ADDED
* 6. User who doesn't satisfy NEWLY EDITED policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new multi-attribute policy IS auto-added
* - User not satisfying new policy IS auto-removed
*/
test('MM-T5791 Editing policy to add attribute with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Delete ALL existing custom attributes to start fresh
try {
const existingFields = await adminClient.getCustomProfileAttributeFields();
for (const field of existingFields) {
try {
await adminClient.deleteCustomProfileAttributeField(field.id);
} catch {
// Ignore deletion errors
}
}
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch {
// Ignore if no fields exist
}
// Enable user-managed attributes FIRST (same pattern as MM-T5783)
await enableUserManagedAttributes(adminClient);
// create attributes using direct API
const attributeFieldsMap: Record<string, any> = {};
const departmentField = await adminClient.createCustomProfileAttributeField({
name: 'Department',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 0},
} as any);
attributeFieldsMap[departmentField.id] = departmentField;
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies BOTH (after edit)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies ORIGINAL only, NOT the edited policy
// 3. salesUser: Dept=Sales → doesn't satisfy any policy
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel and add engineerOfficeUser (satisfies original policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(engineerOfficeUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with ONE attribute (Department=Engineering)
// Auto-add ON so users are auto-added
// ===========================================
const policyName = `ABAC-AddAttr-Test-${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Wait for automatic sync to complete
await page.waitForTimeout(3000);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to ADD another attribute (Office=Remote)
// New expression: Department=Engineering AND Office=Remote
// ===========================================
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, use search
if (!isPolicyVisible) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.click();
await policySearchInput.fill(policyName);
await page.waitForTimeout(1500);
}
}
// Click policy to edit
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Check if "Add attribute" button is disabled (means attributes not loaded)
// If so, reload to fetch the Office attribute
const addAttributeButtonCheck = page.getByRole('button', {name: /add attribute/i});
if (await addAttributeButtonCheck.isVisible({timeout: 2000})) {
const isDisabled = await addAttributeButtonCheck.isDisabled();
if (isDisabled) {
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
}
// Stay in Simple Mode and add a second attribute row
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 5000});
await addAttributeButton.click();
await page.waitForTimeout(1000);
// The attribute dropdown opens automatically after clicking "Add attribute"
// Wait for the menu to be visible and select "Office"
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
const officeOption = attributeMenu.locator('li:has-text("Office")').first();
await officeOption.waitFor({state: 'visible', timeout: 5000});
await officeOption.click({force: true});
await page.waitForTimeout(500);
// Select operator "==" (is)
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').last();
await operatorButton.waitFor({state: 'visible', timeout: 5000});
await operatorButton.click({force: true});
await page.waitForTimeout(500);
const operatorOption = page.locator('[id^="operator-selector-menu"] li:has-text("is")').first();
await operatorOption.click({force: true});
await page.waitForTimeout(500);
// Fill value "Remote"
const valueInput = page.locator('.values-editor__simple-input').last();
await valueInput.waitFor({state: 'visible', timeout: 5000});
await valueInput.fill('Remote');
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for auto-triggered sync job
await navigateToABACPage(page);
await page.waitForTimeout(2000);
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// Additional wait for membership changes to propagate
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be REMOVED (only satisfies original, not new policy)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should not be in channel',
})
.toBe(false);
});
/**
* MM-T5792: Editing existing access policy to remove one of the rules applies access control as specified (with auto-add)
*
* Precondition: At least one policy with MULTIPLE rules in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit policy to REMOVE one of the rules (attribute/value)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies newly edited (simpler) policy but not in channel → auto-ADDED
* 6. User who no longer satisfies newly edited policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new simpler policy IS auto-added
* - User not satisfying new policy IS auto-removed
*
* This is the OPPOSITE of MM-T5791:
* - MM-T5791: ADD rule → policy MORE restrictive
* - MM-T5792: REMOVE rule → policy LESS restrictive
*/
test('MM-T5792 Editing policy to remove attribute rule with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Delete ALL existing custom attributes to start fresh
try {
const existingFields = await adminClient.getCustomProfileAttributeFields();
for (const field of existingFields) {
try {
await adminClient.deleteCustomProfileAttributeField(field.id);
} catch {
// Ignore deletion errors
}
}
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch {
// Ignore if no fields exist
}
// Enable user-managed attributes FIRST (same pattern as MM-T5783)
await enableUserManagedAttributes(adminClient);
// create attributes using direct API
const attributeFieldsMap: Record<string, any> = {};
const departmentField = await adminClient.createCustomProfileAttributeField({
name: 'Department',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 0},
} as any);
attributeFieldsMap[departmentField.id] = departmentField;
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies ORIGINAL (both rules)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies EDITED policy (Dept only)
// 3. salesRemoteUser: Dept=Sales, Office=Remote → doesn't satisfy (wrong Dept)
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
// Create channel and add salesRemoteUser (does NOT satisfy any policy)
// This user will be REMOVED after we edit policy (to verify removal behavior)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesRemoteUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with TWO attributes
// Department=Engineering AND Office=Remote
// Auto-add ON
// ===========================================
const policyName = `ABAC-RemoveRule-${pw.random.id()}`;
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Use advanced mode for multi-attribute policy
await createAdvancedPolicy(page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering" && user.attributes.Office == "Remote"',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
const policyId = await getPolicyIdByName(adminClient, policyName);
// Wait for automatic sync to complete
await page.waitForTimeout(3000);
// Verify initial state after original policy sync
// Original policy: Department=Engineering AND Office=Remote
// - engineerRemoteUser: satisfies → should be auto-added
// - engineerOfficeUser: does NOT satisfy (HQ != Remote) → should NOT be in channel
// - salesRemoteUser: does NOT satisfy (Sales != Engineering) → should be auto-removed
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to REMOVE Location rule
// New expression: Department=Engineering (only)
// This makes policy LESS restrictive
// ===========================================
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, try with search
if (!isPolicyVisible) {
// Use a more specific selector for the search input in the policies table
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.click();
await policySearchInput.fill(policyName);
// DON'T press Enter - just wait for the search to filter
await page.waitForTimeout(1500);
}
}
// Wait for policy row to be visible
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is ON
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (!isChecked) {
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
}
// Check if Monaco editor is visible - if not, switch to Advanced mode
// Policy may open in Simple mode even if created in Advanced mode
let monacoContainer = page.locator('.monaco-editor').first();
const isMonacoVisible = await monacoContainer.isVisible({timeout: 2000}).catch(() => false);
if (!isMonacoVisible) {
const advancedModeButton = page.getByRole('button', {name: /advanced|switch to advanced/i});
if (await advancedModeButton.isVisible({timeout: 5000})) {
await advancedModeButton.click();
await page.waitForTimeout(2000); // Wait for Monaco to fully initialize
}
}
// Find Monaco editor and update expression to REMOVE Office rule
monacoContainer = page.locator('.monaco-editor').first();
await monacoContainer.waitFor({state: 'visible', timeout: 10000});
const editorLines = page.locator('.monaco-editor .view-lines').first();
await editorLines.waitFor({state: 'visible', timeout: 5000});
await page.waitForTimeout(500);
// Click to focus the editor
await editorLines.click({force: true});
await page.waitForTimeout(300);
// Use platform-specific select all (Meta+a on Mac, Control+a on others)
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+a' : 'Control+a');
await page.waitForTimeout(200);
// Type the new CEL expression (REMOVING Office rule)
const newExpression = 'user.attributes.Department == "Engineering"';
await page.keyboard.type(newExpression, {delay: 10});
await page.waitForTimeout(1000);
// Wait for the "Valid" indicator to confirm the expression is valid
const validIndicator = page.locator('text=Valid').first();
try {
await validIndicator.waitFor({state: 'visible', timeout: 10000});
} catch {
// Ignore if Valid indicator doesn't appear
}
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for sync job to complete (race-safe by policyId)
await navigateToABACPage(page);
if (!policyId) {
throw new Error('Policy ID not found after creation');
}
await waitForPolicySyncJob(adminClient, policyId);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may interleave.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should still be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesRemoteUser should NOT be in channel',
})
.toBe(false);
});
/**
* MM-63848: Renaming a policy to a name that already exists should show an error
*/
test('MM-63848 Should show error when renaming policy to an existing name', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const departmentAttribute: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
await setupCustomProfileAttributeFields(adminClient, departmentAttribute);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
await enableABAC(page);
// Create two policies with different names
const policyName1 = `Edit Dup Test A ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName1,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false,
channels: [privateChannel.display_name],
});
await navigateToABACPage(page);
const privateChannel2 = await createPrivateChannelForABAC(adminClient, team.id);
const policyName2 = `Edit Dup Test B ${pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName2,
attribute: 'Department',
operator: '==',
value: 'Sales',
autoSync: false,
channels: [privateChannel2.display_name],
});
// Navigate back and edit policy2's name to match policy1
await navigateToABACPage(page);
await page.waitForTimeout(1000);
// Search for the second policy
const policySearchInput = page.locator('input[placeholder*="Search" i]').first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName2);
await page.waitForTimeout(1000);
}
const policyRow = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName2}).first();
await policyRow.waitFor({state: 'visible', timeout: 10000});
await policyRow.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Change the name to match the first policy
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill('');
await nameInput.fill(policyName1);
// Save and expect failure
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.click();
await page.waitForTimeout(2000);
// Handle confirmation modal if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000}).catch(() => false)) {
await applyPolicyButton.click();
await page.waitForTimeout(2000);
}
// Verify error message is shown
const errorMessage = page.locator('.EditPolicy__error');
await expect(errorMessage).toBeVisible({timeout: 5000});
const errorText = await errorMessage.textContent();
expect(errorText).toContain('A policy with this name already exists');
});
});

View File

@@ -0,0 +1,549 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
getAdminClient,
test,
navigateToABACPage,
verifyUserInChannel,
verifyUserNotInChannel,
} from '@mattermost/playwright-lib';
import {
createUserForABAC,
testAccessRule,
createPrivateChannelForABAC,
createBasicPolicy,
createAdvancedPolicy,
waitForLatestSyncJob,
getPolicyIdByName,
enableUserManagedAttributes,
} from '../support';
import {deleteFieldFromDB} from '../masking/masking_db_setup';
// Restore AccessControlSettings to the shared baseline expected by
// `specs/test_setup.ts` (ABAC enabled) after this file's tests complete, so
// later files on the same worker see the expected setup-state.
test.afterAll(async () => {
try {
const {adminClient} = await getAdminClient({skipLog: true});
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
} catch {
// Best-effort cleanup.
}
});
/**
* MM-T5791: Editing existing access policy to add another attribute applies access control as specified (with auto-add)
*
* Precondition: At least one policy in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit an existing policy rule to add another attribute/value
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies NEWLY EDITED policy but not in channel → auto-ADDED
* 6. User who doesn't satisfy NEWLY EDITED policy and is in channel → auto-REMOVED
*
* Expected:
* - User satisfying new multi-attribute policy IS auto-added
* - User not satisfying new policy IS auto-removed
*/
test('MM-T5791 Editing policy to add attribute with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure Office field exists with correct managed:admin permissions.
// Fields created by older server versions may lack permission_values, causing 403
// when setting attribute values — delete and recreate if permission_values is missing.
// Legacy fields with permission_field=null reject API deletion (403), so fall back to DB.
const existingFields = await adminClient.getCustomProfileAttributeFields();
const attributeFieldsMap: Record<string, any> = {};
for (const field of existingFields) {
attributeFieldsMap[field.id] = field;
}
const existingOffice = existingFields.find((f: any) => f.name === 'Office') as any;
if (!existingOffice || !(existingOffice as any).permission_values) {
if (existingOffice) {
try {
await adminClient.deleteCustomProfileAttributeField(existingOffice.id);
} catch {
await deleteFieldFromDB(existingOffice.id);
}
delete attributeFieldsMap[existingOffice.id];
}
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
}
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies BOTH (after edit)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies ORIGINAL only, NOT the edited policy
// 3. salesUser: Dept=Sales → doesn't satisfy any policy
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesUser.id);
// Create channel and add engineerOfficeUser (satisfies original policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(engineerOfficeUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with ONE attribute (Department=Engineering)
// Auto-add ON so users are auto-added
// ===========================================
const policyName = `ABAC-AddAttr-Test-${await pw.random.id()}`;
await createBasicPolicy(page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
(await getPolicyIdByName(adminClient, policyName))!;
// Wait for automatic sync to complete
await page.waitForTimeout(500);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to ADD another attribute (Office=Remote)
// New expression: Department=Engineering AND Office=Remote
// ===========================================
// Navigate back to ABAC list page
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(2000);
// Verify we're on the list page by checking for "Add policy" button
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Try to find the policy row first without search
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
let isPolicyVisible = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
// If not visible, use search
if (!isPolicyVisible) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
}
// Re-bind and poll — grid refresh under parallel load may be delayed.
await expect
.poll(() => policyRowLocator.isVisible(), {
timeout: 20_000,
message: `policy "${policyName}" should appear in grid after search`,
})
.toBe(true);
isPolicyVisible = true;
}
// Click policy to edit
await policyRowLocator.waitFor({state: 'visible', timeout: 15000});
await policyRowLocator.click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Check if "Add attribute" button is disabled (means attributes not loaded)
const addAttributeButtonCheck = page.getByRole('button', {name: /add attribute/i});
if (await addAttributeButtonCheck.isVisible({timeout: 2000})) {
const isDisabled = await addAttributeButtonCheck.isDisabled();
if (isDisabled) {
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}
}
// Stay in Simple Mode and add a second attribute row
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await addAttributeButton.waitFor({state: 'visible', timeout: 5000});
await addAttributeButton.click();
await page.waitForTimeout(1000);
// The attribute dropdown opens automatically after clicking "Add attribute"
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
await expect
.poll(() => attributeMenu.isVisible(), {
timeout: 15_000,
message: 'attribute dropdown should appear',
})
.toBe(true);
const officeOption = attributeMenu.locator('li:has-text("Office")').first();
await expect
.poll(() => officeOption.isVisible(), {
timeout: 15_000,
message: 'Office option should be visible in attribute dropdown',
})
.toBe(true);
await officeOption.click({force: true});
await page.waitForTimeout(500);
// Select operator "==" (is)
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').last();
await operatorButton.waitFor({state: 'visible', timeout: 10_000});
await operatorButton.click({force: true});
await page.waitForTimeout(500);
const operatorOption = page.locator('[id^="operator-selector-menu"] li:has-text("is")').first();
await operatorOption.click({force: true});
await page.waitForTimeout(500);
// Fill value "Remote"
const valueInput = page.locator('.values-editor__simple-input').last();
await valueInput.waitFor({state: 'visible', timeout: 10_000});
await valueInput.fill('Remote');
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
// exact job ID instead of using the racy UI-table path.
const editSyncJobIdPromise = page
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
.catch(() => null);
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
// Handle "Apply policy" confirmation if it appears
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
// Navigate to ABAC page and wait for auto-triggered sync job
await navigateToABACPage(page);
await page.waitForTimeout(500);
// Wait for the auto-triggered sync job to complete (policy edit triggers sync automatically)
const editSyncJobId = await editSyncJobIdPromise;
await waitForLatestSyncJob(page, 10, editSyncJobId);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Poll under PW_WORKERS>=2: another shard's sync job may briefly change
// membership after we read it, so we retry until stable.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should be in channel (satisfies BOTH attributes)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be REMOVED (does not satisfy new policy)',
})
.toBe(false);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesUser should not be in channel',
})
.toBe(false);
});
/**
* MM-T5792: Editing existing access policy to remove one of the rules applies access control as specified (with auto-add)
*
* Precondition: At least one policy with MULTIPLE rules in existence
*
* Step 1:
* 1. Go to ABAC page, click a policy to edit. Ensure Auto-add is TRUE
* 2. Edit policy to REMOVE one of the rules (attribute/value)
* 3. Click Test Access Rule, observe users who satisfy the policy
* 4. Save the changes
* 5. User who satisfies newly edited (simpler) policy but not in channel → auto-ADDED
* 6. User who no longer satisfies newly edited policy and is in channel → auto-REMOVED
*
* This is the OPPOSITE of MM-T5791:
* - MM-T5791: ADD rule → policy MORE restrictive
* - MM-T5792: REMOVE rule → policy LESS restrictive
*/
test('MM-T5792 Editing policy to remove attribute rule with auto-add enabled', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
const {adminUser, adminClient, team} = await pw.initSetup();
// Ensure Office field exists with correct managed:admin permissions.
// Fields created by older server versions may lack permission_values, causing 403
// when setting attribute values — delete and recreate if permission_values is missing.
// Legacy fields with permission_field=null reject API deletion (403), so fall back to DB.
const existingFields = await adminClient.getCustomProfileAttributeFields();
const attributeFieldsMap: Record<string, any> = {};
for (const field of existingFields) {
attributeFieldsMap[field.id] = field;
}
const existingOffice = existingFields.find((f: any) => f.name === 'Office') as any;
if (!existingOffice || !(existingOffice as any).permission_values) {
if (existingOffice) {
try {
await adminClient.deleteCustomProfileAttributeField(existingOffice.id);
} catch {
await deleteFieldFromDB(existingOffice.id);
}
delete attributeFieldsMap[existingOffice.id];
}
const officeField = await adminClient.createCustomProfileAttributeField({
name: 'Office',
type: 'text',
attrs: {managed: 'admin', visibility: 'when_set', sort_order: 1},
} as any);
attributeFieldsMap[officeField.id] = officeField;
}
// Wait for attributes to be indexed
await new Promise((resolve) => setTimeout(resolve, 2000));
await enableUserManagedAttributes(adminClient);
// Create users:
// 1. engineerRemoteUser: Dept=Engineering, Office=Remote → satisfies ORIGINAL (both rules)
// 2. engineerOfficeUser: Dept=Engineering, Office=HQ → satisfies EDITED policy (Dept only)
// 3. salesRemoteUser: Dept=Sales, Office=Remote → doesn't satisfy (wrong Dept)
const engineerRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
const engineerOfficeUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
{name: 'Office', type: 'text', value: 'HQ'},
]);
const salesRemoteUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Sales'},
{name: 'Office', type: 'text', value: 'Remote'},
]);
// Add users to team
await adminClient.addToTeam(team.id, engineerRemoteUser.id);
await adminClient.addToTeam(team.id, engineerOfficeUser.id);
await adminClient.addToTeam(team.id, salesRemoteUser.id);
// Create channel and add salesRemoteUser (does NOT satisfy any policy)
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(salesRemoteUser.id, privateChannel.id);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await navigateToABACPage(page);
// ===========================================
// PRECONDITION: Create ORIGINAL policy with TWO attributes
// Department=Engineering AND Office=Remote, Auto-add ON
// ===========================================
const policyName = `ABAC-RemoveRule-${await pw.random.id()}`;
await createAdvancedPolicy(page, {
name: policyName,
celExpression: 'user.attributes.Department == "Engineering" && user.attributes.Office == "Remote"',
autoSync: true, // Auto-add is ON
channels: [privateChannel.display_name],
});
(await getPolicyIdByName(adminClient, policyName))!;
// Wait for automatic sync to complete
await page.waitForTimeout(500);
// Verify initial state after original policy sync
await verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, engineerOfficeUser.id, privateChannel.id);
await verifyUserNotInChannel(adminClient, salesRemoteUser.id, privateChannel.id);
// ===========================================
// STEP 1-2: Edit policy to REMOVE Location rule
// New expression: Department=Engineering (only)
// ===========================================
await page.goto('/admin_console/system_attributes/membership_policies', {waitUntil: 'networkidle'});
await page.waitForTimeout(500);
const addPolicyButton = page.getByRole('button', {name: 'Add policy'});
await addPolicyButton.waitFor({state: 'visible', timeout: 10000});
// Wait for the exact policy row to appear with retry — under parallel load the
// grid update from the server may lag behind the page load.
const policyRowLocator = page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
const found = await policyRowLocator.isVisible({timeout: 3000}).catch(() => false);
if (!found) {
const policySearchInput = page
.locator('.DataGrid input[type="text"], input[placeholder*="Search policies" i]')
.first();
if (await policySearchInput.isVisible({timeout: 3000})) {
await policySearchInput.fill(policyName);
}
const policyRow = () => page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first();
await expect
.poll(() => policyRow().isVisible(), {
timeout: 45_000,
intervals: [500, 1000, 2000, 3000],
message: `policy "${policyName}" should appear in grid after search`,
})
.toBe(true);
}
await page.locator('tr.clickable, .DataGrid_row').filter({hasText: policyName}).first().click();
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify Auto-add is ON
const autoAddCheckbox = page.locator('#auto-add-header-checkbox');
if (await autoAddCheckbox.isVisible({timeout: 3000})) {
const isChecked = await autoAddCheckbox.isChecked();
if (!isChecked) {
await autoAddCheckbox.click();
await page.waitForTimeout(500);
}
}
// Remove the Office rule in Simple mode (table editor). Opening an Advanced-created policy
// can leave the UI in CEL mode; "Switch to Advanced Mode" stays disabled while attributes load.
const switchToSimpleButton = page.getByRole('button', {name: /switch to simple mode/i});
if (await switchToSimpleButton.isVisible({timeout: 5000}).catch(() => false)) {
await expect(switchToSimpleButton).toBeEnabled({timeout: 60_000});
await switchToSimpleButton.click();
await page.waitForTimeout(500);
}
const officeRowRemove = page
.locator('.table-editor__row')
.filter({hasText: 'Office'})
.getByRole('button', {name: 'Remove row'});
await expect(officeRowRemove).toBeVisible({timeout: 15_000});
await officeRowRemove.click();
await page.waitForTimeout(500);
// ===========================================
// STEP 3: Test Access Rule
// ===========================================
await testAccessRule(page);
// ===========================================
// STEP 4: Save the changes
// ===========================================
// Intercept the sync-job POST triggered by "Apply policy" so we can poll the
// exact job ID instead of using the racy UI-table path.
const editSyncJobIdPromiseT5792 = page
.waitForResponse((r) => r.url().includes('/api/v4/jobs') && r.request().method() === 'POST', {timeout: 15_000})
.then(async (r) => (r.ok() ? (((await r.json()) as {id?: string}).id ?? null) : null))
.catch(() => null);
const saveButton = page.getByRole('button', {name: 'Save'});
await saveButton.waitFor({state: 'visible', timeout: 5000});
await saveButton.click();
await page.waitForTimeout(1000);
const applyPolicyButton = page.getByRole('button', {name: /apply policy/i});
if (await applyPolicyButton.isVisible({timeout: 3000})) {
await applyPolicyButton.click();
await page.waitForTimeout(1000);
}
await navigateToABACPage(page);
const editSyncJobIdT5792 = await editSyncJobIdPromiseT5792;
await waitForLatestSyncJob(page, 10, editSyncJobIdT5792);
// ===========================================
// STEP 5 & 6: Verify channel membership after edit
// ===========================================
// Re-apply guard: a concurrent initSetup() may have reset ABAC between the policy save
// and the sync job completing. Without ABAC enabled the sync job is a no-op.
await adminClient.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
} as any);
// Poll under PW_WORKERS>=2: other shards' sync jobs may briefly change membership.
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerOfficeUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerOfficeUser should be AUTO-ADDED (satisfies simpler Dept-only policy)',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, engineerRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'engineerRemoteUser should still be in channel',
})
.toBe(true);
await expect
.poll(async () => verifyUserInChannel(adminClient, salesRemoteUser.id, privateChannel.id), {
timeout: 30_000,
intervals: [500, 1000, 2000],
message: 'salesRemoteUser should NOT be in channel',
})
.toBe(false);
});

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {enableTeamMembershipPolicies} from '../teams/helpers';
import {createChannelTextField, createParentPolicyViaAPI, expectAssignTeamsDenied} from './helpers';
/**
* Authoring round-trip for resource.attributes.* over the real HTTP boundary
* (Playwright drives a live server with the enterprise engine, so SavePolicy /
* cel-check validation runs for real — the api4 Go tests mock the engine and
* cannot).
*
* One representative case per rule; the exhaustive save-validation matrix
* (multiselect reject, rank scale-match, permission-policy accept) lives in the
* enterprise engine unit tests.
*/
test.describe('ABAC resource.attributes - authoring', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('accepts a parent policy mixing user and resource attributes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Save succeeds and returns a policy id — the round-trip accepts a
// mixed user/resource expression on a parent policy.
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Accept Resource ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
expect(policyId).toBeTruthy();
});
test('rejects has(resource.attributes.*) at check time', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// Absence is handled by deny-on-miss, so has() guards on resource
// attributes are rejected. cel/check surfaces the error to the editor.
// Assert the reason, not just that validation failed: a bare count also
// passes on an unrelated compile or engine error, and would have kept
// passing while the feature flag denied every resource reference.
const errors = await adminClient.checkAccessControlExpression(`has(resource.attributes.${attr})`);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].message).toContain('has() is not supported on resource attributes');
});
test('rejects assigning a resource parent to a team', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await enableTeamMembershipPolicies(adminClient);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Team Boundary ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "us"`,
});
// A team's resource is a team, which has no CPA attributes, so a parent
// that references resource.attributes.* must not be importable by a team.
await expectAssignTeamsDenied(adminClient, policyId, [team.id]);
});
});

View File

@@ -0,0 +1,295 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, type Page} from '@playwright/test';
import type {Client4} from '@mattermost/client';
/**
* Helpers for exercising resource.attributes.* (channel custom profile
* attributes) end to end. The subject of a policy stays the user; these helpers
* provision the *resource* side — channel-object-type CPA fields in the
* access_control group and per-channel values — plus API-driven policy authoring
* so a spec can exercise the resource side without also driving the policy
* editor UI.
*
* The server under test must run with MM_FEATUREFLAGS_RESOURCEATTRIBUTESINPOLICIES
* set: the feature is flag-gated, and a feature flag cannot be turned on through
* the config API (the config store restores flags on write), so no amount of
* patchConfig in a spec substitutes for the environment variable. Both CI paths
* set it — SERVER_ENV_BASELINE for the testcontainers stack the Playwright suite
* runs on, and e2e-tests/.ci/server.generate.sh for the docker-compose one — and
* every spec here guards with skipIfFeatureFlagNotSet so a server without it
* skips rather than failing on a policy save the server refuses.
*/
const PROPERTY_GROUP = 'access_control';
const CHANNEL_OBJECT_TYPE = 'channel';
const USER_OBJECT_TYPE = 'user';
const TEMPLATE_OBJECT_TYPE = 'template';
/**
* Create a channel-object-type text CPA field in the access_control group. The
* ABAC materialized view surfaces it as resource.attributes.<name>. Marked
* admin-managed so SavePolicy's name normalization accepts a reference to it
* (channel fields have no user-managed-attributes toggle). Returns the field id.
*/
export async function createChannelTextField(adminClient: Client4, name: string): Promise<string> {
const field = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name,
type: 'text',
target_type: 'system',
target_id: '',
attrs: {managed: 'admin'},
} as Parameters<Client4['createPropertyField']>[2]);
return field.id;
}
/**
* Set a single channel's value for a channel CPA field. A refresh-before-read
* is not needed for the sync lane (the sync job reads a freshly refreshed
* matview), but note the matview is refreshed on a timer, so set values before
* triggering the sync job.
*/
export async function setChannelAttributeValue(
adminClient: Client4,
channelId: string,
fieldId: string,
value: string,
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [{field_id: fieldId, value}]);
}
type ParentPolicyOptions = {
name: string;
expression: string;
version?: string;
};
/**
* Create a parent membership policy via the REST API. A parent is the reusable
* rule-carrier: assign channels to it (assignChannelsToPolicy) to put those
* channels under its rules. Returns the created policy id.
*/
export async function createParentPolicyViaAPI(adminClient: Client4, opts: ParentPolicyOptions): Promise<string> {
// The version sent here is advisory: CreateOrUpdateAccessControlPolicy
// overwrites it (v0.3, bumped to v0.4 only for permission-action rules), and
// no version gates resource.attributes.* — that's the
// ResourceAttributesInPolicies flag plus the "not a team policy" rule.
const policy = await adminClient.updateOrCreateAccessControlPolicy({
id: '',
name: opts.name,
type: 'parent',
version: opts.version ?? 'v0.3',
revision: 0,
active: true,
rules: [{expression: opts.expression, actions: ['membership']}],
});
return policy.id;
}
/**
* Assign channels to a parent policy (creates the per-channel child policy that
* imports the parent and flips enforcement on). Mirrors the System Console
* "Add channels" flow.
*/
export async function assignChannelsToPolicy(
adminClient: Client4,
policyId: string,
channelIds: string[],
): Promise<void> {
// The /assign endpoint returns 200 with an empty body but a JSON content
// type, which Client4.doFetch chokes on ("Unexpected end of JSON input").
// Use a raw request and check status, matching the pattern the rest of the
// ABAC e2e helpers use for these no-body endpoints.
const res = await fetch(`${adminClient.getBaseRoute()}/access_control_policies/${policyId}/assign`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${adminClient.getToken()}`},
body: JSON.stringify({channel_ids: channelIds}),
});
if (!res.ok) {
throw new Error(`assign channels failed: ${res.status} ${await res.text()}`);
}
}
/**
* Trigger an access_control_sync job. Pass a policyId to target the channels
* governed by that policy; omit it for a global sweep. Returns the job id.
*/
export async function triggerSyncJob(adminClient: Client4, policyId?: string): Promise<string> {
const job = await adminClient.createAccessControlSyncJob(policyId ? {policy_id: policyId} : {});
return job.id;
}
export type MultiselectScale = {
templateId: string;
userFieldId: string;
userFieldName: string;
channelFieldId: string;
channelFieldName: string;
// Server-assigned option ids keyed by option name. Both the user and channel
// fields inherit these exact ids from the template (that's what "shared option
// scale" means), so hasAnyOf/hasAllOf compares the same id space on both sides.
optionIds: Record<string, string>;
};
/**
* Provision a shared-scale multiselect setup for a list-vs-list (hasAnyOf /
* hasAllOf) rule: a template multiselect field plus a user field and a channel
* field that both link to it via linked_field_id. All three live in the
* access_control group — a user CPA field in the custom_profile_attributes group
* cannot carry a linked_field_id (the server rejects it), and the scale-match
* validation requires the receiver (user) and argument (channel) fields to share
* a non-nil, equal linked_field_id. The linked user field still surfaces as
* user.attributes.<name> to the engine. Returns the field ids/names and the
* option ids (identical across all three fields).
*/
export async function createLinkedMultiselectScale(
adminClient: Client4,
baseName: string,
optionNames: string[],
): Promise<MultiselectScale> {
const template = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
name: `${baseName}_tmpl`,
type: 'multiselect',
target_type: 'system',
target_id: '',
attrs: {options: optionNames.map((name) => ({id: '', name, color: '#0000ff'}))},
} as Parameters<Client4['createPropertyField']>[2]);
const userFieldName = `${baseName}_user`;
const userField = await adminClient.createPropertyField(PROPERTY_GROUP, USER_OBJECT_TYPE, {
name: userFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const channelFieldName = `${baseName}_chan`;
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
name: channelFieldName,
type: 'multiselect',
target_type: 'system',
target_id: '',
linked_field_id: template.id,
} as Parameters<Client4['createPropertyField']>[2]);
const optionIds: Record<string, string> = {};
for (const opt of (template.attrs?.options ?? []) as Array<{id: string; name: string}>) {
optionIds[opt.name] = opt.id;
}
return {
templateId: template.id,
userFieldId: userField.id,
userFieldName,
channelFieldId: channelField.id,
channelFieldName,
optionIds,
};
}
/**
* Set a user's multiselect value (list of option ids) for an access_control-group
* user field. Multiselect values are stored as option ids, not names.
*/
export async function setUserMultiselectValue(
adminClient: Client4,
userId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, USER_OBJECT_TYPE, userId, [
{field_id: fieldId, value: optionIds},
]);
}
export async function setChannelMultiselectValue(
adminClient: Client4,
channelId: string,
fieldId: string,
optionIds: string[],
): Promise<void> {
await adminClient.patchPropertyValues(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, channelId, [
{field_id: fieldId, value: optionIds},
]);
}
/**
* Open an existing membership policy in the System Console editor by id. The
* parent-policy editor page carries the rule table and the Test-access-rule
* button, so UI specs provision the policy over the API and drive only the
* feature under test here.
*/
export async function openPolicyEditor(page: Page, policyId: string): Promise<void> {
await page.goto(`/admin_console/system_attributes/membership_policies/edit_policy/${policyId}`);
await page.waitForLoadState('networkidle');
}
type ApiError = {status_code?: number; server_error_id?: string};
/**
* Run an API call that the server is expected to refuse, and assert it failed
* for the named reason. A bare `try { … } catch {}` proves only that something
* threw, so it passes just as happily on a 500, a transport fault, or a gating
* rejection that never reached the rule under test — which is how these specs
* once passed with the feature flag off.
*/
async function expectRejection(
call: () => Promise<unknown>,
expected: {statusCode: number; serverErrorId: string},
because: string,
): Promise<void> {
let error: ApiError | undefined;
try {
await call();
} catch (err) {
error = err as ApiError;
}
expect(error, `expected the server to refuse: ${because}`).toBeDefined();
expect(error?.status_code, because).toBe(expected.statusCode);
expect(error?.server_error_id, because).toBe(expected.serverErrorId);
}
/**
* Assert the runtime PDP refuses to add a user to a channel. The "user is not in
* the channel" check that usually follows can pass on its own, because the sync
* job already removed them — only this establishes that the add was refused.
*/
export async function expectAddToChannelDenied(adminClient: Client4, userId: string, channelId: string): Promise<void> {
await expectRejection(
() => adminClient.addToChannel(userId, channelId),
{statusCode: 403, serverErrorId: 'api.channel.add_user.to.channel.rejected'},
'the runtime PDP denies a non-matching user',
);
}
/**
* Assert assigning a parent policy to a team is refused. The team-boundary
* rejection carries its own error id, so pinning it rules out a permission,
* feature-gate or generic server error reaching this assertion instead.
*/
export async function expectAssignTeamsDenied(
adminClient: Client4,
policyId: string,
teamIds: string[],
): Promise<void> {
await expectRejection(
() => adminClient.assignTeamsToAccessControlPolicy(policyId, teamIds),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.team_resource_attributes'},
'a team cannot import a parent that references resource.attributes.*',
);
}
/**
* Assert a policy save is refused because its expression still carries the
* masked-value sentinel. That rejection has its own error id, distinguishing
* it from other save failures rather than reporting only the general one.
*/
export async function expectMaskedTokenRejected(adminClient: Client4, opts: ParentPolicyOptions): Promise<void> {
await expectRejection(
() => createParentPolicyViaAPI(adminClient, opts),
{statusCode: 400, serverErrorId: 'app.pap.save_policy.masked_token_in_expression'},
'a masked sentinel cannot be resolved to a stored value',
);
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {enableUserManagedAttributes} from '../support';
import {createChannelTextField, expectMaskedTokenRejected} from './helpers';
/**
* Attribute-value masking on the resource.attributes.* write path.
*
* The masked-value sentinel ("--------", model.MaskingTokenValue) is a
* response-only placeholder the server substitutes for literals a caller can't
* see. It must never round-trip back into storage: saving a policy whose
* resource.attributes.* condition still carries the sentinel is rejected. This
* is the write-path half of the symmetric masking gate, asserted over HTTP.
*
* The read-path (caller-relative redaction of resource literals in GET /
* visual_ast / simulate) reuses the shared-template bridge and protected /
* shared_only fields, which require direct DB provisioning; that path is covered
* by the app-layer resolver test (TestAppMaskingResolver_ChannelFieldUsesUserHoldings),
* the CEL-walker/visual-AST masking tests, and the existing user-attribute
* masking Playwright suite whose mechanics the resource path shares.
*/
test.describe('ABAC resource.attributes - masking write path', {tag: ['@abac', '@abac_masking']}, () => {
test('rejects saving a resource.attributes condition carrying the masked sentinel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
// The sentinel rejection under test lives inside the server's
// AttributeValueMasking branch, so the flag has to be on. It cannot be
// turned on from here (the config store restores feature flags on
// write), so guard rather than try to set it.
await pw.skipIfFeatureFlagNotSet('AttributeValueMasking', true);
const {adminClient} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
await createChannelTextField(adminClient, attr);
// The 8-dash sentinel is server-generated and never a real value, so a
// submitted expression containing it cannot be resolved to a stored
// value and is rejected.
await expectMaskedTokenRejected(adminClient, {
name: `Masked Sentinel ${pw.random.id()}`,
expression: `resource.attributes.${attr} == "--------"`,
});
});
});

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createChannelTextField,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelAttributeValue,
triggerSyncJob,
} from './helpers';
/**
* resource.attributes.* membership sync + enforcement (lane agreement).
*
* A policy that mixes the requesting user's attribute with the accessed
* channel's attribute (user.attributes.<a> == resource.attributes.<a>) must:
* - remove only the members whose value differs from the channel's (SQL sync lane),
* - let an admin add a matching user but block a non-matching one (runtime PDP lane),
* - remove everyone when the channel doesn't set the referenced attribute (deny-on-miss).
*
* Authoring/sync are driven through the REST API so the tests don't depend on
* the table-editor's RHS constraints.
*/
test.describe('ABAC resource.attributes - membership sync', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('mixed user/resource scalar policy syncs and enforces joins', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types → user.attributes.<attr> compared
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const userAttribute: CustomProfileAttribute[] = [{name: attr, type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, userAttribute);
const channelFieldId = await createChannelTextField(adminClient, attr);
const matchingUserNotInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const matchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
const nonMatchingUserInChannel = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'eu'},
]);
for (const u of [matchingUserNotInChannel, matchingUserInChannel, nonMatchingUserInChannel]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channel.id, channelFieldId, 'us');
await adminClient.addToChannel(matchingUserInChannel.id, channel.id);
await adminClient.addToChannel(nonMatchingUserInChannel.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource Region ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane on a private channel with an active policy:
// - the non-matching (eu) member is removed,
// - the matching (us) member stays,
// - the matching (us) user who was only in the team is auto-added —
// an active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchingUserInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
// Runtime lane agrees with the sync result: re-adding the matching user
// succeeds, while adding the non-matching user is blocked.
await adminClient.addToChannel(matchingUserNotInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchingUserNotInChannel.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatchingUserInChannel.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatchingUserInChannel.id, channel.id)).toBe(false);
});
test('deny-on-miss removes all members when the channel attribute is absent', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const attr = `region${pw.random.id()}`;
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: attr, type: 'text', value: ''},
]);
// Channel field exists so the reference resolves at save time, but the
// channel below never sets a value → the referenced field is missing for
// that channel → deny-on-miss.
await createChannelTextField(adminClient, attr);
const member = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: attr, type: 'text', value: 'us'},
]);
await adminClient.addToTeam(team.id, member.id);
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await adminClient.addToChannel(member.id, channel.id);
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(true);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Resource DenyOnMiss ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// The channel is missing the referenced attribute, so the whole policy
// denies and every governed member is removed — fail-secure.
expect(await verifyUserInChannel(adminClient, member.id, channel.id)).toBe(false);
});
});

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
waitForPolicySyncJob,
} from '../support';
import {
assignChannelsToPolicy,
createLinkedMultiselectScale,
createParentPolicyViaAPI,
expectAddToChannelDenied,
setChannelMultiselectValue,
setUserMultiselectValue,
triggerSyncJob,
} from './helpers';
/**
* List-vs-list (multiselect) resource targets end to end: a rule comparing a
* multiselect user attribute against a multiselect channel attribute with
* hasAnyOf / hasAllOf, authored here over the real HTTP boundary. Both fields
* link to a shared template (equal linked_field_id) so they share one option-id
* scale — the only shape the save-time validation accepts.
*
* Each test asserts the two evaluation lanes agree on the same fixtures:
* - SQL sync lane: a non-matching member is removed, a matching member stays,
* and a matching team-only user is auto-added to the assigned private channel.
* - Runtime PDP lane: re-adding a matching user succeeds; adding a non-matching
* user is blocked.
*
* hasAnyOf = the lists intersect. hasAllOf = the channel's list is a subset of
* the user's (the user holds every value the channel requires).
*/
test.describe('ABAC resource.attributes - multiselect targets', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('has any of syncs and enforces on list intersection', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `region${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-any-of matches a user sharing >=1.
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [beta] -> shares beta
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha] -> shares alpha
const nonMatch = await createUserForABAC(adminClient, {}, []); // [gamma] -> disjoint
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [beta]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [gamma]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAnyOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAnyOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the disjoint member is removed, the intersecting member
// stays, and the intersecting team-only user is auto-added — an active
// private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees with the sync verdict: re-adding the
// intersecting user succeeds, while the disjoint one is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
test('has all of syncs and enforces on channel-list subset', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
const scale = await createLinkedMultiselectScale(adminClient, `clr${pw.random.id()}`, [
'alpha',
'beta',
'gamma',
]);
const {alpha, beta, gamma} = scale.optionIds;
// Channel requires [alpha, beta]. has-all-of matches only a user holding
// BOTH — a user with just alpha does not (missing beta).
const matchInChannel = await createUserForABAC(adminClient, {}, []); // [alpha, beta, gamma] superset
const matchTeamOnly = await createUserForABAC(adminClient, {}, []); // [alpha, beta] exact
const nonMatch = await createUserForABAC(adminClient, {}, []); // [alpha] missing beta
await setUserMultiselectValue(adminClient, matchInChannel.id, scale.userFieldId, [alpha, beta, gamma]);
await setUserMultiselectValue(adminClient, matchTeamOnly.id, scale.userFieldId, [alpha, beta]);
await setUserMultiselectValue(adminClient, nonMatch.id, scale.userFieldId, [alpha]);
for (const u of [matchInChannel, matchTeamOnly, nonMatch]) {
await adminClient.addToTeam(team.id, u.id);
}
const channel = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelMultiselectValue(adminClient, channel.id, scale.channelFieldId, [alpha, beta]);
await adminClient.addToChannel(matchInChannel.id, channel.id);
await adminClient.addToChannel(nonMatch.id, channel.id);
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `HasAllOf ${pw.random.id()}`,
expression: `user.attributes.${scale.userFieldName}.hasAllOf(resource.attributes.${scale.channelFieldName})`,
});
await assignChannelsToPolicy(adminClient, policyId, [channel.id]);
await triggerSyncJob(adminClient, policyId);
await waitForPolicySyncJob(adminClient, policyId);
// SQL sync lane: the superset member stays, the member missing a required
// value is removed, and the exact-match team-only user is auto-added — an
// active private-channel policy pulls in matching team members.
expect(await verifyUserInChannel(adminClient, matchInChannel.id, channel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
// Runtime PDP lane agrees: re-adding the exact-match user succeeds, the
// subset-missing user is blocked.
await adminClient.addToChannel(matchTeamOnly.id, channel.id);
expect(await verifyUserInChannel(adminClient, matchTeamOnly.id, channel.id)).toBe(true);
await expectAddToChannelDenied(adminClient, nonMatch.id, channel.id);
expect(await verifyUserInChannel(adminClient, nonMatch.id, channel.id)).toBe(false);
});
});

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
assertAccessControlAutocompleteContains,
createPrivateChannelForABAC,
createUserForABAC,
enableUserManagedAttributes,
} from '../support';
import {createChannelTextField, createParentPolicyViaAPI, openPolicyEditor, setChannelAttributeValue} from './helpers';
/**
* Test-matching-users channel picker end to end.
*
* A parent policy that references resource.attributes.* has no channel scope of
* its own, so "Test access rule" cannot resolve the rule until the admin picks a
* concrete channel. The shared test modal therefore opens a channel-picker step
* first; the picked channel's attribute values are threaded into the matching-users
* query. This asserts that flow over the real UI + HTTP: the picker finds a
* channel by name, choosing it resolves the rule against that channel (a matching
* user appears, a non-matching one does not), and the back arrow returns to the
* picker.
*
* The matching-users query reads the attribute materialized view, refreshed on a
* throttled (~30s) cadence, so the first read after setting values may lag — the
* matching assertion re-searches until the view catches up.
*/
test.describe('ABAC resource.attributes - test picker', {tag: ['@abac', '@abac_resource_attributes']}, () => {
test('picker resolves a resource rule against the chosen channel', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ResourceAttributesInPolicies', true);
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
} as Parameters<typeof adminClient.patchConfig>[0]);
// Same field name on both object types so user.attributes.<attr> compares
// to resource.attributes.<attr>.
const attr = `region${pw.random.id()}`;
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, [{name: attr, type: 'text', value: ''}]);
const channelFieldId = await createChannelTextField(adminClient, attr);
const userUS = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'us'}]);
const userEU = await createUserForABAC(adminClient, fieldsMap, [{name: attr, type: 'text', value: 'eu'}]);
await adminClient.addToTeam(team.id, userUS.id);
await adminClient.addToTeam(team.id, userEU.id);
const channelUS = await createPrivateChannelForABAC(adminClient, team.id);
await setChannelAttributeValue(adminClient, channelUS.id, channelFieldId, 'us');
const policyId = await createParentPolicyViaAPI(adminClient, {
name: `Picker ${pw.random.id()}`,
expression: `user.attributes.${attr} == resource.attributes.${attr}`,
});
// Fail fast if the editor won't see the attribute (keeps the Test button disabled).
await assertAccessControlAutocompleteContains(adminClient, [attr]);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const page = systemConsolePage.page;
await openPolicyEditor(page, policyId);
const testButton = page.getByRole('button', {name: /test access rule/i});
await expect(testButton).toBeVisible({timeout: 10000});
await expect(testButton).toBeEnabled({timeout: 15000});
await testButton.click();
const modal = page.locator('.TestResultsModal');
// Picker step: no channel scope on the parent, so the picker precedes the
// members list.
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
const channelSearch = modal.locator('.TestChannelPicker__search-input');
await channelSearch.fill(channelUS.display_name);
const channelRow = modal.locator('.TestChannelPicker__row', {hasText: channelUS.display_name});
await expect(channelRow).toBeVisible({timeout: 10000});
await channelRow.click();
// Members step: the rule now resolves against channelUS (region == "us").
await expect(modal.getByText('Access Rule Test Results')).toBeVisible({timeout: 10000});
await expect(modal.locator('.TestResultsModal__back')).toBeVisible();
const memberSearch = modal.locator('input[placeholder*="Search" i]').first();
await expect(async () => {
await memberSearch.fill(userUS.username);
await expect(modal.locator('.more-modal__name', {hasText: userUS.username})).toBeVisible({timeout: 3000});
}).toPass({timeout: 60000, intervals: [3000]});
// The non-matching user (region == "eu") is not admitted against this channel.
// Wait for the search round-trip before asserting absence: the previous
// row is cleared the moment the term changes, so toHaveCount(0) would
// pass on that intermediate empty render even if userEU were admitted.
const euSearch = page.waitForResponse(
(r) => r.url().includes('/access_control_policies/cel/test') && r.request().method() === 'POST',
{timeout: 15000},
);
await memberSearch.fill(userEU.username);
await euSearch;
await expect(modal.locator('.more-modal__name', {hasText: userEU.username})).toHaveCount(0);
// The back arrow returns to the picker (only present because it preceded).
await modal.locator('.TestResultsModal__back').click();
await expect(modal.getByText('Select a channel to test against')).toBeVisible({timeout: 10000});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Add Members to Team modal (System Console) blocks non-qualifying candidates in strict mode
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies} from './helpers';
test.describe('ABAC - Add members to team (System Console)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-68846-T7 PRIVATE governed team blocks non-qualifying candidates inline', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team with Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — not added to team yet
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin and navigate to the per-team console page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
// MultiSelect uses React-Select which renders the input as a combobox role, not
// a plain <input placeholder="...">. Use getByRole to match the accessible name.
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for eng1 — qualifying candidate
await searchInput.pressSequentially(eng1.username.slice(0, 8));
const eng1Row = addModal.locator('.more-modal__row', {hasText: eng1.username});
await expect(eng1Row).toBeVisible({timeout: 10000});
// * eng1's row is NOT disabled
await expect(eng1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(eng1Row.locator('.more-modal__error')).not.toBeVisible();
// # Search for mkt1 — non-qualifying candidate
await searchInput.clear();
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * mkt1's row is disabled with the blocking message
await expect(mkt1Row).toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).toHaveText('Does not meet membership requirements');
});
test('MM-68846-T8 PUBLIC governed team does NOT block candidates (advisory)', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team with Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 and mkt1
const createUser = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
first_name: dept,
last_name: label,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Advisory policy (public team)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// # Log in as system admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(`/admin_console/user_management/teams/${team.id}`);
// # Click "Add Members" button
await page.locator('#addTeamMembers').click();
const addModal = page.locator('#addUsersToTeamModal');
await expect(addModal).toBeVisible({timeout: 10000});
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
await expect(searchInput).toBeVisible({timeout: 10000});
// # Search for mkt1 — non-qualifying but advisory = no block
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
await expect(mkt1Row).toBeVisible({timeout: 10000});
// * Row is NOT disabled
await expect(mkt1Row).not.toHaveClass(/more-modal__row--disabled/);
await expect(mkt1Row.locator('.more-modal__error')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,425 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {
setupCustomProfileAttributeFields,
setupCustomProfileAttributeValuesForUser,
} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory hiding
*
* Enforcement is mode-dependent. A private (non-open-invite) governed team is
* strict: it is surfaced into the directory only for non-members who satisfy the
* policy and omitted for everyone else. A public (open-invite) governed team is
* advisory: it stays visible to all. Asserted at the API layer (each user's own
* client) so it proves server-side filtering, not just DOM hiding — exercising the
* full api4 → app filter → store-listing chain, including the governed-team
* listing widening that surfaces private governed teams to qualifying users.
*/
test.describe('ABAC - Team directory hiding', {tag: ['@abac', '@team_membership']}, () => {
// Each test cleans up the resources it creates, even on failure. initSetup's
// own team/users are owned by the framework and left alone.
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
// Hard user deletion requires server config; deactivating is the API-safe cleanup.
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-68846-T1 - omits a policy-governed team from the directory for non-qualifying users', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient, team} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Two non-members: one qualifies (Engineering), one does not (Sales).
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A private (non-open-invite) governed team is strict: ABAC surfaces it into
// the directory only for users who qualify and hides it from everyone else.
// The policy never mutates allow_open_invite.
await adminClient.patchTeam({id: team.id, allow_open_invite: false} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [team.id]);
// Control: a private team with NO policy. The listing widening must surface
// only governed teams, so this one stays invisible to both users regardless
// of qualification — guarding against the OR clause over-widening.
const ungoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Ungoverned ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(ungoverned.id);
// ABAC evaluates against a materialized view that refreshes on an interval;
// wait until the qualifying user is visible to the evaluator before asserting.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(team.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting directory visibility',
})
.toBe(true);
// API layer: proves the server filters the listing, not just the DOM.
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
// Paginate to collect all visible teams — busy CI environments accumulate
// many public teams across test runs, so a single page-0 fetch misses teams
// that land beyond per_page=200. Use batch.length === 0 (not < per_page) as
// the stop condition: the server applies FilterNonQualifyingTeamsForUser
// after fetching a full DB page, so a batch can legitimately come back
// smaller than per_page while more pages still exist in the DB.
const allTeamIds = async (client: Client4): Promise<string[]> => {
const ids: string[] = [];
let page = 0;
while (true) {
const batch = (await client.getTeams(page, 200)) as Team[];
if (batch.length === 0) {
break;
}
ids.push(...batch.map((t) => t.id));
page++;
}
return ids;
};
const qualIds = await allTeamIds(qualClient);
const nonQualIds = await allTeamIds(nonQualClient);
expect(qualIds).toContain(team.id);
expect(nonQualIds).not.toContain(team.id);
// The ungoverned private team is never surfaced, qualified or not.
expect(qualIds).not.toContain(ungoverned.id);
expect(nonQualIds).not.toContain(ungoverned.id);
// The boundary is asserted at the API layer. The /select_team directory page
// additionally filters joinable teams to open-invite teams client-side, so it
// never renders a private governed team regardless of the server decision —
// surfacing those in the directory UI is separate webapp work.
});
test('MM-68846-T2 - hides a governed team from a non-qualifying system admin in the directory while the management listing stays complete', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Give the admin a present-but-non-matching Department so the policy
// deterministically denies them. A missing attribute is not the same as a
// wrong one — the expression must evaluate to false, not error on an absent
// key — so the admin gets "Sales" against an "Engineering" policy.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
// A private (non-open-invite), policy-governed team the admin is NOT a member
// of: create it as admin (the creator is auto-joined), then remove the admin
// so the directory treats them as a non-member non-qualifying browser. Private
// → strict, so for_directory hiding applies to the admin too.
const governed = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(governed.id);
await adminClient.removeFromTeam(governed.id, adminUser.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Admin Directory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [governed.id]);
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
const listDirectoryTeamIds = async (forDirectory: boolean): Promise<string[]> => {
const url = `${base}/teams?page=0&per_page=200${forDirectory ? '&for_directory=true' : ''}`;
const resp = await fetch(url, {headers});
const teams = (await resp.json()) as Team[];
return teams.map((t) => t.id);
};
// The assignment becomes visible through a materialized attribute view and a
// read replica, so poll the directory listing until the governed team drops
// out for the admin (for_directory) rather than asserting on the first read.
// A transient fetch error counts as "not ready yet" so the poll keeps going.
await expect
.poll(
async () => {
try {
return (await listDirectoryTeamIds(true)).includes(governed.id);
} catch {
return true;
}
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'governed team should be hidden from the admin directory listing',
},
)
.toBe(false);
// Without for_directory the admin stays exempt, so the System Console listing
// is still complete.
expect(await listDirectoryTeamIds(false)).toContain(governed.id);
// UI: with the backend confirmed above, the team-selection directory hides it
// for the admin too.
const adminSession = await pw.testBrowser.login(adminUser);
await adminSession.page.goto('/select_team');
await adminSession.page.waitForLoadState('networkidle');
await expect(adminSession.page.getByText(governed.display_name, {exact: false})).toHaveCount(0);
});
test('MM-68846-T3 - keeps a public governed team visible to a non-qualifying user (advisory mode)', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// A user who does NOT satisfy the policy (Sales against an Engineering rule).
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// A public (open-invite) governed team: the policy is advisory, so it stays
// in the directory for everyone regardless of qualification — no hiding.
// open-invite is set via patch (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Advisory Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Confirm enforcement is actually live before asserting advisory behavior —
// otherwise "visible" is indistinguishable from a plain ungoverned public team
// and a regression to strict-on-public would go undetected.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: despite being governed, the public team stays visible to the
// non-qualifying user — the policy never hides it.
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
expect(nonQualTeams.map((t) => t.id)).toContain(publicGoverned.id);
});
test('MM-68846-T4 - join gate forks on privacy mode: advisory public admits, strict private denies', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
// Present-but-wrong attribute (Sales against an Engineering rule) so the PDP
// evaluates the user to false rather than erroring on a missing key.
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(nonQualUser.id);
// The admin drives the readiness poll below; give them the same present-but-
// wrong attribute so the private team deterministically drops from their
// for_directory listing once enforcement is live.
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: 'Department', value: 'Sales', type: 'text'}],
attributeFieldsMap,
adminUser.id,
);
const expression = 'user.attributes.Department == "Engineering"';
const newGovernedTeam = async (open: boolean) => {
const t = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(t.id);
if (open) {
// open-invite is set via patch (the create payload does not persist it).
await adminClient.patchTeam({id: t.id, allow_open_invite: true} as any);
}
// The creator is auto-joined, and members always retain directory
// visibility — remove the admin so they read as a non-member browser.
await adminClient.removeFromTeam(t.id, adminUser.id);
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Join Gate ${open ? 'Public' : 'Private'} ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [t.id]);
return t;
};
const publicGoverned = await newGovernedTeam(true);
const privateGoverned = await newGovernedTeam(false);
// The private team's enforcement reaches the join gate through a materialized
// view and a read replica. Poll the admin's for_directory listing until the
// team drops out: the admin always has it in-candidate (list_private_teams),
// so it dropping out positively confirms the policy is live AND the PDP
// denies — exactly the state the join gate evaluates. Polling the
// non-qualifying user's listing would be ambiguous (absent both before the
// policy propagates and after it is hidden).
const base = adminClient.getBaseRoute();
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
await expect
.poll(
async () => {
try {
const resp = await fetch(`${base}/teams?page=0&per_page=200&for_directory=true`, {headers});
return ((await resp.json()) as Team[]).map((t) => t.id).includes(privateGoverned.id);
} catch {
return true;
}
},
{timeout: 60_000, intervals: [1000, 2000, 5000, 5000, 5000]},
)
.toBe(false);
// Confirm the public team is genuinely governed before asserting advisory
// admission — otherwise a "successful join" would just be a plain public team
// and a regression to strict-on-public would slip through.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// Advisory: the gate is skipped on a governed public team, so the
// non-qualifying user is admitted.
const member = await adminClient.addToTeam(publicGoverned.id, nonQualUser.id);
expect(member.user_id).toBe(nonQualUser.id);
// Strict: the same user is denied on the private team — directory visibility
// never translates into join access, and no role bypasses the gate.
let denied = false;
try {
await adminClient.addToTeam(privateGoverned.id, nonQualUser.id);
} catch {
denied = true;
}
expect(denied).toBe(true);
});
});

View File

@@ -0,0 +1,271 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import {expect, test} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
import {createUserForABAC, ensureUserAttributes} from '../support';
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
/**
* ABAC — Team directory "Recommended" tag
*
* A public (open-invite) governed team is advisory: it stays visible to everyone,
* and a non-member who satisfies the policy is surfaced a transient, per-viewer
* "recommended" hint. The flag is fail-secure (a non-qualifying viewer is never
* tagged) and is never set on private governed teams (those are hidden, not
* recommended). Asserted at the API layer so it proves the server-side annotation
* across the full api4 → app filter/annotate → store-listing chain, plus a UI check
* that the chip renders in the team-selection directory.
*
* @reference MM-69100
*/
test.describe('ABAC - Team directory recommended tag', {tag: ['@abac', '@team_membership']}, () => {
let cleanupClient: Client4 | undefined;
const createdTeamIds: string[] = [];
const createdUserIds: string[] = [];
const createdPolicyIds: string[] = [];
test.afterEach(async () => {
const client = cleanupClient;
cleanupClient = undefined;
const policyIds = createdPolicyIds.splice(0);
const teamIds = createdTeamIds.splice(0);
const userIds = createdUserIds.splice(0);
if (!client) {
return;
}
const base = client.getBaseRoute();
const headers = {Authorization: `Bearer ${client.getToken()}`};
for (const id of policyIds) {
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
}
for (const id of teamIds) {
await client.deleteTeam(id).catch(() => {});
}
for (const id of userIds) {
await client.updateUserActive(id, false).catch(() => {});
}
});
test('MM-69100-T1 - recommends a public governed team to a qualifying non-member only', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Sales', type: 'text'},
]);
createdUserIds.push(qualUser.id, nonQualUser.id);
// A public (open-invite) governed team: advisory, so it stays visible to all;
// qualifying non-members get the recommended hint. open-invite is set via patch
// (the create payload does not persist it).
const publicGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Public Governed ${pw.random.id()}`,
type: 'O',
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
// API layer: the qualifying non-member's listing tags the team recommended;
// the non-qualifying user's listing does not (fail-secure).
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
const {client: nonQualClient} = await pw.makeClient({
username: nonQualUser.username,
password: nonQualUser.password,
});
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.find((t) => t.id === publicGoverned.id)?.recommended === true;
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'public governed team should be recommended to the qualifying user',
},
)
.toBe(true);
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
const nonQualRow = nonQualTeams.find((t) => t.id === publicGoverned.id);
expect(nonQualRow).toBeDefined(); // advisory: still visible
expect(Boolean(nonQualRow?.recommended)).toBe(false);
});
test('MM-69100-T2 - never recommends a private governed team, even to a qualifying user', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// A private (non-open-invite) governed team: strict. It is surfaced to the
// qualifying user (directory widening) but must never carry the recommended tag.
const privateGoverned = await adminClient.createTeam({
name: `team-${pw.random.id()}`,
display_name: `Private Governed ${pw.random.id()}`,
type: 'O',
allow_open_invite: false,
email: `${pw.random.id()}@example.com`,
} as any);
createdTeamIds.push(privateGoverned.id);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Private Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [privateGoverned.id]);
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
await expect
.poll(async () => (await adminClient.getTeam(privateGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
})
.toBe(true);
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
// The qualifying user can see the private governed team (strict widening), but it
// is never recommended — recommendation is a public-team-only, advisory concept.
await expect
.poll(
async () => {
const teams = (await qualClient.getTeams(0, 200)) as Team[];
return teams.some((t) => t.id === privateGoverned.id);
},
{
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'qualifying user should see the private governed team before asserting recommendation',
},
)
.toBe(true);
const teams = (await qualClient.getTeams(0, 200)) as Team[];
const row = teams.find((t) => t.id === privateGoverned.id);
expect(Boolean(row?.recommended)).toBe(false);
});
test('MM-69100-T3 - renders the Recommended chip in the team-selection directory for a qualifying user', async ({
pw,
}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
const {adminClient} = await pw.initSetup();
cleanupClient = adminClient;
await enableTeamMembershipPolicies(adminClient);
await ensureUserAttributes(adminClient);
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
{name: 'Department', type: 'text'},
]);
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', value: 'Engineering', type: 'text'},
]);
createdUserIds.push(qualUser.id);
// Prefix with "AA" so the team sorts near the top of most environments.
// The UI check in Part 2 below walks InfiniteScroll regardless, so an
// exact page-0 guarantee is not required.
const teamId = pw.random.id();
const publicGoverned = await adminClient.createTeam({
name: `team-${teamId}`,
display_name: `AA Recommended${teamId}`,
type: 'O',
email: `${teamId}@example.com`,
} as any);
createdTeamIds.push(publicGoverned.id);
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
const expression = 'user.attributes.Department == "Engineering"';
const policy = await createTeamMembershipParentPolicy(
adminClient,
`Recommended UI Policy ${pw.random.id()}`,
expression,
);
createdPolicyIds.push(policy.id);
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
// Wait for the materialized attribute view to include the qualifying user
// before asserting policy propagation — the PDP reads from this view.
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
// The child policy row is written to the master DB; the directory query
// reads from a read replica. Poll policy_enforced until the replica has
// caught up so the EXISTS subquery in GetAllPage widens correctly.
await expect
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
timeout: 60_000,
intervals: [1000, 2000, 5000, 5000, 5000],
message: 'team should show policy_enforced=true before asserting recommended',
})
.toBe(true);
const {page} = await pw.testBrowser.login(qualUser);
await page.goto('/select_team');
await page.waitForLoadState('networkidle');
const chip = page
.locator('.signup-team-dir')
.filter({hasText: publicGoverned.display_name})
.getByLabel('Recommended based on your attributes');
await expect(chip).toBeVisible({timeout: 15000});
});
});

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
/**
* Enable team-membership ABAC end to end: the umbrella attribute-based access
* control setting plus the `TeamMembershipAccessControl` feature flag that gates
* the per-team System Console section. Both must be on for the team page to
* render the membership-policy toggle — mirroring the server enforcement gate.
*/
export async function enableTeamMembershipPolicies(client: Client4): Promise<void> {
await client.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: true,
EnableUserManagedAttributes: true,
},
FeatureFlags: {
TeamMembershipAccessControl: true,
},
} as any);
}
/**
* Create a parent membership policy that can be assigned to a TEAM.
*
* Team assignment runs `child.Inherit(parent)` with the child at v0.3, which
* requires the parent to also be v0.3 — so (unlike the channel-side
* createParentPolicy, which is v0.2) this helper stamps v0.3 and a membership
* action. Returns the created policy (carries `id` and `name`).
*/
export async function createTeamMembershipParentPolicy(
client: Client4,
name: string,
expression: string,
): Promise<{id: string; name: string}> {
return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
method: 'put',
body: JSON.stringify({
id: '',
name,
type: 'parent',
version: 'v0.3',
revision: 0,
rules: [{expression, actions: ['membership']}],
}),
});
}
/**
* Trigger an access_control_team_sync job and poll that specific job until it
* finishes. Polling by ID (not list position) avoids a race where older jobs
* occupy jobs[0] and the newly created one is never checked.
*
* Both `success` and `warning` are terminal completions: the worker reports
* `warning` (not `success`) whenever the mass-removal guardrail trips — i.e. a
* sync that drops >50% of a team. The sync still ran to completion, so callers
* that exercise removals must accept it. The final status is returned so a
* caller can assert on it (e.g. expecting the warning state).
*/
export async function triggerSyncJobAndPoll(
client: Client4,
policyId = '',
timeoutMs = 90_000,
pollIntervalMs = 3_000,
): Promise<string> {
// Scope the sync to the team's policy (team policies are keyed by team id),
// mirroring the product trigger createAccessControlTeamSyncJob({policy_id}).
// A scoped sync also chains a scoped channel sync, so the chained job is
// created deterministically rather than skipped by the unscoped dedupe.
const body: {type: string; data?: {policy_id: string}} = {type: 'access_control_team_sync'};
if (policyId) {
body.data = {policy_id: policyId};
}
const job: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs`, {
method: 'POST',
body: JSON.stringify(body),
});
const jobId: string = job.id;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
const current: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs/${jobId}`, {method: 'GET'});
if (current.status === 'success' || current.status === 'warning') {
return current.status;
}
if (current.status === 'error') {
throw new Error(`access_control_team_sync job failed: ${JSON.stringify(current)}`);
}
}
throw new Error('Timed out waiting for access_control_team_sync job to finish');
}
/**
* Assign a parent policy to teams via the REST API (the `team_ids` field).
* Mirrors `assignChannelsToPolicy` from team_settings/helpers — same endpoint,
* different resource list.
*/
export async function assignTeamsToPolicy(client: Client4, policyId: string, teamIds: string[]): Promise<void> {
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/assign`;
const response = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
body: JSON.stringify({team_ids: teamIds}),
});
if (!response.ok) {
throw new Error(`assignTeamsToPolicy failed: ${response.status}`);
}
}

View File

@@ -0,0 +1,251 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective API-level enforcement gate: non-qualifying users are rejected when joining a private
* ABAC-governed team; the same call is allowed on a public (advisory) team.
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
ensureDepartmentAttribute,
enableTeamMembershipABACConfig,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
// Raw fetch wrapper — returns status + body so tests can assert on both success and rejection
// without doFetch swallowing the error.
async function addTeamMemberRaw(
token: string | null,
baseRoute: string,
teamId: string,
userId: string,
): Promise<{status: number; body: any}> {
const res = await fetch(`${baseRoute}/teams/${teamId}/members`, {
method: 'POST',
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
body: JSON.stringify({team_id: teamId, user_id: userId}),
});
let body: any = {};
try {
body = await res.json();
} catch {
// empty body is fine
}
return {status: res.status, body};
}
// Log in via raw REST to obtain the user's OWN session token, so a self-join can be
// attempted with the requesting user's session rather than the admin's (which would
// bypass the attribute gate). The token is returned in the response 'Token' header.
async function loginRaw(baseRoute: string, loginId: string, password: string): Promise<string> {
const res = await fetch(`${baseRoute}/users/login`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({login_id: loginId, password}),
});
return res.headers.get('Token') ?? '';
}
test.describe('ABAC - Join enforcement gate (API level)', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T8 non-qualifying user is rejected at the API level on a private ABAC team', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create eng1 (qualifies) and mkt1 (does not qualify)
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await makeUser('Engineering', 'eng1gate');
const mkt1 = await makeUser('Marketing', 'mkt1gate');
createdUserIds.push(eng1.id, mkt1.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
// Wait for the materialized view to reflect both users before hitting the gate
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user (Marketing) is rejected with 403
const mktResult = await addTeamMemberRaw(token, baseRoute, team.id, mkt1.id);
expect(mktResult.status).toBe(403);
// * Error message is generic — must NOT leak policy name, attribute name or CEL expression
const errMsg: string = mktResult.body?.message ?? '';
expect(errMsg).not.toMatch(/Engineering/i);
expect(errMsg).not.toMatch(/Department/i);
expect(errMsg).not.toMatch(/policy/i);
expect(errMsg.length).toBeGreaterThan(0);
// * mkt1 is NOT a member of the team
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).not.toContain(mkt1.id);
// * Qualifying user (Engineering) is accepted with 201 (Created)
const engResult = await addTeamMemberRaw(token, baseRoute, team.id, eng1.id);
expect(engResult.status).toBe(201);
// * eng1 is now a member
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIdsAfter = membersAfter.map((m: any) => m.user_id);
expect(memberIdsAfter).toContain(eng1.id);
});
test('MM-69100-T9 non-qualifying user is allowed at the API level on a public ABAC team (advisory)', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Create public team — advisory mode, same Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const uid = `mkt2gate${suffix}`;
const mkt2 = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt2.id, 'Department', 'Marketing');
createdUserIds.push(mkt2.id);
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
const token = adminClient.getToken();
const baseRoute = adminClient.getBaseRoute();
// * Non-qualifying user is allowed on a public ABAC team — advisory, no gate
const result = await addTeamMemberRaw(token, baseRoute, team.id, mkt2.id);
expect(result.status).toBe(201);
// * mkt2 is a member despite not matching the policy
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
const memberIds = members.map((m: any) => m.user_id);
expect(memberIds).toContain(mkt2.id);
});
test('MM-69100-T10 qualifying user self-joins a private ABAC team by attribute match; non-qualifying is rejected', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team governed by the Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Two regular users (no join_private_teams role): eng qualifies, mkt does not.
// Capture their passwords so we can log in as them and self-join.
const makeUser = async (dept: string, label: string) => {
const uid = `${label}${suffix}`;
const password = newTestPassword();
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, user.id, 'Department', dept);
createdUserIds.push(user.id);
return {user, password};
};
const eng = await makeUser('Engineering', 'engself');
const mkt = await makeUser('Marketing', 'mktself');
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng.user.id]);
const baseRoute = adminClient.getBaseRoute();
// # Qualifying user logs in and joins themselves with their OWN session
const engToken = await loginRaw(baseRoute, eng.user.username, eng.password);
expect(engToken.length).toBeGreaterThan(0);
// * Attribute match authorizes the self-join even without the join_private_teams role
const engResult = await addTeamMemberRaw(engToken, baseRoute, team.id, eng.user.id);
expect(engResult.status).toBe(201);
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(members.map((m: any) => m.user_id)).toContain(eng.user.id);
// # Non-qualifying user attempts the same self-join with their own session
const mktToken = await loginRaw(baseRoute, mkt.user.username, mkt.password);
expect(mktToken.length).toBeGreaterThan(0);
// * Rejected — the attribute gate applies to the requesting user's session too
const mktResult = await addTeamMemberRaw(mktToken, baseRoute, team.id, mkt.user.id);
expect(mktResult.status).toBe(403);
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
expect(membersAfter.map((m: any) => m.user_id)).not.toContain(mkt.user.id);
});
});

View File

@@ -0,0 +1,243 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A team sync removal cascades to private channel membership.
*
* When a user is removed from a private ABAC-governed team, the server calls
* LeaveTeam which synchronously removes that user from every private channel
* in the team. This spec exercises the full path end-to-end:
*
* policy update → team sync → team removal → channel cascade (LeaveTeam)
*
* Two users start in both the team and a private channel. Updating the policy
* to exclude one of them removes that user from the team AND the channel, while
* the qualifying user is unaffected.
*
* Assertions run at two layers:
* 1. API — direct membership queries confirm the server state.
* 2. UI — browser logins confirm what each user actually sees rendered.
*
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test, verifyUserInChannel} from '@mattermost/playwright-lib';
import {
createPrivateChannel,
createPrivateTeam,
createPublicTeam,
createTeamMembershipPolicy,
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Team Sync Channel Cascade', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(240000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
await adminClient.deleteTeam(id).catch(() => {});
}
createdTeamIds = [];
for (const id of createdUserIds) {
await adminClient.updateUserActive(id, false).catch(() => {});
}
createdUserIds = [];
});
/**
* When the sync job removes a non-qualifying user from a private team, the
* underlying LeaveTeam call also removes them from every private channel in
* that team. Validated at both the API layer (membership queries) and the UI
* layer (browser logins for each affected user).
*/
test('MM-69100-T_cascade - team sync removal cascades to private channel membership', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
if (!adminClient) {
throw new Error('Admin client not available');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team (allow_open_invite=false → strict ABAC enforcement)
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
const {adminUser} = await pw.getAdminClient();
if (adminUser) {
await adminClient.removeFromTeam(team.id, adminUser.id).catch(() => {});
}
// # Private channel inside the governed team — the cascade target
const privateChannel = await createPrivateChannel(adminClient, team.id);
// # Public home team for mkt1. A user removed from their only team lands
// # on a "Team not found" error and cannot navigate anywhere meaningful.
// # homeTeam gives mkt1 a valid context for the UI assertions below.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # Create eng1 (Engineering) and mkt1 (Marketing) — track passwords for browser login
const eng1Password = newTestPassword();
const eng1 = await adminClient.createUser(
{
email: `eng1${suffix}@sample.mattermost.com`,
username: `eng1${suffix}`,
password: eng1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, eng1.id, 'Department', 'Engineering');
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `mkt1${suffix}@sample.mattermost.com`,
username: `mkt1${suffix}`,
password: mkt1Password,
} as any,
'',
'',
);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await adminClient.addToTeam(homeTeam.id, mkt1.id);
createdUserIds.push(eng1.id, mkt1.id);
// Gate on the materialized attribute view — ABAC sync reads from a Postgres
// view that refreshes at most once every 30 s. Freshly-written attributes
// are not visible until the next refresh tick.
await waitForAttributeViewToInclude(
adminClient,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
[eng1.id, mkt1.id],
);
// =========================================================
// Phase 1 — policy admits both; sync auto-adds both to team
// =========================================================
await createTeamMembershipPolicy(
adminClient,
team.id,
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
true, // active = auto-add
);
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] Both are team members after first sync
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'both users should be auto-added to team'},
)
.toEqual(expect.arrayContaining([eng1.id, mkt1.id]));
// Team sync adds users to the team (and town-square) only — not to
// arbitrary private channels. Add both manually so the cascade has something to remove.
await adminClient.addToChannel(eng1.id, privateChannel.id);
await adminClient.addToChannel(mkt1.id, privateChannel.id);
// * [API] Both in private channel — baseline confirmed
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
expect(await verifyUserInChannel(adminClient, mkt1.id, privateChannel.id)).toBe(true);
// =========================================================
// Phase 2 — policy narrows to Engineering; mkt1 loses access
// =========================================================
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// Strict mode: sync removes mkt1 from team; LeaveTeam cascades to channels
await triggerSyncJobAndPoll(adminClient, team.id);
// * [API] mkt1 removed from team
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 15000, intervals: [500, 1000, 2000], message: 'mkt1 should be removed from team'},
)
.not.toContain(mkt1.id);
// * [API] mkt1 cascade-removed from private channel
await expect
.poll(() => verifyUserInChannel(adminClient, mkt1.id, privateChannel.id), {
timeout: 15000,
intervals: [500, 1000, 2000],
message: 'mkt1 should be cascade-removed from private channel',
})
.toBe(false);
// * [API] eng1 still in team and channel
await expect
.poll(
async () => {
const members: any[] = await adminClient.getTeamMembers(team.id);
return members.map((m: any) => m.user_id);
},
{timeout: 10000, intervals: [500, 1000], message: 'eng1 should remain in team'},
)
.toContain(eng1.id);
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
// =========================================================
// UI — mkt1: governed team gone from sidebar; channel inaccessible
// =========================================================
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page: mkt1Page} = await pw.testBrowser.login(mkt1WithPassword);
const mkt1ChannelsPage = new ChannelsPage(mkt1Page);
// Land on homeTeam so we have a valid context to inspect the sidebar from
await mkt1ChannelsPage.goto(homeTeam.name, 'town-square');
await mkt1ChannelsPage.toBeVisible();
// * Navigating to the governed team URL redirects mkt1 away (no membership)
await mkt1Page.goto(`/${team.name}/channels/town-square`);
await mkt1Page.waitForLoadState('networkidle');
await expect(mkt1Page).not.toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// =========================================================
// UI — eng1: governed team and private channel still accessible
// =========================================================
const eng1WithPassword = {...eng1, password: eng1Password};
const {page: eng1Page} = await pw.testBrowser.login(eng1WithPassword);
const eng1ChannelsPage = new ChannelsPage(eng1Page);
// * eng1 can access the governed team — URL confirms they are on it
// (team sidebar icon only renders when the user belongs to multiple teams,
// so we assert the route instead of the sidebar element)
await eng1ChannelsPage.goto(team.name, 'town-square');
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page).toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
// * Private channel is accessible to eng1
await eng1ChannelsPage.goto(team.name, privateChannel.name);
await eng1ChannelsPage.toBeVisible();
await expect(eng1Page.locator('#channelHeaderTitle', {hasText: privateChannel.display_name})).toBeVisible({
timeout: 10000,
});
});
});

View File

@@ -0,0 +1,232 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective Sync Job Details modal shows per-team added/removed counts and the mass-removal warning
* @reference MM-69100
*/
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
test.describe('ABAC - Sync Job Details Modal', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T4 sync job details shows per-team member counts and section title', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # two users below — keeps the removal count deterministic and below the
// # >50% mass-removal guardrail.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # eng1 is a qualifying member; mkt1 is non-qualifying (will be removed by sync)
const createUser = async (dept: string, label: string, addToTeam: boolean) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
if (addToTeam) {
await adminClient.addToTeam(team.id, user.id);
}
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createUser('Engineering', `eng1${suffix}`, true);
const mkt1 = await createUser('Marketing', `mkt1${suffix}`, true);
createdUserIds.push(eng1.id, mkt1.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy (strict mode removes non-qualifiers)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Log in and navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Section title is "Membership sync jobs" (capital S verified in en.json)
await expect(page.getByText(/Membership sync jobs/i)).toBeVisible({timeout: 10000});
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
// * Row exposes a visible "View details" affordance (MM-69827 finding 12)
await expect(jobRow.getByText('View details')).toBeVisible();
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
// * Team row present for our team
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Removed count shows -1 (mkt1 removed)
await expect(teamRow.locator('.changes-cell .removed')).toHaveText(/-1/);
// # Click team row to open the user drill-down modal
await teamRow.click();
await expect(page.locator('#user-list-modal-dialog')).toBeVisible({timeout: 10000});
});
test('MM-69827-T1 Membership Sync Jobs names the channel scope and points to team sync when team ABAC is on', async ({
pw,
}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// * Button names its scope so a green success isn't mistaken for a team re-sync
await expect(page.getByRole('button', {name: /Run Channel Sync/i})).toBeVisible({timeout: 10000});
// * A note points admins to where team-membership sync actually lives
await expect(page.getByText(/This syncs channel membership only/i)).toBeVisible({timeout: 10000});
await expect(page.getByText(/Team Settings/i)).toBeVisible();
});
test('MM-69100-T5 sync job details shows mass-removal warning when >50% of members are removed', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.getAdminClient();
if (!adminUser) {
throw new Error('Admin user not found');
}
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # The creator is auto-added; drop them so the member set is exactly the
// # four users below (1 qualifying + 3 not), giving a clean 75% removal.
await adminClient.removeFromTeam(team.id, adminUser.id);
// # Add 1 qualifying member and 3 non-qualifying members so >50% are removed
const createAndAdd = async (dept: string, label: string) => {
const uid = `${suffix}${label}`;
const user = await adminClient.createUser(
{
email: `${uid}@sample.mattermost.com`,
username: uid,
password: newTestPassword(),
} as any,
'',
'',
);
await adminClient.addToTeam(team.id, user.id);
await setUserAttribute(adminClient, user.id, 'Department', dept);
return user;
};
const eng1 = await createAndAdd('Engineering', `eng${suffix}`);
const mkt1 = await createAndAdd('Marketing', `mkt1${suffix}`);
const mkt2 = await createAndAdd('Marketing', `mkt2${suffix}`);
const mkt3 = await createAndAdd('Marketing', `mkt3${suffix}`);
createdUserIds.push(eng1.id, mkt1.id, mkt2.id, mkt3.id);
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
// # Active policy — 3 of 4 members removed (75%)
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for completion
await triggerSyncJobAndPoll(adminClient, team.id);
// # Navigate to sync jobs page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
// # Open the most recent job's details (the channel sync chained from the team sync)
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
await expect(jobRow).toBeVisible({timeout: 10000});
await jobRow.click();
const detailsModal = page.locator('#job-details-modal');
await expect(detailsModal).toBeVisible({timeout: 15000});
// # Switch to the Teams tab where the team membership changes are surfaced
await detailsModal.getByRole('button', {name: 'Teams'}).click();
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
await expect(teamRow).toBeVisible({timeout: 10000});
// * Mass-removal warning indicator present (>50% removed)
await expect(teamRow.locator('.mass-removal-warning')).toBeVisible({timeout: 10000});
});
});

View File

@@ -0,0 +1,187 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* @objective A real team sync job creates the addition/removal system posts, which are
* rendered correctly in the affected user's DM from the system bot.
* @reference MM-69100
*/
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
import {
enableTeamMembershipABACConfig,
ensureDepartmentAttribute,
createPublicTeam,
createPrivateTeam,
createTeamMembershipPolicy,
setUserAttribute,
waitForAttributeViewToInclude,
} from '../../../channels/team_settings/helpers';
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
const SYSTEM_BOT_USERNAME = 'system-bot';
test.describe('ABAC - Sync System Messages', {tag: ['@abac', '@team_membership']}, () => {
test.setTimeout(120000);
let createdTeamIds: string[] = [];
let createdUserIds: string[] = [];
test.afterEach(async ({pw}) => {
const {adminClient} = await pw.getAdminClient();
for (const id of createdTeamIds) {
try {
await adminClient.deleteTeam(id);
} catch {
// ignore
}
}
createdTeamIds = [];
for (const id of createdUserIds) {
try {
await adminClient.updateUserActive(id, false);
} catch {
// ignore
}
}
createdUserIds = [];
});
test('MM-69100-T6 qualifying non-member receives an addition DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Public team + active Engineering policy
const team = await createPublicTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # Create joiner with Engineering attribute — NOT yet a team member
const joinerUid = `joiner${suffix}`;
const joinerPassword = newTestPassword();
const joiner = await adminClient.createUser(
{
email: `${joinerUid}@sample.mattermost.com`,
username: joinerUid,
password: joinerPassword,
} as any,
'',
'',
);
createdUserIds.push(joiner.id);
await setUserAttribute(adminClient, joiner.id, 'Department', 'Engineering');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [joiner.id]);
// # Active policy — auto-add will add joiner on next sync
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
// # Trigger sync and wait for it to complete
await triggerSyncJobAndPoll(adminClient);
// * joiner is now a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const joinerMember = members.find((m: any) => m.user_id === joiner.id);
expect(joinerMember).toBeDefined();
// # Locate the system-bot user
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
expect(botUsers.length).toBeGreaterThan(0);
const systemBot = botUsers[0];
// # Get or create the DM channel between joiner and system-bot
const dmChannel = await adminClient.createDirectChannel([joiner.id, systemBot.id]);
expect(dmChannel).toBeDefined();
// # Log in as joiner and navigate to the DM
const joinerWithPassword = {...joiner, password: joinerPassword};
const {page} = await pw.testBrowser.login(joinerWithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(team.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Addition system message rendered in the DM (scoped to this run's team name
// to avoid matching accumulated messages from prior test runs in the same DM).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been added to ${escapedName} because you now meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
test('MM-69100-T7 removed member receives a removal DM after a sync job', async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await pw.getAdminClient();
const suffix = pw.random.id();
await enableTeamMembershipABACConfig(adminClient);
await enableTeamMembershipPolicies(adminClient);
await ensureDepartmentAttribute(adminClient);
// # Private team + active Engineering policy
const team = await createPrivateTeam(adminClient, suffix);
createdTeamIds.push(team.id);
// # A second, non-governed team that mkt1 keeps after being removed from the
// governed one. A user with zero teams lands on a "Team Not Found" page and
// can't reach the system-bot DM, so they need a team context to view it.
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
createdTeamIds.push(homeTeam.id);
// # mkt1 has Marketing — does not match Engineering rule; IS a current member
const mkt1Uid = `mkt1${suffix}`;
const mkt1Password = newTestPassword();
const mkt1 = await adminClient.createUser(
{
email: `${mkt1Uid}@sample.mattermost.com`,
username: mkt1Uid,
password: mkt1Password,
} as any,
'',
'',
);
createdUserIds.push(mkt1.id);
await adminClient.addToTeam(team.id, mkt1.id);
await adminClient.addToTeam(homeTeam.id, mkt1.id);
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Marketing"', [mkt1.id]);
// # Active policy (strict) — mkt1 will be removed
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
await triggerSyncJobAndPoll(adminClient, team.id);
// * mkt1 is no longer a team member
const members: any[] = await adminClient.getTeamMembers(team.id);
const mkt1Member = members.find((m: any) => m.user_id === mkt1.id);
expect(mkt1Member).toBeUndefined();
// # Ensure the DM channel between mkt1 and system-bot exists before logging in
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
const systemBot = botUsers[0];
const dmChannel = await adminClient.createDirectChannel([mkt1.id, systemBot.id]);
// # Log in as mkt1 and open the system-bot DM from the retained team's context
const mkt1WithPassword = {...mkt1, password: mkt1Password};
const {page} = await pw.testBrowser.login(mkt1WithPassword);
const channelsPage = new ChannelsPage(page);
await channelsPage.goto(homeTeam.name, dmChannel.name);
await channelsPage.toBeVisible();
// * Removal system message rendered (scoped to this run's team name).
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const expectedText = new RegExp(
`You have been removed from ${escapedName} because you no longer meet the membership requirements`,
'i',
);
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
timeout: 15000,
});
});
});

View File

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
createUserWithAttributes,
} from '@mattermost/playwright-lib';
import {
ensureUserAttributes,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5794: User is auto-added to channel when a qualifying attribute is added to their profile (auto-add true)
*
* Step 1:
* With at least one access policy in existence on the server, set to auto-add, and applied to a channel:
* 1. As system admin make a note of the attribute needed for a user to be auto-added to a channel
* 2. As a user not in the channel and not having the required attribute
* 3. Click user's own profile picture top right and select Profile
* 4. Scroll down to the required custom attribute, click Edit, and add the required value
*/
test('MM-T5794 User auto-added when qualifying attribute is added to profile', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create attribute, policy, and channel
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
// Setup attributes (using ensureUserAttributes like MM-T5800 does)
await ensureUserAttributes(adminClient);
// Create test user with NON-qualifying Department attribute (same pattern as MM-T5800)
// MM-T5800 creates user with Department=Sales, then changes to Engineering
// We do the same: Start with Sales (non-qualifying), then change to Engineering (qualifying)
const testUser = await createUserWithAttributes(adminClient, {Department: 'Sales'});
await adminClient.addToTeam(team.id, testUser.id);
// Create private channel
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// STEP 1: Create ABAC policy with auto-add enabled
// Policy requirement: Department == "Engineering"
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policyName = `Engineering Access ${pw.random.id()}`;
const jobId = await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // ✅ Auto-add enabled
channels: [privateChannel.display_name],
});
// Activate policy (EXACT same pattern as MM-T5800)
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policyName.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policyName;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyId = (await policyRow.getAttribute('id'))?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// Re-apply guard: concurrent initSetup() resets ABAC between enableABAC() UI call and sync
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// ============================================================
// STEP 2: Verify user is NOT in channel initially
// ============================================================
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1);
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(initialInChannel).toBe(false);
// ============================================================
// STEPS 3-5: Add qualifying attribute to user's profile
// Note: Using API for attribute update. UI testing for profile editing
// is covered in separate user profile test suite.
// ============================================================
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
// ============================================================
// STEP 6: Run sync job to trigger auto-add
// ============================================================
// DEBUG: Verify attribute was updated before sync
await adminClient.getUserCustomProfileAttributesValues(testUser.id);
// Get the Department field to check its value
await adminClient.getCustomProfileAttributeFields();
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
// ============================================================
// VERIFICATION: User should now be auto-added to channel
// ============================================================
// DEBUG: Check all channel members
await adminClient.getChannelMembers(privateChannel.id);
const finalInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
if (!finalInChannel) {
// console.error('\n[ERROR] User NOT in channel after sync!');
// console.error('[ERROR] This means the ABAC sync did not add the user.');
// console.error('[ERROR] Possible causes:');
// console.error('[ERROR] 1. Policy not active');
// console.error('[ERROR] 2. Attribute value not matching policy');
// console.error('[ERROR] 3. Sync job failed silently');
}
expect(finalInChannel).toBe(true);
// ============================================================
// VERIFICATION: Check for "User added" system message
// ============================================================
// Get recent posts from the channel
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
// Find system message for user being added
const userAddedMessage = postList.find((post: any) => {
return (
post.type === 'system_add_to_channel' &&
post.props?.addedUserId === testUser.id &&
post.user_id === 'system'
);
});
if (userAddedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
// System messages might be disabled in test env, so we don't fail the test
// The important verification is that the user was added
expect(finalInChannel).toBe(true);
});
});

View File

@@ -0,0 +1,139 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
verifyUserInChannel,
updateUserAttributes,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5795: User can be added to channel by system admin after a qualifying attribute is added to their profile (auto-add false)
*
* Preconditions:
* - Access policy with auto-add set to FALSE
*
* Steps:
* 1. As system admin, note the required attribute for channel access
* 2. As a user not in the channel and lacking the required attribute:
* - Add the required attribute value to user profile
* 3. As system admin, go to the channel and add the user
*
* Expected:
* - User who now meets the policy CAN be added to the channel by the admin
* - "User added" message is posted in the channel by System
*/
test('MM-T5795 User can be added by admin after attribute added (auto-add false)', async ({pw}) => {
test.setTimeout(120000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP: Create attribute, policy with auto-add FALSE, and channel
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create test user WITHOUT the qualifying attribute
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, []);
await adminClient.addToTeam(team.id, testUser.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// STEP 1: Create policy with auto-add DISABLED
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
const policyName = `Engineering Manual Add ${pw.random.id()}`;
await createBasicPolicy(systemConsolePage.page, {
name: policyName,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // ✅ Auto-add DISABLED
channels: [privateChannel.display_name],
});
// ============================================================
// STEP 2: Add qualifying attribute to user
// ============================================================
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
// ============================================================
// STEP 3: Admin manually adds user to channel
// ============================================================
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.AccessControlSettings?.EnableAttributeBasedAccessControl === true;
});
// Re-create the Department CPA field if a concurrent initSetup() deleted it.
// Without the field the server returns "An attribute is missing from the expression"
// on addToChannel, because the policy references a field id that no longer exists.
await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Verify user can be added (policy allows it since user has qualifying attribute)
await adminClient.addToChannel(testUser.id, privateChannel.id);
// Membership + ABAC evaluation can lag behind the REST response in CI.
await expect
.poll(async () => verifyUserInChannel(adminClient, testUser.id, privateChannel.id), {
timeout: 60000,
intervals: [1000, 2000, 3000],
})
.toBe(true);
// ============================================================
// VERIFICATION: Check for "User added" system message
// ============================================================
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
const userAddedMessage = postList.find((post: any) => {
return post.type === 'system_add_to_channel' && post.props?.addedUserId === testUser.id;
});
if (userAddedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
});
});

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
expect,
test,
enableABAC,
navigateToABACPage,
runSyncJob,
verifyUserInChannel,
updateUserAttributes,
} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
import {
createUserForABAC,
createPrivateChannelForABAC,
createBasicPolicy,
activatePolicy,
waitForLatestSyncJob,
enableUserManagedAttributes,
} from '../support';
/**
* ABAC User Attributes - Attribute Changes
* Tests for user attribute changes affecting ABAC policies
*/
test.describe('ABAC User Attributes - Attribute Changes', () => {
/**
* MM-T5796: User is auto-removed from channel when required attribute is removed
*
* Test Scenario 1 & 2 (Auto-add: False & True):
* Steps:
* 1. As system admin, identify the required attribute for channel access
* 2. Log in as a user currently in the channel with the required attribute
* 3. Edit user's profile
* 4. Remove or change the required attribute value
* 5. Save changes
*
* Expected:
* - User is automatically removed from the channel
* - System posts a "User removed" message in the channel
*/
test('MM-T5796 User auto-removed when required attribute is removed', async ({pw}) => {
test.setTimeout(180000);
await pw.skipIfNoLicense();
// ============================================================
// SETUP
// ============================================================
const {adminUser, adminClient, team} = await pw.initSetup();
await enableUserManagedAttributes(adminClient);
const attributeFields: CustomProfileAttribute[] = [{name: 'Department', type: 'text', value: ''}];
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, attributeFields);
// Create test user WITH the qualifying attribute (starts with Department=Engineering)
const testUser = await createUserForABAC(adminClient, attributeFieldsMap, [
{name: 'Department', type: 'text', value: 'Engineering'},
]);
await adminClient.addToTeam(team.id, testUser.id);
const privateChannel = await createPrivateChannelForABAC(adminClient, team.id);
// ============================================================
// TEST SCENARIO 1: Auto-add FALSE
// ============================================================
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await navigateToABACPage(systemConsolePage.page);
await enableABAC(systemConsolePage.page);
const policy1Name = `Engineering Access NoAutoAdd ${pw.random.id()}`;
const jobId1 = await createBasicPolicy(systemConsolePage.page, {
name: policy1Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: false, // Auto-add FALSE
channels: [privateChannel.display_name],
});
// Manually add user to channel
await adminClient.addToChannel(testUser.id, privateChannel.id);
const initialInChannel = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(initialInChannel).toBe(true);
// Get policy ID and activate
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId1);
const searchInput = systemConsolePage.page.locator('input[placeholder*="Search" i]').first();
await searchInput.waitFor({state: 'visible', timeout: 5000});
const idMatch = policy1Name.match(/([a-z0-9]+)$/i);
const uniqueId = idMatch ? idMatch[1] : policy1Name;
await searchInput.fill(uniqueId);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow = systemConsolePage.page.locator('.policy-name').first();
const policyElementId = await policyRow.getAttribute('id');
const policyId = policyElementId?.replace('customDescription-', '');
if (policyId) {
await activatePolicy(adminClient, policyId);
}
await searchInput.clear();
// Remove the qualifying attribute
await updateUserAttributes(adminClient, testUser.id, {Department: 'Sales'});
// Wait for attribute change to propagate
await systemConsolePage.page.waitForTimeout(1000);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Run sync job
const syncJob1 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob1);
// Wait for membership updates to apply
await systemConsolePage.page.waitForTimeout(1000);
// Verify user is removed
const userInChannelAfterRemoval = await verifyUserInChannel(adminClient, testUser.id, privateChannel.id);
expect(userInChannelAfterRemoval).toBe(false);
// Check for removal system message
const posts = await adminClient.getPosts(privateChannel.id, 0, 10);
const postList = posts.order.map((postId: string) => posts.posts[postId]);
const userRemovedMessage = postList.find((post: any) => {
return (
(post.type === 'system_remove_from_channel' || post.type === 'system_leave_channel') &&
(post.props?.removedUserId === testUser.id || post.user_id === testUser.id)
);
});
if (userRemovedMessage) {
// System message found
} else {
// System message not found (may be disabled in test env)
}
// ============================================================
// TEST SCENARIO 2: Auto-add TRUE
// ============================================================
// Restore user attribute and create new policy with auto-add=true
await updateUserAttributes(adminClient, testUser.id, {Department: 'Engineering'});
const channel2 = await createPrivateChannelForABAC(adminClient, team.id);
await navigateToABACPage(systemConsolePage.page);
const policy2Name = `Engineering Access WithAutoAdd ${pw.random.id()}`;
const jobId2 = await createBasicPolicy(systemConsolePage.page, {
name: policy2Name,
attribute: 'Department',
operator: '==',
value: 'Engineering',
autoSync: true, // Auto-add TRUE
channels: [channel2.display_name],
});
// Activate and run sync to auto-add user
await waitForLatestSyncJob(systemConsolePage.page, undefined, jobId2);
await searchInput.fill(policy2Name.match(/([a-z0-9]+)$/i)?.[1] || policy2Name);
await systemConsolePage.page.waitForTimeout(1000);
const policyRow2 = systemConsolePage.page.locator('.policy-name').first();
const policyId2 = (await policyRow2.getAttribute('id'))?.replace('customDescription-', '');
if (policyId2) {
await activatePolicy(adminClient, policyId2);
}
await searchInput.clear();
// Re-apply ABAC enable guard: a concurrent initSetup() on another shard may have
// disabled ABAC between the initial enableABAC call and this sync job.
// Without ABAC enabled the server skips policy evaluation and won't auto-add the user.
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
const syncJob2 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob2);
const userAutoAdded = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
expect(userAutoAdded).toBe(true);
// Remove attribute again
await updateUserAttributes(adminClient, testUser.id, {Department: 'Marketing'});
// Wait for attribute change to propagate
await systemConsolePage.page.waitForTimeout(1000);
await adminClient.patchConfig({
AccessControlSettings: {EnableAttributeBasedAccessControl: true},
});
// Run sync
const syncJob3 = await runSyncJob(systemConsolePage.page);
await waitForLatestSyncJob(systemConsolePage.page, undefined, syncJob3);
// Small delay for channel membership update
await systemConsolePage.page.waitForTimeout(1000);
// Verify user is removed
const userRemovedFromChannel2 = await verifyUserInChannel(adminClient, testUser.id, channel2.id);
expect(userRemovedFromChannel2).toBe(false);
});
});

View File

@@ -0,0 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test, enableABAC, navigateToABACPage} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../../channels/custom_profile_attributes/helpers';
import {
deleteCustomProfileAttributes,
setupCustomProfileAttributeFields,
} from '../../../channels/custom_profile_attributes/helpers';
import {fillSingleConditionValue, getPolicyIdByName} from '../support';
type FieldsMap = Record<string, UserPropertyField>;
async function clearExistingFields(client: Client4): Promise<void> {
try {
const existing = await client.getCustomProfileAttributeFields();
if (existing?.length) {
const map: FieldsMap = {};
for (const f of existing) {
map[f.id] = f;
}
await deleteCustomProfileAttributes(client, map);
}
} catch {
// No fields to clean up
}
}
test.describe('ABAC Attribute Selector - display_name rendering and filtering', () => {
/**
* @objective Verify the attribute selector renders display_name when set,
* falls back to `name`, filters on both, and persists the CEL identifier
* in saved policy expressions.
*
* @precondition
* Two admin-managed CPA fields seeded via REST: `dept_head` with
* display_name 'Department Head', and `office` with no display_name.
*/
test(
'renders and filters by display_name while persisting CEL identifier',
{tag: '@user_attributes'},
async ({pw}) => {
test.setTimeout(120000);
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
await clearExistingFields(adminClient);
const seedAttributes: CustomProfileAttribute[] = [
{
name: 'dept_head',
type: 'text',
attrs: {
display_name: 'Department Head',
visibility: 'when_set',
managed: 'admin',
},
},
{
name: 'office',
type: 'text',
attrs: {
visibility: 'when_set',
managed: 'admin',
},
},
];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, seedAttributes);
const policyName = `Display Name Selector ${pw.random.id()}`;
let policyId: string | null = null;
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await navigateToABACPage(page);
await enableABAC(page);
// # Open the new-policy form
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
const nameInput = page.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName');
await nameInput.waitFor({state: 'visible', timeout: 10000});
await nameInput.fill(policyName);
// # Add an attribute rule and open its selector
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await expect(addAttributeButton).toBeEnabled({timeout: 10000});
await addAttributeButton.click();
const attributeButton = page.locator('[data-testid="attributeSelectorMenuButton"]').first();
await attributeButton.waitFor({state: 'visible', timeout: 5000});
const attributeMenu = page.locator('[id^="attribute-selector-menu"]');
if (!(await attributeMenu.isVisible({timeout: 1000}).catch(() => false))) {
await attributeButton.click();
}
await attributeMenu.waitFor({state: 'visible', timeout: 5000});
const deptHeadItem = page.locator('[id^="attribute-selector-menu"] li:has-text("Department Head")');
const officeItem = page.locator('[id^="attribute-selector-menu"] li:has-text("office")');
// * Both fields render: 'Department Head' (display_name) and 'office' (name fallback)
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toBeVisible();
const filterInput = attributeMenu.locator('.attribute-selector-search input');
await filterInput.waitFor({state: 'visible', timeout: 5000});
// * Filter by display_name keeps only 'Department Head'
await filterInput.fill('department');
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toHaveCount(0);
// * Filter by CEL identifier keeps only 'Department Head'
await filterInput.fill('');
await filterInput.fill('dept_head');
await expect(deptHeadItem).toBeVisible();
await expect(officeItem).toHaveCount(0);
// * Filter on the no-display_name field's `name` keeps only 'office'
await filterInput.fill('');
await filterInput.fill('office');
await expect(officeItem).toBeVisible();
await expect(deptHeadItem).toHaveCount(0);
// # Select 'Department Head'
await filterInput.fill('');
await deptHeadItem.first().click({force: true});
// * The trigger button shows display_name, not the CEL identifier
await expect(attributeButton).toContainText('Department Head', {timeout: 5000});
// # Wait for the attribute-selector popover to close before opening the next menu
const attributeMenuPopover = page.locator('[id^="attribute-selector-menu"]');
await attributeMenuPopover.waitFor({state: 'hidden', timeout: 5000});
const operatorButton = page.locator('[data-testid="operatorSelectorMenuButton"]').first();
await operatorButton.waitFor({state: 'visible', timeout: 5000});
await operatorButton.click();
const operatorMenu = page.locator('[id^="operator-selector-menu"]');
await operatorMenu.waitFor({state: 'visible', timeout: 5000});
await operatorMenu.locator('li:has-text("is")').first().click();
await fillSingleConditionValue(page, 'engineering');
const saveButton = page.getByRole('button', {name: 'Save'});
await expect(saveButton).toBeEnabled({timeout: 10000});
// # Click Save and wait on the create-policy PUT (button unmounts on navigate)
const createPolicyResponse = page.waitForResponse(
(r) => /\/access_control_policies(?:\?|$)/.test(r.url()) && r.request().method() === 'PUT',
{timeout: 15000},
);
await saveButton.click();
const createResponse = await createPolicyResponse;
expect(createResponse.ok()).toBe(true);
await page.waitForURL(/\/admin_console\/system_attributes\/membership_policies/, {timeout: 10000});
// * The persisted CEL uses the canonical identifier, not display_name
policyId = await getPolicyIdByName(adminClient, policyName);
expect(policyId).not.toBeNull();
const policy = await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
{method: 'GET'},
);
const rules = (policy?.rules || []) as Array<{actions?: string[]; expression?: string}>;
const membershipRule = rules.find((r) => r.actions?.includes('membership')) || rules[0];
expect(membershipRule).toBeDefined();
expect(membershipRule?.expression || '').toContain('user.attributes.dept_head');
expect(membershipRule?.expression || '').not.toContain('Department Head');
} finally {
if (policyId) {
try {
await (adminClient as any).doFetch(
`${adminClient.getBaseRoute()}/access_control_policies/${policyId}`,
{method: 'DELETE'},
);
} catch {
// best-effort cleanup
}
}
await deleteCustomProfileAttributes(adminClient, fieldsMap);
}
},
);
});

View File

@@ -0,0 +1,109 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
// Spec-scoped test data prefix. afterEach cleanup is filtered by this so
// concurrent specs sharing the same server can't delete each other's fields.
const BA_PREFIX = 'BA_';
test.describe('System Console - Board Attributes', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields((name) => name.startsWith(BA_PREFIX));
});
test('renders page with seeded Status and Assignee rows and a disabled save button', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
// # Navigate via the sidebar entry
await systemConsolePage.sidebar.systemAttributes.boardAttributes.click();
await ba.toBeVisible();
// * Status row is present with its three seeded values
await expect(ba.nameInputByValue('status')).toBeVisible();
await expect(ba.optionChip('Todo')).toBeVisible();
await expect(ba.optionChip('In Progress')).toBeVisible();
await expect(ba.optionChip('Complete')).toBeVisible();
// * Assignee row is present
await expect(ba.nameInputByValue('assignee')).toBeVisible();
// * Save button is present but disabled — no pending changes
await expect(ba.saveButton).toBeVisible();
await expect(ba.saveButton).toBeDisabled();
});
test('seeded Status row is protected: read-only values, disabled name input', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * The read-only values container exists exactly once — only the
// seeded protected select renders it
await expect(ba.container.getByTestId('property-values-readonly')).toHaveCount(1);
// * Read-only chips render the seeded value names
await expect(
ba.container.getByTestId('property-values-readonly').getByText('Todo', {exact: true}),
).toBeVisible();
// * Status name input is disabled — protected fields cannot be renamed
await expect(ba.nameInputByValue('status')).toBeDisabled();
});
test('Assignee row uses the placeholder values column (user type renders an em-dash)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * Assignee row exists
await expect(ba.nameInputByValue('assignee')).toBeVisible();
// * Its values cell renders the em-dash placeholder (no options panel)
const assigneeRow = ba.rowByName('assignee');
await expect(assigneeRow.getByText('—')).toBeVisible();
await expect(assigneeRow.getByTestId('property-values-readonly')).toHaveCount(0);
await expect(assigneeRow.getByTestId('property-values-input')).toHaveCount(0);
});
test('adds, names, saves, and persists a custom text attribute across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `${BA_PREFIX}Priority_${Date.now()}`;
// # Add a new attribute and name it
await ba.addAttribute(name);
// # Save
await ba.saveAndWaitForSettled();
// * Field exists on the server
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const created = (fields ?? []).find((f) => f.name === name);
expect(created).toBeDefined();
expect(created!.type).toBe('text');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Field is still rendered
await expect(ba.nameInputByValue(name)).toBeVisible();
});
});

View File

@@ -0,0 +1,99 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {COLOR_TOKEN_NAMES, SERVER_COLOR_BY_UI_LABEL, setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - option color picker', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('assigns a color token to an option and the server persists the value', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Color_${Date.now()}`;
const optionName = 'Tagged';
// # Add a select attribute with one option, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, optionName);
await ba.saveAndWaitForSettled();
// # Assign the Green color
await ba.setOptionColor(optionName, 'Green');
await ba.saveAndWaitForSettled();
// * Server reflects the color token
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const updated = (fields ?? []).find((f) => f.name === attrName);
const option = ((updated!.attrs as {options?: Array<{name: string; color?: string}>})?.options ?? []).find(
(o) => o.name === optionName,
);
expect(option?.color).toBe(SERVER_COLOR_BY_UI_LABEL.Green);
});
test('color assignment persists across reload and the picker shows the correct checked state', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Persist_${Date.now()}`;
// # Add a select attribute with one option, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Colored');
await ba.saveAndWaitForSettled();
// # Assign the Red color and save
await ba.setOptionColor('Colored', 'Red');
await ba.saveAndWaitForSettled();
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Chip is still rendered
await expect(ba.optionChip('Colored')).toBeVisible();
// * The picker reflects the correct checked color
await ba.openOptionMenu('Colored');
const checkedColor = ba.container.page().getByRole('menuitemradio', {name: 'Red', exact: true});
await expect(checkedColor).toHaveAttribute('aria-checked', 'true');
});
test('color picker exposes all nine color tokens', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Picker_${Date.now()}`;
// # Add a select with one option, open its menu
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Pick me');
await ba.openOptionMenu('Pick me');
// * Every named color token is offered
for (const uiColor of COLOR_TOKEN_NAMES) {
await expect(ba.container.page().getByRole('menuitemradio', {name: uiColor, exact: true})).toBeVisible();
}
});
});

View File

@@ -0,0 +1,71 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {cleanupCustomBoardFields, setupBoardAttributesTest} from './setup';
// Spec-scoped test data prefix. afterEach cleanup is filtered by this so
// concurrent specs sharing the same server (or running in alternation on a
// given worker) can't delete each other's fields.
const BA_DND_PREFIX = 'BA_DND_';
// Row reorder is driven through the drag handle's keyboard interface
// (ArrowUp / ArrowDown — see DraggableRow in webapp's list_table.tsx). That
// codepath dispatches the same `meta.onReorder` callback the native HTML5
// drag flow uses, so a passing keyboard test proves the wiring end-to-end.
//
// Chip-level option reorder uses @atlaskit/pragmatic-drag-and-drop with no
// keyboard fallback in the product, and PDND requires real HTML5 drag
// events (Playwright's locator.dragTo() only emulates mouse events). A
// dedicated chip-reorder E2E is therefore deferred to a follow-up that
// either (a) adds keyboard a11y to chip reorder, or (b) introduces a
// shared playwright-lib helper for synthetic HTML5 drag dispatch.
test.describe('Board Attributes - reorder', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields((name) => name.startsWith(BA_DND_PREFIX));
});
test('reorders custom rows via drag-handle keyboard and persists order across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const a = `${BA_DND_PREFIX}Row_A_${Date.now()}`;
const b = `${BA_DND_PREFIX}Row_B_${Date.now()}`;
// # Add two custom rows: A first (so it sits above B), then B
await ba.addAttribute(a);
await ba.addAttribute(b);
await ba.saveAndWaitForSettled();
// # Reload so both rows render with their persisted sort_order and
// can be addressed by name
await ba.goto();
await ba.toBeVisible();
// # Focus A's drag handle and press ArrowDown to swap A past B.
// The handler in list_table.tsx clamps moves above the protected
// region, so moving custom A down by one cannot collide with the
// seeded protected rows.
const aHandle = ba.rowByName(a).locator('button.dragHandle');
await aHandle.press('ArrowDown');
await ba.saveAndWaitForSettled();
// * Persisted sort_order reflects the swap: A is now below B
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const aField = (fields ?? []).find((f) => f.name === a);
const bField = (fields ?? []).find((f) => f.name === b);
const aOrder = (aField?.attrs as {sort_order?: number})?.sort_order ?? 0;
const bOrder = (bField?.attrs as {sort_order?: number})?.sort_order ?? 0;
expect(aOrder).toBeGreaterThan(bOrder);
});
});

View File

@@ -0,0 +1,138 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - row dot menu', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('duplicates a custom attribute via the dot menu — copy gets a (2) suffix and persists', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const original = `Dup_${Date.now()}`;
// # Add a saved custom attribute
await ba.addAttribute(original);
await ba.saveAndWaitForSettled();
// # Open the row's dot menu and click Duplicate
await ba.openDotMenuByName(original);
await ba.clickDuplicate();
// * A new row appears with the "(2)" suffix
const copy = `${original} (2)`;
await expect(ba.nameInputByValue(copy)).toBeVisible();
// # Save and reload
await ba.saveAndWaitForSettled();
await ba.goto();
await ba.toBeVisible();
// * Both the original and the (2)-suffixed copy persist
await expect(ba.nameInputByValue(original)).toBeVisible();
await expect(ba.nameInputByValue(copy)).toBeVisible();
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const names = (fields ?? []).map((f) => f.name);
expect(names).toEqual(expect.arrayContaining([original, copy]));
});
test('deletes a saved custom attribute via the confirm modal — row disappears, server reflects', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Del_${Date.now()}`;
// # Add a saved custom attribute
await ba.addAttribute(name);
await ba.saveAndWaitForSettled();
// # Open the dot menu, click Delete attribute
await ba.openDotMenuByName(name);
await ba.clickDeleteAttribute();
// * The confirmation modal appears
await expect(ba.container.page().getByRole('heading', {name: 'Delete board attribute'})).toBeVisible();
// # Confirm
await ba.container.page().getByRole('button', {name: 'Delete', exact: true}).click();
// # Save the delete
await ba.saveAndWaitForSettled();
// * The row is gone
await expect(ba.nameInputByValue(name)).toHaveCount(0);
// * The server no longer lists the attribute
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const names = (fields ?? []).map((f) => f.name);
expect(names).not.toContain(name);
});
test('dismissing the delete-confirm modal keeps the row intact', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Keep_${Date.now()}`;
// # Add and save a custom attribute
await ba.addAttribute(name);
await ba.saveAndWaitForSettled();
// # Open dot menu, click Delete, then Cancel the modal
await ba.openDotMenuByName(name);
await ba.clickDeleteAttribute();
await ba.container.page().getByRole('button', {name: 'Cancel'}).click();
// * Modal is closed
await expect(ba.container.page().getByRole('heading', {name: 'Delete board attribute'})).toHaveCount(0);
// * Row is still present
await expect(ba.nameInputByValue(name)).toBeVisible();
// * Save remains disabled — no pending changes were committed
await expect(ba.saveButton).toBeDisabled();
});
test('Duplicate and Delete items are disabled on protected (seeded) rows', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Open the dot menu on the seeded Status row
await ba.openDotMenuByName('status');
// * Both menu items render but are aria-disabled
const duplicate = ba.container
.page()
.getByText('Duplicate', {exact: true})
.locator('xpath=ancestor::*[@role="menuitem"][1]');
const del = ba.container
.page()
.getByText('Delete attribute', {exact: true})
.locator('xpath=ancestor::*[@role="menuitem"][1]');
await expect(duplicate).toHaveAttribute('aria-disabled', 'true');
await expect(del).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -0,0 +1,109 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
const MAX_BOARD_ATTRIBUTES = 20;
test.describe('Board Attributes - edge cases', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('hides the Add attribute button when the table is at the 20-attribute cap', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
// # Seed 18 custom attributes (Status + Assignee + 18 = 20)
for (let i = 0; i < MAX_BOARD_ATTRIBUTES - 2; i++) {
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: `Cap_${i}_${Date.now()}`,
type: 'text',
attrs: {sort_order: i},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
}
await ba.goto();
await ba.toBeVisible();
// * Add attribute button is no longer rendered (canCreate=false)
await expect(ba.addAttributeButton).toHaveCount(0);
});
test('changing a saved select attribute to text wipes its options on save', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Conv_${Date.now()}`;
// # Add a select attribute with two options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'A');
await ba.addOptionInRow(row, 'B');
await ba.saveAndWaitForSettled();
// # Reload so the input's `value` attribute reflects the saved name —
// rowByName / changeTypeByName rely on `input[value=...]` for filter
await ba.goto();
await ba.toBeVisible();
// # Change the type to Text
await ba.changeTypeByName(attrName, 'Text');
// # Save
await ba.saveAndWaitForSettled();
// * Server-side: the saved field is now type=text. The commit path
// omits the `options` key from the PATCH body for non-select types
// (board_attributes_utils.ts:126), so the server keeps the
// underlying option data — the UI is what hides it. Asserting the
// server-side wipe would require sending `options: []` explicitly.
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
const updated = (fields ?? []).find((f) => f.name === attrName);
expect(updated).toBeDefined();
expect(updated!.type).toBe('text');
// * UI-side: no editable values container is rendered for the row
const row2 = ba.rowByName(attrName);
await expect(row2.getByTestId('property-values-input')).toHaveCount(0);
});
test('clicking another sidebar entry with unsaved changes shows the discard-changes modal', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row to create a pending change
await ba.addAttribute(`Pending_${Date.now()}`);
// * Save button is enabled (pending changes exist)
await expect(ba.saveButton).toBeEnabled();
// # Click another sidebar entry (User Attributes)
await systemConsolePage.sidebar.systemAttributes.userAttributes.link.click();
// * The unsaved-changes modal appears
await expect(systemConsolePage.page.getByText('Discard Changes?', {exact: true})).toBeVisible();
});
});

View File

@@ -0,0 +1,150 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - select option values', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('adds options and renames one — persists across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Opts_${Date.now()}`;
// # Add a select attribute with three options
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Low');
await ba.addOptionInRow(row, 'Medium');
await ba.addOptionInRow(row, 'High');
await ba.saveAndWaitForSettled();
// # Rename "Medium" → "Mid" and save
await ba.renameOption('Medium', 'Mid');
await expect(ba.optionChip('Mid')).toBeVisible();
await expect(ba.optionChip('Medium')).toHaveCount(0);
await ba.saveAndWaitForSettled();
// * Server reflects the create and rename
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const created = (fields ?? []).find((f) => f.name === attrName);
expect(created).toBeDefined();
const optionNames = ((created!.attrs as {options?: Array<{name: string}>})?.options ?? []).map((o) => o.name);
expect(optionNames).toEqual(expect.arrayContaining(['Low', 'Mid', 'High']));
expect(optionNames).not.toContain('Medium');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Persisted state renders correctly
await expect(ba.optionChip('Low')).toBeVisible();
await expect(ba.optionChip('Mid')).toBeVisible();
await expect(ba.optionChip('High')).toBeVisible();
});
test('deletes an option via the inline X button and persists across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Del_${Date.now()}`;
// # Add a select attribute with two options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'KeepMe');
await ba.addOptionInRow(row, 'DeleteMe');
await ba.saveAndWaitForSettled();
// # Delete one option
await ba.deleteOptionViaXButton('DeleteMe');
await expect(ba.optionChip('DeleteMe')).toHaveCount(0);
await expect(ba.optionChip('KeepMe')).toBeVisible();
await ba.saveAndWaitForSettled();
// * Server reflects the deletion
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const updated = (fields ?? []).find((f) => f.name === attrName);
const optionNames = ((updated!.attrs as {options?: Array<{name: string}>})?.options ?? []).map((o) => o.name);
expect(optionNames).toContain('KeepMe');
expect(optionNames).not.toContain('DeleteMe');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Only the kept option renders
await expect(ba.optionChip('KeepMe')).toBeVisible();
await expect(ba.optionChip('DeleteMe')).toHaveCount(0);
});
test('shows an in-menu duplicate-name warning while typing an existing option name', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Dup_${Date.now()}`;
// # Add a select attribute with two distinct options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Alpha');
await ba.addOptionInRow(row, 'Beta');
await ba.saveAndWaitForSettled();
// # Open Alpha's menu and type "Beta"
await ba.openOptionMenu('Alpha');
const renameInput = ba.container.page().getByPlaceholder('Option name');
await renameInput.fill('Beta');
// * In-menu duplicate warning appears
await expect(
ba.container.page().getByText('A value with this name already exists.', {exact: true}),
).toBeVisible();
// * aria-invalid is set on the input
await expect(renameInput).toHaveAttribute('aria-invalid', 'true');
});
test('appends a unique default name when adding multiple unnamed options in a row', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Auto_${Date.now()}`;
// # Add a select attribute
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
// # Click Add value three times without naming the options
await row.getByRole('button', {name: 'Add value'}).click();
await row.getByRole('button', {name: 'Add value'}).click();
await row.getByRole('button', {name: 'Add value'}).click();
// * Three default-named option chips appear with auto-incrementing suffixes
await expect(ba.optionChip('Option 1')).toBeVisible();
await expect(ba.optionChip('Option 2')).toBeVisible();
await expect(ba.optionChip('Option 3')).toBeVisible();
});
});

View File

@@ -0,0 +1,110 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
import type {BoardAttributeType} from './setup';
const SERVER_TYPE_BY_UI_LABEL: Record<BoardAttributeType, string> = {
Text: 'text',
Select: 'select',
'Multi-select': 'multiselect',
Date: 'date',
User: 'user',
};
const TYPES: BoardAttributeType[] = ['Text', 'Select', 'Multi-select', 'Date', 'User'];
test.describe('Board Attributes - attribute types', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
for (const uiType of TYPES) {
test(`adds a new attribute and changes its type to ${uiType} — persists across reload`, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Type_${uiType.replace(/[^A-Za-z]/g, '')}_${Date.now()}`;
// # Add a new attribute, fill its name, change the type
const row = await ba.addAttribute(name);
await ba.changeTypeInRow(row, uiType);
// # Select / Multi-select require at least one option, otherwise
// validation gates save (ValidationWarningOptionsRequired).
if (uiType === 'Select' || uiType === 'Multi-select') {
await ba.addOptionInRow(row, 'Initial');
}
// # Save
await ba.saveAndWaitForSettled();
// * Server reflects the chosen type
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
const created = (fields ?? []).find((f) => f.name === name);
expect(created).toBeDefined();
expect(created!.type).toBe(SERVER_TYPE_BY_UI_LABEL[uiType]);
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Row is still rendered
await expect(ba.nameInputByValue(name)).toBeVisible();
// * Row's type selector reflects the chosen type
await expect(ba.typeSelectorByName(name)).toContainText(uiType);
});
}
test('type menu opens with all five types and the default (Text) is checked', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row (defaults to Text)
const row = await ba.addAttribute(`Menu_${Date.now()}`);
// # Open the type menu
await ba.openTypeMenuInRow(row);
// * All five types are listed
for (const uiType of TYPES) {
await expect(ba.container.page().getByRole('menuitemradio', {name: uiType, exact: true})).toBeVisible();
}
// * Default type (Text) is marked as the current selection
const textItem = ba.container.page().getByRole('menuitemradio', {name: 'Text', exact: true});
await expect(textItem).toHaveAttribute('aria-checked', 'true');
});
test('type selector is disabled on protected (seeded) rows', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * Status and Assignee type selectors are disabled
await expect(ba.typeSelectorByName('status')).toBeDisabled();
await expect(ba.typeSelectorByName('assignee')).toBeDisabled();
});
});

View File

@@ -0,0 +1,161 @@
// 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)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
test.describe('Board Attributes - validation', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('blocks save while a new row has an empty name (name_required)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row but leave the name empty
await ba.addAttribute('');
// * The "please enter a name" warning is visible
await expect(ba.container.getByText('Please enter an attribute name.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when two pending rows share the same name (name_unique)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const shared = `Dup_${Date.now()}`;
// # Add two new rows with the same name
await ba.addAttribute(shared);
await ba.addAttribute(shared);
// * Uniqueness warning surfaces on at least one row
await expect(ba.container.getByText('Attribute names must be unique.', {exact: true}).first()).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when a new row reuses a protected/seeded name', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a new row and name it after the seeded protected "status" field
await ba.addAttribute('status');
// * A uniqueness warning surfaces. The validator's pendingByName lookup
// sees both the seeded status (always in the pending collection)
// and the new row, so name_unique fires first — name_taken is
// reserved for rename-against-a-saved-name conflicts.
await expect(ba.container.getByText('Attribute names must be unique.', {exact: true}).first()).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when renaming a saved attribute to match another saved name (name_taken)', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
const a = `NameTakenA_${Date.now()}`;
const b = `NameTakenB_${Date.now()}`;
const tmp = `NameTakenTmp_${Date.now()}`;
// # Seed two saved attributes via API for deterministic setup —
// two rapid UI addAttribute calls can race the React dispatch
// batching, leaving the second's name update unflushed.
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: a,
type: 'text',
attrs: {sort_order: 0},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: b,
type: 'text',
attrs: {sort_order: 1},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
await ba.goto();
await ba.toBeVisible();
// # Rename A out of the way first, then rename B to A's saved name.
// The validator runs name_unique BEFORE name_taken, so if both rows
// shared the name 'a' in pending state name_unique would fire and
// mask the name_taken path. Renaming A → tmp leaves pendingByName
// ['a'] with only one entry (the renamed B), so the
// currentByName['a'] lookup fires name_taken against the saved A.
//
// React updates the `value` HTML attribute on re-render for
// controlled inputs, so once fill() commits the attribute changes
// and `rowByName(...)` can no longer locate the row by its
// original value — capture the input first, then blur via keyboard
// so the locator isn't re-resolved against a stale name.
const aInput = ba.rowByName(a).locator('[data-testid="board-attribute-field-input"]');
await aInput.fill(tmp);
await systemConsolePage.page.keyboard.press('Tab');
const bInput = ba.rowByName(b).locator('[data-testid="board-attribute-field-input"]');
await bInput.fill(a);
await systemConsolePage.page.keyboard.press('Tab');
// * The "name already taken" warning surfaces
await expect(ba.container.getByText('Attribute name already taken.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when two options in the same select share a name (values_unique)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `OptDup_${Date.now()}`;
// # Add a select attribute with two options committed to the same name.
// addOptionInRow opens the new chip's menu, fills its name, and blurs
// to commit — calling it twice with the same name commits a duplicate
// into the options collection, which is what triggers values_unique.
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Alpha');
await ba.addOptionInRow(row, 'Alpha');
// * Collection-level uniqueness warning surfaces under the chip list
await expect(ba.container.getByText('Values must be unique.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {SystemConsolePage, PlaywrightExtended} from '@mattermost/playwright-lib';
export const BOARDS_GROUP = 'boards';
export const OBJECT_TYPE_POST = 'post';
export const SYSTEM_TARGET_TYPE = 'system';
export type BoardAttributeType = 'Text' | 'Select' | 'Multi-select' | 'Date' | 'User';
export type ColorTokenName = 'Default' | 'Brown' | 'Orange' | 'Yellow' | 'Green' | 'Blue' | 'Purple' | 'Pink' | 'Red';
export const COLOR_TOKEN_NAMES: ColorTokenName[] = [
'Default',
'Brown',
'Orange',
'Yellow',
'Green',
'Blue',
'Purple',
'Pink',
'Red',
];
// Server-side color tokens map to the UI labels above. The hex/wire token is
// the lowercase form except 'default' which doubles as the fallback.
export const SERVER_COLOR_BY_UI_LABEL: Record<ColorTokenName, string> = {
Default: 'default',
Brown: 'brown',
Orange: 'orange',
Yellow: 'yellow',
Green: 'green',
Blue: 'blue',
Purple: 'purple',
Pink: 'pink',
Red: 'red',
};
export type BoardAttributesTestContext = {
adminClient: Client4;
systemConsolePage: SystemConsolePage;
};
let cachedAdminClient: Client4 | null = null;
export async function setupBoardAttributesTest(pw: PlaywrightExtended): Promise<BoardAttributesTestContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Feature flags are read at server start from MM_FEATUREFLAGS_* env,
// not from runtime config patches — CI sets it in server.generate.sh;
// locally, export it before running the server.
await pw.skipIfFeatureFlagNotSet('IntegratedBoards', true);
const {adminUser, adminClient} = await pw.initSetup();
cachedAdminClient = adminClient;
await deleteNonProtectedFields(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, systemConsolePage};
}
/**
* Best-effort teardown for custom (non-protected) board fields.
*
* Pass `nameFilter` (recommended) to scope cleanup to fields this spec
* created — important when multiple board_attributes specs share the same
* server. Without a filter, all non-protected fields in the system group
* are removed.
*/
export async function cleanupCustomBoardFields(nameFilter?: (name: string) => boolean): Promise<void> {
if (!cachedAdminClient) {
return;
}
try {
await deleteNonProtectedFields(cachedAdminClient, nameFilter);
} catch {
// best-effort
}
}
async function deleteNonProtectedFields(adminClient: Client4, nameFilter?: (name: string) => boolean): Promise<void> {
try {
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
for (const field of fields ?? []) {
if (field.protected) {
continue;
}
if (nameFilter && !nameFilter(field.name)) {
continue;
}
await adminClient.deletePropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, field.id);
}
} catch (error: unknown) {
const statusCode =
typeof error === 'object' && error !== null && 'status_code' in error
? (error as {status_code?: number}).status_code
: undefined;
if (statusCode === 404) {
// Boards group not yet seeded — first board creation will trigger
// doSetupBoardsProperties.
return;
}
throw error;
}
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AdminConfig} from '@mattermost/types/config';
import {expect, test} from '@mattermost/playwright-lib';
// Wait for the GET /api/v4/config re-fetch that our config_changed websocket
// handler triggers. This is the signal that entities.admin.config in the
// browser's Redux store is now up-to-date with the server's current state.
async function waitForAdminConfigRefresh(page: import('@playwright/test').Page) {
return page.waitForResponse(
(resp) => resp.url().endsWith('/api/v4/config') && resp.request().method() === 'GET' && resp.status() === 200,
{timeout: 10000},
);
}
// Each test case opens a System Console page, makes a parallel API change to a
// completely separate config section (simulating another admin), then saves the
// open page and asserts both changes survived.
test.describe('System Console > Concurrent config saves', () => {
test('Emoji page: parallel change to TeamSettings is not clobbered', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Set a known baseline for both sections under test
await adminClient.patchConfig({
ServiceSettings: {EnableCustomEmoji: false},
TeamSettings: {MaxUsersPerTeam: 50},
} as Partial<AdminConfig>);
// # Open the Emoji settings page
const {page} = await pw.testBrowser.login(adminUser);
await page.goto('/admin_console/site_config/emoji');
const emojiSection = page.getByTestId('sysconsole_section_EmojiSettings');
await expect(emojiSection).toBeVisible();
const saveButton = emojiSection.getByRole('button', {name: 'Save'});
// # Simulate another admin saving an unrelated section via API.
// Set up the response listener before triggering the change so we don't
// miss the GET /api/v4/config that our config_changed handler dispatches.
const configRefresh = waitForAdminConfigRefresh(page);
await adminClient.patchConfig({TeamSettings: {MaxUsersPerTeam: 99}} as Partial<AdminConfig>);
// # Wait for the browser to re-fetch the admin config before we save,
// confirming that this.props.config is now fresh. Then assert the
// toggle's current state to confirm the Redux store update has
// propagated to the UI before we interact.
await configRefresh;
const emojiToggle = emojiSection.getByTestId('ServiceSettings.EnableCustomEmojitrue');
await expect(emojiToggle).not.toBeChecked();
// # Change a setting on the open Emoji page and save
await emojiToggle.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// * Both the UI change and the parallel API change must be present
const finalConfig = await adminClient.getConfig();
expect(finalConfig.ServiceSettings.EnableCustomEmoji).toBe(true);
expect(finalConfig.TeamSettings.MaxUsersPerTeam).toBe(99);
});
test('Announcement Banner page: parallel change to ServiceSettings is not clobbered', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Set a known baseline for both sections under test
await adminClient.patchConfig({
AnnouncementSettings: {EnableBanner: false},
ServiceSettings: {EnableCustomEmoji: false},
} as Partial<AdminConfig>);
// # Open the Announcement Banner settings page
const {page} = await pw.testBrowser.login(adminUser);
await page.goto('/admin_console/site_config/announcement_banner');
const bannerSection = page.getByTestId('sysconsole_section_AnnouncementSettings');
await expect(bannerSection).toBeVisible();
const saveButton = bannerSection.getByRole('button', {name: 'Save'});
// # Simulate another admin saving an unrelated section via API
const configRefresh = waitForAdminConfigRefresh(page);
await adminClient.patchConfig({ServiceSettings: {EnableCustomEmoji: true}} as Partial<AdminConfig>);
await configRefresh;
const bannerToggle = bannerSection.getByTestId('AnnouncementSettings.EnableBannertrue');
await expect(bannerToggle).not.toBeChecked();
// # Change a setting on the open Announcement Banner page and save
await bannerToggle.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// * Both changes must be present
const finalConfig = await adminClient.getConfig();
expect(finalConfig.AnnouncementSettings.EnableBanner).toBe(true);
expect(finalConfig.ServiceSettings.EnableCustomEmoji).toBe(true);
});
test('Users and Teams page: parallel change to EmailSettings is not clobbered', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Set a known baseline for both sections under test
await adminClient.patchConfig({
TeamSettings: {EnableJoinLeaveMessageByDefault: false},
EmailSettings: {EnableSignUpWithEmail: true},
} as Partial<AdminConfig>);
// # Open the Users and Teams settings page
const {page} = await pw.testBrowser.login(adminUser);
await page.goto('/admin_console/site_config/users_and_teams');
const teamSection = page.getByTestId('sysconsole_section_UserAndTeamsSettings');
await expect(teamSection).toBeVisible();
const saveButton = teamSection.getByRole('button', {name: 'Save'});
// # Simulate another admin saving EmailSettings via API
const configRefresh = waitForAdminConfigRefresh(page);
await adminClient.patchConfig({EmailSettings: {EnableSignUpWithEmail: false}} as Partial<AdminConfig>);
await configRefresh;
const joinLeaveToggle = teamSection.getByTestId('TeamSettings.EnableJoinLeaveMessageByDefaulttrue');
await expect(joinLeaveToggle).not.toBeChecked();
// # Toggle Enable Join/Leave messages on the Users and Teams page and save
await joinLeaveToggle.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// * Both changes must be present
const finalConfig = await adminClient.getConfig();
expect(finalConfig.TeamSettings.EnableJoinLeaveMessageByDefault).toBe(true);
expect(finalConfig.EmailSettings.EnableSignUpWithEmail).toBe(false);
});
});

View File

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
const MINIMUM_VERSION = '6.0.0';
const OLD_DESKTOP_VERSION = '5.0.0';
const DESKTOP_APP_USER_AGENT = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Mattermost/${OLD_DESKTOP_VERSION} Chrome/120.0.0.0 Electron/28.0.0`;
test('Desktop App update required screen shows when connecting with older version', async ({pw}) => {
const {adminClient} = await pw.initSetup();
await adminClient.patchConfig({
ServiceSettings: {
MinimumDesktopAppVersion: MINIMUM_VERSION,
},
});
const config = await adminClient.getConfig();
const appDownloadLink = config.NativeAppSettings.AppDownloadLink;
const context = await pw.testBrowser.browser.newContext({
userAgent: DESKTOP_APP_USER_AGENT,
});
try {
const page = await context.newPage();
await page.goto('/');
await expect(page.getByRole('heading', {name: 'Update Required'})).toBeVisible();
const message = page.locator('.message');
await expect(message).toContainText(OLD_DESKTOP_VERSION);
await expect(message).toContainText(MINIMUM_VERSION);
const downloadLink = page.getByRole('link', {name: 'Download Updated App'});
await expect(downloadLink).toBeVisible();
await expect(downloadLink).toHaveAttribute('href', appDownloadLink);
} finally {
await context.close();
}
});

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Page} from '@playwright/test';
import {expect, test} from '@mattermost/playwright-lib';
// Helper function to intercept API request and modify the response
async function interceptConfigWithLandingPage(page: Page, enabled: boolean) {
const apiUrl = '**/api/v4/config/client?format=old**';
await page.route(apiUrl, (route) => {
route.fulfill({
status: 200,
body: JSON.stringify({
EnableDesktopLandingPage: enabled,
EnableSignInWithUsername: 'true',
}),
headers: {'Content-Type': 'application/json'},
});
});
}
test('MM-T5640_1 should not see landing page ', async ({pw, page}) => {
await interceptConfigWithLandingPage(page, false);
// Navigate to your starting URL
await page.goto('/');
// Wait until the URL contains '/login'
await page.waitForURL(/.*\/login.*/);
// At this point, the URL should contain '/login'
expect(page.url()).toContain('/login');
// Verify the login page is visible
await pw.loginPage.toBeVisible();
});
test('MM-T5640_2 should see landing page', async ({pw, page}) => {
// Navigate to your starting URL
await page.goto('/');
// Wait until the URL contains '/landing'
await page.waitForURL(/.*\/landing.*/, {timeout: pw.duration.ten_sec});
// At this point, the URL should contain '/landing'
expect(page.url()).toContain('/landing');
// Verify the landing page is visible
await pw.landingLoginPage.toBeVisible();
});

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify a FeatureFlags entry can be switched via a server restart, since FeatureFlags
* are never re-read from a running server's config store and only take effect through a boot-time
* MM_FEATUREFLAGS_* env var.
*
* @precondition
* Full (Testcontainers) mode, so the server can be restarted with a different feature flag env var.
*/
test('flips a feature flag by restarting the server with it set', async ({pw}) => {
// Ensure prerequisites
await pw.ensureFeatureFlag('TestBoolFeature', true);
const {adminClient} = await pw.getAdminClient();
const config = await adminClient.getConfig();
expect(String(config.FeatureFlags?.TestBoolFeature)).toBe('true');
});

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {uploadAndVerifyFile} from './file_storage_helpers';
/**
* @objective Verify a file uploaded while FileSettings is configured for Azure Blob storage
* (against Azurite) can be uploaded and downloaded back through Mattermost.
*
* @precondition
* An Azurite instance reachable at the configured FileSettings.AzureEndpoint.
*/
test('uploads and retrieves a file backed by Azure Blob storage', async ({pw}) => {
// Ensure prerequisites
await pw.ensureAzurite();
const {userClient, team} = await pw.initSetup();
await uploadAndVerifyFile(userClient, team.id, () => pw.listAzuriteBlobNames());
});

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, getFileFromAsset, getRandomId} from '@mattermost/playwright-lib';
/**
* Uploads the shared test asset to the given team's town-square channel and downloads it back
* through Mattermost, asserting the round-tripped bytes match exactly. When `listObjectKeys` is
* provided (Minio/Azurite), also asserts the object landed in the backend's own listing —
* local storage has no such listing to check.
*/
export async function uploadAndVerifyFile(
userClient: Client4,
teamId: string,
listObjectKeys?: () => Promise<string[]>,
): Promise<void> {
const townSquare = await userClient.getChannelByName(teamId, 'town-square');
const filename = 'mattermost-icon_128x128.png';
const file = getFileFromAsset(filename);
const formData = new FormData();
formData.set('channel_id', townSquare.id);
formData.set('client_ids', getRandomId());
formData.set('files', file, filename);
const objectKeysBeforeUpload = listObjectKeys ? await listObjectKeys() : undefined;
const uploadResponse = await userClient.uploadFile(formData);
const fileId = uploadResponse.file_infos[0].id;
if (listObjectKeys) {
const objectKeysAfterUpload = await listObjectKeys();
const newObjectKeys = objectKeysAfterUpload.filter((key) => !objectKeysBeforeUpload?.includes(key));
expect(newObjectKeys.length).toBeGreaterThan(0);
}
const downloadResponse = await fetch(userClient.getFileUrl(fileId, 0), {
headers: {Authorization: `Bearer ${userClient.getToken()}`},
});
expect(downloadResponse.ok).toBe(true);
const downloadedBytes = Buffer.from(await downloadResponse.arrayBuffer());
const originalBytes = Buffer.from(await file.arrayBuffer());
expect(downloadedBytes.equals(originalBytes)).toBe(true);
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {uploadAndVerifyFile} from './file_storage_helpers';
/**
* @objective Verify a file uploaded while FileSettings is configured for local disk storage (the
* default) can be uploaded and downloaded back through Mattermost. Untagged, unlike the Minio/
* Azurite specs: local is already every core test's default backend, so ensureLocalFile() here is
* a no-op check rather than a disruptive restart, and this can run alongside normal core tests.
*
* @precondition
* None beyond the default server config.
*/
test('uploads and retrieves a file backed by local disk storage', async ({pw}) => {
// Ensure prerequisites
await pw.ensureLocalFile();
const {userClient, team} = await pw.initSetup();
await uploadAndVerifyFile(userClient, team.id);
});

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {uploadAndVerifyFile} from './file_storage_helpers';
/**
* @objective Verify a file uploaded while FileSettings is configured for Minio S3-compatible
* storage is actually written to the Minio bucket and can be downloaded back through Mattermost.
*
* @precondition
* A Minio instance reachable at the configured FileSettings.AmazonS3Endpoint.
*/
test('uploads and retrieves a file backed by Minio S3 storage', async ({pw}) => {
// Ensure prerequisites
await pw.ensureMinio();
const {userClient, team} = await pw.initSetup();
await uploadAndVerifyFile(userClient, team.id, () => pw.listMinioObjectKeys());
});

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PropertyField} from '@mattermost/types/properties';
import type {SystemConsolePage} from '@mattermost/playwright-lib';
const PROPERTY_GROUP = 'access_control';
export type ChannelDisplayLocation = 'display_label_header' | 'display_label_info' | 'display_banner_top';
export type ChannelAttributeConfig = {
displayName: string;
type?: 'Select' | 'Multiselect' | 'Text';
options?: string[];
required?: boolean;
// The menu label, e.g. 'Cannot be changed once set'. Left alone the row keeps
// its default, 'Can be changed at any time'.
changePolicy?: string;
displayLocations?: ChannelDisplayLocation[];
};
/**
* Mirrors slugifyForCEL in webapp utils/properties.ts, which is what the console
* derives the unique name from as the display name is typed.
*/
export function derivedAttributeName(displayName: string): string {
let slug = displayName
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_');
if (/^[0-9]/.test(slug)) {
slug = '_' + slug;
}
return slug.replace(/_+/g, '_').replace(/_+$/, '') || '_copy';
}
/**
* Creates an attribute and its Channels resource entirely through the System
* Console UI, and returns the name the console derived for it.
*/
export async function configureChannelAttribute(
systemConsolePage: SystemConsolePage,
{displayName, type, options = [], required = false, changePolicy, displayLocations = []}: ChannelAttributeConfig,
): Promise<string> {
const {globalAttributes} = systemConsolePage;
const {attributeAppliesToChannels} = globalAttributes;
await globalAttributes.gotoNewAttribute();
await globalAttributes.setDisplayName(displayName);
if (type && type !== 'Text') {
await globalAttributes.selectType(type);
}
if (options.length) {
await globalAttributes.addOptions(options);
}
await attributeAppliesToChannels.addResource();
await attributeAppliesToChannels.setRequired(required);
if (changePolicy) {
await attributeAppliesToChannels.setChangePolicy(changePolicy);
}
await attributeAppliesToChannels.setDisplayLocations(displayLocations);
await globalAttributes.save();
return derivedAttributeName(displayName);
}
export async function findChannelField(adminClient: Client4, name: string): Promise<PropertyField | undefined> {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', 'system', undefined, {perPage: 200});
return fields.find((field) => field.name === name && field.delete_at === 0);
}
export async function deleteChannelFieldIfExists(adminClient: Client4, name: string) {
try {
const field = await findChannelField(adminClient, name);
if (field) {
await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', field.id);
}
} catch {
// May not exist, or the property routes may be unavailable; ignore.
}
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — applying an attribute to channels.
*
* The Channels resource row writes the channel keys onto a linked channel field,
* and the channel surfaces honour them. Every test here configures through the
* console UI and then checks what a user sees, with no API seeding in between.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {configureChannelAttribute, deleteChannelFieldIfExists, findChannelField} from './applies_to_helpers';
import {
deleteGlobalAttributeFieldIfExists,
requireGlobalAttributesEnabled,
setGlobalAttributesFeatureFlag,
} from './global_attributes_helpers';
test.describe(
'System Console - applying an attribute to channels',
{tag: ['@system_console', '@channel_attributes']},
() => {
// Shares the server-wide GlobalAttributes flag with the sibling spec.
test.describe.configure({mode: 'serial'});
let originalFlagValue: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalFlagValue = FeatureFlags.GlobalAttributes === true;
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (adminClient && originalFlagValue !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalFlagValue);
}
});
/**
* @objective Ensure the Channels row writes every channel key onto a linked channel field.
*/
test('creates a linked channel field carrying the configured keys', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
const displayName = `Program ${suffix}`;
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Apply it to channels, required, shown in the header and the banner
name = await configureChannelAttribute(systemConsolePage, {
displayName,
required: true,
displayLocations: ['display_label_header', 'display_banner_top'],
});
// * The linked channel field carries every configured key
const channelField = await findChannelField(adminClient, name);
expect(channelField).toBeDefined();
expect(channelField?.linked_field_id).toBeTruthy();
expect(channelField?.object_type).toBe('channel');
// The console has no control for this, so the payload has to pin it: the
// server would otherwise default a channel field to any member.
expect(channelField?.permission_values).toBe('admin');
expect(channelField?.attrs?.required).toBe(true);
expect(channelField?.attrs?.actions).toEqual(['display_label_header', 'display_banner_top']);
// * Options stay on the template; a linked field never carries its own
expect(channelField?.attrs?.options).toBeUndefined();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure an attribute configured through the System Console is asked for at
* channel creation and rendered afterwards, with no API seeding anywhere in the path.
*/
test('an attribute configured here is required when creating a channel and shown in its header', async ({
pw,
}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Caveat ${suffix}`,
type: 'Select',
options: ['NOFORN'],
required: true,
displayLocations: ['display_label_header'],
});
// # Create a channel as the same admin, who is a member of a team
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// * The attribute is asked for, because it was marked required here
await expect(channelsPage.page.getByTestId(`channelAttributeRow-${name}`)).toBeVisible();
await modal.fillDisplayName(`Attr Console ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('NOFORN', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value renders as a header chip, because it was designated here
await expect(channelsPage.centerView.header.attributes.chip('NOFORN')).toBeVisible();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the Channel Info display location puts the attribute in Channel Info only.
*/
test('shows a Channel-Info-only attribute in Channel Info and not in the header', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Info ${suffix}`,
type: 'Select',
options: ['INTERNAL'],
required: true,
displayLocations: ['display_label_info'],
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Info ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('INTERNAL', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The inline header slot carries it, the row-below slot does not
await expect(channelsPage.centerView.header.infoAttributes.chip('INTERNAL')).toBeVisible();
await expect(channelsPage.centerView.header.attributes.chip('INTERNAL')).toHaveCount(0);
// * And Channel Info carries it, as it does for every attribute
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('INTERNAL');
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the Banner display location renders a banner and nothing else.
*/
test('renders a Banner-only attribute as a banner, with no chip', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Marking ${suffix}`,
type: 'Select',
options: ['RESTRICTED'],
required: true,
displayLocations: ['display_banner_top'],
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('RESTRICTED', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value drives the banner and stays out of the header
await expect(channelsPage.page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
await expect(channelsPage.centerView.header.attributes.chip('RESTRICTED')).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure an attribute with no display location is stored and shown nowhere.
*/
test('stores a value for an attribute with no display location and renders it nowhere', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Quiet ${suffix}`,
type: 'Select',
options: ['QUIET'],
required: true,
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const displayName = `Attr Quiet ${suffix}`;
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(displayName);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('QUIET', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value reached the store
const channel = await adminClient.getChannelByName(
team.id,
displayName.toLowerCase().replace(/\s+/g, '-'),
);
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.length).toBeGreaterThan(0);
// * And is rendered on no display surface
await expect(channelsPage.centerView.header.attributes.chip('QUIET')).toHaveCount(0);
await expect(channelsPage.centerView.header.infoAttributes.chip('QUIET')).toHaveCount(0);
await expect(channelsPage.page.getByTestId('channel_banner_container')).toHaveCount(0);
// * Channel Info still lists it: the display locations govern the
// header and banner, never whether an admin can reach a value
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('QUIET');
await expect(info.attributes.editButton(name)).toBeVisible();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure "Cannot be changed once set" locks the value in Channel Info.
*/
test('locks the value in Channel Info when the change policy forbids changes', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Locked ${suffix}`,
type: 'Select',
options: ['FINAL'],
required: true,
changePolicy: 'Cannot be changed once set',
displayLocations: ['display_label_info'],
});
// * The console wrote both keys: change_policy, and the editable key the
// * channel UI still reads
const channelField = await findChannelField(adminClient, name);
expect(channelField?.attrs?.change_policy).toBe('never');
expect(channelField?.attrs?.editable).toBe(false);
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Locked ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('FINAL', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * Once set at creation the value is read-only, even to a system admin
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.lock(name)).toBeVisible();
await expect(info.attributes.editButton(name)).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the channel-admin tier the console pins reaches the channel
* field, so an attribute configured here is not editable by an ordinary member.
*
* There is no control for this, which is what makes the assertion worth keeping:
* the tier is only correct as long as the payload keeps pinning it.
*/
test('keeps a console-configured attribute out of a plain member reach', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Restricted ${suffix}`,
type: 'Select',
options: ['SET'],
required: true,
displayLocations: ['display_label_info'],
});
const channelField = await findChannelField(adminClient, name);
expect(channelField?.permission_values).toBe('admin');
const {team, user} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const channelDisplayName = `Attr Restricted ${suffix}`;
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(channelDisplayName);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('SET', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(
team.id,
channelDisplayName.toLowerCase().replace(/\s+/g, '-'),
);
await adminClient.addToChannel(user.id, channel.id);
// # Look at the same attribute as an ordinary member
const asMember = await pw.testBrowser.login(user);
await asMember.channelsPage.goto(team.name, channel.name);
await asMember.channelsPage.toBeVisible();
// * The member reads the value but is offered no way to change it
const info = await asMember.channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('SET');
await expect(info.attributes.editButton(name)).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
},
);

View File

@@ -0,0 +1,262 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — classification's own attribute page.
*
* Classification's fields are created by the Classification Markings page, so the
* create-only card on the New attribute page can never reach them. This page is the
* only way to configure which resources classification applies to without the API,
* which is what these tests exercise.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {
deleteClassificationFieldsIfExist,
setClassificationMarkingsFeatureFlag,
setupClassificationWithChannelField,
} from '../../channels/channel_classification/helpers';
import {findChannelField} from './applies_to_helpers';
import {requireGlobalAttributesEnabled, setGlobalAttributesFeatureFlag} from './global_attributes_helpers';
test.describe(
'System Console - the classification attribute page',
{tag: ['@system_console', '@channel_attributes']},
() => {
// Shares the server-wide GlobalAttributes and ClassificationMarkings flags with
// its sibling specs.
test.describe.configure({mode: 'serial'});
let originalGlobalAttributes: boolean | undefined;
let originalClassificationMarkings: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalGlobalAttributes = FeatureFlags.GlobalAttributes === true;
originalClassificationMarkings = FeatureFlags.ClassificationMarkings === true;
await setClassificationMarkingsFeatureFlag(adminClient, true);
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (!adminClient) {
return;
}
await deleteClassificationFieldsIfExist(adminClient);
if (originalGlobalAttributes !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalGlobalAttributes);
}
if (originalClassificationMarkings !== undefined) {
await setClassificationMarkingsFeatureFlag(adminClient, originalClassificationMarkings);
}
});
/**
* @objective Verify the definition is shown read-only, with the levels editor a link away.
*/
test('shows the definition read-only and links to Classification Markings', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
// * Every level is listed, and none of them is editable here
for (const level of levels) {
await expect(globalAttributes.classificationLevels).toContainText(level.name);
}
await expect(
systemConsolePage.page.getByTestId('classificationAttribute').getByRole('textbox'),
).toHaveCount(0);
// * The one place levels can be changed is a link away
await expect(globalAttributes.classificationMarkingsLink).toHaveAttribute(
'href',
'/admin_console/site_config/classification_markings',
);
});
/**
* @objective Verify a display location chosen here reaches the channel field and the channel header.
*/
test('applies a header chip to channels once Header is chosen', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team, user} = await pw.initSetup();
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
await globalAttributes.appliesToChannels.setDisplayLocations(['display_label_header']);
await globalAttributes.saveInPlace();
// * The choice landed on the linked channel field
const channelField = await findChannelField(adminClient, 'classification');
expect(channelField?.attrs?.actions).toEqual(['display_label_header']);
// # Give a channel a classification, then look at it as a member
const channel = await adminClient.createChannel({
team_id: team.id,
name: `classification-attr-${pw.random.id()}`,
display_name: 'Classification Attr',
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelField!.id, value: levels[0].id},
] as Parameters<typeof adminClient.patchPropertyValues>[3]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * The header carries the chip the console asked for
await expect(channelsPage.centerView.header.attributes.chip(levels[0].name)).toBeVisible();
});
/**
* @objective Verify classification is offered at channel creation while optional, and demanded once Required is on.
*
* Optional attributes are otherwise kept off the create dialog. Classification is
* the exception: it has always been offered there, and its own control in that
* dialog is suppressed once the ChannelAttributes flag is on, so the generic
* section has to carry it either way.
*/
test('offers classification at channel creation, and demands it once Required is on', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
// * Offered while optional, and Create is not held up by leaving it empty
let modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Optional Classification ${pw.random.id()}`);
await expect(channelsPage.page.getByTestId('channelAttribute-classification')).toBeVisible();
await expect(modal.createButton).toBeEnabled();
await modal.cancel();
// # Mark it required on its attribute page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.globalAttributes.gotoClassificationAttribute();
await systemConsolePage.globalAttributes.appliesToChannels.setRequired(true);
await systemConsolePage.globalAttributes.saveInPlace();
const asAdmin = await pw.testBrowser.login(adminUser);
await asAdmin.channelsPage.goto(team.name);
await asAdmin.channelsPage.toBeVisible();
modal = await asAdmin.channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Required Classification ${pw.random.id()}`);
// * Now it holds up Create until a level is chosen
await expect(modal.createButton).toBeDisabled();
await asAdmin.channelsPage.page.getByTestId('channelAttribute-classification').click();
await asAdmin.channelsPage.page.getByText(levels[0].name, {exact: true}).click();
await expect(modal.createButton).toBeEnabled();
});
/**
* @objective Verify unticking Banner actually stops the banner.
*
* Classification banners on a field carrying no display locations at all, which is
* what keeps a server that predates this page behaving as it did. A configured
* field has to be obeyed instead, or the Banner checkbox would do nothing.
*/
test('stops bannering when the display locations exclude the banner', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team, user} = await pw.initSetup();
const channelFieldBefore = await findChannelField(adminClient, 'classification');
const channel = await adminClient.createChannel({
team_id: team.id,
name: `classification-banner-${pw.random.id()}`,
display_name: 'Classification Banner',
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelFieldBefore!.id, value: levels[0].id},
] as Parameters<typeof adminClient.patchPropertyValues>[3]);
// # Configure Channel Info only — deliberately not the banner
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
await globalAttributes.appliesToChannels.setDisplayLocations(['display_label_info']);
await globalAttributes.saveInPlace();
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * No banner, even though the channel has a classification
await expect(page.getByTestId('channel_banner_container')).toHaveCount(0);
});
/**
* @objective Verify removing the resource asks first, and that a removal is not undone by an unrelated save.
*/
test('removes the Channels resource only after confirming, and keeps it removed', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
await setupClassificationWithChannelField(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
const page = systemConsolePage.page;
await globalAttributes.gotoClassificationAttribute();
// # Ask to remove, then back out
await globalAttributes.appliesToChannels.removeResource();
await page.getByRole('button', {name: 'Cancel'}).click();
// * Nothing was deleted
await expect(globalAttributes.appliesToChannels.row).toBeVisible();
expect(await findChannelField(adminClient, 'classification')).toBeDefined();
// # Ask again and confirm
await globalAttributes.appliesToChannels.removeResource();
await page.getByRole('button', {name: 'Remove and delete values'}).click();
// * The field is gone, and the card offers to add it back
await expect(globalAttributes.appliesToChannels.addResourceButton).toBeVisible();
expect(await findChannelField(adminClient, 'classification')).toBeUndefined();
// * Saving the Classification Markings page does not reinstate it: the field is
// * created on the transition into enabled, not on every save
await page.goto('/admin_console/site_config/classification_markings');
// Save only enables once something changes, so the save under test needs an
// edit -- one with nothing to do with the Channels resource. The cell keeps the
// typing local and only reports it on blur, so the edit has to be committed.
const levelName = page.getByRole('textbox', {name: 'Classification level name'}).first();
await levelName.fill('UNCLASSIFIED EDITED');
await levelName.blur();
await page.getByTestId('saveSetting').click();
await expect(page.getByTestId('saveSetting')).toBeDisabled();
expect(await findChannelField(adminClient, 'classification')).toBeUndefined();
});
},
);

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PropertyField} from '@mattermost/types/properties';
import {getAdminClient, licenseTier, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
export const GLOBAL_ATTRIBUTES_ADMIN_PATH = '/admin_console/system_attributes/manage_attributes';
// Canonical values: webapp/channels/src/components/admin_console/global_attributes/constants.ts
// (cross-package import not feasible between e2e-tests and webapp)
const PROPERTY_GROUP = 'access_control';
const OBJECT_TYPE = 'template';
const TARGET_TYPE = 'system';
// The three resource object types an Applies-to linked field can use. Also the
// only object types (besides 'template') PropertyField.IsValid allows a linked
// field to carry -- a template field itself is rejected for having a
// linked_field_id ("template fields cannot have a linked field").
// Canonical values: webapp/channels/.../attribute_details/attribute_applies_to_constants.ts
export type ResourceObjectType = 'user' | 'channel' | 'post';
const ALL_RESOURCE_OBJECT_TYPES: ResourceObjectType[] = ['user', 'channel', 'post'];
// Server clamps per_page to this max (see web.PerPageMaximum in server/channels/web/params.go).
// Directory-mode search with no cursor sorts CreateAt ASC, so the default 60-item page only
// returns the oldest fields — request the max to reduce the risk of missing newer ones.
const MAX_PROPERTY_FIELDS_PER_PAGE = 200;
/**
* Toggle via System Console config API. On servers without SplitKey, feature flags are
* read-only from config (see server/config/store.go); effective values come from env
* (e.g. MM_FEATUREFLAGS_GLOBALATTRIBUTES).
*/
export async function setGlobalAttributesFeatureFlag(adminClient: Client4, enabled: boolean) {
await adminClient.patchConfig({
FeatureFlags: {
GlobalAttributes: enabled,
},
} as any);
}
/**
* Shared precondition for every test that needs the Attribute Management page actually
* reachable (as opposed to the flag-off gate test, which deliberately doesn't need this):
* skips on a sub-Enterprise license, enables the GlobalAttributes flag, and skips if the
* flag didn't actually take (e.g. env/SplitKey overrides). Returns the admin session.
*/
export async function requireGlobalAttributesEnabled(pw: PlaywrightExtended) {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
const license = await adminClient.getClientLicenseOld();
test.skip(
licenseTier(license.SkuShortName) < 20,
'Attribute Management requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). ' +
'Professional is not sufficient—the admin route is hidden and redirects away.',
);
await setGlobalAttributesFeatureFlag(adminClient, true);
await pw.skipIfFeatureFlagNotSet('GlobalAttributes', true);
return {adminUser, adminClient};
}
/**
* Removes any access_control/template field with the given name (clean slate for E2E),
* ignoring failures — the property routes may be unavailable when the feature flag is off,
* or the field may simply not exist yet.
*/
export async function deleteGlobalAttributeFieldIfExists(adminClient: Client4, name: string) {
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
});
for (const field of fields.filter((f) => f.name === name && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, OBJECT_TYPE, field.id);
}
} catch {
// May not exist, or routes unavailable; ignore.
}
}
/**
* Best-effort cleanup for an Applies-to save: delete linked fields first
* (looked up from the live template, not from the test's success-path locals)
* so the template delete is not 409'd by leftover dependents.
*/
export async function deleteAppliesToAttributeAndLinkedFieldsIfExists(adminClient: Client4, name: string) {
try {
const templates = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
});
for (const template of templates.filter((field) => field.name === name && field.delete_at === 0)) {
const linked = await fetchLinkedFieldsForTemplate(adminClient, template.id);
for (const field of linked) {
await deleteLinkedDependentField(adminClient, field.id, field.object_type as ResourceObjectType);
}
}
} catch {
// Listing may fail if the flag is off; still try the template delete below.
}
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
/**
* Creates an access_control/template property field for E2E seeding. Ensures a clean
* slate first so reruns don't collide with a field left over from a prior failed run.
*/
export async function createGlobalAttributeField(
adminClient: Client4,
name: string,
field: Partial<Parameters<Client4['createPropertyField']>[2]>,
) {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
return adminClient.createPropertyField(PROPERTY_GROUP, OBJECT_TYPE, {
name,
target_type: TARGET_TYPE,
target_id: '',
...field,
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Creates a field that links to `sourceFieldId` (e.g. the template). Two uses:
* seeding an already-saved Applies-to resource for a given `objectType`
* (defaulting to 'user', the one pre-existing call site's shape), and making
* the server refuse to delete the source field -- deletePropertyField counts
* live linked dependents and returns 409 `has_linked_dependents` when any
* exist (server/channels/app/properties/property_field.go), which this is
* the only way to exercise against a real server response. The create flow
* for a fresh Applies-to resource itself is exercised through the UI, not
* this helper.
*/
export async function createLinkedDependentField(
adminClient: Client4,
name: string,
sourceFieldId: string,
type: string,
objectType: ResourceObjectType = 'user',
attrs?: Record<string, unknown>,
) {
return adminClient.createPropertyField(PROPERTY_GROUP, objectType, {
name,
type,
target_type: TARGET_TYPE,
target_id: '',
linked_field_id: sourceFieldId,
...(attrs ? {attrs} : {}),
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Deletes a linked property field, ignoring failures -- mirrors deleteGlobalAttributeFieldIfExists'
* best-effort cleanup style, since a test's own save/rollback assertions may have already deleted it.
* Must run BEFORE deleting the field it points at -- the source delete stays blocked with a 409
* for as long as a live dependent exists.
*/
export async function deleteLinkedDependentField(
adminClient: Client4,
fieldId: string,
objectType: ResourceObjectType = 'user',
) {
try {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, fieldId);
} catch {
// May already be gone (e.g. a prior rollback already deleted it); ignore.
}
}
/**
* Finds every linked field (across all three resource object types) pointing at `templateFieldId`.
* Queries user/channel/post separately -- there is no single "all object types" listing endpoint --
* and requests the max page size per call, since the `user` object type's result page is shared
* with every Custom Profile Attributes field on the server (see deleteGlobalAttributeFieldIfExists'
* own MAX_PROPERTY_FIELDS_PER_PAGE comment) and a freshly-created linked field is exactly the kind
* of newest-row a default ascending-CreateAt page can drop.
*/
export async function fetchLinkedFieldsForTemplate(
adminClient: Client4,
templateFieldId: string,
): Promise<PropertyField[]> {
const results = await Promise.all(
ALL_RESOURCE_OBJECT_TYPES.map((objectType) =>
adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
}),
),
);
return results.flat().filter((field) => field.linked_field_id === templateFieldId && field.delete_at === 0);
}

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — Global Attributes access gate and attribute listing.
* Visibility is gated by the GlobalAttributes feature flag AND an Enterprise-tier license.
* Once past the gate, the page lists every access_control/template property field on the server.
*
* Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`.
* Professional-only licenses hide this admin route (React Router redirects away).
*
* Flag-off access-gate tests live only in this file. The form spec assumes the flag is on
* and must not turn it off — both files share a server, and default PW_WORKERS is 1.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
} from '../site_configuration/classification_markings_helpers';
import {
GLOBAL_ATTRIBUTES_ADMIN_PATH,
createGlobalAttributeField,
createLinkedDependentField,
deleteGlobalAttributeFieldIfExists,
deleteLinkedDependentField,
requireGlobalAttributesEnabled,
setGlobalAttributesFeatureFlag,
} from './global_attributes_helpers';
test.describe('System Console - Global Attributes listing', {tag: '@system_console'}, () => {
// Access-gate tests toggle the server-wide GlobalAttributes flag. Stay serial so a
// flag-off assertion cannot race a later listing/delete test in this file.
test.describe.configure({mode: 'serial'});
let originalFlagValue: boolean | undefined;
let originalClassificationFlagValue: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalFlagValue = FeatureFlags.GlobalAttributes === true;
originalClassificationFlagValue = FeatureFlags.ClassificationMarkings === true;
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (adminClient && originalFlagValue !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalFlagValue);
}
if (adminClient && originalClassificationFlagValue !== undefined) {
await setClassificationMarkingsFeatureFlag(adminClient, originalClassificationFlagValue);
}
});
test.describe('access gate', () => {
/**
* @objective Ensure the Attribute Management admin route is unavailable when the feature flag is off.
*/
test('feature flag off hides Attribute Management regardless of license', async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Turn off GlobalAttributes in server config
await setGlobalAttributesFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.GlobalAttributes === true,
'GlobalAttributes stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.',
);
// # Navigate directly to the Attribute Management path
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * User is redirected away from the hidden route (no Route registered)
await expect(systemConsolePage.page).not.toHaveURL(/manage_attributes/);
// * Attribute Management menu entry is not shown in the sidebar
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('Attribute Management'),
).not.toBeVisible();
});
/**
* @objective Ensure the Attribute Management page is reachable and shows its page frame
* once the feature flag is on and the license meets the Enterprise tier.
*/
test('feature flag on with Enterprise+ license shows the page frame', async ({pw}) => {
const {adminUser} = await requireGlobalAttributesEnabled(pw);
// # Log in and open the Attribute Management URL
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * URL stays on the Attribute Management section
await expect(systemConsolePage.page).toHaveURL(/manage_attributes/);
// * Sidebar menu entry and page heading are both visible ("Attribute Management"
// renders in both places, so each is asserted within its own scope)
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('Attribute Management'),
).toBeVisible();
await expect(
systemConsolePage.page.getByTestId('admin-console-header').getByText('Attribute Management'),
).toBeVisible();
// * The old User Attributes sidebar entry is replaced by Attribute Management
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('User Attributes', {exact: true}),
).not.toBeVisible();
// * Page frame's static subtitle is present (renders regardless of fetch state)
await expect(
systemConsolePage.page.getByText('Define an attribute once, then choose which resources can use it.'),
).toBeVisible();
});
});
test.describe('listing', () => {
/**
* @objective Ensure a real access_control/template attribute renders in the table with
* its display name, type icon+label, source, and options count — across every field
* type the ticket's Type column has a mapping for (Text/Select/Multiselect/Ranked),
* plus one unmapped type (date) to prove the fallback also holds end-to-end.
*/
test('renders one seeded field per type with correct Attribute/Type/Source/Options values', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
// Covers the "Managed here" Source branch (all of these) alongside every Type/Options
// combination reachable through the admin API — the plugin+protected Source branch is
// unit-test-only since the admin API blocks source_plugin_id/protected from non-plugin
// callers, and rank uses type: 'rank' directly (the same type Classification Markings'
// own saveCreateField creates), not the select-based seeding some older e2e helpers use.
// displayName embeds the same per-run timestamp as name — required so the row locator
// below can't collide with another concurrently-running browser project's seeded row
// (this suite's projects share one server/worker pool; see playwright.config.ts).
const seeds = [
{
name: `e2e_global_attribute_text_${timestamp}`,
displayName: `E2E Text Attribute ${timestamp}`,
type: 'text',
attrs: {},
expectedType: 'Text',
expectedOptions: 'Free Text',
},
{
name: `e2e_global_attribute_select_${timestamp}`,
displayName: `E2E Select Attribute ${timestamp}`,
type: 'select',
attrs: {
options: [
{id: '', name: 'Option A'},
{id: '', name: 'Option B'},
],
},
expectedType: 'Select',
expectedOptions: '2 options',
},
{
name: `e2e_global_attribute_multiselect_${timestamp}`,
displayName: `E2E Multiselect Attribute ${timestamp}`,
type: 'multiselect',
attrs: {
options: [
{id: '', name: 'Option A'},
{id: '', name: 'Option B'},
{id: '', name: 'Option C'},
],
},
expectedType: 'Multiselect',
expectedOptions: '3 options',
},
{
name: `e2e_global_attribute_rank_${timestamp}`,
displayName: `E2E Ranked Attribute ${timestamp}`,
type: 'rank',
attrs: {
options: [
{id: '', name: 'Low', rank: 1},
{id: '', name: 'High', rank: 2},
],
},
expectedType: 'Ranked',
expectedOptions: '2 options',
},
{
name: `e2e_global_attribute_date_${timestamp}`,
displayName: `E2E Date Attribute ${timestamp}`,
type: 'date',
attrs: {},
expectedType: 'Other',
expectedOptions: 'Free Text',
},
] as const;
try {
// # Seed every field inside the try block — if creation fails partway
// through (e.g. field 3 of 5), the finally below still cleans up whatever
// was already created instead of leaving it orphaned on the shared server.
for (const seed of seeds) {
await createGlobalAttributeField(adminClient, seed.name, {
type: seed.type,
attrs: {display_name: seed.displayName, ...seed.attrs},
});
}
// # Log in and open the Attribute Management page once every field is seeded
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
for (const seed of seeds) {
// Re-rooted `has` probe on the name cell's testid+text, mirroring the
// established row-lookup pattern (see board_attributes.ts's rowByName).
const row = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: seed.displayName}),
});
// * Each seeded field's display name, type, source, and options render correctly
await expect(row.getByTestId('global-attribute-name')).toHaveText(seed.displayName);
await expect(row.getByTestId('global-attribute-type')).toContainText(seed.expectedType);
// * A leading icon renders alongside the label, including for the
// unmapped "date" type (fallback icon, not a blank cell)
await expect(row.getByTestId('global-attribute-type').locator('svg')).toBeVisible();
await expect(row.getByTestId('global-attribute-source')).toContainText('Managed here');
// * "Managed here" is the one source kind reachable through the admin
// API (plugin/ldap/saml require server-side attrs the API blocks from
// non-plugin callers — those icon mappings are unit-test-only) and it
// renders with no leading icon, unlike plugin/ldap/saml sources
await expect(row.getByTestId('global-attribute-source').locator('svg')).toHaveCount(0);
await expect(row.getByTestId('global-attribute-options')).toContainText(seed.expectedOptions);
}
} finally {
// # Clean up regardless of assertion outcome, so reruns start from a clean slate
for (const seed of seeds) {
await deleteGlobalAttributeFieldIfExists(adminClient, seed.name);
}
}
});
/**
* @objective Ensure the table sorts by the same value shown in the Attribute column
* (display_name, falling back to name) rather than by the hidden internal name.
*/
test('sorts rows by the displayed Attribute value, not the internal field name', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
// Internal names sort z-then-a; display names (what the table actually shows and
// must sort by) sort a-then-z. If the table sorted by the internal name instead,
// these rows would render in the opposite order.
const firstName = `zzz_e2e_sort_${timestamp}`;
const secondName = `aaa_e2e_sort_${timestamp}`;
const firstDisplayName = `Aardvark E2E Sort Attribute ${timestamp}`;
const secondDisplayName = `Zebra E2E Sort Attribute ${timestamp}`;
// No display_name set — the rendered (and sorted-by) value falls back to this
// internal name directly. Chosen to alphabetically land between the two display
// names above, so its position in the rendered order proves the fallback value
// participates in sorting correctly alongside explicit display names, not just
// in isolation (the unit tests already cover the fallback alone).
const thirdName = `mmm_e2e_sort_fallback_${timestamp}`;
try {
// # Seed all three fields inside the try block — if creation fails
// partway through, the finally below still cleans up whatever was
// already created instead of leaving it orphaned on the shared server.
await createGlobalAttributeField(adminClient, firstName, {
type: 'text',
attrs: {display_name: firstDisplayName},
});
await createGlobalAttributeField(adminClient, secondName, {
type: 'text',
attrs: {display_name: secondDisplayName},
});
await createGlobalAttributeField(adminClient, thirdName, {
type: 'text',
attrs: {},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
await systemConsolePage.page.getByTestId('global-attribute-name').getByText(firstDisplayName).waitFor();
// * The fallback field renders under its internal name, since no display_name was set
await expect(
systemConsolePage.page.getByTestId('global-attribute-name').getByText(thirdName),
).toBeVisible();
// * Rendered order follows the displayed Attribute value (Aardvark, then the
// fallback name, then Zebra) — not the internal name (which would put "aaa_..."
// first if sorted that way)
const names = await systemConsolePage.page.getByTestId('global-attribute-name').allTextContents();
const firstIndex = names.indexOf(firstDisplayName);
const thirdIndex = names.indexOf(thirdName);
const secondIndex = names.indexOf(secondDisplayName);
expect(firstIndex).toBeGreaterThanOrEqual(0);
expect(thirdIndex).toBeGreaterThanOrEqual(0);
expect(secondIndex).toBeGreaterThanOrEqual(0);
expect(firstIndex).toBeLessThan(thirdIndex);
expect(thirdIndex).toBeLessThan(secondIndex);
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, firstName);
await deleteGlobalAttributeFieldIfExists(adminClient, secondName);
await deleteGlobalAttributeFieldIfExists(adminClient, thirdName);
}
});
/**
* @objective Ensure a real Classification Markings field (name/object_type/group_id
* matching production's saveCreateField) renders the read-only subtitle and an
* open-in-new link to its own admin page instead of the ordinary dot-menu, and that an
* unrelated field — including one that shares the same 'rank' type — is entirely unaffected.
*/
test(
'renders the Classification Markings row as a read-only open-in-new link, leaving an unrelated rank field unaffected',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
// # The link's destination page is gated by its own independent feature flag
// (ClassificationMarkings), separate from the GlobalAttributes flag gating this
// listing page — both must be on for the link to render.
// Tagged @classification_markings like every other spec that touches this same
// shared server-wide field/flag (classification_markings.spec.ts,
// global_classification_banner.spec.ts) — those specs are NOT otherwise
// concurrency-guarded against each other; the tag is this suite's existing
// (if informal) convention for grouping tests that share this exact resource.
await setClassificationMarkingsFeatureFlag(adminClient, true);
await pw.skipIfFeatureFlagNotSet('ClassificationMarkings', true);
const timestamp = Date.now();
const classificationDisplayName = `E2E Classification Attribute ${timestamp}`;
const unrelatedRankName = `e2e_unrelated_rank_${timestamp}`;
const unrelatedRankDisplayName = `E2E Unrelated Ranked Attribute ${timestamp}`;
try {
// # Clean slate first via the classification-aware helper (not the generic
// deleteGlobalAttributeFieldIfExists): it also removes any linked system/channel
// field first, avoiding server-side deletion-protection errors that leave a
// stale template field behind if another classification spec ran previously.
await deleteClassificationMarkingsFieldIfExists(adminClient);
// # Seed the real classification field: name 'classification', type 'rank',
// matching classification_markings/utils/index.ts's saveCreateField exactly —
// not the select-based shape some older, test-only e2e helpers use.
await createGlobalAttributeField(adminClient, 'classification', {
type: 'rank',
attrs: {
display_name: classificationDisplayName,
options: [
{id: '', name: 'Unclassified', rank: 1},
{id: '', name: 'Secret', rank: 2},
],
},
});
// # Seed an unrelated field that shares the same 'rank' type, to prove the
// predicate keys on name/object_type/group_id, not on type.
await createGlobalAttributeField(adminClient, unrelatedRankName, {
type: 'rank',
attrs: {
display_name: unrelatedRankDisplayName,
options: [
{id: '', name: 'Low', rank: 1},
{id: '', name: 'High', rank: 2},
],
},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const classificationRow = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: classificationDisplayName}),
});
await classificationRow.waitFor();
// * The read-only subtitle renders under the attribute name
await expect(classificationRow.getByText('Read-only')).toBeVisible();
// * The Source column identifies this row's true source, not the generic
// "Managed here" every other native field gets
await expect(classificationRow.getByTestId('global-attribute-source')).toContainText(
'Classification Markings',
);
// * The rightmost cell is an open-in-new link to the Classification Markings
// admin page, not the dot-menu action trigger
const openInNewLink = classificationRow.getByRole('link', {name: 'Open Classification Markings'});
await expect(openInNewLink).toBeVisible();
await expect(openInNewLink).toHaveAttribute('href', CLASSIFICATION_MARKINGS_ADMIN_PATH);
await expect(classificationRow.getByRole('button', {name: 'More actions'})).toHaveCount(0);
// # Clicking the link actually navigates to the Classification Markings page
await openInNewLink.click();
// * Navigation lands on the Classification Markings admin page
await expect(systemConsolePage.page).toHaveURL(new RegExp(CLASSIFICATION_MARKINGS_ADMIN_PATH));
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * An unrelated 'rank'-type field is unaffected: ordinary dot-menu, no subtitle
const unrelatedRow = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: unrelatedRankDisplayName}),
});
await unrelatedRow.waitFor();
await expect(unrelatedRow.getByRole('button', {name: 'More actions'})).toBeVisible();
await expect(unrelatedRow.getByText('Read-only')).toHaveCount(0);
await expect(unrelatedRow.getByRole('link', {name: 'Open Classification Markings'})).toHaveCount(0);
await expect(unrelatedRow.getByTestId('global-attribute-source')).toContainText('Managed here');
} finally {
await deleteClassificationMarkingsFieldIfExists(adminClient);
await deleteGlobalAttributeFieldIfExists(adminClient, unrelatedRankName);
}
},
);
});
test.describe('delete attribute', () => {
/**
* @objective Ensure the row kebab's Delete action removes the attribute end-to-end:
* the confirmation names the attribute, and confirming drops the row from the table.
*/
test('deletes an attribute from the row menu after confirming, and the row disappears', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_delete_${timestamp}`;
const displayName = `E2E Delete Attribute ${timestamp}`;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Open the row kebab and click Delete
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
// * The confirmation names the specific attribute rather than prompting generically
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
// # Confirm
await page.getByRole('button', {name: 'Delete', exact: true}).click();
// * The row is gone and no error banner appeared
await expect(row).toHaveCount(0);
await expect(page.getByTestId('global-attributes-delete-error')).toHaveCount(0);
// * The delete really hit the server, not just the client store — a fresh
// page load still doesn't show it
await page.reload();
await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toHaveCount(0);
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure cancelling the confirmation is a true no-op — no delete call fires
* and the attribute survives a reload.
*/
test('leaves the attribute in place when the confirmation is cancelled', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_cancel_${timestamp}`;
const displayName = `E2E Cancel Attribute ${timestamp}`;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Open the row kebab, click Delete, then back out
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
await page.getByRole('button', {name: 'Cancel'}).click();
// * The modal closed and the row survived
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toHaveCount(0);
await expect(row).toBeVisible();
// * Nothing was deleted server-side either
await page.reload();
await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toBeVisible();
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure a server-side 409 (the attribute still has live linked dependents)
* surfaces as the specific "still linked" banner above the table, not the generic error,
* and leaves the row intact. Exercised against a real 409 from the server rather than a
* stubbed rejection.
*/
test('shows the linked-dependents banner and keeps the row when the server refuses the delete', async ({
pw,
}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_linked_${timestamp}`;
const displayName = `E2E Linked Attribute ${timestamp}`;
const dependentName = `e2e_global_attribute_dependent_${timestamp}`;
let dependentFieldId: string | undefined;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
// # Point a dependent field at it, which is what makes the server refuse the delete
const dependent = await createLinkedDependentField(adminClient, dependentName, field.id, 'text');
dependentFieldId = dependent.id;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Try to delete it
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
await page.getByRole('button', {name: 'Delete', exact: true}).click();
// * The banner explains the blocking dependency instead of the generic failure
const banner = page.getByTestId('global-attributes-delete-error');
await expect(banner).toBeVisible();
await expect(banner).toContainText('other attributes are still linked to it');
await expect(banner).not.toContainText('An error occurred while deleting this attribute');
// * The row survived the rejected delete
await expect(row).toBeVisible();
// # The banner is dismissible
await banner.getByRole('button', {name: 'Close'}).click();
await expect(banner).toHaveCount(0);
} finally {
// Dependent first: the source delete stays blocked while it exists
if (dependentFieldId) {
await deleteLinkedDependentField(adminClient, dependentFieldId);
}
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
});
});

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import {expect, test, testConfig} from '@mattermost/playwright-lib';
/**
* E2E coverage for the "Revoke non-compliant tokens" admin console control added in
* MM-69075 (System Console > Integrations > Integration Management).
*
* A token is non-compliant once ServiceSettings.MaximumPersonalAccessTokenLifetimeDays > 0
* and the token never expires, or expires beyond that cap. The policy only applies at
* creation time, so every test clears the cap before seeding its tokens and patches it in
* afterwards. The cap is server-wide, so a test that already ran leaves it set.
* Bot account tokens are exempt regardless of the policy.
*
* The non-compliant count and revoke operation are global (every user's tokens, not just
* the test's own), and this server is shared across concurrently running tests/workers.
* So assertions here check per-token outcomes (does this specific token still authenticate)
* and UI state transitions (enabled/disabled, which banner mode) rather than exact global
* counts, which would be flaky. See commit 56716c3616 for the same lesson learned in the
* server-side store tests.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const TOKEN_ROLES = 'system_user system_user_access_token';
// Returns whether the given personal access token can still authenticate. The token
// secret is only present on the UserAccessToken returned at creation time.
async function tokenIsUsable(token: string | undefined): Promise<boolean> {
if (!token) {
throw new Error('Expected a token secret, but none was returned by createUserAccessToken');
}
const client = new Client4();
client.setUrl(testConfig.baseURL);
client.setToken(token);
try {
await client.getMe();
return true;
} catch {
return false;
}
}
test.describe('System Console > Integrations > Revoke non-compliant tokens @system_console', () => {
test('disables the button and shows a compliant banner when no policy is configured', async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await adminClient.patchConfig({
ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: 0},
});
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 0;
});
// # A never-expiring token is fine while there is no cap
const token = await adminClient.createUserAccessToken(user.id, 'never expires token');
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.integrations.integrationManagement.click();
await page.waitForURL(/\/admin_console\/integrations\/integration_management/);
const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings');
await expect(section).toBeVisible();
// * With no policy configured the server-side count is always 0 (nothing is
// "non-compliant" without a cap to violate), so the button is deterministically disabled
await expect(section.getByText('No personal access tokens currently need to be revoked.')).toBeVisible();
await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeDisabled();
expect(await tokenIsUsable(token.token)).toBe(true);
});
test('shows a violation and reveals a confirmation modal that can be dismissed without revoking', async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await adminClient.patchConfig({
ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: 0},
});
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed a never-expiring token while there is no cap, then enable the cap so it becomes non-compliant
const token = await adminClient.createUserAccessToken(user.id, 'never expires token');
await adminClient.patchConfig({ServiceSettings: {MaximumPersonalAccessTokenLifetimeDays: 30}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 30;
});
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.integrations.integrationManagement.click();
await page.waitForURL(/\/admin_console\/integrations\/integration_management/);
const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings');
await expect(section).toBeVisible();
// * At least one violation is now shown (ours), and the button is enabled
await expect(
section.getByText(/\d+ personal access tokens? currently violates? the maximum lifetime policy\./),
).toBeVisible();
const revokeButton = section.getByRole('button', {name: 'Revoke non-compliant tokens'});
await expect(revokeButton).toBeEnabled();
// # Open the confirmation and cancel it
await revokeButton.click();
const confirmModal = page.locator('#confirmModal');
await expect(confirmModal.getByText('Revoke non-compliant personal access tokens?')).toBeVisible();
await expect(confirmModal.getByText(/This will permanently revoke \d+ personal access tokens?/)).toBeVisible();
await confirmModal.getByRole('button', {name: 'Cancel'}).click();
await expect(confirmModal).toBeHidden();
// * Cancelling did not revoke our token
expect(await tokenIsUsable(token.token)).toBe(true);
});
test('revokes non-compliant tokens on confirm, invalidating them while compliant and bot tokens survive', async ({
pw,
}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await adminClient.patchConfig({
ServiceSettings: {
EnableUserAccessTokens: true,
EnableBotAccountCreation: true,
MaximumPersonalAccessTokenLifetimeDays: 0,
},
});
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed one non-compliant token, one compliant token, and one exempt bot token, all
// # before the cap is enabled so the server allows their creation.
const nonCompliantToken = await adminClient.createUserAccessToken(user.id, 'never expires token');
const compliantToken = await adminClient.createUserAccessToken(
user.id,
'compliant token',
Date.now() + 10 * DAY_MS,
);
const bot = await adminClient.createBot({
username: `revoke-bot-${user.id.slice(0, 8)}`,
display_name: 'Revoke test bot',
});
const botToken = await adminClient.createUserAccessToken(bot.user_id, 'bot token');
await adminClient.patchConfig({ServiceSettings: {MaximumPersonalAccessTokenLifetimeDays: 30}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings?.MaximumPersonalAccessTokenLifetimeDays === 30;
});
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.integrations.integrationManagement.click();
await page.waitForURL(/\/admin_console\/integrations\/integration_management/);
const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings');
await expect(section).toBeVisible();
// # Confirm the revoke
await section.getByRole('button', {name: 'Revoke non-compliant tokens'}).click();
const confirmModal = page.locator('#confirmModal');
await confirmModal.getByRole('button', {name: 'Revoke tokens'}).click();
// * The success banner reports how many were revoked, and the button disables again
// (nothing left to revoke, since a revoke sweeps every non-compliant token server-wide)
await expect(section.getByText(/Revoked \d+ non-compliant personal access tokens?\./)).toBeVisible();
await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeDisabled();
// * The non-compliant token can no longer authenticate
await expect(async () => {
expect(await tokenIsUsable(nonCompliantToken.token)).toBe(false);
}).toPass();
// * The compliant token and the exempt bot token still authenticate
expect(await tokenIsUsable(compliantToken.token)).toBe(true);
expect(await tokenIsUsable(botToken.token)).toBe(true);
});
test('refreshes the violation banner after saving a new maximum lifetime policy from the same page', async ({
pw,
}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await adminClient.patchConfig({
ServiceSettings: {EnableUserAccessTokens: true, MaximumPersonalAccessTokenLifetimeDays: 0},
});
await adminClient.updateUserRoles(user.id, TOKEN_ROLES);
// # Seed a never-expiring token while there is no cap
const token = await adminClient.createUserAccessToken(user.id, 'never expires token');
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.integrations.integrationManagement.click();
await page.waitForURL(/\/admin_console\/integrations\/integration_management/);
const section = page.getByTestId('sysconsole_section_CustomIntegrationSettings');
await expect(section).toBeVisible();
// # Set a maximum lifetime and save, without leaving the page
const maxLifetimeInput = section.getByTestId('ServiceSettings.MaximumPersonalAccessTokenLifetimeDaysnumber');
await maxLifetimeInput.fill('30');
await section.getByRole('button', {name: 'Save'}).click();
// * The banner refreshes to flag the newly non-compliant token, without a page reload
await expect(
section.getByText(/\d+ personal access tokens? currently violates? the maximum lifetime policy\./),
).toBeVisible();
await expect(section.getByRole('button', {name: 'Revoke non-compliant tokens'})).toBeEnabled();
expect(await tokenIsUsable(token.token)).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
/**
* @objective Verify a user who only exists in the directory (never created in Mattermost) can
* authenticate through the standard login form once LDAP authentication is enabled, and that the
* server provisions their account via LDAP on first login rather than local auth.
*
* @precondition
* An LDAP directory reachable at the configured LdapSettings.LdapServer/LdapPort.
*/
test('logs in a directory-only user through the standard login form', {tag: '@ldap'}, async ({pw}) => {
// Ensure prerequisites
await pw.ensureLicense();
await pw.skipIfNoLicense();
await pw.ensureOpenldap();
const {adminClient} = await pw.getAdminClient();
const ldapUser = pw.generateLdapUser();
await pw.createLdapUser(ldapUser);
try {
// # Log in through the real login form with the directory-only user's credentials
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
// * Verify the login form reflects LDAP being enabled
await expect(pw.loginPage.loginWithAdLdapPlaceholder).toBeVisible();
await pw.loginPage.loginInput.fill(ldapUser.username);
await pw.loginPage.passwordInput.fill(ldapUser.password);
await pw.loginPage.signInButton.click();
// * Verify the login succeeded
await expect(pw.loginPage.page).not.toHaveURL(/\/login/);
// * Verify the server provisioned the account via LDAP, with the directory's attributes
const provisionedUser = await adminClient.getUserByUsername(ldapUser.username);
expect(provisionedUser.auth_service).toBe('ldap');
expect(provisionedUser.email).toBe(ldapUser.email);
} finally {
await pw.deleteLdapUser(ldapUser.username);
}
});

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('should lock account after exceeding max login attempts and unlock via admin API', async ({pw}) => {
// 1. Setup — create a test user and get admin client
const {adminClient, user} = await pw.initSetup();
// 2. Lower MaximumLoginAttempts to 3 via API so the test runs quickly
const originalConfig = await adminClient.getConfig();
const originalMax = originalConfig.ServiceSettings.MaximumLoginAttempts;
await adminClient.patchConfig({
ServiceSettings: {MaximumLoginAttempts: 3},
});
try {
// 3. Navigate to the login page — set localStorage first to skip the native app landing page
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
// 4. Attempt login with wrong password 3 times
for (let i = 0; i < 3; i++) {
await pw.loginPage.loginInput.fill(user.username);
await pw.loginPage.passwordInput.fill('WrongPassword!');
await pw.loginPage.signInButton.click();
await expect(
pw.loginPage.page.getByText('The email/username or password is invalid.', {exact: true}),
).toBeVisible();
}
// 5. Correct password should now be rejected — account is locked
await pw.loginPage.loginInput.fill(user.username);
await pw.loginPage.passwordInput.fill(user.password);
await pw.loginPage.signInButton.click();
await expect(
pw.loginPage.page.getByText(
'Your account is locked because of too many failed password attempts. Please reset your password.',
{exact: true},
),
).toBeVisible();
await expect(pw.loginPage.page).toHaveURL(/\/login/);
// 6. Admin unlocks the account via API
await adminClient.resetFailedAttempts(user.id);
// 7. User can now log in with correct password
await pw.loginPage.loginInput.fill(user.username);
await pw.loginPage.passwordInput.fill(user.password);
await pw.loginPage.signInButton.click();
await expect(pw.loginPage.page).not.toHaveURL(/\/login/);
} finally {
// 8. Restore original MaximumLoginAttempts regardless of test outcome
await adminClient.patchConfig({
ServiceSettings: {MaximumLoginAttempts: originalMax},
});
}
});

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, getRandomId, newTestPassword} from '@mattermost/playwright-lib';
/**
* @objective Verify mmctl — the same binary bundled in the server image — can administer the
* real test server as a remote client, run from a separate container instance built from that
* same image rather than the server's own container's `--local` unix socket.
*
* @precondition
* Full (Testcontainers) mode, so a Docker network and the server image are available to build a
* second, independent mmctl container instance from.
*/
test('creates a user through a remote mmctl container instance', {tag: '@mmctl'}, async ({pw}) => {
await pw.ensureMmctl();
const {adminClient} = await pw.getAdminClient();
const randomId = getRandomId();
const username = `mmctluser${randomId}`;
const email = `${username}@mmtest.com`;
// # Create a user via mmctl running in a separate remote container instance
const result = await pw.runMmctl([
'user',
'create',
'--username',
username,
'--email',
email,
'--password',
newTestPassword(),
]);
// * Verify the mmctl command succeeded
expect(result.exitCode, result.output).toBe(0);
// * Verify the user was actually created on the real server
const createdUser = await adminClient.getUserByUsername(username);
expect(createdUser.email).toBe(email);
});
/**
* @objective Verify the remote mmctl container reports the same version as the server, since
* both come from the same image — a sanity check that the "different container/instance, same
* image" setup is actually using the image it claims to.
*
* @precondition
* Full (Testcontainers) mode, so a Docker network and the server image are available to build a
* second, independent mmctl container instance from.
*/
test('reports the same version as the server it is bundled with', {tag: '@mmctl'}, async ({pw}) => {
await pw.ensureMmctl();
const {adminClient} = await pw.getAdminClient();
const serverConfig = await adminClient.getClientConfig();
// # Query the version reported by the remote mmctl container instance
const result = await pw.runMmctl(['version', '--json']);
expect(result.exitCode, result.output).toBe(0);
// * Verify the remote mmctl reports the same version as the server it's bundled with
const [mmctlVersionInfo] = JSON.parse(result.output);
expect(mmctlVersionInfo.Version).toBe(serverConfig.Version);
});

View File

@@ -0,0 +1,746 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, testConfig} from '@mattermost/playwright-lib';
test('should be able to enable mobile security settings when licensed', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'enterprise' || license.short_sku_name !== 'advanced',
'Skipping test - server has no enterprise or enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// # Enable Biometric Authentication
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.selectTrue();
// * Verify only Biometric Authentication is enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeFalse();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeFalse();
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Biometric Authentication is still enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeFalse();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeFalse();
// # Enable Prevent Screen Capture
await systemConsolePage.mobileSecurity.preventScreenCapture.selectTrue();
// * Verify only Biometric Authentication and Prevent Screen Capture are enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeFalse();
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Biometric Authentication and Prevent Screen Capture are still enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeFalse();
// # Enable Jailbreak Protection
await systemConsolePage.mobileSecurity.enableJailbreakProtection.selectTrue();
// * Verify all toggles are enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify all toggles are still enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
if (license.SkuShortName === 'advanced') {
// # Enable Secure File Preview
await systemConsolePage.mobileSecurity.enableSecureFilePreviewMode.selectTrue();
// * Verify all toggles are enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
await systemConsolePage.mobileSecurity.enableSecureFilePreviewMode.toBeTrue();
await systemConsolePage.mobileSecurity.allowPdfLinkNavigation.toBeFalse();
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify all toggles are still enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
await systemConsolePage.mobileSecurity.enableSecureFilePreviewMode.toBeTrue();
await systemConsolePage.mobileSecurity.allowPdfLinkNavigation.toBeFalse();
// # Enable Allow PDF Link Navigation
await systemConsolePage.mobileSecurity.allowPdfLinkNavigation.selectTrue();
// * Verify all toggles are enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
await systemConsolePage.mobileSecurity.enableSecureFilePreviewMode.toBeTrue();
await systemConsolePage.mobileSecurity.allowPdfLinkNavigation.toBeTrue();
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify all toggles are still enabled
await systemConsolePage.mobileSecurity.enableBiometricAuthentication.toBeTrue();
await systemConsolePage.mobileSecurity.preventScreenCapture.toBeTrue();
await systemConsolePage.mobileSecurity.enableJailbreakProtection.toBeTrue();
await systemConsolePage.mobileSecurity.enableSecureFilePreviewMode.toBeTrue();
await systemConsolePage.mobileSecurity.allowPdfLinkNavigation.toBeTrue();
}
});
test('should show mobile security upsell when not licensed', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'enterprise' || license.short_sku_name !== 'advanced',
'Skipping test - server has no enterprise or enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.featureDiscovery.toBeVisible();
// * Verify title is correct
await systemConsolePage.featureDiscovery.toHaveTitle('Enhance mobile app security with Mattermost Enterprise');
});
test('should show and enable Intune MAM when Enterprise Advanced licensed and Office365 configured', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure Office365 settings
await adminClient.patchConfig({
Office365Settings: {
Enable: true,
Id: 'test-client-id',
Secret: 'test-client-secret',
DirectoryId: 'test-directory-id',
},
} as any);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
// * Verify Intune MAM toggle is visible
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.trueOption).toBeVisible();
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.falseOption).toBeVisible();
// # Enable Intune MAM
await systemConsolePage.mobileSecurity.enableIntuneMAM.selectTrue();
// * Verify Intune MAM is enabled
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
await systemConsolePage.mobileSecurity.authProvider.select('office365');
// # Fill in Intune configuration
await systemConsolePage.mobileSecurity.tenantId.fill('12345678-1234-1234-1234-123456789012');
await systemConsolePage.mobileSecurity.clientId.fill('87654321-4321-4321-4321-210987654321');
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Intune MAM is still enabled
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
});
test('should hide Intune MAM when Office365 is not configured', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Disable Office365, and Intune MAM in the same patch — the server rejects a config where
// # Intune MAM is on with AuthService 'office365' but Office365 is off
await adminClient.patchConfig({
IntuneSettings: {
Enable: false,
},
Office365Settings: {
Enable: false,
},
} as any);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
// * Verify Intune MAM toggle is visible
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.trueOption).toBeVisible();
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.falseOption).toBeVisible();
await expect(systemConsolePage.mobileSecurity.authProvider.dropdown).toBeDisabled();
});
test('should configure new IntuneSettings with Office365 auth provider', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure Office365 settings
await adminClient.patchConfig({
Office365Settings: {
Enable: true,
Id: 'test-office365-client-id',
Secret: 'test-office365-secret',
DirectoryId: 'test-office365-directory-id',
},
SamlSettings: {
EmailAttribute: 'useremail',
},
} as any);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.Office365Settings?.Enable === true && Boolean(cfg.Office365Settings?.Id);
});
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
// * Verify new Intune toggle is visible
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.trueOption).toBeVisible();
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.falseOption).toBeVisible();
// # Enable Intune
await systemConsolePage.mobileSecurity.enableIntuneMAM.selectTrue();
// * Verify Intune is enabled
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
// # Select Office365 as auth provider.
// After enabling Intune MAM the form re-renders; scroll the dropdown into view
// before selecting so it is both visible and interactive.
await systemConsolePage.mobileSecurity.authProvider.dropdown.scrollIntoViewIfNeeded();
await expect(systemConsolePage.mobileSecurity.authProvider.dropdown).toBeEnabled({timeout: 15000});
await systemConsolePage.mobileSecurity.authProvider.select('office365');
// # Fill in Intune configuration
await systemConsolePage.mobileSecurity.tenantId.fill('12345678-1234-1234-1234-123456789012');
await systemConsolePage.mobileSecurity.clientId.fill('87654321-4321-4321-4321-210987654321');
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Intune is still enabled and configured
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
expect(await systemConsolePage.mobileSecurity.tenantId.getValue()).toBe('12345678-1234-1234-1234-123456789012');
expect(await systemConsolePage.mobileSecurity.clientId.getValue()).toBe('87654321-4321-4321-4321-210987654321');
});
test('should configure new IntuneSettings with SAML auth provider', async ({pw}) => {
// # Configure SAML settings
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Set server URL for fetch calls
const serverUrl = testConfig.baseURL;
// # Upload a valid SAML IdP certificate using fetch
const idpCert =
'-----BEGIN CERTIFICATE-----\nMIIDXTCCAkWgAwIBAgIJAKC1r6Qw3v6OMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAlVTMRYwFAYDVQQIDA1Tb21lLVN0YXRlMRYwFAYDVQQKDA1FeGFtcGxlIEluYy4wHhcNMTkwMTAxMDAwMDAwWhcNMjkwMTAxMDAwMDAwWjBFMQswCQYDVQQGEwJVUzEWMBQGA1UECAwNU29tZS1TdGF0ZTEWMBQGA1UECgwNRXhhbXBsZSBJbmMuMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6QwIDAQABo1AwTjAdBgNVHQ4EFgQU6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAU6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAKQw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw3v6OMC1r6Qw=\n-----END CERTIFICATE-----\n';
const idpFormData = new FormData();
idpFormData.append(
'certificate',
new Blob([idpCert], {type: 'application/x-x509-ca-cert'}),
'Intune SAML Test.cer',
);
await fetch(`${serverUrl}/api/v4/saml/certificate/idp`, {
method: 'POST',
body: idpFormData,
credentials: 'include',
});
// # Upload a minimal, valid SP public certificate (PEM-encoded)
const spCert =
'-----BEGIN CERTIFICATE-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArv1Qw4v7OMC2r7Qw4v7OMC2r7Qw4v7OMC2r7QwIDAQAB\n-----END CERTIFICATE-----\n';
const spFormData = new FormData();
spFormData.append('certificate', new Blob([spCert], {type: 'application/x-x509-ca-cert'}), 'saml-public-cert.pem');
await fetch(`${serverUrl}/api/v4/saml/certificate/public`, {
method: 'POST',
body: spFormData,
credentials: 'include',
});
// # Configure SAML settings
await adminClient.patchConfig({
SamlSettings: {
Enable: true,
IdpURL: 'https://example.com/saml',
IdpDescriptorURL: 'https://example.com/saml/metadata',
IdpCertificateFile: 'saml-idp.crt',
EmailAttribute: 'useremail',
UsernameAttribute: 'username',
ServiceProviderIdentifier: 'sp-entity-id',
AssertionConsumerServiceURL: 'https://sp.example.com/login',
PrivateKeyFile: 'saml-idp.crt',
PublicCertificateFile: 'saml-public-cert.pem',
},
} as any);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
// * Verify new Intune toggle is visible
await expect(systemConsolePage.mobileSecurity.enableIntuneMAM.trueOption).toBeVisible();
// # Enable Intune
await systemConsolePage.mobileSecurity.enableIntuneMAM.selectTrue();
// # Select SAML as auth provider
await systemConsolePage.mobileSecurity.authProvider.select('saml');
// # Fill in Intune configuration
await systemConsolePage.mobileSecurity.tenantId.fill('abcdef01-2345-6789-abcd-ef0123456789');
await systemConsolePage.mobileSecurity.clientId.fill('fedcba98-7654-3210-fedc-ba9876543210');
// # Save settings
await systemConsolePage.mobileSecurity.save();
// # Wait until the save button has settled
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Go to any other section and come back to Mobile Security
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Intune is still enabled and configured with SAML
await systemConsolePage.mobileSecurity.enableIntuneMAM.toBeTrue();
expect(await systemConsolePage.mobileSecurity.tenantId.getValue()).toBe('abcdef01-2345-6789-abcd-ef0123456789');
expect(await systemConsolePage.mobileSecurity.clientId.getValue()).toBe('fedcba98-7654-3210-fedc-ba9876543210');
});
test('should disable Intune inputs when toggle is off', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(license.SkuShortName !== 'advanced', 'Skipping test - server does not have enterprise advanced license');
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure Office365 settings, with the Intune MAM toggle off as this test requires
await adminClient.patchConfig({
IntuneSettings: {
Enable: false,
},
Office365Settings: {
Enable: true,
Id: 'test-client-id',
Secret: 'test-secret',
DirectoryId: 'test-directory-id',
},
} as any);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
// * Verify Intune inputs are disabled when toggle is off
expect(await systemConsolePage.mobileSecurity.authProvider.dropdown.isDisabled()).toBe(true);
expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(true);
expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(true);
// # Enable Intune
await systemConsolePage.mobileSecurity.enableIntuneMAM.selectTrue();
// * Verify Intune inputs are now enabled
expect(await systemConsolePage.mobileSecurity.authProvider.dropdown.isDisabled()).toBe(false);
expect(await systemConsolePage.mobileSecurity.tenantId.input.isDisabled()).toBe(false);
expect(await systemConsolePage.mobileSecurity.clientId.input.isDisabled()).toBe(false);
});
/**
* @objective Verify timer settings are disabled when Mobile Ephemeral Mode is not enabled, and become editable when enabled
*/
test(
'should disable Mobile Ephemeral Mode sub-settings when toggle is off and enable them when toggle is on',
{tag: '@mobile_ephemeral_mode'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Mobile Ephemeral Mode toggle is off by default
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeFalse();
// * Verify all sub-settings are disabled
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(true);
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(true);
// # Enable Mobile Ephemeral Mode toggle
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
// * Verify all sub-settings are now enabled
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.input.isDisabled()).toBe(false);
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.input.isDisabled()).toBe(false);
},
);
/**
* @objective Verify all Mobile Ephemeral Mode settings persist after save and navigation
*/
test(
'should save and persist all Mobile Ephemeral Mode settings after navigation',
{tag: '@mobile_ephemeral_mode'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable Mobile Ephemeral Mode setting via config API
config.MobileEphemeralModeSettings.Enable = true;
await adminClient.updateConfig(config);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// # Set custom values
await systemConsolePage.mobileSecurity.disconnectionTimeout.fill('120');
await systemConsolePage.mobileSecurity.offlinePersistenceTimer.fill('48');
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('14');
// # Save settings
await systemConsolePage.mobileSecurity.save();
await pw.waitUntil(async () => (await systemConsolePage.mobileSecurity.saveButton.textContent()) === 'Save');
// # Navigate away and back
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify Mobile Ephemeral Mode is still enabled
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.toBeTrue();
// * Verify all values persisted correctly
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('120');
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('48');
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('14');
},
);
/**
* @objective Verify offline persistence timer is disabled when auto cache cleanup is set to 0 (zero-persistence mode)
*/
test(
'should disable offline persistence timer when auto cache cleanup is set to zero',
{tag: '@mobile_ephemeral_mode'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable Mobile Ephemeral Mode setting via config API
config.MobileEphemeralModeSettings.Enable = true;
await adminClient.updateConfig(config);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// * Verify offline persistence timer is enabled
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
// # Set auto cache cleanup to 0
await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('0');
// * Verify offline persistence timer is now disabled
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(true);
// # Set auto cache cleanup back to 7
await systemConsolePage.mobileSecurity.autoCacheCleanup.clear();
await systemConsolePage.mobileSecurity.autoCacheCleanup.fill('7');
// * Verify offline persistence timer is enabled again
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.input.isDisabled()).toBe(false);
},
);
/**
* @objective Verify Mobile Ephemeral Mode settings show correct defaults on first enable
*/
test(
'should show correct default values when Mobile Ephemeral Mode is first enabled',
{tag: '@mobile_ephemeral_mode'},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags.MobileEphemeralMode !== true && config.FeatureFlags.MobileEphemeralMode !== 'true',
'Skipping test - MobileEphemeralMode feature flag is not enabled on the server',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Mobile Security section
await systemConsolePage.sidebar.mobileSecurity.click();
await systemConsolePage.mobileSecurity.toBeVisible();
// # Enable Mobile Ephemeral Mode
await systemConsolePage.mobileSecurity.enableMobileEphemeralMode.selectTrue();
// * Verify default values
expect(await systemConsolePage.mobileSecurity.disconnectionTimeout.getValue()).toBe('60');
expect(await systemConsolePage.mobileSecurity.offlinePersistenceTimer.getValue()).toBe('24');
expect(await systemConsolePage.mobileSecurity.autoCacheCleanup.getValue()).toBe('7');
},
);

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
const roleCases = [{accessor: 'sharedChannelManager' as const, roleId: 'system_shared_channel_manager'}];
for (const {accessor, roleId} of roleCases) {
test(
`Assigning a user to ${roleId} persists after page reload`,
{tag: ['@smoke', '@system_console']},
async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
// Login as admin and navigate to System Console
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
// Navigate to Delegated Granular Administration
await systemConsolePage.sidebar.delegatedGranularAdministration.click();
await systemConsolePage.delegatedGranularAdministration.toBeVisible();
// Click Edit on the role
await systemConsolePage.delegatedGranularAdministration.adminRolesPanel[accessor].clickEdit();
await systemConsolePage.delegatedGranularAdministration.systemRoles.toBeVisible();
// Click "Add People" to open the modal
await systemConsolePage.delegatedGranularAdministration.systemRoles.assignedPeoplePanel.clickAddPeople();
// Interact with the Add Users modal
const modal = systemConsolePage.page.locator('#addUsersToRoleModal');
await expect(modal).toBeVisible();
// Search for the test user using react-select's combobox input
const searchInput = modal.getByRole('combobox', {name: 'Search for people'});
await searchInput.click();
await searchInput.pressSequentially(user.username, {delay: 50});
// Wait for search results and click the user row
const userRow = modal.locator('.more-modal__row').filter({hasText: user.username});
await expect(userRow).toBeVisible();
await userRow.click();
// Click the Add button in the modal
await modal.locator('#saveItems').click();
// The user should now appear in the Assigned People panel
const assignedPanel = systemConsolePage.delegatedGranularAdministration.systemRoles.assignedPeoplePanel;
const assignedUserRow = assignedPanel.getUserRowByUsername(user.username);
await assignedUserRow.toBeVisible();
// Save the role assignment
await systemConsolePage.delegatedGranularAdministration.systemRoles.save();
// Wait for redirect back to system_roles list
await systemConsolePage.page.waitForURL('**/admin_console/user_management/system_roles');
// Navigate back to the role
await systemConsolePage.delegatedGranularAdministration.adminRolesPanel[accessor].clickEdit();
await systemConsolePage.delegatedGranularAdministration.systemRoles.toBeVisible();
// Verify the user persists in the Assigned People panel
const persistedUserRow =
systemConsolePage.delegatedGranularAdministration.systemRoles.assignedPeoplePanel.getUserRowByUsername(
user.username,
);
await persistedUserRow.toBeVisible();
// Also verify via API that the user has the role
const updatedUser = await adminClient.getUser(user.id);
expect(updatedUser.roles).toContain(roleId);
},
);
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserProfile} from '@mattermost/types/users';
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
test(
'MM-63378 System Manager without team access permissions cannot view team details',
{tag: ['@smoke', '@system_console']},
async ({pw}) => {
const {
adminUser,
adminClient,
user: systemManagerUser,
userClient: systemManagerClient,
team,
} = await pw.initSetup();
// Update user with system_manager role
await adminClient.updateUserRoles(systemManagerUser.id, 'system_user system_manager');
// Create another team of which the user is not a member.
const otherTeam = await adminClient.createTeam(await pw.random.team());
// Configure the system manager with the default permissions (as admin).
await setupSystemManagerPermission(pw, adminUser, 'reporting', 'team_statistics', 'Can edit');
await setupSystemManagerPermission(pw, adminUser, 'userManagement', 'teams', 'Can edit');
// Re-login as the system manager (the admin login above replaced the page context)
const {systemConsolePage} = await pw.testBrowser.login(systemManagerUser);
// Verify the system manager has access to the site statistics for all teams
await systemConsolePage.goto();
// Navigate to Team Statistics and verify access to user's team
await verifyTeamStatisticsAccess(systemConsolePage, team.id, team.display_name);
// Select the other team by value and verify access
await systemConsolePage.teamStatistics.selectTeamById(otherTeam.id);
await systemConsolePage.teamStatistics.toHaveTeamHeader(otherTeam.display_name);
// Verify the user has API access to the otherTeam.
const fetchedOtherTeam = await systemManagerClient.getTeam(otherTeam.id);
expect(fetchedOtherTeam.id).toEqual(otherTeam.id);
// Configure the system manager without access to team user management
await setupSystemManagerPermission(pw, adminUser, 'userManagement', 'teams', 'No access');
// Re-login as the system manager again after permission change
const {systemConsolePage: systemConsolePage2} = await pw.testBrowser.login(systemManagerUser);
// Verify the system manager only has access to the site statistics for the team they belong to
await systemConsolePage2.goto();
// Navigate to Team Statistics and verify access to user's team
await verifyTeamStatisticsAccess(systemConsolePage2, team.id, team.display_name);
// Verify the user has no API access to the otherTeam.
let apiError: Error | null = null;
try {
await systemManagerClient.getTeam(otherTeam.id);
} catch (error) {
apiError = error as Error;
}
expect(apiError).not.toBeNull();
expect(apiError?.message).toContain('You do not have the appropriate permissions');
},
);
// Helper function to navigate to Team Statistics and verify team access
const verifyTeamStatisticsAccess = async (
systemConsolePage: SystemConsolePage,
teamId: string,
teamDisplayName: string,
) => {
// Navigate to Team Statistics
await systemConsolePage.sidebar.reporting.teamStatistics.click();
await systemConsolePage.teamStatistics.toBeVisible();
// Select the team by value
await systemConsolePage.teamStatistics.selectTeamById(teamId);
// Verify the text shows "Team Statistics for <team name>"
await systemConsolePage.teamStatistics.toHaveTeamHeader(teamDisplayName);
};
type PermissionValue = 'Can edit' | 'Read only' | 'No access';
// setupSystemManagerRole configures the system manager with the given permission ("Can edit", "Read only", "No access")
// for the given section and subsection.
//
// We do this via the system console and not the API because this page has multiple queries to build up the
// final API call that are all ultimately part of the spec, and we want to test the effects of those, not
// merely a hand-created version of the same.
const setupSystemManagerPermission = async (
pw: PlaywrightExtended,
adminUser: UserProfile,
sectionName: 'reporting' | 'userManagement',
subsectionName: string,
permission: PermissionValue,
) => {
// Login as admin and navigate to System Console
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// Go to System Console
await systemConsolePage.goto();
// Navigate to Delegated Granular Administration
await systemConsolePage.sidebar.delegatedGranularAdministration.click();
await systemConsolePage.delegatedGranularAdministration.toBeVisible();
// Click on the System Manager row to go to its settings page
await systemConsolePage.delegatedGranularAdministration.adminRolesPanel.systemManager.clickEdit();
await systemConsolePage.delegatedGranularAdministration.systemRoles.toBeVisible();
// Get the section from privileges panel
const section = systemConsolePage.delegatedGranularAdministration.systemRoles.privilegesPanel[sectionName];
// Expand subsections if needed
await section.expandSubsections();
// Get the subsection and set permission
const subsection = section.getSubsection(subsectionName);
await subsection.setPermission(permission);
// Save
await systemConsolePage.delegatedGranularAdministration.systemRoles.save();
// Wait for save to complete - successful save redirects to system_roles list
await systemConsolePage.page.waitForURL('**/admin_console/user_management/system_roles');
// Wait for the page to fully load
await systemConsolePage.page.waitForLoadState('networkidle');
};

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, getRandomId} from '@mattermost/playwright-lib';
/**
* @objective Verify a user who only exists in Keycloak (never created in Mattermost) can
* authenticate through SAML SSO once SAML authentication is enabled, and that the server
* provisions their account via SAML on first login rather than local auth.
*
* @precondition
* A Keycloak realm reachable at the configured SamlSettings.IdpURL, with a SAML client matching
* ServiceProviderIdentifier.
*/
test('logs in a directory-only user through Keycloak SAML SSO', {tag: '@saml'}, async ({pw}) => {
// Ensure prerequisites
await pw.ensureLicense();
await pw.skipIfNoLicense();
await pw.ensureKeycloak();
const {adminClient} = await pw.getAdminClient();
const randomId = getRandomId();
const keycloakUser = {
username: `samluser${randomId}`,
email: `samluser${randomId}@mmtest.com`,
firstName: `Firstname-${randomId}`,
lastName: `Lastname-${randomId}`,
password: 'Password1',
};
const keycloakUserId = await pw.createKeycloakUser(keycloakUser);
try {
// # Log in through the SAML SSO button, which redirects to Keycloak's hosted login form
await pw.hasSeenLandingPage();
await pw.loginPage.goto();
await pw.loginPage.toBeVisible();
// * Verify the login form reflects SAML being enabled
await expect(pw.loginPage.samlLoginButton).toBeVisible();
await pw.loginPage.samlLoginButton.click();
// Keycloak's own hosted login form.
await pw.loginPage.page.locator('#username').fill(keycloakUser.username);
await pw.loginPage.page.locator('#password').fill(keycloakUser.password);
await pw.loginPage.page.locator('#kc-login').click();
// * Verify the login succeeded
await expect(pw.loginPage.page).not.toHaveURL(/\/login/);
// * Verify the server provisioned the account via SAML, with Keycloak's attributes
const provisionedUser = await adminClient.getUserByUsername(keycloakUser.username);
expect(provisionedUser.auth_service).toBe('saml');
expect(provisionedUser.email).toBe(keycloakUser.email);
} finally {
await pw.deleteKeycloakUser(keycloakUserId);
}
});

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {postAndFindMessage} from './search_helpers';
/**
* @objective Verify a message posted while Elasticsearch indexing/searching is enabled can be
* found through the search API, confirming the server is actually querying Elasticsearch rather
* than silently falling back to database search.
*
* @precondition
* An Elasticsearch cluster reachable at the configured ElasticsearchSettings.ConnectionURL, and a
* server license that includes Elasticsearch.
*/
test('finds a newly posted message through Elasticsearch search', {tag: '@elasticsearch'}, async ({pw}) => {
// Ensure prerequisites
await pw.ensureLicense();
await pw.skipIfNoLicense();
await pw.ensureElasticsearch();
const {adminClient, userClient, team} = await pw.initSetup();
await adminClient.purgeElasticsearchIndexes();
// # Post a message with a unique term, then search for it via the search API
// * Verify the search API returns exactly the post containing the unique term
await postAndFindMessage(pw, userClient, team.id);
});

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {postAndFindMessage} from './search_helpers';
/**
* @objective Verify a message posted while OpenSearch indexing/searching is enabled can be found
* through the search API, confirming the server is actually querying OpenSearch rather than
* silently falling back to database search.
*
* @precondition
* An OpenSearch cluster reachable at the configured ElasticsearchSettings.ConnectionURL, with
* Backend set to "opensearch", and a server license that includes Elasticsearch.
*/
test('finds a newly posted message through OpenSearch search', {tag: '@opensearch'}, async ({pw}) => {
// Ensure prerequisites
await pw.ensureLicense();
await pw.skipIfNoLicense();
await pw.ensureOpensearch();
const {adminClient, userClient, team} = await pw.initSetup();
await adminClient.purgeElasticsearchIndexes();
// # Post a message with a unique term, then search for it via the search API
// * Verify the search API returns exactly the post containing the unique term
await postAndFindMessage(pw, userClient, team.id);
});

View File

@@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {test} from '@mattermost/playwright-lib';
import {postAndFindMessage} from './search_helpers';
/**
* @objective Verify a message posted while Elasticsearch/OpenSearch indexing and searching are
* disabled can still be found through the search API, confirming the server falls back to
* database search.
*
* @precondition
* None beyond a running server — this is the default search mode.
*/
test('finds a newly posted message through database search', async ({pw}) => {
// Ensure prerequisites
await pw.ensurePostgresSearch();
const {userClient, team} = await pw.initSetup();
await postAndFindMessage(pw, userClient, team.id);
});

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import {expect, getRandomId} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
/**
* Posts a message with a unique term, polls the search API until it's indexed, then asserts it's
* the single result returned — the common flow across the Elasticsearch/OpenSearch/database search
* specs, which otherwise differ only in prerequisite setup (ensure*, plus index purge).
*/
export async function postAndFindMessage(pw: PlaywrightExtended, userClient: Client4, teamId: string): Promise<void> {
const townSquare = await userClient.getChannelByName(teamId, 'town-square');
const uniqueTerm = `searchterm${getRandomId()}`;
await userClient.createPost({channel_id: townSquare.id, message: `hello ${uniqueTerm}`});
await pw.waitUntil(
async () => {
const results = await userClient.searchPosts(teamId, uniqueTerm, false);
return results.order.length > 0;
},
{timeout: 15000, intervalBetweenAttempts: 1000},
);
const results = await userClient.searchPosts(teamId, uniqueTerm, false);
expect(results.order).toHaveLength(1);
expect(results.posts[results.order[0]].message).toContain(uniqueTerm);
}

View File

@@ -0,0 +1,563 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AdminConfig} from '@mattermost/types/config';
import {expect, test} from '@mattermost/playwright-lib';
/**
* Patch the Posts page required fields to known valid values so tests that
* load the page always start with a saveable form state, regardless of what
* other parallel tests may have left in the server config.
*/
async function resetPostsConfig(adminClient: {patchConfig: (config: Partial<AdminConfig>) => Promise<unknown>}) {
await adminClient.patchConfig({
ServiceSettings: {
PersistentNotificationIntervalMinutes: 5,
PersistentNotificationMaxRecipients: 5,
PersistentNotificationMaxCount: 6,
},
} as Partial<AdminConfig>);
}
test.describe('System Console > Self-Deleting Messages', () => {
test('admin can enable and disable self-deleting messages', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Reset Posts section required fields so Save button is always enabled
await resetPostsConfig(adminClient);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify Posts section is visible
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
await expect(postsSection).toBeVisible();
// Get BoR setting elements
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # If feature is enabled, disable it first
if (await enableToggleTrue.isChecked()) {
await enableToggleFalse.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
}
// * Verify dropdowns are disabled when feature is off
expect(await durationDropdown.isDisabled()).toBe(true);
expect(await maxTTLDropdown.isDisabled()).toBe(true);
// # Enable the feature
await enableToggleTrue.click();
// * Verify feature is enabled
expect(await enableToggleTrue.isChecked()).toBe(true);
// * Verify dropdowns are now enabled
expect(await durationDropdown.isDisabled()).toBe(false);
expect(await maxTTLDropdown.isDisabled()).toBe(false);
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back to verify persistence
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify feature is still enabled
expect(await enableToggleTrue.isChecked()).toBe(true);
});
test('admin can configure message duration', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Reset Posts section required fields so Save button is always enabled
await resetPostsConfig(adminClient);
// # Ensure BoR is enabled via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Select 60 seconds duration
await durationDropdown.selectOption('60');
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify duration is still 60 seconds
expect(await durationDropdown.inputValue()).toBe('60');
});
test('admin can configure maximum time to live', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Reset Posts section required fields so Save button is always enabled
await resetPostsConfig(adminClient);
// # Ensure BoR is enabled via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Select 1 day (86400 seconds) max TTL
await maxTTLDropdown.selectOption('86400');
// # Save settings
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// # Navigate away and back
await systemConsolePage.sidebar.userManagement.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
// * Verify max TTL is still 1 day
expect(await maxTTLDropdown.inputValue()).toBe('86400');
});
test('dropdowns are disabled when feature is disabled', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Disable BoR via API to start with a known state
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = false;
await adminClient.patchConfig(config);
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings?.EnableBurnOnRead === false;
});
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
// * Verify feature is disabled (from API config) — use built-in retry to tolerate render lag
await expect(enableToggleFalse).toBeChecked({timeout: 10000});
// * Verify dropdowns are disabled when feature is off
await expect(durationDropdown).toBeDisabled({timeout: 30000});
await expect(maxTTLDropdown).toBeDisabled({timeout: 30000});
// # Enable the feature (just toggle, don't save)
await enableToggleTrue.click();
// * Verify dropdowns are now enabled
await expect(durationDropdown).not.toBeDisabled({timeout: 30000});
await expect(maxTTLDropdown).not.toBeDisabled({timeout: 30000});
// # Toggle back to disabled
await enableToggleFalse.click();
// * Verify dropdowns are disabled again
await expect(durationDropdown).toBeDisabled({timeout: 30000});
await expect(maxTTLDropdown).toBeDisabled({timeout: 30000});
});
test('settings persist after page reload', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure BoR via API with specific values (using valid dropdown options)
// Duration: 300 (5 minutes), Max TTL: 259200 (3 days)
await adminClient.patchConfig({
ServiceSettings: {
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 300,
BurnOnReadMaximumTimeToLiveSeconds: 259200,
},
});
// Wait until the server confirms the patch before logging in, so the browser
// reads the correct value when it loads the Posts section. A concurrent
// initSetup() reset may otherwise overwrite BurnOnReadDurationSeconds.
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
});
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const durationDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadDurationSecondsdropdown');
const maxTTLDropdown = postsSection.getByTestId('ServiceSettings.BurnOnReadMaximumTimeToLiveSecondsdropdown');
// * Verify configured values are displayed
expect(await enableToggleTrue.isChecked()).toBe(true);
expect(await durationDropdown.inputValue()).toBe('300');
expect(await maxTTLDropdown.inputValue()).toBe('259200');
// Re-apply guard: a concurrent initSetup() may reset BoR config between
// the initial page load and this reload.
await adminClient.patchConfig({
ServiceSettings: {
BurnOnReadDurationSeconds: 300,
BurnOnReadMaximumTimeToLiveSeconds: 259200,
},
});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
});
// # Reload directly to Posts section
await page.goto('/admin_console/site_config/posts');
await page.waitForLoadState('networkidle');
// * Verify values persist after reload — toHaveValue has built-in retry
await expect(enableToggleTrue).toBeChecked({timeout: 5000});
await expect(durationDropdown).toHaveValue('300', {timeout: 5000});
await expect(maxTTLDropdown).toHaveValue('259200', {timeout: 5000});
});
test('BoR toggle appears in channels when feature is enabled in System Console', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Reset Posts section required fields so Save button is always enabled
await resetPostsConfig(adminClient);
// # First, disable BoR via API to start clean
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = false;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleTrue = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadtrue');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Enable BoR feature
await enableToggleTrue.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// Re-apply guard: concurrent initSetup() may reset EnableBurnOnRead between UI save and navigation
await adminClient.patchConfig({ServiceSettings: {EnableBurnOnRead: true}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.EnableBurnOnRead === true;
});
// # Navigate to Channels by going to the team URL
await page.goto(`/${team.name}/channels/off-topic`);
await page.waitForLoadState('networkidle');
// * Verify BoR toggle is visible in post create area
const borButton = page.getByRole('button', {name: /Burn-on-read/i});
await expect(borButton).toBeVisible({timeout: 10000});
});
test('BoR toggle is hidden when feature is disabled in System Console', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Reset Posts section required fields so Save button is always enabled
await resetPostsConfig(adminClient);
// # First, enable BoR via API
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
await adminClient.patchConfig(config);
// # Log in as admin
const {systemConsolePage, page} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to Posts section
await systemConsolePage.sidebar.siteConfiguration.posts.click();
await page.waitForLoadState('networkidle');
const postsSection = page.getByTestId('sysconsole_section_PostSettings');
const enableToggleFalse = postsSection.getByTestId('ServiceSettings.EnableBurnOnReadfalse');
const saveButton = postsSection.getByRole('button', {name: 'Save'});
// # Disable BoR feature
await enableToggleFalse.click();
await saveButton.click();
await pw.waitUntil(async () => (await saveButton.textContent()) === 'Save');
// Re-apply guard: concurrent initSetup() may re-enable BoR between UI save and navigation
await adminClient.patchConfig({ServiceSettings: {EnableBurnOnRead: false}});
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.EnableBurnOnRead === false;
});
// # Navigate to Channels by going to the team URL
await page.goto(`/${team.name}/channels/off-topic`);
await page.waitForLoadState('networkidle');
// * Verify BoR toggle is NOT visible in post create area
const borButton = page.getByRole('button', {name: /Burn-on-read/i});
await expect(borButton).not.toBeVisible({timeout: 5000});
});
test('configured duration affects timer countdown in channels', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
license.SkuShortName !== 'advanced' && license.short_sku_name !== 'advanced',
'Skipping test - server does not have enterprise advanced license',
);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Configure BoR with 5 minute (300 seconds) duration
const config = await adminClient.getConfig();
config.ServiceSettings.EnableBurnOnRead = true;
config.ServiceSettings.BurnOnReadDurationSeconds = 300; // 5 minutes
config.ServiceSettings.BurnOnReadMaximumTimeToLiveSeconds = 604800; // 7 days (so max TTL doesn't interfere)
await adminClient.patchConfig(config);
// # Verify the config was applied before proceeding (guard against state pollution)
await pw.waitUntil(async () => {
const cfg = await adminClient.getConfig();
return cfg.ServiceSettings.BurnOnReadDurationSeconds === 300;
});
// # Create a second user to receive the message
const randomUser = await pw.random.user();
const receiver = await adminClient.createUser(randomUser, '', '');
(receiver as any).password = randomUser.password;
await adminClient.addToTeam(team.id, receiver.id);
// # Create a private channel with sender and receiver
const channelName = `bor-test-${Date.now().toString(36)}`;
const channel = await adminClient.createChannel({
team_id: team.id,
name: channelName,
display_name: `BoR Duration Test ${channelName}`,
type: 'P',
} as any);
await adminClient.addToChannel(receiver.id, channel.id);
// # Login as admin (sender) and post BoR message
const {channelsPage: senderChannelsPage} = await pw.testBrowser.login(adminUser);
await senderChannelsPage.goto(team.name, channelName);
await senderChannelsPage.toBeVisible();
await adminClient.patchConfig({
ServiceSettings: {
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 300,
BurnOnReadMaximumTimeToLiveSeconds: 604800,
},
});
// # Toggle BoR on and post message
await senderChannelsPage.centerView.postCreate.toggleBurnOnRead();
const messageContent = `Duration test ${Date.now()}`;
await senderChannelsPage.postMessage(messageContent);
// # Login as receiver and reveal the message
const {channelsPage: receiverChannelsPage, page: receiverPage} = await pw.testBrowser.login(receiver as any);
await receiverChannelsPage.goto(team.name, channelName);
await receiverChannelsPage.toBeVisible();
// # Wait for the concealed placeholder to be visible and enabled (not loading)
const concealedPlaceholder = receiverPage.locator('.BurnOnReadConcealedPlaceholder').first();
await expect(concealedPlaceholder).toBeVisible({timeout: 10000});
// Wait for it to not be in loading state
await expect(concealedPlaceholder).not.toHaveClass(/BurnOnReadConcealedPlaceholder--loading/, {timeout: 10000});
await expect(concealedPlaceholder).toBeEnabled({timeout: 5000});
// Re-apply guard: TTL is set by the server at reveal time; ensure BurnOnReadDurationSeconds
// is still 300 at the moment of reveal — a concurrent initSetup() may have reset it.
await adminClient.patchConfig({
ServiceSettings: {
EnableBurnOnRead: true,
BurnOnReadDurationSeconds: 300,
BurnOnReadMaximumTimeToLiveSeconds: 604800,
},
});
// # Click to reveal the concealed message
await concealedPlaceholder.click();
// # Confirm reveal in modal if it appears
const confirmModal = receiverPage.locator('.BurnOnReadConfirmationModal');
if (await confirmModal.isVisible({timeout: 2000}).catch(() => false)) {
const confirmButton = confirmModal.getByRole('button', {name: /reveal/i});
await confirmButton.click();
}
// * Verify timer chip shows approximately 5 minutes (between 4:10 and 5:00)
const timerChip = receiverPage.locator('.BurnOnReadTimerChip').first();
await expect(timerChip).toBeVisible({timeout: 15000});
const timerText = await timerChip.textContent();
// Timer format is "M:SS" or "MM:SS", should be close to 5:00
const match = timerText?.match(/(\d+):(\d{2})/);
expect(match).not.toBeNull();
if (match) {
const minutes = parseInt(match[1], 10);
const seconds = parseInt(match[2], 10);
const totalSeconds = minutes * 60 + seconds;
// Should be between 4:10 (250s) and 5:00 (300s) accounting for test execution time
expect(totalSeconds).toBeGreaterThanOrEqual(250);
expect(totalSeconds).toBeLessThanOrEqual(300);
}
});
});

View File

@@ -0,0 +1,353 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for System Console > System Attributes > Session Attributes.
*
* Covers the seeded session-attribute listing table, per-row TTL/Grace/Enable/
* Disable tuning with staged Save, the unsaved-changes navigation guard, and the
* ABAC picker polarity (enabled session attributes appear in permission-policy
* editors and are absent from membership-policy editors).
*
* The CEL autocomplete endpoint returns access control group attributes and
* does not return session attributes; the webapp fetches the enabled ones separately
* and merges them into the permission-policy pickers client-side,
* where they generate `user.session.<name>` references.
*
* The session_attributes Property group is gated server-side: it requires an
* Enterprise Advanced license (returns HTTP 501 otherwise) and the
* FeatureFlags.SessionAttributes flag. Tests skip cleanly when those are not met.
*/
import type {Page} from '@playwright/test';
import {enableABAC, expect, test} from '@mattermost/playwright-lib';
import {ensureUserAttributes, getUserAttributeFieldByName} from '../abac/support';
import {
findFieldByName,
patchSessionAttribute,
readSessionAttribute,
restoreSessionAttributesToBaseline,
setupSessionAttributesTest,
} from './support';
test.describe('System Console - Session Attributes', () => {
// Return every seeded session attribute to its captured baseline after each
// test. Tests toggle enabled/ttl/grace on various fields, so without this
// the listing test's "Disabled by default" assertions would depend on no
// other test having enabled a field first. Runs even when a test throws.
test.afterEach(async () => {
await restoreSessionAttributesToBaseline();
});
/**
* @objective Verify the gated listing page renders the seeded session
* attributes with correct Type, Platform, Status, and Server-source labeling.
*/
test(
'renders the listing table with correct type, status, and server labels',
{tag: '@session_attributes'},
async ({pw}) => {
const {systemConsolePage, fields} = await setupSessionAttributesTest(pw);
const sa = systemConsolePage.sessionAttributes;
// # Navigate to Session Attributes via the sidebar
await systemConsolePage.sidebar.systemAttributes.sessionAttributes.click();
// * Verify the page loaded
await sa.toBeVisible();
const ipAddress = findFieldByName(fields, 'ip_address');
const clientIpAddress = findFieldByName(fields, 'client_ip_address');
const vpnActive = findFieldByName(fields, 'vpn_active');
const networkInterfaceType = findFieldByName(fields, 'network_interface_type');
const osVersion = findFieldByName(fields, 'os_version');
// * Verify a representative row is rendered with its platform icons
await expect(sa.row(ipAddress.id)).toBeVisible();
await expect(sa.platforms(ipAddress.id)).toBeVisible();
// * Verify request-derived fields carry the "Server" label
await expect(sa.serverLabel(ipAddress.id)).toBeVisible();
// * Verify a seeded-but-client-native field does NOT carry the Server label
await expect(sa.serverLabel(clientIpAddress.id)).toHaveCount(0);
// * Verify the derived Type column maps text/select fields correctly
await expect(sa.type(ipAddress.id)).toContainText('IP');
await expect(sa.type(vpnActive.id)).toContainText('Boolean');
await expect(sa.type(networkInterfaceType.id)).toContainText('Enum');
await expect(sa.type(osVersion.id)).toContainText('Version');
// * Verify seeded fields render as Disabled by default
await expect(sa.status(ipAddress.id)).toContainText('Disabled');
await expect(sa.status(ipAddress.id)).toHaveAttribute('data-enabled', 'false');
},
);
/**
* @objective Verify staging a TTL preset, a Grace preset, and a one-click
* Enable, then Saving, persists the changes after a full page reload.
*/
test('persists TTL, grace, and enable edits after save and reload', {tag: '@session_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
const sa = systemConsolePage.sessionAttributes;
const field = findFieldByName(fields, 'os_version');
// # Navigate to Session Attributes page
await sa.goto();
// # Stage a new TTL (1h), a new Grace period (5m), and enable the field
await sa.setTtlPreset(field.id, 3600);
await sa.setGracePreset(field.id, 300);
await sa.enable(field.id);
// * Verify the staged values are reflected before saving
await expect(sa.ttl(field.id)).toHaveText('1h');
await expect(sa.grace(field.id)).toHaveText('5m');
await expect(sa.status(field.id)).toContainText('Enabled');
// # Save the staged edits (one PATCH per changed field)
await sa.saveAndWaitForSettled();
// # Reload the page
await sa.goto();
// * Verify the edits persisted in the UI after reload
await expect(sa.ttl(field.id)).toHaveText('1h');
await expect(sa.grace(field.id)).toHaveText('5m');
await expect(sa.status(field.id)).toContainText('Enabled');
// * Verify the edits persisted server-side via the property API
const persisted = await readSessionAttribute(adminClient, field.id);
expect(persisted.ttl_seconds).toBe(3600);
expect(persisted.grace_period_seconds).toBe(300);
expect(persisted.enabled).toBe(true);
});
/**
* @objective Verify disabling an enabled attribute requires confirming the
* modal, and that the disabled state persists after save and reload.
*
* @precondition
* The target session attribute is enabled via API before the test.
*/
test('disables an enabled attribute via confirmation modal', {tag: '@session_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
const sa = systemConsolePage.sessionAttributes;
const field = findFieldByName(fields, 'client_version');
// # Enable the field via API so the dot menu offers Disable
await patchSessionAttribute(adminClient, field.id, {enabled: true});
// # Navigate to Session Attributes page
await sa.goto();
// * Verify it shows as Enabled
await expect(sa.status(field.id)).toContainText('Enabled');
// # Open the dot menu and trigger Disable (opens the confirmation modal)
await sa.openDisableModal(field.id);
// * Verify the confirmation modal is shown
await expect(sa.disableModal()).toBeVisible();
// # Confirm the disable
await sa.confirmDisable();
// * Verify the staged status flips to Disabled before saving
await expect(sa.status(field.id)).toContainText('Disabled');
// # Save the staged change
await sa.saveAndWaitForSettled();
// # Reload the page
await sa.goto();
// * Verify the disabled state persisted in the UI and server-side
await expect(sa.status(field.id)).toContainText('Disabled');
const persisted = await readSessionAttribute(adminClient, field.id);
expect(persisted.enabled).toBe(false);
});
/**
* @objective Verify a staged edit blocks sidebar navigation with a discard
* prompt, and that Cancel reverts the staged change.
*/
test('blocks navigation while dirty and reverts on cancel', {tag: '@session_attributes'}, async ({pw}) => {
const {systemConsolePage, fields} = await setupSessionAttributesTest(pw);
const sa = systemConsolePage.sessionAttributes;
const page = systemConsolePage.page;
const field = findFieldByName(fields, 'ssid');
// # Navigate to Session Attributes page
await sa.goto();
// # Capture the rendered TTL, then stage a different TTL (24h)
const beforeTtl = (await sa.ttl(field.id).textContent())?.trim() ?? '';
await sa.setTtlPreset(field.id, 86400);
// * Verify the edit is staged and Save is enabled
await expect(sa.ttl(field.id)).toHaveText('24h');
await expect(sa.saveButton).toBeEnabled();
// # Attempt to navigate away via the sidebar while dirty
await systemConsolePage.sidebar.systemAttributes.userAttributes.click();
// * Verify the unsaved-changes navigation guard appears
const discardModal = page.getByRole('dialog').filter({hasText: 'Discard Changes?'});
await expect(discardModal).toBeVisible();
await expect(discardModal.getByText('You have unsaved changes', {exact: false})).toBeVisible();
// # Decline the discard prompt to stay on the page
await discardModal.getByRole('button', {name: 'Cancel'}).click();
await expect(discardModal).toBeHidden();
// # Cancel the staged edit via the Save Changes panel
await sa.cancel();
// * Verify the TTL reverted and Save returned to disabled
await expect(sa.ttl(field.id)).toHaveText(beforeTtl);
await expect(sa.saveButton).toBeDisabled();
});
/**
* @objective Verify session attributes are selectable in a permission-policy
* editor's attribute picker but absent from a membership-policy editor's picker.
*
* @precondition
* ABAC is enabled, at least one user attribute exists, and a session
* attribute is enabled via API.
*/
test(
'appears in permission-policy picker and is absent from membership-policy picker',
{tag: '@session_attributes'},
async ({pw}) => {
test.setTimeout(120000);
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
const page = systemConsolePage.page;
const sessionField = findFieldByName(fields, 'ip_address');
// A second session attribute kept DISABLED: the merge lists enabled
// attributes only, so this one must never reach the picker.
const disabledSessionField = findFieldByName(fields, 'vpn_active');
// # Ensure prerequisites: a user attribute, ABAC on, one session
// attribute enabled and a second one explicitly disabled.
await ensureUserAttributes(adminClient, ['Department']);
await patchSessionAttribute(adminClient, sessionField.id, {enabled: true});
await patchSessionAttribute(adminClient, disabledSessionField.id, {enabled: false});
await enableABAC(page);
// Picker menu items are keyed by field id (`#attribute-<id>`), not by
// name, so resolve the user attribute's id the same way the session
// attribute ids come from the property-fields API.
const userField = await getUserAttributeFieldByName(adminClient, 'Department');
// ── Permission policy editor: session attribute IS available ──
// # Open a new permission policy and add an attribute row
await page.goto('/admin_console/system_attributes/permission_policies');
await page.waitForLoadState('networkidle');
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
await page.getByPlaceholder('Add a unique policy name').fill(`PP Session ${pw.random.id()}`);
await openAttributePicker(page);
const permissionMenu = page.getByRole('menu', {name: 'Select attribute'});
await expect(permissionMenu).toBeVisible();
// * Verify the SESSION ATTRIBUTES section and the enabled session attribute are present
await expect(permissionMenu.getByText('Session attributes', {exact: true})).toBeVisible();
await expect(permissionMenu.locator(`#attribute-${sessionField.id}`)).toBeVisible();
// * Verify the DISABLED session attribute is absent (enabled-only filter)
await expect(permissionMenu.locator(`#attribute-${disabledSessionField.id}`)).toHaveCount(0);
// ── Membership policy editor: session attribute is NOT available ──
//
// Done before building a permission expression so this navigation
// isn't blocked by the unsaved-changes guard.
// # Open a new membership policy and add an attribute row
await page.goto('/admin_console/system_attributes/membership_policies');
await page.waitForLoadState('networkidle');
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
await page
.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName')
.fill(`MP Session ${pw.random.id()}`);
await openAttributePicker(page);
const membershipMenu = page.getByRole('menu', {name: 'Select attribute'});
await expect(membershipMenu).toBeVisible();
// * Verify the picker loaded user attributes (proves the menu is populated)
await expect(membershipMenu.locator(`#attribute-${userField.id}`)).toBeVisible();
// * Verify session attributes are absent from the membership picker
await expect(membershipMenu.getByText('Session attributes', {exact: true})).toHaveCount(0);
await expect(membershipMenu.locator(`#attribute-${sessionField.id}`)).toHaveCount(0);
// ── CEL path: a picked session attribute generates user.session.<name> ──
//
// Last step: building an expression dirties the form, so any
// navigation must already be complete.
// # Open a fresh permission policy, pick the session attribute, set a value
await page.goto('/admin_console/system_attributes/permission_policies');
await page.waitForLoadState('networkidle');
await page.getByRole('button', {name: 'Add policy'}).click();
await page.waitForLoadState('networkidle');
await page.getByPlaceholder('Add a unique policy name').fill(`PP CEL ${pw.random.id()}`);
await openAttributePicker(page);
const celMenu = page.getByRole('menu', {name: 'Select attribute'});
await expect(celMenu).toBeVisible();
await celMenu.locator(`#attribute-${sessionField.id}`).click();
const valueInput = page.locator('.values-editor__simple-input').first();
await valueInput.click();
await valueInput.fill('10.0.0.1');
await valueInput.press('Enter');
// # Switch to Advanced (CEL) mode
await page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
// * Verify the generated CEL uses the session namespace, not user.attributes
await expect(page.locator('.cel-editor')).toContainText('user.session.ip_address');
await expect(page.locator('.cel-editor')).not.toContainText('user.attributes.ip_address');
},
);
});
/**
* Add an attribute row in the policy table editor and open its attribute picker.
* Shared by the permission and membership editor checks since both reuse the
* same table-editor attribute selector.
*/
async function openAttributePicker(page: Page): Promise<void> {
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
await addAttributeButton.click();
// Adding a row auto-opens the attribute menu. Only click the selector
// button to open it when the auto-open didn't fire — clicking while the
// menu is already open would land on its backdrop and be intercepted.
const menu = page.getByRole('menu', {name: 'Select attribute'});
try {
await expect(menu).toBeVisible({timeout: 3000});
} catch {
await page.getByTestId('attributeSelectorMenuButton').first().click();
await expect(menu).toBeVisible();
}
}

View File

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
export const SESSION_ATTRIBUTES_GROUP = 'session_attributes';
export const SESSION_ATTRIBUTES_OBJECT_TYPE = 'session';
export const SESSION_ATTRIBUTES_TARGET_TYPE = 'system';
export type SessionAttrPatch = Partial<{
enabled: boolean;
ttl_seconds: number;
grace_period_seconds: number;
}>;
export interface SessionAttributesContext {
adminClient: Client4;
systemConsolePage: SystemConsolePage;
fields: PropertyField[];
}
/**
* The seeded session_attributes Property group is gated server-side: requests
* return HTTP 501 below an Enterprise Advanced license, and the System Console
* page is hidden unless FeatureFlags.SessionAttributes is true. Probe both and
* skip (rather than fail) when the environment does not satisfy them, so the
* suite reports honestly on stacks that cannot exercise the feature.
*/
export async function setupSessionAttributesTest(pw: PlaywrightExtended): Promise<SessionAttributesContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags?.SessionAttributes !== true,
'Requires FeatureFlags.SessionAttributes=true on the server',
);
let fields: PropertyField[] = [];
try {
fields = await getSessionAttributeFields(adminClient);
} catch (error) {
test.skip(
true,
`session_attributes property group unavailable (requires Enterprise Advanced license): ${String(error)}`,
);
}
test.skip(fields.length === 0, 'No seeded session attributes returned by the server');
captureSessionAttributesBaseline(adminClient, fields);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, systemConsolePage, fields};
}
/**
* Baseline snapshot of every seeded session attribute, captured the first time
* the suite sets up (before any test mutates state). Used by
* restoreSessionAttributesToBaseline so the suite is order-independent: each
* test returns the fields to this original state, so a field enabled by one
* test cannot leak into another's assertions.
*/
let baselineFields: PropertyField[] | null = null;
let baselineClient: Client4 | null = null;
function captureSessionAttributesBaseline(client: Client4, fields: PropertyField[]): void {
if (!baselineFields) {
baselineFields = fields.map((field) => ({...field}));
baselineClient = client;
}
}
/**
* Restore every seeded session attribute to the baseline captured at suite
* start. Call from afterEach so tests that toggle enabled/ttl/grace cannot leak
* state into later tests, regardless of execution order.
*/
export async function restoreSessionAttributesToBaseline(): Promise<void> {
if (!baselineClient || !baselineFields) {
return;
}
for (const field of baselineFields) {
await restoreSessionAttribute(baselineClient, field);
}
}
export async function getSessionAttributeFields(client: Client4): Promise<PropertyField[]> {
return client.getPropertyFields(
SESSION_ATTRIBUTES_GROUP,
SESSION_ATTRIBUTES_OBJECT_TYPE,
SESSION_ATTRIBUTES_TARGET_TYPE,
);
}
export function findFieldByName(fields: PropertyField[], name: string): PropertyField {
const field = fields.find((f) => f.name === name);
expect(field, `Seeded session attribute "${name}" not found`).toBeDefined();
return field as PropertyField;
}
export async function patchSessionAttribute(
client: Client4,
fieldId: string,
attrs: SessionAttrPatch,
): Promise<PropertyField> {
return client.patchPropertyField(SESSION_ATTRIBUTES_GROUP, SESSION_ATTRIBUTES_OBJECT_TYPE, fieldId, {attrs});
}
/**
* Re-fetch a single field's tunable attrs from the server (source of truth for
* persistence assertions).
*/
export async function readSessionAttribute(client: Client4, fieldId: string): Promise<SessionAttrPatch> {
const fields = await getSessionAttributeFields(client);
const field = fields.find((f) => f.id === fieldId);
const attrs = (field?.attrs ?? {}) as Record<string, unknown>;
return {
enabled: attrs.enabled === true,
ttl_seconds: typeof attrs.ttl_seconds === 'number' ? attrs.ttl_seconds : undefined,
grace_period_seconds: typeof attrs.grace_period_seconds === 'number' ? attrs.grace_period_seconds : undefined,
};
}
/**
* Restore a field's tunables to the captured values. Use in a finally block so
* each test leaves the seeded fields in their original state and tests stay
* independent.
*/
export async function restoreSessionAttribute(client: Client4, field: PropertyField): Promise<void> {
const attrs = (field.attrs ?? {}) as Record<string, unknown>;
try {
await patchSessionAttribute(client, field.id, {
enabled: attrs.enabled === true,
ttl_seconds: typeof attrs.ttl_seconds === 'number' ? attrs.ttl_seconds : undefined,
grace_period_seconds:
typeof attrs.grace_period_seconds === 'number' ? attrs.grace_period_seconds : undefined,
});
} catch {
// Best-effort cleanup; ignore if the server rejects (e.g. license downgrade mid-run).
}
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
// Teams here are created on the shared admin account, so track and delete them instead of letting
// them inflate the team list of every later spec in the run.
const createdTeamIds: string[] = [];
async function initSetupTracked(pw: PlaywrightExtended) {
const setup = await pw.initSetup();
createdTeamIds.push(setup.team.id);
return setup;
}
test.afterEach(async () => {
const {adminClient} = await getAdminClient();
await Promise.allSettled(createdTeamIds.splice(0).map((id) => adminClient.deleteTeam(id)));
});
/**
* @objective Verify the System Console header menu is not clipped by the fixed-width sidebar, so the
* scroll bar it renders when the team list overflows stays visible and reachable.
*/
test(
'does not clip the System Console header menu scroll bar when the team list overflows',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient} = await initSetupTracked(pw);
// # Give the admin enough teams for the header menu's team list to overflow
await Promise.all(
Array.from({length: 30}, async () => {
const team = await adminClient.createTeam(await pw.random.team());
createdTeamIds.push(team.id);
}),
);
// # Open the System Console as the admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Open the menu in the sidebar header
const menu = await systemConsolePage.sidebar.header.openMenu();
// * Verify the team list is long enough for the menu to scroll
const verticalOverflow = await menu.evaluate((el: HTMLElement) => el.scrollHeight - el.clientHeight);
expect(
verticalOverflow,
'the menu should have more entries than fit, so that it renders a scroll bar',
).toBeGreaterThan(0);
// * Verify the menu is wider than the sidebar column, which is what put its scroll bar inside
// the region the sidebar clips
const menuBox = await menu.boundingBox();
const sidebarBox = await systemConsolePage.sidebar.container.boundingBox();
expect(
menuBox!.x + menuBox!.width,
'the menu should be wider than the sidebar column it is rendered in',
).toBeGreaterThan(sidebarBox!.x + sidebarBox!.width);
// * Verify the strip just inside the menu's right border, where the scroll bar is drawn, still
// hit-tests back to the menu rather than to whatever the sidebar clips it down to. Headless
// Chromium runs with --hide-scrollbars, so probe that strip rather than measure its width.
await expect
.poll(
async () =>
menu.evaluate((el: HTMLElement) => {
const rect = el.getBoundingClientRect();
const borderRight = parseFloat(getComputedStyle(el).borderRightWidth);
const hit = document.elementFromPoint(rect.right - borderRight - 1, rect.top + rect.height / 2);
if (el.contains(hit)) {
return 'the menu';
}
return hit ? `${hit.tagName.toLowerCase()}.${hit.getAttribute('class')}` : 'nothing';
}),
{message: 'the menu scroll bar strip should not be clipped away by the sidebar'},
)
.toBe('the menu');
// # Scroll the menu with the wheel
await menu.hover();
await systemConsolePage.page.mouse.wheel(0, 200);
// * Verify the menu scrolled
await expect.poll(async () => menu.evaluate((el: HTMLElement) => el.scrollTop)).toBeGreaterThan(0);
},
);
/**
* @objective Verify the System Console sidebar itself still cannot be scrolled, so that letting the
* header menu overflow it horizontally does not bring back the scrollable sidebar.
*/
test('keeps the System Console sidebar itself unscrollable', {tag: '@system_console'}, async ({pw}) => {
const {adminUser} = await initSetupTracked(pw);
// # Open the System Console as the admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Try to scroll the sidebar in both directions
const sidebarScroll = await systemConsolePage.sidebar.container.evaluate((el: HTMLElement) => {
el.scrollTop = 9999;
el.scrollLeft = 9999;
return {
hasMoreContentThanFits: el.scrollHeight > el.clientHeight,
scrollTop: el.scrollTop,
scrollLeft: el.scrollLeft,
};
});
// * Verify the sidebar has more content than fits, so that it would scroll if it were allowed to
expect(
sidebarScroll.hasMoreContentThanFits,
'the sidebar should have more sections than fit, otherwise the scroll assertion is vacuous',
).toBe(true);
// * Verify the sidebar stayed put
expect({scrollTop: sidebarScroll.scrollTop, scrollLeft: sidebarScroll.scrollLeft}).toEqual({
scrollTop: 0,
scrollLeft: 0,
});
});

View File

@@ -0,0 +1,468 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, getRandomId, test} from '@mattermost/playwright-lib';
test.describe('Single-channel guests', () => {
test.setTimeout(180000);
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
});
async function setupSingleChannelGuestsTest(pw: any) {
const {adminClient, adminUser} = await pw.getAdminClient();
const suffix = getRandomId();
const team = await adminClient.createTeam({
name: `scg-${suffix}`,
display_name: `SCG ${suffix}`,
type: 'O',
});
await adminClient.addToTeam(team.id, adminUser.id);
return {adminClient, adminUser, team};
}
async function patchGuestEnabled(adminClient: any, enabled: boolean): Promise<boolean> {
const cfg = await adminClient.getConfig();
const previous = cfg.GuestAccountsSettings?.Enable ?? false;
await adminClient.patchConfig({GuestAccountsSettings: {Enable: enabled}});
return previous;
}
async function navigateWithGuestPatch(page: any, adminClient: any, url: string, guestEnabled: boolean) {
await page.goto(url);
await page.waitForLoadState('networkidle');
await patchGuestEnabled(adminClient, guestEnabled);
await page.reload();
await page.waitForLoadState('networkidle');
}
/**
* @objective Verify the Single-channel Guests stat card appears on the Site Statistics page when guests are enabled
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'displays single-channel guests card on site statistics page when guest accounts are enabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Create a guest user and add to one channel
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin and navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// Re-apply patch after initial load + reload to counter WebSocket config resets
// from concurrent initSetup() calls (default_config has Enable: false).
await navigateWithGuestPatch(
systemConsolePage.page,
adminClient,
'/admin_console/reporting/system_analytics',
true,
);
// * Verify the single-channel guests card is visible
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).toBeVisible({timeout: 30000});
// * Verify the count is at least 1.
// Analytics are indexed asynchronously on the server — poll with reload until
// the card reflects the newly created guest (can take several seconds in CI).
await expect
.poll(
async () => {
// Re-apply guest patch before reload: a concurrent initSetup() may
// reset the config back to the default (Enable: false), which would
// hide the card and make the textContent call return null.
await patchGuestEnabled(adminClient, true);
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
const text = await systemConsolePage.page
.getByTestId('singleChannelGuests')
.textContent()
.catch(() => '');
return Number(text?.match(/(\d+)/)?.[1] ?? 0);
},
{timeout: 180000, intervals: [3000, 5000, 8000, 12000]},
)
.toBeGreaterThanOrEqual(1);
},
);
/**
* @objective Verify the Single-channel Guests row appears on the Edition and License page when guests are enabled
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and a single-channel guest limit configured
*/
test(
'displays single-channel guests row on edition and license page when guest accounts are enabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Create a guest user and add to one channel
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({teamId: team.id, name: 'guest-channel', displayName: 'Guest Channel', unique: true}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin and navigate to edition and license page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// Re-apply patch after initial load + reload to counter WebSocket config resets.
await navigateWithGuestPatch(systemConsolePage.page, adminClient, '/admin_console/about/license', true);
// * Verify the single-channel guests row is visible
await expect(systemConsolePage.page.getByText('SINGLE-CHANNEL GUESTS:')).toBeVisible();
},
);
/**
* @objective Verify the Single-channel Guests stat card is not shown when guest accounts are disabled
*/
test(
'hides single-channel guests card on site statistics page when guest accounts are disabled',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Disable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, false);
// # Log in as admin and navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// Re-apply patch (disabled) + reload to ensure the browser reads the fresh config.
await navigateWithGuestPatch(
systemConsolePage.page,
adminClient,
'/admin_console/reporting/system_analytics',
false,
);
// * Verify the single-channel guests card is not in the DOM
await expect(systemConsolePage.page.getByTestId('singleChannelGuests')).not.toBeVisible();
},
);
/**
* @objective Verify the server limits API returns single-channel guest count and limit for admin users
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'returns single-channel guest data from server limits API for admin users',
{tag: '@system_console'},
async ({pw}) => {
const {adminClient} = await setupSingleChannelGuestsTest(pw);
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Fetch server limits
const {data: limits} = await adminClient.getServerLimits();
// * Verify the response includes single-channel guest fields
expect(limits).toHaveProperty('singleChannelGuestCount');
expect(limits).toHaveProperty('singleChannelGuestLimit');
expect(typeof limits.singleChannelGuestCount).toBe('number');
expect(typeof limits.singleChannelGuestLimit).toBe('number');
expect(limits.singleChannelGuestCount).toBeGreaterThanOrEqual(0);
expect(limits.singleChannelGuestLimit).toBeGreaterThanOrEqual(0);
},
);
/**
* @objective Verify the single-channel guests card does not show error styling when count is within limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
*/
test(
'shows no error styling on single-channel guests card when count is within limit',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Create a single-channel guest (count will be well within any license limit)
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
const channel = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-no-overage',
displayName: 'Guest No Overage',
unique: true,
}),
);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Navigate to site statistics
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// Re-apply patch + reload to counter WebSocket config resets.
await navigateWithGuestPatch(
systemConsolePage.page,
adminClient,
'/admin_console/reporting/system_analytics',
true,
);
// * Verify the card title does NOT have error class (count is within limit)
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
await expect(cardTitle).toBeVisible({timeout: 15000});
await expect(cardTitle).not.toHaveClass(/team_statistics--error/);
},
);
/**
* @objective Verify the dismissible banner is not shown when single-channel guest count is within limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled and guest count is within the allowed limit
*/
test('does not show guest limit banner when count is within limit', {tag: '@system_console'}, async ({pw}) => {
const {adminUser, adminClient} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Navigate to system console and re-apply patch + reload so the browser reads
// the latest config (not a WebSocket-clobbered Redux store from a concurrent initSetup).
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await patchGuestEnabled(adminClient, true);
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
// * Verify the guest limit banner is not visible (count is within limit)
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).not.toBeVisible();
});
/**
* @objective Verify error styling appears on the single-channel guests card and dismissible banner shows when single-channel guest count exceeds the limit
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'shows error styling on guests card and banner when single-channel guest count exceeds limit',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Create multiple single-channel guests so the analytics count exceeds the mocked limit
for (let i = 0; i < 3; i++) {
const guest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guest.id, 'system_guest');
await adminClient.addToTeam(team.id, guest.id);
const ch = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: `scg-overage-${i}`,
displayName: `SCG Overage ${i}`,
unique: true,
}),
);
await adminClient.addToChannel(guest.id, ch.id);
}
// # Log in as admin (new browser context + page).
const {context, systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Mock GET /api/v4/limits/server on the *context* before any navigation so the first
// getServerLimits() populates Redux with a low limit. Also fulfill with an explicit
// JSON body — route.fulfill({response, json}) does not reliably merge bodies in Playwright.
await context.route('**/api/v4/limits/server', async (route) => {
const response = await route.fetch();
let json: Record<string, unknown> = {};
try {
json = (await response.json()) as Record<string, unknown>;
} catch {
// ignore — replace with minimal payload
}
json.singleChannelGuestLimit = 1;
json.singleChannelGuestCount = 3;
await route.fulfill({
status: response.status(),
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(json),
});
});
// # Navigate to site statistics page, re-applying patch to counter concurrent resets.
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await navigateWithGuestPatch(
systemConsolePage.page,
adminClient,
'/admin_console/reporting/system_analytics',
true,
);
// * Verify the card title has error styling
const cardTitle = systemConsolePage.page.getByTestId('singleChannelGuestsTitle');
await expect(cardTitle).toBeVisible({timeout: 15000});
await expect(cardTitle).toHaveClass(/team_statistics--error/);
// * Verify the dismissible guest limit banner is visible
await expect(systemConsolePage.page.getByTestId('single_channel_guest_limit_banner')).toBeVisible();
},
);
/**
* @objective Verify that a guest in multiple channels is not counted as a single-channel guest
*
* @precondition
* Server has a non-Entry license with guest accounts enabled
*/
test(
'does not count multi-channel guest as single-channel guest on site statistics page',
{tag: '@system_console'},
async ({pw}) => {
const {adminUser, adminClient, team} = await setupSingleChannelGuestsTest(pw);
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Enable guest accounts (narrow patch, not destructive full-config update)
await patchGuestEnabled(adminClient, true);
// # Create a guest user and add to TWO channels
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, multiChannelGuest.id);
const channelA = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-channel-a',
displayName: 'Guest Channel A',
unique: true,
}),
);
const channelB = await adminClient.createChannel(
pw.random.channel({
teamId: team.id,
name: 'guest-channel-b',
displayName: 'Guest Channel B',
unique: true,
}),
);
await adminClient.addToChannel(multiChannelGuest.id, channelA.id);
await adminClient.addToChannel(multiChannelGuest.id, channelB.id);
// # Log in as admin and navigate to site statistics, re-applying patch to counter
// concurrent initSetup() resets (default_config has GuestAccountsSettings.Enable: false).
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await navigateWithGuestPatch(
systemConsolePage.page,
adminClient,
'/admin_console/reporting/system_analytics',
true,
);
// * Verify the single-channel guests card is visible
const singleChannelGuestsCard = systemConsolePage.page.getByTestId('singleChannelGuests');
await expect(singleChannelGuestsCard).toBeVisible({timeout: 15000});
// * Verify the count text is present — multi-channel guest should not increment it
const readGuestCount = async () => {
await patchGuestEnabled(adminClient, true);
await systemConsolePage.page.reload();
await systemConsolePage.page.waitForLoadState('networkidle');
const text = await singleChannelGuestsCard.textContent().catch(() => '');
return Number(text?.match(/(\d+)/)?.[1] ?? NaN);
};
let singleChannelGuestCount = NaN;
await expect
.poll(
async () => {
singleChannelGuestCount = await readGuestCount();
return singleChannelGuestCount;
},
{timeout: 180000, intervals: [3000, 5000, 8000, 12000]},
)
.toBeGreaterThanOrEqual(0);
// # Now create a single-channel guest to confirm baseline counting works
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, singleChannelGuest.id);
await adminClient.addToChannel(singleChannelGuest.id, channelA.id);
// * Verify the count increased by exactly 1 for the new single-channel guest
await expect
.poll(readGuestCount, {timeout: 180000, intervals: [3000, 5000, 8000, 12000]})
.toBe(singleChannelGuestCount + 1);
},
);
});

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console autotranslation settings (Site Configuration -> Localization).
* AT-SC-01: Without EA license, feature discovery is shown.
* AT-SC-02: With EA license, Auto-translation toggle is visible and off by default.
* AT-SC-03: Enabling the system toggle expands provider and target language configuration.
* AT-SC-05: Selecting LibreTranslate shows endpoint and API key fields.
* AT-SC-07: Target languages multi-select is visible and usable.
*/
import {expect, test, hasAutotranslationLicense} from '@mattermost/playwright-lib';
test.describe('System Console - Autotranslation (Localization)', () => {
test(
'without EA license, auto-translation shows feature discovery block',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
hasAutotranslationLicense(license.SkuShortName),
'Skipping test - this test requires non-Entry/Advanced license to see feature discovery',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await expect(systemConsolePage.localization.featureDiscoveryBlock).toBeVisible();
await expect(systemConsolePage.localization.autoTranslationSection).not.toBeVisible();
},
);
test(
'with EA license, Auto-translation toggle is visible and off by default',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await expect(systemConsolePage.localization.autoTranslationSection).toBeVisible();
await expect(systemConsolePage.localization.autoTranslationToggle).toBeVisible();
// Check toggle state directly (not via unscoped text search which is flaky)
const isOn = await systemConsolePage.localization.isToggleOn();
expect(isOn).toBe(false);
},
);
test(
'enabling system toggle expands provider and target language configuration',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await systemConsolePage.localization.turnOnAutoTranslation();
await expect(systemConsolePage.localization.providerDropdown).toBeVisible();
await expect(systemConsolePage.localization.targetLanguagesMultiSelect).toBeVisible();
},
);
test(
'selecting LibreTranslate shows endpoint and API key fields',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await systemConsolePage.localization.turnOnAutoTranslation();
await systemConsolePage.localization.selectTranslationProvider('LibreTranslate');
await expect(systemConsolePage.localization.libreTranslateUrlInput).toBeVisible();
await expect(systemConsolePage.localization.libreTranslateApiKeyInput).toBeVisible();
await expect(systemConsolePage.localization.container.getByText(/LibreTranslate docs/i)).toBeVisible();
},
);
test(
'selecting Mattermost Agents hides LibreTranslate fields and shows plugin guidance',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Open Localization settings in the System Console
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
// # Enable auto-translation and switch the provider to Mattermost Agents
await systemConsolePage.localization.turnOnAutoTranslation();
await systemConsolePage.localization.selectTranslationProvider('Mattermost Agents');
// * Verify Mattermost Agents guidance is shown for the inactive plugin
await expect(systemConsolePage.localization.mattermostAgentsInactiveNotice).toBeVisible();
await expect(systemConsolePage.localization.mattermostAgentsConfigLink).toBeVisible();
await expect(systemConsolePage.localization.mattermostAgentsConfigLink).toHaveAttribute(
'href',
'/admin_console/plugins/plugin_mattermost-ai',
);
// * Verify LibreTranslate-specific inputs are hidden for the Mattermost Agents provider
await expect(systemConsolePage.localization.libreTranslateUrlInput).not.toBeVisible();
await expect(systemConsolePage.localization.libreTranslateApiKeyInput).not.toBeVisible();
},
);
test(
'admin can select multiple target languages',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
// Enable autotranslation first
await systemConsolePage.localization.turnOnAutoTranslation();
// Verify multiselect is visible
await expect(systemConsolePage.localization.targetLanguagesMultiSelect).toBeVisible();
await expect(systemConsolePage.localization.container.getByText('Languages allowed')).toBeVisible();
// Select multiple languages (Spanish and French)
const multiSelect = systemConsolePage.localization.targetLanguagesMultiSelect;
const multiSelectInput = multiSelect.locator('input');
const languageOptions = multiSelect.getByRole('option');
// Open dropdown and select Spanish
await multiSelectInput.click();
await languageOptions.filter({hasText: /Español/}).click();
// Dropdown closes after each selection — reopen it, then select French
await multiSelectInput.click();
await languageOptions.filter({hasText: /Français/}).click();
// Close multiselect
await multiSelect.press('Escape');
// Verify both languages are selected by checking for their remove buttons in the multiselect
const spanishChip = multiSelect.getByRole('button', {name: /Remove Español/});
const frenchChip = multiSelect.getByRole('button', {name: /Remove Français/});
await expect(spanishChip).toBeVisible();
await expect(frenchChip).toBeVisible();
},
);
});

View File

@@ -0,0 +1,599 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — Classification markings (Enterprise-tier license + ClassificationMarkings feature flag).
* Covers admin UI for presets, save validation, preset-change confirmation, and custom preset detection.
*
* Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`.
* Professional-only licenses hide this admin route (React Router redirects to /admin_console/about/license).
*/
import type {Page} from '@playwright/test';
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
async function selectClassificationPreset(page: Page, optionLabel: string) {
await page.getByTestId('classificationPreset').click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await menu.getByText(optionLabel, {exact: true}).click();
}
test.describe('System Console - Classification markings', () => {
test.beforeAll(async () => {
const {adminClient} = await getAdminClient({skipLog: true});
await setClassificationMarkingsFeatureFlag(adminClient, true);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags?.ClassificationMarkings !== true &&
config.FeatureFlags?.ClassificationMarkings !== 'true',
'ClassificationMarkings feature flag is off (probably overridden by env); skipping.',
);
});
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(
licenseTier(license.SkuShortName) < 20,
'Classification markings requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). Professional/trial Professional is not sufficient—the admin route is hidden and redirects to /admin_console/about/license.',
);
// Skip if the custom_profile_attributes property group is absent on this server.
// The group must exist (seeded by the server) before classification markings can be saved;
// the API returns "The specified property group was not found." otherwise.
try {
await adminClient.getPropertyFields('custom_profile_attributes', 'template', 'system');
} catch {
test.skip(
true,
'custom_profile_attributes property group not found on this server; skipping classification markings save tests.',
);
}
});
/**
* @objective Ensure the classification markings admin route is unavailable when the feature flag is off (when the server allows disabling it).
*/
test(
'MM-T6201 classification markings: feature flag off redirects away from admin URL',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Turn off ClassificationMarkings in server config
await setClassificationMarkingsFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.ClassificationMarkings === true,
'ClassificationMarkings stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.',
);
// # Open system console and navigate directly to the classification markings path
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// * User is redirected away from the hidden route (no Route registered)
await expect(systemConsolePage.page).not.toHaveURL(/classification_markings/);
// * Classification markings page title is not shown
await expect(systemConsolePage.page.getByText('Classification Markings').first()).not.toBeVisible();
},
);
/**
* @objective Ensure the classification markings page is reachable when the feature flag is on.
*/
test(
'MM-T6202 classification markings: feature flag on loads configuration page',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and clear any existing classification field
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
// # Log in and open the classification markings URL
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// * URL stays on the classification markings section
await expect(systemConsolePage.page).toHaveURL(/classification_markings/);
// * Page title is visible
await expect(systemConsolePage.page.getByText('Classification Markings').first()).toBeVisible();
},
);
/**
* @objective Validate that enabling classification without any levels shows a save error.
*/
test(
'MM-T6203 classification markings: save fails when enabled with zero levels',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable feature flag and ensure no classification field exists
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// # Enable classification markings without choosing a preset or adding levels
await systemConsolePage.page.locator('input[name="classificationEnabled"][value="true"]').click();
await systemConsolePage.page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(
systemConsolePage.page.getByText(/At least one classification level is required/i),
).toBeVisible();
},
);
/**
* @objective Verify selecting a built-in preset and saving creates the classification field successfully.
*/
// Skipped: master regression. The CM frontend creates a property field on the
// `custom_profile_attributes` group, registered as PSAv1 in server.go, but
// the API rejects v1 groups since #36171 ("PSAv2 generic APIs blacklist").
// Re-enable once the CPA group is migrated to v2 (or v1 template/system
// creates are allowed, mirroring the patch-route FIXME at properties.go:299).
test.skip(
'MM-T6204 classification markings: select NATO preset and save',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and start from no classification field
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// # Enable markings and choose NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
const firstLevelNameInput = page.getByLabel('Classification level name').first();
// * Preset levels appear in the table
await expect(firstLevelNameInput).toBeVisible();
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
// # Save
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * No server error and first level name is unchanged after save
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
},
);
/**
* @objective When a classification field already exists, changing preset shows a warning modal; confirming applies the new preset.
*/
// Skipped: see MM-T6204 — saving a preset hits the v2_group_not_found check.
test.skip(
'MM-T6205 classification markings: preset change shows confirm modal then applies',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and clear field, then prepare saved UK levels
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'UK (GSCP)');
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * UK preset first level is present
await expect(page.getByLabel('Classification level name').first()).toHaveValue('OFFICIAL');
// # Select a different preset while a field exists on the server
await selectClassificationPreset(page, 'United States');
// * Warning modal appears with expected copy
await expect(page.getByText('Change classification preset?')).toBeVisible();
await expect(
page.getByText(/Changing the classification preset will affect all existing classifications/i),
).toBeVisible();
// # Confirm preset change
await page.getByRole('button', {name: 'Change preset'}).click();
// * Modal closes and US preset first level is shown
await expect(page.getByText('Change classification preset?')).not.toBeVisible();
await expect(page.getByLabel('Classification level name').first()).toHaveValue('UNCLASSIFIED');
},
);
/**
* @objective After saving a preset, deleting a level switches the preset dropdown to Custom and save still succeeds.
*/
// Skipped: see MM-T6204 — saving a preset hits the v2_group_not_found check.
test.skip(
'MM-T6206 classification markings: delete level switches to custom and saves',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and save Canada preset as baseline
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'Canada');
await page.getByRole('button', {name: 'Save', exact: true}).click();
await expect(page.getByLabel('Classification level name').first()).toHaveValue('PROTECTED A');
// # Remove one level from the saved preset
await page.getByRole('button', {name: 'Delete level'}).first().click();
const presetControl = page.getByTestId('classificationPreset');
// * Preset selection switches to custom
await expect(presetControl).toContainText('Custom classification levels');
// # Save custom levels
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * No error and preset remains custom
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(presetControl).toContainText('Custom classification levels');
},
);
/**
* @objective Global Classification Indicators section appears when classification is enabled.
*/
test(
'MM-T6207 classification markings: global banner section visible when enabled',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * Global Classification Indicators section should NOT be visible before enabling
await expect(page.getByText('Global Classification Indicators')).not.toBeVisible();
// # Enable classification markings
await page.locator('input[name="classificationEnabled"][value="true"]').click();
// * The section now appears
await expect(page.getByText('Global Classification Indicators')).toBeVisible();
await expect(page.getByText('Configure the global classification banner')).toBeVisible();
await expect(page.getByText('Global Classification Banner', {exact: true})).toBeVisible();
},
);
/**
* @objective Enabling the global banner shows placement and level controls; saving a level
* persists the configuration and loads it back correctly on page reload.
*/
test(
'MM-T6208 classification markings: enable global banner, select level, save, and reload',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings and select NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
await expect(page.getByLabel('Classification level name').first()).toHaveValue('NATO UNCLASSIFIED');
// # Enable the global banner
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// * Placement and level controls appear
await expect(page.getByText('Banner visibility')).toBeVisible();
await expect(page.getByText('Global classification level')).toBeVisible();
// # Select "Top and bottom" for placement
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
// # Pick the first level (NATO UNCLASSIFIED) from the level dropdown
await page.getByTestId('globalBannerLevel').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('NATO UNCLASSIFIED', {exact: true}).click();
// # Save
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
// Wait for the save to fully complete: the button becomes disabled once
// the async persistLevels flow finishes and hasChanges resets to false.
// networkidle alone is unreliable because there is a JS processing gap
// between the re-fetch GETs and the subsequent POST/PATCH calls.
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload the page
await page.reload();
await page.waitForLoadState('networkidle');
// * Banner configuration persisted: enabled, top_and_bottom, NATO UNCLASSIFIED selected.
// The linked field + property value fetch is async; wait for the level dropdown
// (only rendered when the banner is enabled) as the hydration signal.
await expect(page.getByTestId('globalBannerLevel')).toContainText('NATO UNCLASSIFIED', {timeout: 30000});
await expect(page.locator('input[name="globalBannerEnabled"][value="true"]')).toBeChecked();
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked();
},
);
/**
* @objective Placement and level controls remain editable after a banner level has been
* persisted — the write-once lock behavior no longer exists.
*/
test(
'MM-T6209 classification markings: global banner placement and level remain editable after save',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed a field and banner config via API (skip the UI save step)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'natounclassified0000000000', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'nato-restricted', name: 'NATO RESTRICTED', color: '#FFD700', rank: 2},
],
{levelId: 'natounclassified0000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * No locked notice should be present
await expect(page.getByText(/Global classification placement and level are locked/)).not.toBeVisible();
// * Placement inputs are editable
await expect(page.locator('input[name="globalBannerPlacement"]').first()).not.toBeDisabled();
// * Delete buttons for saved level rows are not disabled by a lock
await expect(page.getByRole('button', {name: 'Delete level'}).first()).not.toBeDisabled();
// # Switch placement to "top_and_bottom" and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No server error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload and verify the new placement persisted
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked({
timeout: 30000,
});
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective When the level currently referenced by the global banner is removed, saving
* surfaces a validation error forcing the admin to pick a valid level.
*/
test(
'MM-T6210 classification markings: deleting referenced banner level blocks save until resolved',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed two levels and a banner pointing at the first one
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Delete the level used by the banner (first row)
await page.getByRole('button', {name: 'Delete level'}).first().click();
// # Try to save — banner still references the deleted level
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(
page.getByText(/The global classification banner is configured with a level that no longer exists/i),
).toBeVisible();
// # Pick a valid replacement level (click the react-select control directly)
await page.getByTestId('globalBannerLevel').locator('.DropDown__control').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('CONFIDENTIAL', {exact: true}).click();
// # Save again
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error and banner now references the replacement level
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(page.getByTestId('globalBannerLevel')).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Verify that modifying a preset's levels (rename, delete, add) automatically
* switches the dropdown to "Custom classification levels", and selecting a real preset
* again removes the Custom option from the dropdown.
*/
test(
'MM-T6212 classification markings: modifying a preset switches dropdown to Custom',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings and select NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
const presetControl = page.getByTestId('classificationPreset');
await expect(presetControl).toContainText('NATO');
// # Rename the first level — this should switch to Custom
const firstLevelInput = page.getByLabel('Classification level name').first();
await firstLevelInput.clear();
await firstLevelInput.fill('MY CUSTOM LEVEL');
// * Preset dropdown should now show "Custom classification levels"
await expect(presetControl).toContainText('Custom classification levels');
// # Open the preset dropdown and verify "Custom classification levels" is listed
await presetControl.click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await expect(menu.getByText('Custom classification levels', {exact: true})).toBeVisible();
// # Select a real preset (Canada) — should show the confirmation modal
await menu.getByText('Canada', {exact: true}).click();
await expect(page.getByText('Change classification preset?')).toBeVisible();
// # Confirm the preset change
await page.getByRole('button', {name: 'Change preset'}).click();
// * Dropdown now shows Canada, no longer Custom
await expect(presetControl).toContainText('Canada');
// # Open the dropdown again and verify Custom is no longer listed
await presetControl.click();
const menuAfterSwitch = page.locator('.DropDown__menu');
await expect(menuAfterSwitch).toBeVisible();
await expect(menuAfterSwitch.getByText('Custom classification levels', {exact: true})).not.toBeVisible();
// # Close menu by pressing Escape
await page.keyboard.press('Escape');
// # Delete a level from the Canada preset
await page.getByRole('button', {name: 'Delete level'}).first().click();
// * Should switch back to Custom
await expect(presetControl).toContainText('Custom classification levels');
},
);
/**
* @objective Validate that saving with global banner enabled but no level selected shows an error.
*/
test(
'MM-T6211 classification markings: save fails when global banner enabled without a level',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings, select NATO preset (provides levels)
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
// # Enable the global banner without picking a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// # Try to save — no level selected in the dropdown
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
});

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts
// (cross-package import not feasible between e2e-tests and webapp)
const PROPERTY_GROUP = 'access_control';
const OBJECT_TYPE = 'template';
const LINKED_OBJECT_TYPE = 'system';
const TARGET_TYPE = 'system';
const SYSTEM_FIELD_TARGET_ID = ''; // target_type 'system' requires empty target_id on the field
const CLASSIFICATION_FIELD_NAME = 'classification';
const LINKED_CLASSIFICATION_FIELD_NAME = 'classification';
const DISPLAY_BANNER_TOP = 'display_banner_top';
const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom';
export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/classification_markings';
/**
* Toggle via System Console config API. On servers without SplitKey, feature flags are
* read-only from config (see server/config/store.go); effective values come from env
* (e.g. MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS). E2E docker sets that env in server.generate.sh.
*/
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
await adminClient.patchConfig({
FeatureFlags: {
ClassificationMarkings: enabled,
},
} as any);
}
/**
* Removes the classification property field and its linked system field if present
* (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors.
*/
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
// Delete channel linked fields first (created by channel classification tests).
try {
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', TARGET_TYPE, '');
for (const f of channelFields.filter((f) => f.name === 'classification' && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', f.id);
}
} catch {
// May not exist; ignore.
}
// Clean up both the current 'system' object type and the legacy 'user' object type
// to handle stale data from earlier versions of the feature.
for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) {
try {
const linkedFields = await adminClient.getPropertyFields(
PROPERTY_GROUP,
objectType,
TARGET_TYPE,
SYSTEM_FIELD_TARGET_ID,
);
const matchingLinkedFields = linkedFields.filter(
(f) => f.name === LINKED_CLASSIFICATION_FIELD_NAME && f.delete_at === 0 && f.linked_field_id,
);
for (const f of matchingLinkedFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
}
} catch {
// Linked field may not exist; ignore.
}
}
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE);
const matchingFields = fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0);
for (const f of matchingFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, OBJECT_TYPE, f.id);
}
} catch {
// Property routes may be unavailable when the feature flag is off; ignore.
}
}
export type SetupGlobalBannerOptions = {
/** Level ID that the global banner should display */
levelId: string;
enabled?: boolean;
placement?: 'top' | 'top_and_bottom';
};
/**
* Creates (or recreates) the classification property field with the provided levels.
* The template field stores classification levels (attrs.options) only.
*/
export async function setupClassificationField(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
) {
// Ensure a clean slate first.
await deleteClassificationMarkingsFieldIfExists(adminClient);
return adminClient.createPropertyField(PROPERTY_GROUP, OBJECT_TYPE, {
name: CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: '',
attrs: {
options: levels.map((l) => ({id: l.id ?? '', name: l.name, color: l.color, rank: l.rank})),
},
permission_field: 'admin',
permission_values: 'admin',
permission_options: 'admin',
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Creates the classification template field, the linked system classification field
* (with attrs.actions encoding banner placement), and upserts the system property value
* (the option ID of the selected classification level) — the full three-layer setup.
*
* Data model:
* Template field → attrs.options (levels)
* Linked field → attrs.actions (display_banner_top / display_banner_bottom)
* Property value → value = option_id (UUID from template attrs.options)
*/
export async function setupClassificationFieldWithGlobalBanner(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
bannerOpts: SetupGlobalBannerOptions,
) {
// 1. Create the template field (levels only).
const templateField = await setupClassificationField(adminClient, levels);
const enabled = bannerOpts.enabled ?? true;
// Resolve the option ID for the requested level (only needed when enabled).
let optionId = '';
if (enabled && bannerOpts.levelId) {
const options = (templateField.attrs?.options ?? []) as Array<{id: string; name: string}>;
const matchedOption = options.find((o) => o.id === bannerOpts.levelId);
if (!matchedOption) {
const available = options.map((o) => `${o.name} (${o.id})`).join(', ');
throw new Error(
`setupClassificationFieldWithGlobalBanner: unknown level ID "${bannerOpts.levelId}". ` +
`Available options on template field ${templateField.id}: [${available}]`,
);
}
optionId = matchedOption.id;
}
const actions: string[] = [];
if (enabled) {
actions.push(DISPLAY_BANNER_TOP);
if (bannerOpts.placement === 'top_and_bottom') {
actions.push(DISPLAY_BANNER_BOTTOM);
}
}
// 2. Create the linked system classification field.
// type, options, and permissions are inherited from the source template by the server.
const linkedField = await adminClient.createPropertyField(PROPERTY_GROUP, LINKED_OBJECT_TYPE, {
name: LINKED_CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: SYSTEM_FIELD_TARGET_ID,
linked_field_id: templateField.id,
attrs: {actions},
} as Parameters<Client4['createPropertyField']>[2]);
// 3. Upsert the system property value via the dedicated system endpoint.
if (enabled) {
if (!optionId) {
throw new Error(
`setupClassificationFieldWithGlobalBanner: resolved optionId is empty for level "${bannerOpts.levelId}". ` +
'The server may not have assigned an ID to the option.',
);
}
if (typeof (adminClient as any).patchSystemPropertyValues !== 'function') {
throw new Error('adminClient.patchSystemPropertyValues is not available — rebuild @mattermost/client');
}
await (adminClient as any).patchSystemPropertyValues(PROPERTY_GROUP, [
{field_id: linkedField.id, value: optionId},
]);
}
return {templateField, linkedField};
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Global Classification Banner — end-to-end tests.
* Validates that the banner component renders (or does not render) correctly
* based on property field attrs: feature flag, enabled state, level selection, placement.
*
* These tests sit next to the classification_markings admin-page tests because
* they share the same helpers and feature-flag gating.
*/
import type {Page} from '@playwright/test';
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
const TOP_BANNER_SELECTOR = '[data-testid="global-classification-banner-top"]';
const BOTTOM_BANNER_SELECTOR = '[data-testid="global-classification-banner-bottom"]';
async function selectClassificationPreset(page: Page, optionLabel: string) {
await page.getByTestId('classificationPreset').click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await menu.getByText(optionLabel, {exact: true}).click();
}
test.describe('Global Classification Banner', () => {
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(licenseTier(license.SkuShortName) < 20, 'Classification markings requires Enterprise-tier license.');
});
/**
* @objective Banner does not render when the ClassificationMarkings feature flag is off.
*/
test(
'MM-T6220 global banner: not rendered when feature flag is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.ClassificationMarkings === true,
'Feature flag cannot be disabled in this environment.',
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Restore the flag for subsequent tests
await setClassificationMarkingsFeatureFlag(adminClient, true);
},
);
/**
* @objective Banner does not render when classifications are enabled and configured,
* but the global banner toggle is still disabled.
*/
test(
'MM-T6221 global banner: not rendered when global banner toggle is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Set up classification levels but keep the global banner disabled
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: '', enabled: false, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Enabling the global banner without selecting a level prevents saving.
*/
test(
'MM-T6222 global banner: save fails when enabled without selecting a level',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Enable classification markings and select a preset to have levels
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'United States');
// Enable global banner without selecting a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// Try to save
await page.getByRole('button', {name: 'Save', exact: true}).click();
// Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
/**
* @objective After full setup, the top banner renders with the correct level name,
* background color, and contrasting text color.
*/
test(
'MM-T6223 global banner: renders at top with correct text and color after full setup',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('SECRET');
await expect(topBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)'); // #C8102E
// Bottom banner should NOT be visible (placement is top only)
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Selecting "Top and bottom" placement renders both banners.
*/
test(
'MM-T6224 global banner: top and bottom banners render when placement is top_and_bottom',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
const bottomBanner = channelsPage.page.locator(BOTTOM_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('TOP SECRET');
await expect(bottomBanner).toBeVisible();
await expect(bottomBanner).toContainText('TOP SECRET');
// Both should have the same background color
await expect(topBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)'); // #FCE83A
await expect(bottomBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner also renders on the admin console page.
*/
test(
'MM-T6225 global banner: renders on the admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
{levelId: 'lvlconfidential00000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.page.waitForLoadState('networkidle');
const topBanner = systemConsolePage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner disappears after the admin disables it and saves.
*/
test(
'MM-T6226 global banner: disappears after being disabled via admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlrestricted0000000000000', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
{levelId: 'lvlrestricted0000000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Banner should be visible initially
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
// Disable the global banner
await page.locator('input[name="globalBannerEnabled"][value="false"]').click();
const saveBtn = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn.click();
await expect(saveBtn).toBeDisabled({timeout: 30000});
// Banner should no longer be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Switching placement from top to top_and_bottom makes the bottom banner appear.
*/
test(
'MM-T6227 global banner: switching placement to top_and_bottom shows bottom banner',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 1}],
{
levelId: 'lvlsecret00000000000000000',
enabled: true,
placement: 'top',
},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Initially only top banner
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Switch placement to top_and_bottom and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveBtn2 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn2.click();
await expect(saveBtn2).toBeDisabled({timeout: 30000});
// Both banners should now be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Disabling classification markings entirely removes the banner even
* if the global banner was previously configured.
*/
test(
'MM-T6228 global banner: cleared when classification markings are disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Both banners should be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
// Disable classification markings entirely
await page.locator('input[name="classificationEnabled"][value="false"]').click();
const saveBtn3 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn3.click();
await expect(saveBtn3).toBeDisabled({timeout: 30000});
// Banners should be gone
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Changes made by an admin propagate to a non-admin user's banner
* in real-time without requiring a page reload.
*/
test(
'MM-T6230 global banner: propagates to non-admin users via websocket',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminClient, user} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
// Login the non-admin user
const {channelsPage: userChannelsPage} = await pw.testBrowser.login(user);
await userChannelsPage.goto();
await userChannelsPage.toBeVisible();
const userTopBanner = userChannelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(userTopBanner).toBeVisible();
await expect(userTopBanner).toContainText('UNCLASSIFIED');
// Admin changes the banner level
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
);
// The non-admin user should see the updated banner via websocket
await expect(userTopBanner).toContainText('SECRET');
await expect(userTopBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Text color adapts for readability: dark text on light background,
* white text on dark background.
* Color is now derived from the level's color in attrs.options (not stored separately).
*/
test(
'MM-T6229 global banner: text color contrasts with background for readability',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Light background (#FFFFFF) — text should be dark (#000000)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(0, 0, 0)');
// Dark background (#000000) — text should be white (#FFFFFF)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#000000', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top'},
);
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(255, 255, 255)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
});

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type PlaywrightExtended, expect, test} from '@mattermost/playwright-lib';
test('MM-T5520-1 should activate and deactivate users', async ({pw}) => {
const {getUser, systemConsolePage} = await setupAndGetRandomUser(pw);
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and deactivate the user
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickDeactivate();
// # Press confirm on the modal
await systemConsolePage.users.confirmModal.confirm();
// * Verify user is deactivated
await expect(userRow.container.getByText('Deactivated')).toBeVisible();
expect((await getUser()).delete_at).toBeGreaterThan(0);
// # Open menu and reactivate the user
const actionMenu2 = await userRow.openActionMenu();
await actionMenu2.clickActivate();
// * Verify user is activated
await expect(userRow.container.getByText('Member')).toBeVisible();
});
test('MM-T5520-2 should change user roles', async ({pw}) => {
const {getUser, systemConsolePage} = await setupAndGetRandomUser(pw);
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and click Manage roles
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickManageRoles();
// # Change to System Admin and click Save
const systemAdmin = systemConsolePage.page.locator('input[name="systemadmin"]');
await systemAdmin.waitFor();
await systemAdmin.click();
await systemConsolePage.users.manageRolesModal.save();
// * Verify that the role was updated
await expect(userRow.container.getByText('System Admin')).toBeVisible();
expect((await getUser()).roles).toContain('system_admin');
// # Open menu and click Manage roles
const actionMenu2 = await userRow.openActionMenu();
await actionMenu2.clickManageRoles();
// # Change to Member and click Save
const systemMember = systemConsolePage.page.locator('input[name="systemmember"]');
await systemMember.waitFor();
await systemMember.click();
await systemConsolePage.users.manageRolesModal.save();
// * Verify that the role was updated
await expect(userRow.container.getByText('Member')).toBeVisible();
expect((await getUser()).roles).toContain('system_user');
});
test('MM-T5520-3 should be able to manage teams', async ({pw}) => {
const {systemConsolePage} = await setupAndGetRandomUser(pw);
const {page} = systemConsolePage;
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and click Manage teams
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickManageTeams();
// The role dropdown renders its menu in a portal (MUI Popover), so the menu
// items live at the page level rather than inside the team row.
const team = page.locator('div.manage-teams__team');
// # Open the role dropdown and click Make Team Admin
await team.getByRole('button', {name: 'Team Member'}).click();
await page.getByRole('menuitem', {name: 'Make Team Admin'}).click();
// * Verify role is updated
await expect(team.getByText('Team Admin')).toBeVisible();
// # Change back to Team Member
await team.getByRole('button', {name: 'Team Admin'}).click();
await page.getByRole('menuitem', {name: 'Make Team Member'}).click();
// * Verify role is updated
await expect(team.getByText('Team Member')).toBeVisible();
// # Click Remove from Team
await team.getByRole('button', {name: 'Team Member'}).click();
await page.getByRole('menuitem', {name: 'Remove from Team'}).click();
// * The team should be detached
await team.waitFor({state: 'detached'});
await expect(team).not.toBeVisible();
});
test('MM-T5520-4 should reset the users password', async ({pw}) => {
const {systemConsolePage} = await setupAndGetRandomUser(pw);
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and click Reset Password
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickResetPassword();
// # Enter a random password and click Reset
await systemConsolePage.users.resetPasswordModal.fillPassword(pw.newTestPassword());
await systemConsolePage.users.resetPasswordModal.reset();
});
test('MM-T5520-5 should change the users email', async ({pw}) => {
const {getUser, systemConsolePage} = await setupAndGetRandomUser(pw);
const newEmail = `${pw.random.id()}@example.com`;
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and click Update Email
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickUpdateEmail();
// # Enter new email and click Update
await systemConsolePage.users.updateEmailModal.fillEmail(newEmail);
await systemConsolePage.users.updateEmailModal.update();
// * Verify that the email updated
await expect(userRow.container.getByText(newEmail)).toBeVisible();
expect((await getUser()).email).toEqual(newEmail);
});
test('MM-T5520-6 should revoke sessions', async ({pw}) => {
const {systemConsolePage} = await setupAndGetRandomUser(pw);
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
// # Open menu and revoke sessions
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickRevokeSessions();
// # Press confirm on the modal
await systemConsolePage.users.confirmModal.confirm();
// * Verify no error is displayed
await expect(userRow.container.locator('.error')).not.toBeVisible();
});
/**
* Setup a new random user, and search for it such that it's the first row in the list
* @param pw
* @returns A function to get the refreshed user, and the System Console page for navigation
*/
async function setupAndGetRandomUser(pw: PlaywrightExtended) {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create a random user to edit
const user = await adminClient.createUser(await pw.random.user(), '', '');
const team = await adminClient.createTeam(await pw.random.team());
await adminClient.addToTeam(team.id, user.id);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Search for the user
await systemConsolePage.users.searchUsers(user.email);
// Wait for search results
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
await expect(userRow.container.getByText(user.email)).toBeVisible();
return {getUser: () => adminClient.getUser(user.id), systemConsolePage};
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test.describe('System Console - Users table sorting', () => {
test.describe.configure({mode: 'serial'});
test('MM-T5523-1 Sortable columns should sort the list when clicked', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create 10 random users
for (let i = 0; i < 10; i++) {
await adminClient.createUser(await pw.random.user(), '', '');
}
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// * Verify that 'Email' column has aria-sort attribute
const emailColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Email');
await expect(emailColumnHeader).toBeVisible();
await expect(emailColumnHeader).toHaveAttribute('aria-sort');
// # Click on the 'Email' column header to sort and wait for sort to complete
const sortDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
// * Verify that emails are sorted in the expected direction (table can still be
// re-fetching rows from other workers creating users — poll longer than default).
await expect(async () => {
await systemConsolePage.page.waitForLoadState('networkidle').catch(() => {});
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
const maxRows = Math.min(rowCount, 40);
const emails: string[] = [];
for (let i = 0; i < maxRows; i++) {
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
const email = (await row.getEmail()).trim();
if (email) {
emails.push(email);
}
}
expect(emails.length).toBeGreaterThan(3);
const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
if (sortDirection === 'descending') {
sorted.reverse();
}
expect(emails).toEqual(sorted);
}).toPass({timeout: 120_000});
// # Click on the 'Email' column header again to toggle sort direction
const reversedDirection = await systemConsolePage.users.usersTable.sortByColumn('Email');
// * Verify that the sort direction has toggled
expect(reversedDirection).not.toEqual(sortDirection);
// * Verify that emails are sorted in the toggled direction
await expect(async () => {
await systemConsolePage.page.waitForLoadState('networkidle').catch(() => {});
const rowCount = await systemConsolePage.users.usersTable.bodyRows.count();
const maxRows = Math.min(rowCount, 40);
const emails: string[] = [];
for (let i = 0; i < maxRows; i++) {
const row = systemConsolePage.users.usersTable.getRowByIndex(i);
const email = (await row.getEmail()).trim();
if (email) {
emails.push(email);
}
}
expect(emails.length).toBeGreaterThan(3);
const sorted = [...emails].sort((a, b) => a.localeCompare(b, undefined, {ignorePunctuation: true}));
if (reversedDirection === 'descending') {
sorted.reverse();
}
expect(emails).toEqual(sorted);
}).toPass({timeout: 120_000});
});
test('MM-T5523-2 Non sortable columns should not sort the list when clicked', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create 10 random users
for (let i = 0; i < 10; i++) {
await adminClient.createUser(await pw.random.user(), '', '');
}
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// * Verify that 'Last login' column does not have aria-sort attribute
const lastLoginColumnHeader = systemConsolePage.users.usersTable.getColumnHeader('Last login');
await expect(lastLoginColumnHeader).toBeVisible();
await expect(lastLoginColumnHeader).not.toHaveAttribute('aria-sort');
// # Store the first row's email without sorting
const firstRowWithoutSort = systemConsolePage.users.usersTable.getRowByIndex(0);
const firstRowEmailWithoutSort = await firstRowWithoutSort.container
.getByText(pw.simpleEmailRe)
.allInnerTexts();
// # Try to click on the 'Last login' column header to sort
await systemConsolePage.users.usersTable.clickSortOnColumn('Last login');
// # Store the first row's email after sorting
const firstRowWithSort = systemConsolePage.users.usersTable.getRowByIndex(0);
const firstRowEmailWithSort = await firstRowWithSort.container.getByText(pw.simpleEmailRe).allInnerTexts();
// * Verify that the first row's email is still the same
expect(firstRowEmailWithoutSort).toEqual(firstRowEmailWithSort);
});
});

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('MM-T5523-3 Should list the column names with checkboxes in the correct order', async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the column toggle menu
const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
// # Get all the menu items
const menuItems = columnToggleMenu.getAllMenuItems();
const menuItemsTexts = await menuItems.allInnerTexts();
// * Verify menu items exists in the correct order
expect(menuItemsTexts).toHaveLength(10);
expect(menuItemsTexts).toEqual([
'User details',
'Email',
'Member since',
'Last login',
'Last activity',
'Last post',
'Days active',
'Messages posted',
'Channel count',
'Actions',
]);
});
test('MM-T5523-4 Should allow certain columns to be checked and others to be disabled', async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the column toggle menu
const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
// * Verify that 'Display Name' is disabled
const displayNameMenuItem = await columnToggleMenu.getMenuItem('User details');
expect(displayNameMenuItem).toBeDisabled();
// * Verify that 'Actions' is disabled
const actionsMenuItem = await columnToggleMenu.getMenuItem('Actions');
expect(actionsMenuItem).toBeDisabled();
// * Verify that 'Email' however is enabled
const emailMenuItem = await columnToggleMenu.getMenuItem('Email');
expect(emailMenuItem).not.toBeDisabled();
});
test('MM-T5523-5 Should show/hide the columns which are toggled on/off', async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the column toggle menu
let columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
// # Uncheck the Email and Last login columns to hide them
await columnToggleMenu.clickMenuItem('Email');
await columnToggleMenu.clickMenuItem('Last login');
// * Close the column toggle menu
await columnToggleMenu.close();
// * Verify that Email column and Last login column are hidden
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Email'})).not.toBeVisible();
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Last login'})).not.toBeVisible();
// # Now open the column toggle menu again
columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
// # Check the Email column to show it
await columnToggleMenu.clickMenuItem('Email');
// * Close the column toggle menu
await columnToggleMenu.close();
// * Verify that Email column is now shown
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Email'})).toBeVisible();
// * Verify that however Last login column is still hidden as we did not check it on
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Last login'})).not.toBeVisible();
});
/**
* @objective Verify that the Channel count column displays a numeric value for a user with known channel memberships
*
* @precondition
* A guest user exists with exactly two channel memberships
*/
test(
'displays numeric channel count value when Channel count column is enabled',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create two channels
const ch1Name = `count-ch1-${pw.random.id()}`;
const channel1 = await adminClient.createChannel({
team_id: team.id,
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch1Name,
type: 'O',
});
const ch2Name = `count-ch2-${pw.random.id()}`;
const channel2 = await adminClient.createChannel({
team_id: team.id,
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch2Name,
type: 'O',
});
// # Create a guest user and add to exactly two channels
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
await adminClient.addToChannel(guestUser.id, channel1.id);
await adminClient.addToChannel(guestUser.id, channel2.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Enable Channel count column
const columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is visible
await expect(
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
).toBeVisible();
// # Search for the guest user
await systemConsolePage.users.searchUsers(guestUser.email);
await systemConsolePage.users.isLoadingComplete();
// * Verify the Channel count cell displays the expected numeric value
const firstRow = systemConsolePage.users.container.locator('tbody tr').first();
const channelCountCell = firstRow.locator('.channelCountColumn');
await expect(channelCountCell).toHaveText('2');
},
);
/**
* @objective Verify that the Channel count column can be toggled on and off
*/
test('toggles Channel count column visibility on and off', {tag: '@system_users'}, async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the column toggle menu and enable Channel count
let columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is visible
await expect(systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'})).toBeVisible();
// # Open column toggle menu again and disable Channel count
columnToggleMenu = await systemConsolePage.users.openColumnToggleMenu();
await columnToggleMenu.clickMenuItem('Channel count');
await columnToggleMenu.close();
// * Verify Channel count column header is hidden
await expect(
systemConsolePage.users.container.getByRole('columnheader', {name: 'Channel count'}),
).not.toBeVisible();
});

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test.fixme('MM-T5522 Should begin export of data when export button is pressed', async ({pw}) => {
test.slow();
// # Skip test if no license
await pw.skipIfNoLicense();
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {page, channelsPage, systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Change the export duration to All time
const dateRangeMenu = await systemConsolePage.users.openDateRangeSelectorMenu();
await dateRangeMenu.clickMenuItem('All time');
// # Click Export button and confirm the modal
await systemConsolePage.users.clickExport();
await systemConsolePage.users.confirmModal.confirm();
// # Change the export duration to Last 30 days
const dateRangeMenu2 = await systemConsolePage.users.openDateRangeSelectorMenu();
await dateRangeMenu2.clickMenuItem('Last 30 days');
// # Click Export button and confirm the modal
await systemConsolePage.users.clickExport();
await systemConsolePage.users.confirmModal.confirm();
// # Click Export again button and confirm the modal
await systemConsolePage.users.clickExport();
await systemConsolePage.users.confirmModal.confirm();
// * Verify that we are told that one is already running
expect(page.getByText('Export is in progress')).toBeVisible();
// # Go back to Channels and open the system bot DM
channelsPage.goto('ad-1/messages', '@system-bot');
await channelsPage.centerView.toBeVisible();
// * Verify that we have started the export and that the second one is running second
const lastPost = await channelsPage.getLastPost();
await lastPost.toContain('export of user data for the last 30 days');
// * Wait until the first export finishes
await channelsPage.centerView.waitUntilLastPostContains('contains user data for all time', pw.duration.half_min);
// * Wait until the second export finishes
await channelsPage.centerView.waitUntilLastPostContains(
'contains user data for the last 30 days',
pw.duration.half_min,
);
});

View File

@@ -0,0 +1,321 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, test} from '@mattermost/playwright-lib';
test('MM-T5521-7 Should be able to filter users with team filter', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create a team with a user
const team1 = await adminClient.createTeam(await pw.random.team());
const user1 = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team1.id, user1.id);
// # Create another team with a user
const team2 = await adminClient.createTeam(await pw.random.team());
const user2 = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.addToTeam(team2.id, user2.id);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter's popover
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Enter the team name of the first user and select it
await filterPopover.filterByTeam(team1.display_name);
// # Save the filter and close the popover
await filterPopover.save();
await filterPopover.close();
await systemConsolePage.users.isLoadingComplete();
// * Verify that the user corresponding to the first team is visible as team-1 filter was applied
await expect(systemConsolePage.users.container.getByText(user1.email)).toBeVisible();
// * Verify that the user corresponding to the second team is not visible as team-2 filter was not applied
await expect(systemConsolePage.users.container.getByText(user2.email)).not.toBeVisible();
});
test('MM-T5521-8 Should be able to filter users with role filter', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create a guest user
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
// # Create a regular user
const regularUser = await adminClient.createUser(await pw.random.user(), '', '');
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Open the role filter in the popover and select Guest
await filterPopover.filterByRole('Guests (all)');
// # Save the filter and close the popover
await filterPopover.save();
await filterPopover.close();
await systemConsolePage.users.isLoadingComplete();
// # Search for the guest user with the filter already applied
await systemConsolePage.users.searchUsers(guestUser.email);
// * Verify that guest user is visible as a 'Guest' role filter was applied
await expect(systemConsolePage.users.container.getByText(guestUser.email)).toBeVisible();
// # Search for the regular user with the filter already applied
await systemConsolePage.users.searchUsers(regularUser.email);
// * Verify that regular user is not visible as 'Guest' role filter was applied
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
});
test('MM-T5521-9 Should be able to filter users with status filter', async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Create a user and then deactivate it
const deactivatedUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserActive(deactivatedUser.id, false);
// # Create a regular user
const regularUser = await adminClient.createUser(await pw.random.user(), '', '');
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Open the status filter in the popover and select Deactivated users
await filterPopover.filterByStatus('Deactivated users');
// # Save the filter and close the popover
await filterPopover.save();
await filterPopover.close();
// # Search for the deactivated user with the filter already applied
await systemConsolePage.users.searchUsers(deactivatedUser.email);
// * Verify that deactivated user is visible as a 'Deactivated' status filter was applied
await expect(systemConsolePage.users.container.getByText(deactivatedUser.email)).toBeVisible();
// # Search for the regular user with the filter already applied
await systemConsolePage.users.searchUsers(regularUser.email);
// * Verify that regular user is not visible as 'Deactivated' status filter was applied
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
});
/**
* @objective Verify that the role filter dropdown shows all guest filter variants
*/
test('displays all guest filter variants in the role filter dropdown', {tag: '@system_users'}, async ({pw}) => {
const {adminUser} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover
const filterPopover = await systemConsolePage.users.openFilterPopover();
// # Open the role filter menu
await filterPopover.openRoleMenu();
// * Verify all 6 role filter options are present
const roleOptions = filterPopover.container.getByRole('option');
const roleTexts = await roleOptions.allInnerTexts();
expect(roleTexts).toEqual([
'Any',
'System Admin',
'Member',
'Guests (all)',
'Guests in a single channel',
'Guests in multiple channels',
]);
});
/**
* @objective Verify that filtering by single-channel guest filter returns only guests with exactly one channel membership
*
* @precondition
* A guest user exists with exactly one channel membership
*/
test(
'filters users by single-channel guest filter and shows only single-channel guests',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create a channel
const channelName = `guest-ch-${pw.random.id()}`;
const channel = await adminClient.createChannel({
team_id: team.id,
name: channelName.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: channelName,
type: 'O',
});
// # Create a guest user and add to exactly one channel
const guestUser = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(guestUser.id, 'system_guest');
await adminClient.addToTeam(team.id, guestUser.id);
await adminClient.addToChannel(guestUser.id, channel.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover and filter by single-channel guests
const filterPopover = await systemConsolePage.users.openFilterPopover();
await filterPopover.filterByRole('Guests in a single channel');
await filterPopover.save();
await systemConsolePage.users.isLoadingComplete();
// # Search for the guest user
await systemConsolePage.users.searchUsers(guestUser.email);
// * Verify the single-channel guest is visible
await expect(systemConsolePage.users.container.getByText(guestUser.email)).toBeVisible();
},
);
/**
* @objective Verify that filtering by multi-channel guest filter returns only guests with more than one channel membership
*
* @precondition
* A guest user exists with two channel memberships and another with one
*/
test(
'filters users by multi-channel guest filter and excludes single-channel guests',
{tag: '@system_users'},
async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
if (!adminUser) {
throw new Error('Failed to create admin user');
}
// # Create two channels
const ch1Name = `guest-multi-1-${pw.random.id()}`;
const channel1 = await adminClient.createChannel({
team_id: team.id,
name: ch1Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch1Name,
type: 'O',
});
const ch2Name = `guest-multi-2-${pw.random.id()}`;
const channel2 = await adminClient.createChannel({
team_id: team.id,
name: ch2Name.toLowerCase().replace(/[^a-z0-9-]/g, ''),
display_name: ch2Name,
type: 'O',
});
// # Create a guest user with 2 channel memberships
const multiChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(multiChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, multiChannelGuest.id);
await adminClient.addToChannel(multiChannelGuest.id, channel1.id);
await adminClient.addToChannel(multiChannelGuest.id, channel2.id);
// # Create a guest user with only 1 channel membership
const singleChannelGuest = await adminClient.createUser(await pw.random.user(), '', '');
await adminClient.updateUserRoles(singleChannelGuest.id, 'system_guest');
await adminClient.addToTeam(team.id, singleChannelGuest.id);
await adminClient.addToChannel(singleChannelGuest.id, channel1.id);
// # Log in as admin
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Visit system console
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Go to Users section
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
// # Open the filter popover and filter by multi-channel guests
const filterPopover = await systemConsolePage.users.openFilterPopover();
await filterPopover.filterByRole('Guests in multiple channels');
await filterPopover.save();
await systemConsolePage.users.isLoadingComplete();
// # Search for the multi-channel guest
await systemConsolePage.users.searchUsers(multiChannelGuest.email);
// * Verify the multi-channel guest is visible
await expect(systemConsolePage.users.container.getByText(multiChannelGuest.email)).toBeVisible();
// # Search for the single-channel guest
await systemConsolePage.users.searchUsers(singleChannelGuest.email);
// * Verify the single-channel guest is NOT visible
await expect(systemConsolePage.users.container.getByText('No data')).toBeVisible();
},
);

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {UserProfile} from '@mattermost/types/users';
import type {SystemConsolePage} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
/**
* Delegated (granular) administration roles surfaced in the Manage Roles modal.
* Mirrors DELEGATED_ROLE_NAMES in the manage_roles_modal component.
*/
const USER_MANAGER_ROLE = 'system_user_manager';
const USER_MANAGER_LABEL = 'User Manager';
const SYSTEM_MANAGER_ROLE = 'system_manager';
const SYSTEM_MANAGER_LABEL = 'System Manager';
/**
* Open the Manage Roles modal for the given user from System Console > User Management > Users.
*/
async function openManageRolesModal(systemConsolePage: SystemConsolePage, user: UserProfile) {
await systemConsolePage.goto();
await systemConsolePage.sidebar.users.click();
await systemConsolePage.users.toBeVisible();
await systemConsolePage.users.searchUsers(user.email);
const userRow = systemConsolePage.users.usersTable.getRowByIndex(0);
await expect(userRow.container.getByText(user.email)).toBeVisible();
const actionMenu = await userRow.openActionMenu();
await actionMenu.clickManageRoles();
const {manageRolesModal} = systemConsolePage.users;
await manageRolesModal.toBeVisible();
return manageRolesModal;
}
/**
* Skip when the server license does not enable delegated granular administration.
* The feature requires an Enterprise/Enterprise Advanced license with the LDAP Groups
* feature and is not available on the Entry SKU (see utils/license_utils.ts).
*/
async function skipIfNoDelegatedAdminLicense(adminClient: Client4) {
const license = await adminClient.getClientLicenseOld();
test.skip(
license.IsLicensed !== 'true' || license.LDAPGroups !== 'true' || license.SkuShortName === 'entry',
'Skipping test - server not licensed for delegated granular administration',
);
}
/**
* @objective Verify a delegated administration role can be granted from the Manage Roles modal and persists.
*/
test(
'grants a delegated administration role from the Manage Roles modal',
{tag: ['@system_console', '@user_management']},
async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await skipIfNoDelegatedAdminLicense(adminClient);
// # Login as admin and open the Manage Roles modal for a regular user
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const manageRolesModal = await openManageRolesModal(systemConsolePage, user);
// * Verify the Delegated Administration Roles section is shown for a regular member
await expect(manageRolesModal.delegatedRolesTitle).toBeVisible();
// # Grant the User Manager role and save
const userManagerCheckbox = manageRolesModal.getDelegatedRoleCheckbox(USER_MANAGER_LABEL);
await expect(userManagerCheckbox).not.toBeChecked();
await userManagerCheckbox.check();
await manageRolesModal.save();
// * Verify the role was persisted via the API
const updatedUser = await adminClient.getUser(user.id);
expect(updatedUser.roles).toContain(USER_MANAGER_ROLE);
},
);
/**
* @objective Verify the modal pre-selects delegated administration roles the user already has.
*/
test(
'pre-selects delegated administration roles the user already has',
{tag: ['@system_console', '@user_management']},
async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await skipIfNoDelegatedAdminLicense(adminClient);
// # Grant the System Manager role to the user up front
await adminClient.updateUserRoles(user.id, `system_user ${SYSTEM_MANAGER_ROLE}`);
// # Login as admin and open the Manage Roles modal for that user
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const manageRolesModal = await openManageRolesModal(systemConsolePage, user);
// * Verify the already-granted role is pre-checked and others are not
await expect(manageRolesModal.getDelegatedRoleCheckbox(SYSTEM_MANAGER_LABEL)).toBeChecked();
await expect(manageRolesModal.getDelegatedRoleCheckbox(USER_MANAGER_LABEL)).not.toBeChecked();
},
);
/**
* @objective Verify the Delegated Administration Roles section is hidden when System Admin is selected.
*/
test(
'hides the delegated administration roles section when System Admin is selected',
{tag: ['@system_console', '@user_management']},
async ({pw}) => {
const {adminUser, adminClient, user} = await pw.initSetup();
await skipIfNoDelegatedAdminLicense(adminClient);
// # Login as admin and open the Manage Roles modal for a regular user
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const manageRolesModal = await openManageRolesModal(systemConsolePage, user);
// * Verify the section is initially visible for a member
await expect(manageRolesModal.delegatedRolesTitle).toBeVisible();
// # Promote the account to System Admin within the modal
await manageRolesModal.systemAdminRadio.check();
// * Verify the delegated roles section is hidden since System Admins already have full access
await expect(manageRolesModal.delegatedRolesTitle).not.toBeVisible();
},
);

Some files were not shown because too many files have changed in this diff Show More