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

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

View File

@@ -0,0 +1,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);
});
});

View File

@@ -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();
},
);

View File

@@ -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();
});
});

View File

@@ -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();
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
},
);

View File

@@ -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);
});

View File

@@ -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};
}

View File

@@ -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});
},
);
});

View File

@@ -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);
},
);

View File

@@ -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();
},
);

View File

@@ -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();
},
);