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,241 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for public-channel ABAC behavior:
|
||||
* - Default channels are not eligible for ABAC policies (channel-settings + team-settings paths)
|
||||
* - A policy on a public channel surfaces matching users a "Recommended" channel entry
|
||||
*
|
||||
* @reference Public-channel ABAC feature
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, getAdminClient, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {enableABACConfig, enableAPIUserDeletion} from '../team_settings/helpers';
|
||||
|
||||
import {
|
||||
CleanupLedger,
|
||||
createPolicyAssignedToChannels,
|
||||
createTrackedAttribute,
|
||||
createTrackedPublicChannel,
|
||||
createTrackedTeamMember,
|
||||
runAccessControlSyncJob,
|
||||
setChannelPolicyActive,
|
||||
waitForJobCompletion,
|
||||
} from './helpers';
|
||||
|
||||
test.describe('ABAC - Public channels', () => {
|
||||
let ledger: CleanupLedger;
|
||||
|
||||
// Enable the permanent-delete API once for the whole describe so the
|
||||
// CleanupLedger's permanentDeleteUser cleanup tasks succeed instead of
|
||||
// spamming "Permanent user deletion feature is not enabled" on teardown.
|
||||
// `pw` is test-scoped, so we use the worker-scoped getAdminClient here.
|
||||
test.beforeAll(async () => {
|
||||
const {adminClient} = await getAdminClient();
|
||||
if (!adminClient) {
|
||||
return;
|
||||
}
|
||||
await enableAPIUserDeletion(adminClient);
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
ledger = new CleanupLedger();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await ledger.drain();
|
||||
});
|
||||
|
||||
test('Default channel hides Membership Policy tab in Channel Settings', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Open Channel Settings on the default (town-square) channel
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Membership Policy tab is NOT rendered for the default channel —
|
||||
// ValidateChannelEligibilityForAccessControl rejects default channels
|
||||
// server-side, so the tab would only let a user assemble unsaveable rules.
|
||||
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('Default channel is excluded from Team Settings policy editor channel selector', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Navigate to Team Settings → Membership Policies → New policy editor
|
||||
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();
|
||||
await teamSettings.container.getByRole('button', {name: 'Add policy'}).click();
|
||||
|
||||
// # Open the channel selector modal
|
||||
await teamSettings.container.getByRole('button', {name: /Add channels/i}).click();
|
||||
const channelSelector = page.locator('.channel-selector-modal');
|
||||
await channelSelector.waitFor();
|
||||
|
||||
// * Both default channels (town-square, off-topic) are absent from the list.
|
||||
// The component contract is `excludeDefaultChannels={true}` — assert the
|
||||
// canonical channel rows by their stable test ids rather than display text.
|
||||
await expect(channelSelector.locator('[data-testid="ChannelRow-town-square"]')).toHaveCount(0);
|
||||
await expect(channelSelector.locator('[data-testid="ChannelRow-off-topic"]')).toHaveCount(0);
|
||||
|
||||
await teamSettings.close();
|
||||
});
|
||||
|
||||
test('Public channel with matching policy surfaces as Recommended for matching user (auto-add off)', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
// # Per-test custom profile attribute. We don't reuse a shared "Department"
|
||||
// field — the server caps CPA fields at 20, and accumulating shared
|
||||
// state across tests has historically saturated that limit and broken
|
||||
// subsequent runs. This field gets cleaned up via the ledger.
|
||||
const attribute = await createTrackedAttribute(adminClient, 'PubAbacDept', ledger);
|
||||
|
||||
// # Public channel + parent policy with rule "<attr> == 'engineering'",
|
||||
// policy assigned to that channel. Auto-add stays OFF (server default
|
||||
// on a freshly-created child policy).
|
||||
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
|
||||
// CEL references the attribute by its field name (lowercased to keep
|
||||
// the identifier characters CEL-safe — uppercase chars in identifiers
|
||||
// are technically valid but the table-editor parser used in the webapp
|
||||
// round-trips through lowercase, so this matches reality).
|
||||
const expression = `user.attributes.${attribute.name} == 'engineering'`;
|
||||
await createPolicyAssignedToChannels(
|
||||
adminClient,
|
||||
`Public Recommend ${Date.now()}`,
|
||||
expression,
|
||||
[publicChannel.id],
|
||||
ledger,
|
||||
);
|
||||
|
||||
// # Matching user — value matches the rule, member of the team but NOT
|
||||
// of the channel. Auto-add is off, so they must NOT be auto-joined;
|
||||
// the channel should instead appear in their Recommended list.
|
||||
const matchingUser = await createTrackedTeamMember(
|
||||
adminClient,
|
||||
team.id,
|
||||
{fieldId: attribute.id, value: 'engineering'},
|
||||
ledger,
|
||||
);
|
||||
|
||||
// * Auto-add OFF contract: the matching user must NOT have been silently
|
||||
// joined to the channel. Verified server-side before any UI flow so a
|
||||
// regression that auto-adds them would fail here, regardless of UI
|
||||
// behavior. The Browse-Channels assertion below proves the channel
|
||||
// *appears as a recommendation* — but only this membership check
|
||||
// distinguishes "recommended (advisory)" from "auto-added".
|
||||
const preMembers = await adminClient.getChannelMembers(publicChannel.id);
|
||||
expect(
|
||||
preMembers.map((m: any) => m.user_id),
|
||||
'matching user must not be auto-joined when auto-add is OFF (advisory recommendation only)',
|
||||
).not.toContain(matchingUser.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(matchingUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open Browse Channels and switch the type filter to "Recommended channels"
|
||||
const browse = await channelsPage.openBrowseChannelsModal();
|
||||
await browse.toBeDoneLoading();
|
||||
|
||||
// The recommended menu item is keyed by `id="channelsMoreDropdownRecommended"`,
|
||||
// gated by the `showRecommendedFilter` prop which is set whenever ABAC is
|
||||
// enabled in client config. Using the stable id avoids brittleness from
|
||||
// the dropdown's localized label.
|
||||
await browse.container.locator('#menuWrapper').click();
|
||||
await page.locator('#channelsMoreDropdownRecommended').click();
|
||||
|
||||
// * The public channel appears in the Recommended results. The row id
|
||||
// is generated by the SearchableChannelList component as
|
||||
// `ChannelRow-${channel.name}` — assert against that, not the display
|
||||
// text, which can be locale-dependent.
|
||||
const channelRow = browse.container.locator(`[data-testid="ChannelRow-${publicChannel.name}"]`);
|
||||
await expect(channelRow).toBeVisible({timeout: 15000});
|
||||
});
|
||||
|
||||
test('Public channel with auto-add ON adds matching users; non-matching members are NOT removed', async ({pw}) => {
|
||||
// The sync job is queued and processed asynchronously — give the test
|
||||
// enough headroom for the queue + evaluation + member writes. Empirically
|
||||
// it finishes in <10s on a quiet dev server, but CI workers are slower.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const attribute = await createTrackedAttribute(adminClient, 'PubAbacAuto', ledger);
|
||||
const publicChannel = await createTrackedPublicChannel(adminClient, team.id, ledger);
|
||||
const expression = `user.attributes.${attribute.name} == 'engineering'`;
|
||||
await createPolicyAssignedToChannels(
|
||||
adminClient,
|
||||
`Public Auto-Add ${Date.now()}`,
|
||||
expression,
|
||||
[publicChannel.id],
|
||||
ledger,
|
||||
);
|
||||
|
||||
// # Auto-add ON: flip the channel-scope (child) policy's Active flag.
|
||||
// The parent default is inactive, and children inherit Active=false at
|
||||
// assign time, so we activate the child directly here. The sync job
|
||||
// only auto-adds members for Active policies.
|
||||
await setChannelPolicyActive(adminClient, publicChannel.id, true);
|
||||
|
||||
// # Two users:
|
||||
// - matching: Department=engineering, in team but NOT in channel.
|
||||
// Sync should auto-add them.
|
||||
// - nonMatching: Department=sales, already a member of the channel.
|
||||
// For PUBLIC channels, ABAC is advisory: sync must NOT
|
||||
// remove them. (For private channels, sync would.)
|
||||
const matching = await createTrackedTeamMember(
|
||||
adminClient,
|
||||
team.id,
|
||||
{fieldId: attribute.id, value: 'engineering'},
|
||||
ledger,
|
||||
);
|
||||
const nonMatching = await createTrackedTeamMember(
|
||||
adminClient,
|
||||
team.id,
|
||||
{fieldId: attribute.id, value: 'sales'},
|
||||
ledger,
|
||||
);
|
||||
await adminClient.addToChannel(nonMatching.id, publicChannel.id);
|
||||
|
||||
// # Trigger sync against this channel-scope policy and wait for it to
|
||||
// reach a terminal state. We pass `publicChannel.id` (the child
|
||||
// policy ID) rather than the parent ID so the sync stays scoped to
|
||||
// this test and ignores any other policies on the dev server.
|
||||
const job = await runAccessControlSyncJob(adminClient, publicChannel.id);
|
||||
const finished = await waitForJobCompletion(adminClient, job.id);
|
||||
expect(finished.status, `sync job did not succeed: ${JSON.stringify(finished)}`).toBe('success');
|
||||
|
||||
// * Membership state via API — no UI flake.
|
||||
const members = await adminClient.getChannelMembers(publicChannel.id);
|
||||
const memberIds = members.map((m: any) => m.user_id);
|
||||
|
||||
expect(memberIds, 'matching user must be auto-added by sync').toContain(matching.id);
|
||||
expect(
|
||||
memberIds,
|
||||
'non-matching member must NOT be removed (public channels are advisory under ABAC)',
|
||||
).toContain(nonMatching.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that a user already in a channel is shown as "Already in channel" (not addable) in the
|
||||
* add-people modal.
|
||||
*/
|
||||
test(
|
||||
'MM-T1809 marks an existing channel member as already in channel in the add people modal',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
const [member] = await adminClient.createUsers(team.id, 1, 'existing');
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Members ${pw.random.id()}`);
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
await adminClient.addToChannel(member.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open the channel members list and the add-people modal
|
||||
const channelMenu = await channelsPage.openChannelMenu();
|
||||
await channelMenu.members.click();
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
await channelsPage.sidebarRight.addMembersButton.click();
|
||||
|
||||
const addModal = channelsPage.getAddPeopleToChannelModal();
|
||||
await addModal.toBeVisible();
|
||||
|
||||
// # Search for the member who is already in the channel (the react-select input is auto-focused)
|
||||
await addModal.search(member.username);
|
||||
|
||||
// * Verify the member is marked as already in the channel
|
||||
await expect(addModal.alreadyInChannelLabel).toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective Channel Settings → Access Control regression test for the
|
||||
* "Test access rule" results for top-level OR rules.
|
||||
* @reference MM-68538
|
||||
*/
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Page} from '@playwright/test';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties_user';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
|
||||
|
||||
const PROGRAM_OPTIONS = [
|
||||
{name: 'Artemis', color: '#FF8800'},
|
||||
{name: 'Helios', color: '#00AAFF'},
|
||||
];
|
||||
|
||||
// CEL the table editor emits for `<field> has any of [Artemis, Helios]`.
|
||||
// See rowToCEL() in webapp/.../table_editor/table_editor.tsx.
|
||||
function buildTwoValueOrExpression(attributeName: string): string {
|
||||
return `("Artemis" in user.attributes.${attributeName} || "Helios" in user.attributes.${attributeName})`;
|
||||
}
|
||||
|
||||
function buildAliceExcludingExpression(attributeName: string): string {
|
||||
return `"Helios" in user.attributes.${attributeName}`;
|
||||
}
|
||||
|
||||
type ProgramField = {
|
||||
id: string;
|
||||
optionIdByName: Record<string, string>;
|
||||
};
|
||||
|
||||
// Create a multiselect CPA field directly via the REST API.
|
||||
async function ensureProgramMultiselectField(adminClient: Client4, fieldName: string): Promise<ProgramField> {
|
||||
const baseRoute = `${adminClient.getBaseRoute()}/custom_profile_attributes/fields`;
|
||||
|
||||
const created: UserPropertyField = await (adminClient as any).doFetch(baseRoute, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: fieldName,
|
||||
type: 'multiselect',
|
||||
attrs: {
|
||||
managed: 'admin',
|
||||
visibility: 'when_set',
|
||||
options: PROGRAM_OPTIONS,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const options: Array<{id: string; name: string}> =
|
||||
((created as any)?.attrs?.options as Array<{id: string; name: string}>) ?? [];
|
||||
const optionIdByName: Record<string, string> = {};
|
||||
for (const opt of options) {
|
||||
optionIdByName[opt.name] = opt.id;
|
||||
}
|
||||
for (const expected of PROGRAM_OPTIONS) {
|
||||
if (!optionIdByName[expected.name]) {
|
||||
throw new Error(
|
||||
`Multiselect field "${fieldName}" was created without an id for option "${expected.name}"; ` +
|
||||
`got: ${JSON.stringify(options)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {id: created.id, optionIdByName};
|
||||
}
|
||||
|
||||
async function createUserWithProgram(
|
||||
adminClient: Client4,
|
||||
programField: ProgramField,
|
||||
program: string[],
|
||||
teamId: string,
|
||||
prefix: string,
|
||||
): Promise<UserProfile> {
|
||||
const password = newTestPassword();
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${prefix}-${id}@sample.mattermost.com`,
|
||||
username: `${prefix}${id}`,
|
||||
password,
|
||||
} as UserProfile,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
user.password = password;
|
||||
|
||||
// Multiselect values must be sent as option IDs not names.
|
||||
const optionIds = program.map((name) => {
|
||||
const optionId = programField.optionIdByName[name];
|
||||
if (!optionId) {
|
||||
throw new Error(`Program option "${name}" not found in field options`);
|
||||
}
|
||||
return optionId;
|
||||
});
|
||||
await adminClient.updateUserCustomProfileAttributesValues(user.id, {[programField.id]: optionIds});
|
||||
|
||||
// Suppress tutorials/onboarding so UI navigation is stable.
|
||||
await adminClient.savePreferences(user.id, [
|
||||
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
|
||||
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
|
||||
]);
|
||||
|
||||
await adminClient.addToTeam(teamId, user.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function createChannelAccessRule(
|
||||
adminClient: Client4,
|
||||
channel: {id: string; display_name: string},
|
||||
expression: string,
|
||||
) {
|
||||
await (adminClient as any).doFetch(`${adminClient.getBaseRoute()}/access_control_policies`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
id: channel.id,
|
||||
name: channel.display_name,
|
||||
type: 'channel',
|
||||
active: false,
|
||||
revision: 1,
|
||||
created_at: Date.now(),
|
||||
rules: [{actions: ['membership'], expression}],
|
||||
imports: [],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function openAccessControlSettings(channelsPage: ChannelsPage) {
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const accessControlTab = channelSettings.container.getByRole('tab', {name: /Membership Policy/i});
|
||||
await expect(accessControlTab).toBeVisible({timeout: 10000});
|
||||
await accessControlTab.click();
|
||||
|
||||
return channelSettings;
|
||||
}
|
||||
|
||||
async function verifyTestAccessRuleDisabled(page: Page) {
|
||||
await expect(page.getByRole('button', {name: /Test access rule/i})).toBeDisabled({timeout: 10000});
|
||||
}
|
||||
|
||||
async function testAccessRuleAndVerifyUser(page: Page, username: string) {
|
||||
const testButton = page.getByRole('button', {name: /Test access rule/i});
|
||||
await expect(testButton).toBeEnabled({timeout: 10000});
|
||||
await testButton.click();
|
||||
|
||||
const modal = page.locator('.TestResultsModal').filter({hasText: 'Access Rule Test Results'});
|
||||
await expect(modal).toBeVisible({timeout: 10000});
|
||||
|
||||
await modal.getByRole('textbox', {name: /Search users/i}).fill(username);
|
||||
await expect(modal.getByText(`@${username}`)).toBeVisible({timeout: 10000});
|
||||
}
|
||||
|
||||
test.describe('Channel Settings → Membership Policy', () => {
|
||||
test('MM-68538 channel admin can test access rule for multiselect "has any of" with multiple values', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Enable ABAC + user-managed attributes so the Membership Policy tab and
|
||||
// the test access rule flow are available.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
});
|
||||
|
||||
// CPA name must be a valid CEL identifier segment; keep alphanumeric + underscore.
|
||||
const programFieldName = `Program_mm68538_${Math.random().toString(36).substring(2, 14)}`;
|
||||
const programField = await ensureProgramMultiselectField(adminClient, programFieldName);
|
||||
|
||||
const aliceExcludingExpression = buildAliceExcludingExpression(programFieldName);
|
||||
const twoValueOrExpression = buildTwoValueOrExpression(programFieldName);
|
||||
|
||||
// Several users matching the OR. Without the fix, SearchUsers ordered
|
||||
// by Users.Id ASC would return whichever of the matching users has the
|
||||
// lowest Id, so populating multiple matches makes the assertion robust
|
||||
// regardless of which Id alice ends up with.
|
||||
const alice = await createUserWithProgram(adminClient, programField, ['Artemis'], team.id, 'alice');
|
||||
await createUserWithProgram(adminClient, programField, ['Helios'], team.id, 'bob');
|
||||
await createUserWithProgram(adminClient, programField, ['Artemis'], team.id, 'carol');
|
||||
|
||||
// Private channel where alice is the channel admin and the only one
|
||||
// with the manage_channel_access_rules permission. Bob/carol exist
|
||||
// purely to populate the OR-matching candidate set.
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `mm68538-${Math.random().toString(36).substring(2, 8)}`,
|
||||
display_name: `MM-68538 ${Math.random().toString(36).substring(2, 6)}`,
|
||||
type: 'P',
|
||||
} as any);
|
||||
await adminClient.addToChannel(alice.id, channel.id);
|
||||
await adminClient.updateChannelMemberSchemeRoles(channel.id, alice.id, true, true);
|
||||
|
||||
await createChannelAccessRule(adminClient, channel, aliceExcludingExpression);
|
||||
|
||||
// Alice can open Channel Settings → Membership Policy as channel admin
|
||||
// and test the existing rule through the same UI users exercise.
|
||||
const {page} = await pw.testBrowser.login(alice);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const excludingRuleSettings = await openAccessControlSettings(channelsPage);
|
||||
await verifyTestAccessRuleDisabled(page);
|
||||
await excludingRuleSettings.close();
|
||||
|
||||
await createChannelAccessRule(adminClient, channel, twoValueOrExpression);
|
||||
|
||||
const includingRuleSettings = await openAccessControlSettings(channelsPage);
|
||||
await testAccessRuleAndVerifyUser(page, alice.username);
|
||||
|
||||
await includingRuleSettings.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// 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 multi-line channel header is truncated in the header bar, that hovering it opens a
|
||||
* popover with the full header, that a markdown link in the header points to the correct URL, and that the
|
||||
* popover closes.
|
||||
*/
|
||||
test(
|
||||
'MM-T879 opens and closes the channel header popover with a working link',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Header ${pw.random.id()}`);
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Set a multi-line channel header that contains a markdown link, via Channel Settings
|
||||
const header = ['Header text line one', '', '- Two', '- [Google](https://google.com/)', '- Four'].join('\n');
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const infoSettings = await channelSettings.openInfoTab();
|
||||
await infoSettings.updateHeader(header);
|
||||
await channelSettings.save();
|
||||
await channelSettings.close();
|
||||
|
||||
// * Verify the header bar shows the first line of the header
|
||||
const headerText = channelsPage.centerView.header.container.getByText('Header text line one');
|
||||
await expect(headerText).toBeVisible();
|
||||
|
||||
// # Hover the header text to open the popover. Force the hover because opening the popover mounts a
|
||||
// full-screen floating overlay that would otherwise be reported as intercepting the pointer.
|
||||
await headerText.hover({force: true});
|
||||
|
||||
// * Verify the popover opens with the full header and a link to the correct URL
|
||||
const popover = page.getByRole('tooltip');
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(popover.getByText('Four')).toBeVisible();
|
||||
const link = popover.getByRole('link', {name: 'Google'});
|
||||
await expect(link).toHaveAttribute('href', 'https://google.com/');
|
||||
|
||||
// # Move the pointer away from the header to dismiss the popover
|
||||
await page.mouse.move(400, 500);
|
||||
await channelsPage.centerView.postCreate.input.hover({force: true});
|
||||
|
||||
// * Verify the popover closes
|
||||
await expect(popover).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify a channel header can be added from the "Set header" link in a new channel's intro,
|
||||
* that cancelling the modal makes no change, and that the saved header appears in the channel info.
|
||||
*/
|
||||
test('MM-T880 adds a channel header from the intro Set header link', {tag: '@channel_settings'}, async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Create a new public channel so its intro (with the "Set header" link) is shown
|
||||
const channelName = `channel header ${pw.random.id()}`;
|
||||
await channelsPage.newChannel(channelName, 'O');
|
||||
await channelsPage.centerView.header.toHaveTitle(channelName);
|
||||
|
||||
// # Open the Edit Header modal from the intro and cancel without entering text
|
||||
await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click();
|
||||
await channelsPage.editChannelHeaderModal.toBeVisible();
|
||||
await channelsPage.editChannelHeaderModal.cancel();
|
||||
|
||||
// # Reopen the Edit Header modal and save a header
|
||||
const header = 'this is the channel header';
|
||||
await channelsPage.centerView.channelIntro.getByRole('button', {name: 'Set header'}).click();
|
||||
await channelsPage.editChannelHeaderModal.toBeVisible();
|
||||
await channelsPage.editChannelHeaderModal.setHeader(header);
|
||||
|
||||
// * Verify the header-change system message is posted
|
||||
await channelsPage.centerView.waitUntilLastPostContains(`updated the channel header to: ${header}`);
|
||||
|
||||
// # Open the channel info panel from the channel header menu
|
||||
const channelMenu = await channelsPage.openChannelMenu();
|
||||
await channelMenu.viewInfo.click();
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
|
||||
// * Verify the saved header appears in the channel info panel
|
||||
await expect(channelsPage.sidebarRight.container.getByText(header)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective E2E coverage for the v0.4 Permissions Policy tab in Channel Settings:
|
||||
* - Tab visibility is gated by ABAC + license + the PermissionPolicies
|
||||
* umbrella + the ChannelPermissionPolicies sub-flag.
|
||||
* - The list view exposes Add rule, search, and a paginated rules table.
|
||||
* - Adding a permission rule (name, role, actions) and committing returns to the list.
|
||||
* - Per-rule expression uses the same TableEditor as Membership Policy, but
|
||||
* re-labelled "Simulate rules" — the button opens the dual-lane
|
||||
* SimulateAccessModal instead of the legacy expression-only one (additionally
|
||||
* gated by the PolicySimulation feature flag).
|
||||
* - Duplicate rule names surface a save-time error.
|
||||
*
|
||||
* @reference Channel-scoped permission policies (v0.4)
|
||||
*
|
||||
* These tests skip themselves at runtime when the PermissionPolicies umbrella
|
||||
* OR the ChannelPermissionPolicies sub-flag is not enabled on the server —
|
||||
* the tab is invisible in either case and the workflow is not exercised.
|
||||
* Run with `MM_FEATUREFLAGS_PERMISSIONPOLICIES=true` AND
|
||||
* `MM_FEATUREFLAGS_CHANNELPERMISSIONPOLICIES=true`. The "Simulate rules"
|
||||
* button additionally requires `MM_FEATUREFLAGS_POLICYSIMULATION=true`.
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {enableABACConfig, ensureDepartmentAttribute, createPrivateChannel} from '../team_settings/helpers';
|
||||
|
||||
test.describe('Channel Settings Modal - Permissions Policy tab (v0.4)', () => {
|
||||
test.beforeEach(async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
// Skip the suite when either flag is OFF on the server rather
|
||||
// than relying on the tab's UI presence as a proxy — a
|
||||
// UI-based guard would silently mask a regression in the tab
|
||||
// visibility logic itself. Both flags must be on for the tab
|
||||
// to render (the channel-scope sub-flag depends on the
|
||||
// umbrella, mirroring `IsChannelPermissionPoliciesEnabled` on
|
||||
// the server).
|
||||
await pw.skipIfFeatureFlagNotSet('PermissionPolicies', true);
|
||||
await pw.skipIfFeatureFlagNotSet('ChannelPermissionPolicies', true);
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c1 Permissions Policy tab visible on private channel when feature flag enabled', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
|
||||
// Suite-level feature-flag guard already covers PermissionPolicies;
|
||||
// assert visibility unconditionally so a regression in the tab's
|
||||
// render gate would surface as a test failure.
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
|
||||
// # Open Permissions Policy
|
||||
await permissionsTab.click();
|
||||
|
||||
// * The list view renders: header, search, table, Add rule button.
|
||||
await expect(channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-add-rule')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-search')).toBeVisible();
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-rules-table')).toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c2 Add rule opens the editor with sections in the expected order, Cancel returns to the list', async ({
|
||||
pw,
|
||||
}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Click "Add rule" → editor view appears
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
const editor = channelSettings.container.getByTestId('permissions-policy-editor');
|
||||
await expect(editor).toBeVisible({timeout: 5000});
|
||||
|
||||
// * Mirror system-console layout: name → role → user-attribute conditions → permissions list.
|
||||
const expressionSection = editor.getByTestId('permissions-policy-editor-expression-section');
|
||||
const permissionsSection = editor.getByTestId('permissions-policy-editor-permissions-section');
|
||||
await expect(expressionSection).toBeVisible();
|
||||
await expect(permissionsSection).toBeVisible();
|
||||
|
||||
// Permissions list must render BELOW the user-attribute conditions
|
||||
// (TableEditor) so we match the System Console policy editor ordering.
|
||||
const expressionBox = await expressionSection.boundingBox();
|
||||
const permissionsBox = await permissionsSection.boundingBox();
|
||||
expect(expressionBox).not.toBeNull();
|
||||
expect(permissionsBox).not.toBeNull();
|
||||
expect(permissionsBox?.y ?? 0).toBeGreaterThan((expressionBox?.y ?? 0) + (expressionBox?.height ?? 0) - 1);
|
||||
|
||||
// # Cancel → back to list view
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-cancel').click();
|
||||
await expect(tab).toBeVisible({timeout: 5000});
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-PP_v0_4_c3 Editor validates: missing name surfaces an inline error', async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const permissionsTab = channelSettings.container.getByTestId('permissions_policy-tab-button');
|
||||
await expect(permissionsTab).toBeVisible();
|
||||
await permissionsTab.click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__permissionsPolicyTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Open the editor without filling the name
|
||||
await tab.getByTestId('permissions-policy-add-rule').click();
|
||||
await channelSettings.container.getByTestId('permissions-policy-editor-save').click();
|
||||
|
||||
// * Inline error mentions name uniqueness/required.
|
||||
await expect(channelSettings.container.getByTestId('permissions-policy-editor-error')).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective E2E tests for the Membership Policy tab (access_rules) in Channel Settings Modal
|
||||
* @reference MM-67326 — public and private channels can carry ABAC membership policies
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
enableABACConfig,
|
||||
ensureDepartmentAttribute,
|
||||
createParentPolicy,
|
||||
assignChannelsToPolicy,
|
||||
createPrivateChannel,
|
||||
createGroupConstrainedPrivateChannel,
|
||||
setUserAttribute,
|
||||
addAttributeRule,
|
||||
createTeamAdmin,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../team_settings/helpers';
|
||||
|
||||
import {waitForJobCompletion} from './helpers';
|
||||
|
||||
/** Unique CPA value so only users this test sets match the rule (avoids clashing with leftover Engineering users on the server). */
|
||||
function uniqueDepartmentValue(testId: string): string {
|
||||
return `E2E-${testId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
test.describe('Channel Settings Modal - Access Control Tab', () => {
|
||||
test('MM-67326_c1 Access Control tab visible for admin on private channel with ABAC enabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Access Control tab is visible
|
||||
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c2 Access Control tab hidden when ABAC disabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// Explicitly disable ABAC. initSetup() resets to the default config which has
|
||||
// EnableAttributeBasedAccessControl:true (required by the ABAC test suite baseline),
|
||||
// so we must patch it off. Concurrent tests in other files also call enableABACConfig()
|
||||
// and may race to re-enable it before this modal opens.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// Disable ABAC once more right before the modal opens to shrink the race window.
|
||||
await adminClient.patchConfig({
|
||||
AccessControlSettings: {EnableAttributeBasedAccessControl: false},
|
||||
});
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Access Control tab is NOT visible
|
||||
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c3 Membership Policy tab visible for admin on public channel with ABAC enabled', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await adminClient.createPublicChannel(team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Membership Policy tab is visible on public channels when ABAC is enabled (not group-constrained / not default)
|
||||
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c4 Access Control tab hidden for group-constrained channel', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
|
||||
const channel = await createGroupConstrainedPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// * Access Control tab is NOT visible for group-constrained channel
|
||||
await expect(channelSettings.container.getByTestId('access_rules-tab-button')).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c5 Auto-add checkbox disabled when no rules defined', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Auto-add checkbox is disabled — no rules or system policies defined
|
||||
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
|
||||
await expect(autoAddCheckbox).toBeDisabled();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c6 System policy banner visible when parent policy is applied to channel', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Create a parent (team-level) policy and assign this channel to it
|
||||
const policy = await createParentPolicy(adminClient, `Parent Policy ${Date.now()}`);
|
||||
await assignChannelsToPolicy(adminClient, policy.id, [channel.id]);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// * System policy banner is visible
|
||||
await expect(tab.locator('.ChannelSettingsModal__systemPolicies')).toBeVisible({timeout: 10000});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c7 Add attribute rule and save without auto-add', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Admin's Department must satisfy the rule (self-exclusion check)
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add an attribute rule: Department == Engineering
|
||||
await addAttributeRule(tab, page, 'Engineering');
|
||||
|
||||
// # Save (auto-add remains off — no confirmation modal for first-time rules with no member changes)
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
// * SaveChangesPanel disappears — rules were saved
|
||||
await expect(saveBtn).not.toBeVisible({timeout: 15000});
|
||||
|
||||
// The dialog may auto-close after save or the Close button may take a moment to stabilise
|
||||
// after the panel removal re-render. Only close if the dialog is still open.
|
||||
const isOpen = await channelSettings.container.isVisible({timeout: 2000}).catch(() => false);
|
||||
if (isOpen) {
|
||||
await channelSettings.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-67326_c8 Auto-add checkbox becomes enabled after adding an attribute rule', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Checkbox starts disabled — no rules or system policies
|
||||
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
|
||||
await expect(autoAddCheckbox).toBeDisabled();
|
||||
|
||||
// # Add an attribute rule
|
||||
await addAttributeRule(tab, page, 'Engineering');
|
||||
|
||||
// * Checkbox is now enabled
|
||||
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c9 Auto-add members: user matching rule is added to channel via sync job', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const departmentValue = uniqueDepartmentValue('c9');
|
||||
|
||||
// # Admin's Department satisfies the rule (self-exclusion check passes)
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
|
||||
|
||||
// # Private channel — admin is the creator and only member
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Target user: in the team, same Department, NOT yet in the channel
|
||||
const targetUser = await createTeamAdmin(adminClient, team.id);
|
||||
await setUserAttribute(adminClient, targetUser.id, 'Department', departmentValue);
|
||||
|
||||
// Save will run validateExpressionAgainstRequester and calculateMembershipChanges,
|
||||
// both of which query the Postgres materialized AttributeView. The enterprise
|
||||
// access-control service gates view refreshes to once per ~30s, so the brand-new
|
||||
// unique CPA value above is not yet visible to CEL queries. Without this wait,
|
||||
// Save hits the self-exclusion modal (admin appears unmatched against the rule
|
||||
// they just satisfied via the API) and the confirmation modal never opens.
|
||||
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
|
||||
adminUser.id,
|
||||
targetUser.id,
|
||||
]);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add attribute rule (unique value → preview lists only targetUser to add)
|
||||
await addAttributeRule(tab, page, departmentValue);
|
||||
|
||||
// * Unsaved changes must be committed before save; otherwise handleSave can skip the confirmation path
|
||||
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Enable auto-add members
|
||||
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
|
||||
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
|
||||
await autoAddCheckbox.click();
|
||||
await expect(autoAddCheckbox).toBeChecked();
|
||||
|
||||
// # Save — confirmation modal appears because targetUser will be added
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
// # Confirm in the membership changes modal
|
||||
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
|
||||
await confirmModal.waitFor({state: 'visible', timeout: 30000});
|
||||
await confirmModal.getByRole('button', {name: /Save and apply/}).click();
|
||||
await confirmModal.waitFor({state: 'hidden', timeout: 15000});
|
||||
|
||||
// * Poll until target user appears as a channel member (sync job runs asynchronously)
|
||||
let isMember = false;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await adminClient.getChannelMember(channel.id, targetUser.id);
|
||||
isMember = true;
|
||||
break;
|
||||
} catch {
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
expect(isMember).toBe(true);
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c10 Self-exclusion validation prevents saving rule that excludes current user', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Admin is in Engineering — rule requires Sales, so admin would be excluded
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add a rule that excludes the admin: Department == Sales
|
||||
await addAttributeRule(tab, page, 'Sales');
|
||||
|
||||
// # Attempt to save
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
// * Self-exclusion error modal appears
|
||||
const selfExclusionModal = page.locator('#confirmModal').filter({hasText: 'Cannot save access rules'});
|
||||
await expect(selfExclusionModal).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Dismiss the modal to go back to editing
|
||||
await selfExclusionModal.getByRole('button', {name: 'Back to editing'}).click();
|
||||
await expect(selfExclusionModal).not.toBeVisible();
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c11 Unsaved changes — closing modal without saving keeps it open on first click', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add a rule to create unsaved changes
|
||||
await addAttributeRule(tab, page, 'Engineering');
|
||||
|
||||
// * SaveChangesPanel is visible — there are unsaved changes
|
||||
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 5000});
|
||||
|
||||
// # First close click — modal stays open (unsaved-changes two-step close)
|
||||
await channelSettings.closeButton.click();
|
||||
await expect(channelSettings.container).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Second click — modal closes
|
||||
await channelSettings.closeButton.click();
|
||||
await expect(channelSettings.container).not.toBeVisible({timeout: 30000});
|
||||
});
|
||||
|
||||
test('MM-67326_c12 View users — Restricted tab shows member count and user when rule removes a channel member', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Admin satisfies the rule
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', 'Engineering');
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Add a second member who will NOT satisfy the rule (no Department attribute)
|
||||
const memberToRemove = await createTeamAdmin(adminClient, team.id);
|
||||
await adminClient.addToChannel(memberToRemove.id, channel.id);
|
||||
// memberToRemove has no Department set → will not match "Department == Engineering"
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add rule: Department == Engineering (memberToRemove doesn't match → will be removed)
|
||||
await addAttributeRule(tab, page, 'Engineering');
|
||||
|
||||
// # Click Save — confirmation modal appears because memberToRemove will be removed
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
|
||||
await confirmModal.waitFor({state: 'visible', timeout: 30000});
|
||||
|
||||
// * Summary message shows 0 users added and 1 member removed
|
||||
await expect(confirmModal).toContainText('remove 1 current channel member');
|
||||
|
||||
// # Click "View users" to open the detailed user list
|
||||
await confirmModal.getByRole('button', {name: 'View users'}).click();
|
||||
|
||||
// * "Restricted (1)" tab is visible — one member will be removed
|
||||
await expect(confirmModal.getByRole('button', {name: /Restricted \(1\)/})).toBeVisible({timeout: 5000});
|
||||
|
||||
// # Switch to the Restricted tab
|
||||
await confirmModal.getByRole('button', {name: /Restricted \(1\)/}).click();
|
||||
|
||||
// * memberToRemove's username appears in the restricted list
|
||||
await expect(confirmModal).toContainText(memberToRemove.username, {timeout: 5000});
|
||||
|
||||
// # Cancel — don't actually apply
|
||||
await confirmModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await confirmModal.waitFor({state: 'hidden', timeout: 10000});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c13 View users — Allowed tab shows member count and user when auto-add brings new members', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const departmentValue = uniqueDepartmentValue('c13');
|
||||
|
||||
// # Admin satisfies the rule
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
|
||||
|
||||
// # Private channel — admin is the only member
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Target user: in the team, same Department, NOT yet in the channel
|
||||
const memberToAdd = await createTeamAdmin(adminClient, team.id);
|
||||
await setUserAttribute(adminClient, memberToAdd.id, 'Department', departmentValue);
|
||||
|
||||
// See c9: wait for the materialized AttributeView to surface admin and
|
||||
// memberToAdd as matching the freshly-written CPA value before clicking Save.
|
||||
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
|
||||
adminUser.id,
|
||||
memberToAdd.id,
|
||||
]);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add rule (unique value → only memberToAdd appears in "to add" with this server data)
|
||||
await addAttributeRule(tab, page, departmentValue);
|
||||
await expect(tab.locator('[data-testid="SaveChangesPanel__save-btn"]')).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Enable auto-add so memberToAdd appears in the "to add" list
|
||||
const autoAddCheckbox = tab.locator('#autoSyncMembersCheckbox');
|
||||
await expect(autoAddCheckbox).toBeEnabled({timeout: 5000});
|
||||
await autoAddCheckbox.click();
|
||||
await expect(autoAddCheckbox).toBeChecked();
|
||||
|
||||
// # Click Save — confirmation modal appears because memberToAdd will be added
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
|
||||
await confirmModal.waitFor({state: 'visible', timeout: 30000});
|
||||
|
||||
// * Summary shows 0 members removed (no one in this channel lacks the attribute)
|
||||
await expect(confirmModal).toContainText('remove 0 current channel members');
|
||||
|
||||
// # Click "View users" to open the detailed user list
|
||||
await confirmModal.getByRole('button', {name: 'View users'}).click();
|
||||
|
||||
// * Allowed tab is visible with at least one user (memberToAdd)
|
||||
await expect(confirmModal.locator('.ChannelAccessRulesConfirmModal__tab', {hasText: /Allowed/})).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
// # The Allowed tab is active by default — verify memberToAdd's username is shown (unique Dept avoids other matches)
|
||||
await expect(confirmModal).toContainText(memberToAdd.username, {timeout: 5000});
|
||||
|
||||
// # Cancel — don't actually apply
|
||||
await confirmModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await confirmModal.waitFor({state: 'hidden', timeout: 10000});
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
test('MM-67326_c14 Applying access rules removes non-matching member from channel and RHS members', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
await enableABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
const departmentValue = uniqueDepartmentValue('c14');
|
||||
|
||||
// # Admin satisfies the rule
|
||||
await setUserAttribute(adminClient, adminUser.id, 'Department', departmentValue);
|
||||
|
||||
const channel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Existing member does not satisfy the rule and should be removed when it is applied
|
||||
const memberToRemove = await createTeamAdmin(adminClient, team.id);
|
||||
await setUserAttribute(adminClient, memberToRemove.id, 'Department', `${departmentValue}-other`);
|
||||
await adminClient.addToChannel(memberToRemove.id, channel.id);
|
||||
|
||||
// See c9: wait for the materialized AttributeView to surface the admin's
|
||||
// freshly-written CPA value before clicking Save.
|
||||
await waitForAttributeViewToInclude(adminClient, `user.attributes.Department == "${departmentValue}"`, [
|
||||
adminUser.id,
|
||||
]);
|
||||
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
|
||||
// # Navigate to Access Control tab
|
||||
await channelSettings.container.getByTestId('access_rules-tab-button').click();
|
||||
|
||||
const tab = channelSettings.container.locator('.ChannelSettingsModal__accessRulesTab');
|
||||
await expect(tab).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Add rule (unique value -> only admin remains allowed in the private channel)
|
||||
await addAttributeRule(tab, page, departmentValue);
|
||||
|
||||
// # Click Save - confirmation modal appears because memberToRemove will be removed
|
||||
const saveBtn = tab.locator('[data-testid="SaveChangesPanel__save-btn"]');
|
||||
await expect(saveBtn).toBeEnabled({timeout: 10000});
|
||||
await saveBtn.click();
|
||||
|
||||
const confirmModal = page.locator('#channel-access-rules-confirm-modal');
|
||||
await confirmModal.waitFor({state: 'visible', timeout: 30000});
|
||||
|
||||
// * Summary message shows 0 users added and 1 member removed
|
||||
await expect(confirmModal).toContainText('remove 1 current channel member');
|
||||
|
||||
// # Confirm and wait for the access-control sync job that applies the removal
|
||||
const [syncJobResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v4/jobs') && response.request().method() === 'POST',
|
||||
{timeout: 10000},
|
||||
),
|
||||
confirmModal.getByRole('button', {name: 'Save'}).click(),
|
||||
]);
|
||||
await confirmModal.waitFor({state: 'hidden', timeout: 15000});
|
||||
|
||||
if (!syncJobResponse.ok()) {
|
||||
throw new Error(`Failed to create access-control sync job: ${syncJobResponse.status()}`);
|
||||
}
|
||||
const syncJob = await syncJobResponse.json();
|
||||
const syncJobId = syncJob.id as string;
|
||||
const finished = await waitForJobCompletion(adminClient, syncJobId, {timeoutMs: 90_000});
|
||||
expect(finished.status, `sync job did not succeed: ${JSON.stringify(finished)}`).toBe('success');
|
||||
|
||||
// * Poll until memberToRemove is no longer a channel member
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
await adminClient.getChannelMember(channel.id, memberToRemove.id);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 15000,
|
||||
intervals: [500, 1000, 1000, 2000],
|
||||
message: `${memberToRemove.username} should be removed from the channel`,
|
||||
},
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
await channelSettings.close();
|
||||
|
||||
// * The ABAC removal system message is the last visible post in the channel
|
||||
await channelsPage.centerView.waitUntilLastPostContains('was removed from the channel', 30000);
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await expect(lastPost.container).toContainText(memberToRemove.username);
|
||||
await expect(lastPost.container).toContainText('was removed from the channel');
|
||||
|
||||
// # Open channel members RHS from the affected channel
|
||||
await channelsPage.centerView.header.openChannelMenu();
|
||||
await page.locator('#channelMembers').click();
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
|
||||
// * Admin remains in the RHS members list, and removed member is absent
|
||||
await expect(page.getByTestId(`memberline-${adminUser.id}`)).toBeVisible({timeout: 10000});
|
||||
await expect(page.getByTestId(`memberline-${memberToRemove.id}`)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that creating and archiving a public channel in one browser session is synced in
|
||||
* real time to another session of the same user.
|
||||
*/
|
||||
test('MM-T837 syncs public channel create and archive across sessions', {tag: '@channel_settings'}, async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
|
||||
// # Log in as the same user in two separate browser sessions
|
||||
const {channelsPage: firstSession} = await pw.testBrowser.login(user);
|
||||
await firstSession.goto(team.name, 'town-square');
|
||||
await firstSession.toBeVisible();
|
||||
|
||||
const {channelsPage: secondSession} = await pw.testBrowser.login(user);
|
||||
await secondSession.goto(team.name, 'town-square');
|
||||
await secondSession.toBeVisible();
|
||||
|
||||
// # Create a public channel in the first session
|
||||
const channelName = `RF ${pw.random.id()}`;
|
||||
await firstSession.newChannel(channelName, 'O');
|
||||
await firstSession.centerView.header.toHaveTitle(channelName);
|
||||
|
||||
// * Verify the new channel appears in the sidebar of both sessions
|
||||
await expect(firstSession.sidebarLeft.container.getByText(channelName)).toBeVisible();
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible();
|
||||
|
||||
// # Archive the channel in the first session, then navigate away from the archived channel
|
||||
await firstSession.archiveChannel();
|
||||
await firstSession.sidebarLeft.goToItem('town-square');
|
||||
|
||||
// * Verify the channel is removed from the sidebar of both sessions
|
||||
await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that leaving a channel in one browser session removes it from the sidebar of another
|
||||
* session that is viewing a different channel, without disrupting that other session's current view.
|
||||
*/
|
||||
test(
|
||||
'MM-T892 leaving a channel syncs to another session viewing a different channel',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
|
||||
// # Log in as the same user in two separate browser sessions
|
||||
const {channelsPage: firstSession} = await pw.testBrowser.login(user);
|
||||
await firstSession.goto(team.name, 'town-square');
|
||||
await firstSession.toBeVisible();
|
||||
|
||||
const {channelsPage: secondSession} = await pw.testBrowser.login(user);
|
||||
await secondSession.goto(team.name, 'town-square');
|
||||
await secondSession.toBeVisible();
|
||||
|
||||
// # Create a public channel in the first session
|
||||
const channelName = `Leave ${pw.random.id()}`;
|
||||
await firstSession.newChannel(channelName, 'O');
|
||||
await firstSession.centerView.header.toHaveTitle(channelName);
|
||||
|
||||
// * Verify the new channel appears in the second session's sidebar
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible();
|
||||
|
||||
// # Move the second session to a different channel
|
||||
await secondSession.sidebarLeft.goToItem('off-topic');
|
||||
await secondSession.centerView.header.toHaveTitle('Off-Topic');
|
||||
|
||||
// # Leave the channel from the first session while still viewing it
|
||||
const channelMenu = await firstSession.openChannelMenu();
|
||||
await channelMenu.leaveChannel.click();
|
||||
|
||||
// * Verify the first session is redirected to the default channel and the channel is gone from its sidebar
|
||||
await firstSession.centerView.header.toHaveTitle('Town Square');
|
||||
await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
|
||||
// * Verify the second session stays on its current channel and the left channel is removed from its sidebar
|
||||
await secondSession.centerView.header.toHaveTitle('Off-Topic');
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that leaving a channel in one session, while another session of the same user is
|
||||
* viewing that same channel, redirects both sessions to the default channel and removes it from both sidebars.
|
||||
*
|
||||
* MM-T2554 covers the same behavior as MM-T2549 and is covered by this test.
|
||||
*/
|
||||
test(
|
||||
'MM-T2549 MM-T2554 leaving a viewed channel redirects the other session viewing it to Town Square',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
|
||||
// # Log in as the same user in two separate browser sessions
|
||||
const {channelsPage: firstSession} = await pw.testBrowser.login(user);
|
||||
await firstSession.goto(team.name, 'town-square');
|
||||
await firstSession.toBeVisible();
|
||||
|
||||
const {channelsPage: secondSession} = await pw.testBrowser.login(user);
|
||||
await secondSession.goto(team.name, 'town-square');
|
||||
await secondSession.toBeVisible();
|
||||
|
||||
// # Create a public channel in the first session
|
||||
const channelName = `Leave ${pw.random.id()}`;
|
||||
await firstSession.newChannel(channelName, 'O');
|
||||
await firstSession.centerView.header.toHaveTitle(channelName);
|
||||
|
||||
// # Bring the second session to the same channel
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).toBeVisible();
|
||||
await secondSession.sidebarLeft.container.getByText(channelName).click();
|
||||
await secondSession.centerView.header.toHaveTitle(channelName);
|
||||
|
||||
// # Leave the channel from the first session while both sessions are viewing it
|
||||
const channelMenu = await firstSession.openChannelMenu();
|
||||
await channelMenu.leaveChannel.click();
|
||||
|
||||
// * Verify the first session is redirected to Town Square and the channel is gone from its sidebar
|
||||
await firstSession.centerView.header.toHaveTitle('Town Square');
|
||||
await expect(firstSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
|
||||
// * Verify the second session is also redirected to Town Square and the channel is removed from its sidebar
|
||||
await secondSession.centerView.header.toHaveTitle('Town Square');
|
||||
await expect(secondSession.sidebarLeft.container.getByText(channelName)).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
const DEFAULT_CHANNEL_URL_LOCKED_TEXT = 'The URL of the default channel cannot be changed.';
|
||||
|
||||
/**
|
||||
* @objective Verify that Channel Settings does not offer a URL edit for the default channel, whose URL the
|
||||
* server always rejects, while its display name can still be saved.
|
||||
* @reference MM-67612
|
||||
*/
|
||||
test(
|
||||
'does not offer a URL edit for the default channel but still saves its display name',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open the Channel Settings modal on the Info tab
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const infoSettings = await channelSettings.openInfoTab();
|
||||
|
||||
// * Verify the URL is shown read-only, with no way to edit it and an explanation why
|
||||
await expect(infoSettings.urlLabel).toContainText('town-square');
|
||||
await expect(infoSettings.urlEditButton).not.toBeVisible();
|
||||
await expect(infoSettings.urlInput).not.toBeVisible();
|
||||
await expect(infoSettings.container.getByText(DEFAULT_CHANNEL_URL_LOCKED_TEXT)).toBeVisible();
|
||||
|
||||
// # Rename the display name, which the server does allow on the default channel
|
||||
await infoSettings.updateName('Company Wide');
|
||||
await channelSettings.save();
|
||||
|
||||
// * Verify the save succeeded: the save panel disappears rather than showing a form error
|
||||
await expect(channelSettings.saveButton).not.toBeVisible();
|
||||
await channelSettings.close();
|
||||
await channelsPage.centerView.header.toHaveTitle('Company Wide');
|
||||
|
||||
// * Verify the server kept the default channel URL and applied the new display name
|
||||
const channel = await adminClient.getChannelByName(team.id, 'town-square');
|
||||
expect(channel.name).toBe('town-square');
|
||||
expect(channel.display_name).toBe('Company Wide');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that an ordinary channel is unaffected by the default-channel restriction and its URL
|
||||
* can still be changed from Channel Settings.
|
||||
* @reference MM-67612
|
||||
*/
|
||||
test('allows the URL of an ordinary channel to be changed', {tag: '@channel_settings'}, async ({pw}) => {
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
const createdChannel = await adminClient.createPublicChannel(team.id, 'Ordinary Channel');
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, createdChannel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open the Channel Settings modal on the Info tab
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const infoSettings = await channelSettings.openInfoTab();
|
||||
|
||||
// * Verify an ordinary channel is not affected by the default-channel restriction
|
||||
await expect(infoSettings.container.getByText(DEFAULT_CHANNEL_URL_LOCKED_TEXT)).not.toBeVisible();
|
||||
await expect(infoSettings.urlEditButton).toBeVisible();
|
||||
|
||||
// # Change the channel URL and save
|
||||
const updatedUrl = `renamed-${pw.random.id()}`;
|
||||
await infoSettings.updateUrl(updatedUrl);
|
||||
await channelSettings.save();
|
||||
|
||||
// * Verify the save succeeded: the save panel disappears rather than showing a form error
|
||||
await expect(channelSettings.saveButton).not.toBeVisible();
|
||||
await channelSettings.close();
|
||||
|
||||
const channel = await adminClient.getChannel(createdChannel.id);
|
||||
expect(channel.name).toBe(updatedUrl);
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* Lean helpers for the public-channel ABAC E2E specs.
|
||||
*
|
||||
* Design rules:
|
||||
* - Reuse the existing primitives in `../team_settings/helpers.ts` for
|
||||
* channel/policy/user creation and configuration. This file only adds
|
||||
* thin wrappers that register cleanup as resources are created.
|
||||
* - All test-created resources are tracked in a `CleanupLedger`. The spec's
|
||||
* `test.afterEach` drains the ledger so resources are removed whether the
|
||||
* test passed, failed, or threw mid-flight.
|
||||
* - Cleanup is best-effort: individual failures are swallowed so one stale
|
||||
* resource cannot block deletion of the others or mask the test result.
|
||||
* - Custom profile attributes are scoped per test (unique names + cleanup).
|
||||
* Tests must not lean on a shared `Department` field — the server caps CPA
|
||||
* fields at 20, and accumulating shared state has historically saturated
|
||||
* that limit and broken every subsequent run.
|
||||
*/
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
import {newTestPassword, getRandomId, type PlaywrightClient4} from '@mattermost/playwright-lib';
|
||||
|
||||
import {assignChannelsToPolicy, deletePolicy, unassignChannelsFromPolicy} from '../team_settings/helpers';
|
||||
|
||||
export type CleanupTask = () => Promise<unknown>;
|
||||
|
||||
export class CleanupLedger {
|
||||
private tasks: CleanupTask[] = [];
|
||||
|
||||
add(task: CleanupTask): void {
|
||||
// LIFO: latest registrations run first so deletions respect dependency
|
||||
// order (e.g. unassign channels from a policy before deleting the policy).
|
||||
this.tasks.unshift(task);
|
||||
}
|
||||
|
||||
async drain(): Promise<void> {
|
||||
const tasks = this.tasks;
|
||||
this.tasks = [];
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
try {
|
||||
await tasks[i]();
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const stack = e instanceof Error ? e.stack : undefined;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`CleanupLedger.drain: cleanup task ${i} failed`, message, stack ?? '');
|
||||
// Swallow — cleanup is best-effort and must not mask the test
|
||||
// result or stop subsequent cleanups from running.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a server request and surface the response body on non-2xx so the test
|
||||
* report shows *what* the server rejected, not just "failed". The shipped
|
||||
* helpers in `team_settings/helpers.ts` swallow these — useful in steady
|
||||
* state, brutal when something legitimately broke (e.g. CPA field-limit
|
||||
* exhaustion silently masquerading as a "field not found" error).
|
||||
*/
|
||||
async function doFetchOrThrow(client: Client4, url: string, init: RequestInit & {body?: string}): Promise<any> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${client.getToken()}`,
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`${init.method} ${url} -> ${response.status}: ${text}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently delete a user via the admin API.
|
||||
* Permanent deletion requires `ServiceSettings.EnableAPIUserDeletion` to be on
|
||||
* server-side; on test deployments where it is off, the call returns 4xx and
|
||||
* the cleanup ledger silently swallows it.
|
||||
*/
|
||||
export async function permanentDeleteUser(client: Client4, userId: string): Promise<void> {
|
||||
await (client as any).doFetch(`${client.getBaseRoute()}/users/${userId}?permanent=true`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-test custom profile attribute (text type) with a unique name and
|
||||
* register cleanup. Returns the field including its server-assigned `id`,
|
||||
* which callers can plug straight into a CEL expression via
|
||||
* `user.attributes.id_<id>`.
|
||||
*
|
||||
* Avoids the shared `Department` slot used by other ABAC tests: those compete
|
||||
* for one of 20 server-cap'd CPA slots and any leak (a failed test, an
|
||||
* abandoned run) cascades into "field not found" or "limit reached" errors
|
||||
* across the whole suite.
|
||||
*/
|
||||
export async function createTrackedAttribute(
|
||||
client: Client4,
|
||||
baseName: string,
|
||||
ledger: CleanupLedger,
|
||||
): Promise<{id: string; name: string}> {
|
||||
const name = `${baseName}_${getRandomId()}`;
|
||||
const field = await doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({name, type: 'text', attrs: {visibility: 'when_set'}}),
|
||||
});
|
||||
ledger.add(() =>
|
||||
doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields/${field.id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
);
|
||||
return {id: field.id, name: field.name};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a CPA value on a user, addressing the field by its ID. Avoids the
|
||||
* field-name-to-ID lookup in the shared `setUserAttribute` helper, which is
|
||||
* unnecessary indirection now that callers hold the field handle directly.
|
||||
*/
|
||||
export async function setUserAttributeById(client: Client4, userId: string, fieldId: string, value: string) {
|
||||
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a public channel and register cleanup for it.
|
||||
*/
|
||||
export async function createTrackedPublicChannel(client: PlaywrightClient4, teamId: string, ledger: CleanupLedger) {
|
||||
const channel = await client.createPublicChannel(teamId);
|
||||
ledger.add(() => client.deleteChannel(channel.id));
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parent ABAC policy with the given CEL rule and assign the given
|
||||
* channels in one shot. Registers cleanup so the policy (and its inherited
|
||||
* channel-scope children) gets removed even if the test bails partway.
|
||||
*
|
||||
* Why we don't reuse `createParentPolicy` from team_settings/helpers.ts and
|
||||
* patch afterwards: that helper hardcodes `expression: 'true'`, and a follow-up
|
||||
* PUT to override the rule fails the server's policy-validation pipeline.
|
||||
* Stamping the desired expression on the initial create is both simpler and
|
||||
* matches what the system console actually does.
|
||||
*/
|
||||
export async function createPolicyAssignedToChannels(
|
||||
client: Client4,
|
||||
name: string,
|
||||
expression: string,
|
||||
channelIds: string[],
|
||||
ledger: CleanupLedger,
|
||||
) {
|
||||
const policy: any = await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
id: '',
|
||||
name,
|
||||
type: 'parent',
|
||||
version: 'v0.2',
|
||||
revision: 0,
|
||||
rules: [{expression, actions: ['*']}],
|
||||
}),
|
||||
});
|
||||
|
||||
// Cleanup is self-sufficient: unassign first, then delete. The LIFO drain
|
||||
// order in the spec runs this BEFORE the channel deletes, so the parent
|
||||
// still has live child-scope policy rows referencing it — DeleteAccessControlPolicy
|
||||
// would silently fail and leak the parent. Unassigning explicitly drops
|
||||
// those children regardless of channel cleanup order.
|
||||
ledger.add(async () => {
|
||||
if (channelIds.length > 0) {
|
||||
try {
|
||||
await unassignChannelsFromPolicy(client, policy.id, channelIds);
|
||||
} catch {
|
||||
// Channels may already be gone (their cleanup tasks may have run
|
||||
// first), in which case the child policies were auto-removed by
|
||||
// cleanupChannelAccessControlPolicy. Either way, best-effort.
|
||||
}
|
||||
}
|
||||
await deletePolicy(client, policy.id);
|
||||
});
|
||||
|
||||
if (channelIds.length > 0) {
|
||||
await assignChannelsToPolicy(client, policy.id, channelIds);
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the auto-add (Active) flag on a channel-scope ABAC policy. Channel-scope
|
||||
* policies share the channel's ID, so this is keyed by channelId. Active=true
|
||||
* is what makes the access-control sync job auto-add matching users.
|
||||
*
|
||||
* Children inherit the parent's Active flag at assign time, but the parent
|
||||
* default on a fresh `createPolicyAssignedToChannels` is false — flip the
|
||||
* child here when the test needs auto-add ON.
|
||||
*/
|
||||
export async function setChannelPolicyActive(client: Client4, channelId: string, active: boolean): Promise<void> {
|
||||
await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies/activate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({entries: [{id: channelId, active}]}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an access-control sync job for a single policy and return the job
|
||||
* record. The job is queued — call `waitForJobCompletion` to block until it
|
||||
* resolves.
|
||||
*/
|
||||
export async function runAccessControlSyncJob(client: Client4, policyId: string): Promise<any> {
|
||||
return client.createJob({
|
||||
type: 'access_control_sync' as any,
|
||||
data: {policy_id: policyId},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a job until it reaches a terminal status (success / error / canceled).
|
||||
* Sync jobs are queued and processed asynchronously; in CI the queue can be
|
||||
* idle for several seconds before pickup. The default 60s ceiling matches what
|
||||
* we've seen empirically; callers can extend via `timeoutMs` when running on
|
||||
* slow servers.
|
||||
*/
|
||||
export async function waitForJobCompletion(
|
||||
client: Client4,
|
||||
jobId: string,
|
||||
opts: {timeoutMs?: number; pollIntervalMs?: number} = {},
|
||||
): Promise<any> {
|
||||
const timeoutMs = opts.timeoutMs ?? 60_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 1_000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const job: any = await client.getJob(jobId);
|
||||
if (job.status === 'success') {
|
||||
return job;
|
||||
}
|
||||
if (job.status === 'error' || job.status === 'canceled') {
|
||||
const detail = job?.data?.message ?? job?.message ?? job?.error ?? JSON.stringify(job?.data ?? job);
|
||||
throw new Error(`Job ${jobId} finished with status ${job.status}: ${detail}`);
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, pollIntervalMs));
|
||||
}
|
||||
throw new Error(`Job ${jobId} did not reach a terminal status within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a user, add them to a team, optionally set a single attribute by ID,
|
||||
* and bypass the tutorial / onboarding so tests can `pw.testBrowser.login()`
|
||||
* straight into the channels view. Cleanup is registered for the user.
|
||||
*/
|
||||
export async function createTrackedTeamMember(
|
||||
adminClient: Client4,
|
||||
teamId: string,
|
||||
attribute: {fieldId: string; value: string} | undefined,
|
||||
ledger: CleanupLedger,
|
||||
): Promise<UserProfile & {password: string}> {
|
||||
const id = getRandomId();
|
||||
const username = `pub${id}`.toLowerCase();
|
||||
const password = newTestPassword();
|
||||
|
||||
const user = await adminClient.createUser(
|
||||
{email: `${username}@sample.mattermost.com`, username, password} as UserProfile & {password: string},
|
||||
'',
|
||||
'',
|
||||
);
|
||||
ledger.add(() => permanentDeleteUser(adminClient, user.id));
|
||||
|
||||
await adminClient.savePreferences(user.id, [
|
||||
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
|
||||
{user_id: user.id, category: 'onboarding', name: 'complete', value: 'true'},
|
||||
]);
|
||||
await adminClient.addToTeam(teamId, user.id);
|
||||
if (attribute) {
|
||||
await setUserAttributeById(adminClient, user.id, attribute.fieldId, attribute.value);
|
||||
}
|
||||
|
||||
// Attach the password back to the user object so pw.testBrowser.login() can
|
||||
// authenticate — the API response does not include it.
|
||||
return {...user, password};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective Verify per-channel suppression of join/leave system messages via
|
||||
* the Configuration tab toggle in Channel Settings.
|
||||
*
|
||||
* The toggle controls `DisableJoinLeaveMessages` on the channel:
|
||||
* - Toggle ON (active class) → messages shown (DisableJoinLeaveMessages = false)
|
||||
* - Toggle OFF (no active class) → messages hidden (DisableJoinLeaveMessages = true)
|
||||
*
|
||||
* Join system posts are generated server-side when a user is added to a
|
||||
* channel, so tests use `adminClient.addToChannel()` to produce them.
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
test.describe('Channel Settings Modal - Join/Leave System Messages', () => {
|
||||
/**
|
||||
* @objective Verify the join/leave messages toggle is visible in Channel Settings
|
||||
* Configuration tab and defaults to ON (messages shown).
|
||||
*/
|
||||
test('toggle is visible and defaults to ON (messages shown)', {tag: '@channel_settings'}, async ({pw}) => {
|
||||
// # Initialize test setup
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// # Create a public channel via API
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `jl-toggle-default-${Date.now()}`,
|
||||
display_name: 'JL Toggle Default',
|
||||
type: 'O',
|
||||
} as any);
|
||||
|
||||
// # Login as admin and navigate to the channel
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open Channel Settings and navigate to Configuration tab
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const configSettings = await channelSettings.openConfigurationTab();
|
||||
|
||||
// * Toggle is visible
|
||||
await expect(configSettings.joinLeaveMessagesToggle).toBeVisible();
|
||||
|
||||
// * Toggle defaults to ON (active class present = messages shown)
|
||||
const classes = await configSettings.joinLeaveMessagesToggle.getAttribute('class');
|
||||
expect(classes).toContain('active');
|
||||
|
||||
await channelSettings.close();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that disabling the join/leave messages toggle hides join system
|
||||
* posts from the channel timeline while keeping regular messages visible.
|
||||
*/
|
||||
test(
|
||||
'disabling the toggle hides join system posts from the channel timeline',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
// # Initialize test setup
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// # Create a public channel via API
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `jl-disable-${Date.now()}`,
|
||||
display_name: 'JL Disable Test',
|
||||
type: 'O',
|
||||
} as any);
|
||||
|
||||
// # Create a second user, add to team and channel to produce a join system post
|
||||
const secondUser = await pw.createNewUserProfile(adminClient, {
|
||||
prefix: 'jl2',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
});
|
||||
await adminClient.addToTeam(team.id, secondUser.id);
|
||||
|
||||
// # Post a normal message to verify it stays visible when system posts are hidden
|
||||
await adminClient.createPost({
|
||||
channel_id: channel.id,
|
||||
message: 'This is a regular message that should remain visible',
|
||||
} as any);
|
||||
|
||||
// # Add the second user to the channel — this triggers a server-side join system post
|
||||
await adminClient.addToChannel(secondUser.id, channel.id);
|
||||
|
||||
// # Login as admin and navigate to the channel
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * The join system post is visible in the timeline before disabling
|
||||
await expect(
|
||||
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
|
||||
).toBeVisible({timeout: pw.duration.ten_sec});
|
||||
|
||||
// # Open Channel Settings → Configuration tab and disable join/leave messages
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const configSettings = await channelSettings.openConfigurationTab();
|
||||
await configSettings.disableJoinLeaveMessages();
|
||||
await configSettings.save();
|
||||
await channelSettings.close();
|
||||
|
||||
// * The join system post is no longer visible — wait for post-list re-fetch after ETag bust
|
||||
await expect(
|
||||
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
|
||||
).not.toBeVisible({timeout: pw.duration.ten_sec});
|
||||
|
||||
// * Normal messages are still visible
|
||||
await expect(
|
||||
channelsPage.centerView.container
|
||||
.getByTestId('postContent')
|
||||
.getByText('This is a regular message that should remain visible'),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that a user added to a channel with DisableJoinLeaveMessages=true
|
||||
* receives a sidebar mention count badge (blue dot) even though the add-to-channel
|
||||
* system post is suppressed from the timeline.
|
||||
*/
|
||||
test(
|
||||
'added user receives sidebar mention badge when join/leave messages are disabled',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
// # Initialize test setup
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
|
||||
// # Create a public channel and immediately disable join/leave messages
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `jl-added-badge-${Date.now()}`,
|
||||
display_name: 'JL Added Badge Test',
|
||||
type: 'O',
|
||||
} as any);
|
||||
await adminClient.patchChannel(channel.id, {disable_join_leave_messages: true} as any);
|
||||
|
||||
// # Create a second user and add them to the team (not the channel yet)
|
||||
const secondUser = await pw.createNewUserProfile(adminClient, {
|
||||
prefix: 'jl-badge',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
});
|
||||
await adminClient.addToTeam(team.id, secondUser.id);
|
||||
|
||||
// # Login as secondUser and navigate to a channel so the app fully loads and
|
||||
// WS connection is established before the add-to-channel event fires
|
||||
const {page: secondPage} = await pw.testBrowser.login(secondUser);
|
||||
const secondChannelsPage = new ChannelsPage(secondPage);
|
||||
await secondChannelsPage.goto(team.name, 'town-square');
|
||||
await secondChannelsPage.toBeVisible();
|
||||
|
||||
// # Admin adds secondUser to the channel — this triggers a suppressed add-to-channel
|
||||
// system post; the server sends a targeted posted WS event only to secondUser so
|
||||
// they receive a desktop notification and sidebar badge without the post appearing
|
||||
// in the channel timeline for any member.
|
||||
await adminClient.addToChannel(secondUser.id, channel.id);
|
||||
|
||||
// * secondUser's sidebar should show the new channel with a mention count badge
|
||||
const badge = secondChannelsPage.sidebarLeft.unreadMentionsBadge(channel.name);
|
||||
await expect(badge).toBeVisible({timeout: pw.duration.ten_sec});
|
||||
|
||||
// * The channel timeline should NOT contain an add-to-channel system post
|
||||
await secondChannelsPage.sidebarLeft.goToItem(channel.name);
|
||||
await expect(secondChannelsPage.centerView.container.getByText('added to the channel')).not.toBeVisible({
|
||||
timeout: pw.duration.ten_sec,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that toggling DisableJoinLeaveMessages live-reloads the channel
|
||||
* post list for all users currently viewing the channel (Option 7 / WS reload).
|
||||
* A second user already viewing the channel should see join system posts appear or
|
||||
* disappear in real time when the admin changes the setting.
|
||||
*/
|
||||
test(
|
||||
'toggling join/leave messages live-reloads the channel for other viewers',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
// # Initialize test setup
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// # Create a public channel and add a second user to produce a join system post
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `jl-ws-reload-${Date.now()}`,
|
||||
display_name: 'JL WS Reload Test',
|
||||
type: 'O',
|
||||
} as any);
|
||||
|
||||
const secondUser = await pw.createNewUserProfile(adminClient, {
|
||||
prefix: 'jl-ws',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
});
|
||||
await adminClient.addToTeam(team.id, secondUser.id);
|
||||
await adminClient.addToChannel(secondUser.id, channel.id);
|
||||
|
||||
// # Login as secondUser and navigate to the channel
|
||||
const {page: secondPage} = await pw.testBrowser.login(secondUser);
|
||||
const secondChannelsPage = new ChannelsPage(secondPage);
|
||||
await secondChannelsPage.goto(team.name, channel.name);
|
||||
await secondChannelsPage.toBeVisible();
|
||||
|
||||
// * secondUser sees the join system post in the channel
|
||||
await expect(secondChannelsPage.centerView.container.getByText('joined the channel')).toBeVisible({
|
||||
timeout: pw.duration.ten_sec,
|
||||
});
|
||||
|
||||
// # Admin logs in and disables join/leave messages from their browser
|
||||
const {page: adminPage} = await pw.testBrowser.login(adminUser);
|
||||
const adminChannelsPage = new ChannelsPage(adminPage);
|
||||
await adminChannelsPage.goto(team.name, channel.name);
|
||||
await adminChannelsPage.toBeVisible();
|
||||
|
||||
const channelSettings = await adminChannelsPage.openChannelSettings();
|
||||
const configSettings = await channelSettings.openConfigurationTab();
|
||||
await configSettings.disableJoinLeaveMessages();
|
||||
await configSettings.save();
|
||||
await channelSettings.close();
|
||||
|
||||
// * secondUser's view updates in real time — join post disappears without a page reload
|
||||
await expect(secondChannelsPage.centerView.container.getByText('joined the channel')).not.toBeVisible({
|
||||
timeout: pw.duration.ten_sec,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that re-enabling the join/leave messages toggle restores
|
||||
* previously hidden join system posts (two-way door behavior).
|
||||
*/
|
||||
test(
|
||||
're-enabling the toggle restores hidden join system posts (two-way door)',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
// # Initialize test setup
|
||||
const {adminUser, adminClient, team} = await pw.initSetup();
|
||||
|
||||
// # Create a public channel via API
|
||||
const channel = await adminClient.createChannel({
|
||||
team_id: team.id,
|
||||
name: `jl-reenable-${Date.now()}`,
|
||||
display_name: 'JL Re-enable Test',
|
||||
type: 'O',
|
||||
} as any);
|
||||
|
||||
// # Create a second user, add to team and channel to produce a join system post
|
||||
const secondUser = await pw.createNewUserProfile(adminClient, {
|
||||
prefix: 'jl3',
|
||||
disableTutorial: true,
|
||||
disableOnboarding: true,
|
||||
});
|
||||
await adminClient.addToTeam(team.id, secondUser.id);
|
||||
await adminClient.addToChannel(secondUser.id, channel.id);
|
||||
|
||||
// # Login as admin and navigate to the channel
|
||||
const {page} = await pw.testBrowser.login(adminUser);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Disable join/leave messages and save
|
||||
let channelSettings = await channelsPage.openChannelSettings();
|
||||
let configSettings = await channelSettings.openConfigurationTab();
|
||||
await configSettings.disableJoinLeaveMessages();
|
||||
await configSettings.save();
|
||||
await channelSettings.close();
|
||||
|
||||
// * Join system post is hidden after disabling — wait for post-list re-fetch
|
||||
await expect(
|
||||
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
|
||||
).not.toBeVisible({timeout: pw.duration.ten_sec});
|
||||
|
||||
// # Re-open Channel Settings → Configuration tab and re-enable join/leave messages
|
||||
channelSettings = await channelsPage.openChannelSettings();
|
||||
configSettings = await channelSettings.openConfigurationTab();
|
||||
await configSettings.enableJoinLeaveMessages();
|
||||
await configSettings.save();
|
||||
await channelSettings.close();
|
||||
|
||||
// * Join system post is visible again after re-enabling
|
||||
await expect(
|
||||
channelsPage.centerView.container.getByText('joined the channel'), // EN locale
|
||||
).toBeVisible({timeout: pw.duration.ten_sec});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that a user viewing a thread in the right-hand side can no longer reply once they are
|
||||
* removed from the channel.
|
||||
*/
|
||||
test(
|
||||
'MM-T843 removes the reply box in the right-hand side after the user is removed from the channel',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, adminUser, team, user} = await pw.initSetup();
|
||||
|
||||
// # Create a channel, add the user, and post a root message as the admin
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Remove ${pw.random.id()}`);
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
const rootMessage = `root ${pw.random.id()}`;
|
||||
await adminClient.createPost({channel_id: channel.id, user_id: adminUser.id, message: rootMessage});
|
||||
|
||||
// # Log in as the user and open the thread in the right-hand side
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
const rootPost = await channelsPage.getLastPost();
|
||||
await rootPost.reply();
|
||||
await channelsPage.sidebarRight.toBeVisible();
|
||||
|
||||
// # Post a reply in the right-hand side
|
||||
const reply = `reply ${pw.random.id()}`;
|
||||
await channelsPage.sidebarRight.postMessage(reply);
|
||||
await channelsPage.sidebarRight.toContainText(reply);
|
||||
|
||||
// * Verify the reply box is available before removal
|
||||
await expect(channelsPage.sidebarRight.postCreate.input).toBeVisible();
|
||||
|
||||
// # Remove the user from the channel
|
||||
await adminClient.removeFromChannel(user.id, channel.id);
|
||||
|
||||
// * Verify the reply box in the right-hand side is no longer available
|
||||
await expect(channelsPage.sidebarRight.postCreate.input).not.toBeVisible({timeout: pw.duration.ten_sec});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that users added to a public channel in one batch are combined into a single system
|
||||
* message, while a user added after an intervening post produces a separate system message.
|
||||
*/
|
||||
test(
|
||||
'MM-T858 combines consecutive join messages and separates them across posts',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
const [member1, member2, member3] = await adminClient.createUsers(team.id, 3, 'join-leave');
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Messages ${pw.random.id()}`);
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Post a message to seal the setup system messages into their own group
|
||||
await channelsPage.postMessage(`setup ${pw.random.id()}`);
|
||||
|
||||
// # Add two members to the channel in quick succession
|
||||
await adminClient.addToChannel(member1.id, channel.id);
|
||||
await adminClient.addToChannel(member2.id, channel.id);
|
||||
|
||||
// * Verify both members are combined into a single "added to the channel" system message
|
||||
await channelsPage.centerView.waitUntilLastPostContains('added to the channel');
|
||||
const combinedPost = await channelsPage.getLastPost();
|
||||
await combinedPost.toContainText(member1.username);
|
||||
await combinedPost.toContainText(member2.username);
|
||||
|
||||
// # Post a message, then add a third member after it
|
||||
await channelsPage.postMessage(`divider ${pw.random.id()}`);
|
||||
await adminClient.addToChannel(member3.id, channel.id);
|
||||
|
||||
// * Verify the third member produces a separate system message that does not include the earlier members
|
||||
await channelsPage.centerView.waitUntilLastPostContains('added to the channel');
|
||||
const separatePost = await channelsPage.getLastPost();
|
||||
await separatePost.toContainText(member3.username);
|
||||
await separatePost.toNotContainText(member1.username);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that releasing a mouse drag that started inside a modal but ends outside it does not
|
||||
* close the modal, while a genuine click outside the modal does close it.
|
||||
*/
|
||||
test('MM-T841 keeps a modal open when a drag is released outside it', {tag: '@channel_settings'}, async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
const {channelsPage, page} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'off-topic');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open the Channel Settings modal
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const modal = channelSettings.container;
|
||||
|
||||
// # Press the mouse down inside the modal, drag to outside the modal, then release
|
||||
const box = await modal.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('Channel Settings modal has no bounding box');
|
||||
}
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(5, 5);
|
||||
await page.mouse.up();
|
||||
|
||||
// * Verify the modal remains open because the press started inside it
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
// # Click outside the modal (both press and release on the backdrop)
|
||||
await page.mouse.click(5, 5);
|
||||
|
||||
// * Verify the modal now closes
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that cancelling the create-channel modal does not create or join a channel, and that
|
||||
* leaving a channel removes it from the sidebar.
|
||||
*/
|
||||
test(
|
||||
'MM-T861 cancelling the create channel modal does not join a channel',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {user, team} = await pw.initSetup();
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Create a new public channel via the modal
|
||||
const createdChannel = `Random ${pw.random.id()}`;
|
||||
await channelsPage.newChannel(createdChannel, 'O');
|
||||
|
||||
// * Verify the new channel is opened
|
||||
await channelsPage.centerView.header.toHaveTitle(createdChannel);
|
||||
|
||||
// # Leave the channel via the channel header menu
|
||||
const channelMenu = await channelsPage.openChannelMenu();
|
||||
await channelMenu.leaveChannel.click();
|
||||
|
||||
// * Verify the user is redirected to the default channel and the channel is no longer in the sidebar
|
||||
await channelsPage.centerView.header.toHaveTitle('Town Square');
|
||||
await expect(channelsPage.sidebarLeft.container.getByText(createdChannel)).not.toBeVisible();
|
||||
|
||||
// # Open the create-channel modal, enter a name, and cancel without creating
|
||||
const cancelledChannel = `AB ${pw.random.id()}`;
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.displayNameInput.fill(cancelledChannel);
|
||||
await newChannelModal.cancel();
|
||||
|
||||
// * Verify the modal is closed and the cancelled channel was never created or joined
|
||||
await expect(newChannelModal.container).not.toBeVisible();
|
||||
await expect(channelsPage.sidebarLeft.container.getByText(cancelledChannel)).not.toBeVisible();
|
||||
|
||||
// * Verify the previously left channel remains absent from the sidebar
|
||||
await expect(channelsPage.sidebarLeft.container.getByText(createdChannel)).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
/**
|
||||
* @objective Verify that Channel Settings opens with no unsaved-changes warning and no channel name
|
||||
* validation error, and closes on a single click, for a channel whose stored purpose and header carry
|
||||
* leading or trailing whitespace. The server keeps that whitespace verbatim, so channels populated
|
||||
* outside this form reach it untidy.
|
||||
*/
|
||||
test(
|
||||
'opens Channel Settings cleanly for a channel whose stored text has surrounding whitespace',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
|
||||
// # Create a channel whose stored purpose and header have surrounding whitespace
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Untidy ${pw.random.id()}`);
|
||||
const paddedPurpose = ' Small annoyances that add up ';
|
||||
const paddedHeader = 'Runbook: [Ops guide](https://example.com/runbook)\nEscalate to the on-call\n';
|
||||
await adminClient.patchChannel(channel.id, {purpose: paddedPurpose, header: paddedHeader});
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open Channel Settings without touching any field
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const infoSettings = await channelSettings.openInfoTab();
|
||||
|
||||
// * Verify the stored text is shown as-is and the form does not report unsaved changes
|
||||
await expect(infoSettings.purposeInput).toHaveValue(paddedPurpose);
|
||||
await expect(infoSettings.headerInput).toHaveValue(paddedHeader);
|
||||
await expect(infoSettings.saveChangesPanel).not.toBeVisible();
|
||||
|
||||
// * Verify no channel name validation error is shown for the populated name field
|
||||
await expect(infoSettings.nameInput).toHaveValue(channel.display_name);
|
||||
await expect(
|
||||
channelSettings.container.getByText('Channel names must have at least 1 character.'),
|
||||
).not.toBeVisible();
|
||||
|
||||
// # Close the modal with a single click
|
||||
await channelSettings.closeButton.click();
|
||||
|
||||
// * Verify the modal closes without the second click that unsaved changes would demand
|
||||
await expect(channelSettings.container).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify that editing a channel whose stored text has surrounding whitespace still surfaces the
|
||||
* unsaved-changes panel and saves the edit.
|
||||
*/
|
||||
test(
|
||||
'saves an edit made on a channel whose stored text has surrounding whitespace',
|
||||
{tag: '@channel_settings'},
|
||||
async ({pw}) => {
|
||||
const {adminClient, team, user} = await pw.initSetup();
|
||||
|
||||
// # Create a channel whose stored header ends in a newline
|
||||
const channel = await adminClient.createPublicChannel(team.id, `Untidy ${pw.random.id()}`);
|
||||
const paddedHeader = 'padded header\n';
|
||||
await adminClient.patchChannel(channel.id, {purpose: ' padded purpose ', header: paddedHeader});
|
||||
await adminClient.addToChannel(user.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// # Open Channel Settings and edit the purpose
|
||||
const channelSettings = await channelsPage.openChannelSettings();
|
||||
const infoSettings = await channelSettings.openInfoTab();
|
||||
await infoSettings.updatePurpose('A brand new purpose');
|
||||
|
||||
// * Verify the edit is recognised as an unsaved change
|
||||
await expect(channelSettings.container.getByText('You have unsaved changes')).toBeVisible();
|
||||
|
||||
// # Save the edit
|
||||
await channelSettings.save();
|
||||
|
||||
// * Verify the save completes and the panel leaves the unsaved state
|
||||
await expect(channelSettings.saveButton).not.toBeVisible();
|
||||
|
||||
// # Close and reopen Channel Settings
|
||||
await channelSettings.close();
|
||||
const reopenedSettings = await channelsPage.openChannelSettings();
|
||||
const reopenedInfo = await reopenedSettings.openInfoTab();
|
||||
|
||||
// * Verify the saved purpose persisted, the untouched header was left exactly as stored, and the
|
||||
// * reopened form is clean
|
||||
await expect(reopenedInfo.purposeInput).toHaveValue('A brand new purpose');
|
||||
await expect(reopenedInfo.headerInput).toHaveValue(paddedHeader);
|
||||
await expect(reopenedInfo.saveChangesPanel).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user