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,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);
|
||||
});
|
||||
Reference in New Issue
Block a user