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
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:
@@ -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}});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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} == "--------"`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
1562
e2e-tests/playwright/specs/functional/system_console/abac/support.ts
Normal file
1562
e2e-tests/playwright/specs/functional/system_console/abac/support.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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
Reference in New Issue
Block a user