snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* Channel Classification E2E tests.
|
||||
* Tests the classification level assignment feature on both new and existing channels.
|
||||
*
|
||||
* Prerequisites: Enterprise-tier license + ClassificationMarkings feature flag enabled.
|
||||
*/
|
||||
|
||||
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
TEST_LEVELS,
|
||||
setClassificationMarkingsFeatureFlag,
|
||||
setupClassificationWithChannelField,
|
||||
deleteClassificationFieldsIfExist,
|
||||
} from './helpers';
|
||||
import type {ClassificationLevel} from './helpers';
|
||||
|
||||
let classificationLevels: ClassificationLevel[] = [];
|
||||
let setupComplete = false;
|
||||
|
||||
// Teams created by pw.initSetup() in each test are tracked here and deleted in
|
||||
// afterEach so local environments don't accumulate stale teams across runs.
|
||||
const createdTeamIds: string[] = [];
|
||||
|
||||
async function initSetupTracked(pw: PlaywrightExtended) {
|
||||
const setup = await pw.initSetup();
|
||||
createdTeamIds.push(setup.team.id);
|
||||
return setup;
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
if (licenseTier(license.SkuShortName) < 20) {
|
||||
return;
|
||||
}
|
||||
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
const setup = await setupClassificationWithChannelField(adminClient);
|
||||
classificationLevels = setup.levels;
|
||||
setupComplete = true;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (!setupComplete) {
|
||||
return;
|
||||
}
|
||||
const {adminClient} = await getAdminClient();
|
||||
try {
|
||||
await deleteClassificationFieldsIfExist(adminClient);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const {adminClient} = await getAdminClient();
|
||||
const license = await adminClient.getClientLicenseOld();
|
||||
test.skip(licenseTier(license.SkuShortName) < 20, 'Channel classification requires Enterprise-tier license');
|
||||
test.skip(!setupComplete, 'Classification levels were not set up');
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags.ClassificationMarkings !== true,
|
||||
'ClassificationMarkings feature flag could not be enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (createdTeamIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ids = createdTeamIds.splice(0);
|
||||
try {
|
||||
const {adminClient} = await getAdminClient({skipLog: true});
|
||||
await Promise.allSettled(ids.map((id) => adminClient.deleteTeam(id)));
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Channel Classification - New channel creation', () => {
|
||||
test('Enabling classification toggle without selecting values prevents channel creation', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
await newChannelModal.publicTypeButton.click();
|
||||
|
||||
// Create button should be enabled before toggling classification
|
||||
await expect(newChannelModal.createButton).toBeEnabled();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Create button should be disabled (no classification level selected, no banner text)
|
||||
await expect(newChannelModal.createButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Classification dropdown displays the correct levels from the template', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Open the classification dropdown
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
// Verify all test levels are present in the dropdown menu
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await expect(menu).toBeVisible();
|
||||
for (const level of TEST_LEVELS) {
|
||||
await expect(menu.getByText(level.name, {exact: true})).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('User can append text to the Banner Text field after selecting a classification', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`test-${pw.random.id()}`);
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Select a classification level
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Banner text should be auto-populated with the bold level name
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
const currentValue = await bannerTextbox.inputValue();
|
||||
expect(currentValue).toContain(selectedLevel!.name);
|
||||
|
||||
// Append custom text to the banner
|
||||
await bannerTextbox.click();
|
||||
await bannerTextbox.press('End');
|
||||
await bannerTextbox.pressSequentially(' - Custom suffix');
|
||||
|
||||
const updatedValue = await bannerTextbox.inputValue();
|
||||
expect(updatedValue).toContain('Custom suffix');
|
||||
});
|
||||
|
||||
test('Creating channel with classification shows banner with correct color', async ({pw}) => {
|
||||
const {adminUser, team} = await initSetupTracked(pw);
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
|
||||
const newChannelModal = await channelsPage.openNewChannelModal();
|
||||
await newChannelModal.fillDisplayName(`classified-${pw.random.id()}`);
|
||||
await newChannelModal.publicTypeButton.click();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Select the classification level
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Wait for banner text to auto-populate, then create the channel
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_classification_banner_text');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
await expect(bannerTextbox).not.toHaveValue('');
|
||||
|
||||
await newChannelModal.create();
|
||||
|
||||
// Should be redirected to the new channel and center view loads
|
||||
await expect(channelsPage.page).toHaveURL(/\/channels\//, {timeout: 30000});
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 30000});
|
||||
|
||||
// Channel banner should be visible (allow extra time for property value fetch)
|
||||
const banner = channelsPage.page.getByTestId('channel_banner_container');
|
||||
await expect(banner).toBeVisible({timeout: 30000});
|
||||
|
||||
// Verify the banner has the correct background color
|
||||
const actualBackgroundColor = await banner.evaluate((el) => {
|
||||
return window.getComputedStyle(el).getPropertyValue('background-color');
|
||||
});
|
||||
const expectedRgb = hexToRgb(selectedLevel!.color);
|
||||
expect(actualBackgroundColor).toBe(expectedRgb);
|
||||
|
||||
// Verify the banner contains the classification level name (rendered from **SECRET** markdown)
|
||||
await expect(banner).toContainText(selectedLevel!.name);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Channel Classification - Existing channel settings', () => {
|
||||
test('Classification toggle can be enabled from channel settings', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// The classification toggle should be available
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await expect(classificationToggle).toBeVisible();
|
||||
|
||||
// Toggle it on
|
||||
const classes = await classificationToggle.getAttribute('class');
|
||||
if (!classes?.includes('active')) {
|
||||
await classificationToggle.click();
|
||||
}
|
||||
|
||||
// Toggle should now be active
|
||||
await expect(classificationToggle).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('Classification level can be set once toggle is enabled', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification toggle
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
// Classification level dropdown should be visible
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await expect(dropdownContainer).toBeVisible();
|
||||
|
||||
// Open dropdown and select a level
|
||||
await dropdownContainer.click();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await expect(menu).toBeVisible();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'CONFIDENTIAL');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// The dropdown should now show the selected value
|
||||
await expect(dropdownContainer.getByText(selectedLevel!.name, {exact: true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('Selecting classification locks banner toggle active and disabled, with matching color', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification and select a level
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// The channel banner toggle should now be forced active and disabled
|
||||
const bannerToggle = channelsPage.page.getByTestId('channelBannerToggle-button');
|
||||
await expect(bannerToggle).toBeVisible();
|
||||
await expect(bannerToggle).toHaveClass(/active/);
|
||||
await expect(bannerToggle).toBeDisabled();
|
||||
|
||||
// Banner color input should be locked to the classification color
|
||||
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
|
||||
await expect(colorInput).toBeVisible();
|
||||
const colorValue = await colorInput.inputValue();
|
||||
expect(colorValue.toLowerCase().replace('#', '')).toBe(selectedLevel!.color.toLowerCase().replace('#', ''));
|
||||
});
|
||||
|
||||
test('Disabling classification with a custom banner color preserves it server-side, and re-enabling restores the Save button', async ({
|
||||
pw,
|
||||
}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
// Step 1: enable classification, select a level, save
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'SECRET')!;
|
||||
await channelsPage.page.locator('.DropDown__menu').getByText(selectedLevel.name, {exact: true}).click();
|
||||
await configurationTab.save();
|
||||
|
||||
// Step 2: in the same open modal, disable classification, enable a manual banner,
|
||||
// set a custom color, save. The manual banner toggle is needed because master no
|
||||
// longer auto-enables banner_info.enabled when classification is on, so the banner
|
||||
// section body (and its color input) is hidden after toggling classification off
|
||||
// until a manual banner is enabled.
|
||||
await classificationToggle.click();
|
||||
await configurationTab.enableChannelBanner();
|
||||
const customColor = 'aa00aa';
|
||||
await configurationTab.setChannelBannerBackgroundColor(customColor);
|
||||
await configurationTab.save();
|
||||
|
||||
// Symptom 1 guard: color input reflects what we typed and the server persisted the custom color
|
||||
const colorInput = channelsPage.page.locator('#channel_banner_banner_background_color_picker-inputColorValue');
|
||||
await expect(colorInput).toHaveValue(`#${customColor}`);
|
||||
const persisted = await adminClient.getChannel(channel.id);
|
||||
expect(persisted.banner_info?.background_color?.toLowerCase().replace('#', '')).toBe(customColor);
|
||||
|
||||
// Symptom 2 guard: re-enable classification → Save button reappears (panel transitions out of 'saved')
|
||||
await classificationToggle.click();
|
||||
const saveButton = channelsPage.page.getByTestId('SaveChangesPanel__save-btn');
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeEnabled();
|
||||
|
||||
await channelSettingsModal.close();
|
||||
});
|
||||
|
||||
test('Editing banner text and saving updates the banner in real time', async ({pw}) => {
|
||||
const {adminUser, team, adminClient} = await initSetupTracked(pw);
|
||||
|
||||
const channel = await adminClient.createChannel(
|
||||
pw.random.channel({teamId: team.id, name: `cls-${pw.random.id()}`, displayName: `Cls ${pw.random.id()}`}),
|
||||
);
|
||||
await adminClient.addToChannel(adminUser.id, channel.id);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(adminUser);
|
||||
await channelsPage.goto(team.name, channel.name);
|
||||
await expect(channelsPage.page.getByTestId('channel_view')).toBeVisible({timeout: 60000});
|
||||
|
||||
const channelSettingsModal = await channelsPage.openChannelSettings();
|
||||
const configurationTab = await channelSettingsModal.openConfigurationTab();
|
||||
|
||||
// Enable classification and select a level
|
||||
const classificationToggle = channelsPage.page.getByTestId('channelClassificationToggle-button');
|
||||
await classificationToggle.click();
|
||||
|
||||
const dropdownContainer = channelsPage.page.getByTestId('channelClassificationLevel');
|
||||
await dropdownContainer.click();
|
||||
|
||||
const selectedLevel = classificationLevels.find((l) => l.name === 'TOP SECRET');
|
||||
expect(selectedLevel).toBeDefined();
|
||||
const menu = channelsPage.page.locator('.DropDown__menu');
|
||||
await menu.getByText(selectedLevel!.name, {exact: true}).click();
|
||||
|
||||
// Edit the banner text to a custom value
|
||||
const customBannerText = 'TOP SECRET - Handle via COMINT channels only';
|
||||
const bannerTextbox = channelsPage.page.locator('#channel_banner_banner_text_textbox');
|
||||
await expect(bannerTextbox).toBeVisible();
|
||||
await bannerTextbox.fill(customBannerText);
|
||||
|
||||
// Save the changes
|
||||
await configurationTab.save();
|
||||
await channelSettingsModal.close();
|
||||
|
||||
// The channel banner should now show the custom text with the classification color
|
||||
const banner = channelsPage.page.getByTestId('channel_banner_container');
|
||||
await expect(banner).toBeVisible({timeout: 30000});
|
||||
await expect(banner).toContainText(customBannerText);
|
||||
|
||||
const actualBackgroundColor = await banner.evaluate((el) => {
|
||||
return window.getComputedStyle(el).getPropertyValue('background-color');
|
||||
});
|
||||
expect(actualBackgroundColor).toBe(hexToRgb(selectedLevel!.color));
|
||||
});
|
||||
});
|
||||
|
||||
function hexToRgb(hex: string): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) {
|
||||
return hex;
|
||||
}
|
||||
return `rgb(${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)})`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
const PROPERTY_GROUP = 'access_control';
|
||||
const TEMPLATE_OBJECT_TYPE = 'template';
|
||||
const CHANNEL_OBJECT_TYPE = 'channel';
|
||||
const TARGET_TYPE = 'system';
|
||||
const CLASSIFICATION_FIELD_NAME = 'classification';
|
||||
const CHANNEL_LINKED_FIELD_NAME = 'classification';
|
||||
|
||||
export const TEST_LEVELS = [
|
||||
{name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{name: 'CONFIDENTIAL', color: '#0033A0', rank: 2},
|
||||
{name: 'SECRET', color: '#C8102E', rank: 3},
|
||||
{name: 'TOP SECRET', color: '#FF8C00', rank: 4},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sets the ClassificationMarkings feature flag via server config.
|
||||
*/
|
||||
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
|
||||
const config = await adminClient.getConfig();
|
||||
await adminClient.updateConfig({
|
||||
...config,
|
||||
FeatureFlags: {
|
||||
...config.FeatureFlags,
|
||||
ClassificationMarkings: enabled,
|
||||
},
|
||||
} as Awaited<ReturnType<Client4['getConfig']>>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes existing classification fields (channel linked, system linked, and template)
|
||||
* to provide a clean slate.
|
||||
*/
|
||||
export async function deleteClassificationFieldsIfExist(adminClient: Client4) {
|
||||
// Delete channel linked fields first
|
||||
try {
|
||||
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, TARGET_TYPE, '');
|
||||
for (const f of channelFields.filter((f) => f.name === CHANNEL_LINKED_FIELD_NAME && f.delete_at === 0)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
|
||||
// Delete system linked fields
|
||||
for (const objectType of ['system', 'user'] as const) {
|
||||
try {
|
||||
const linkedFields = await adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, '');
|
||||
for (const f of linkedFields.filter(
|
||||
(f) => f.name === 'classification' && f.delete_at === 0 && f.linked_field_id,
|
||||
)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
}
|
||||
|
||||
// Delete template fields
|
||||
try {
|
||||
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, TARGET_TYPE);
|
||||
for (const f of fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0)) {
|
||||
await adminClient.deletePropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, f.id);
|
||||
}
|
||||
} catch {
|
||||
// May not exist
|
||||
}
|
||||
}
|
||||
|
||||
export type ClassificationLevel = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export type SetupResult = {
|
||||
templateFieldId: string;
|
||||
channelFieldId: string;
|
||||
levels: ClassificationLevel[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the full classification setup: template field + channel linked field.
|
||||
* Returns the created fields and the resolved levels (with server-assigned IDs).
|
||||
*/
|
||||
export async function setupClassificationWithChannelField(
|
||||
adminClient: Client4,
|
||||
levels: Array<{name: string; color: string; rank: number}> = TEST_LEVELS,
|
||||
): Promise<SetupResult> {
|
||||
await deleteClassificationFieldsIfExist(adminClient);
|
||||
|
||||
// Create template field
|
||||
const templateField = await adminClient.createPropertyField(PROPERTY_GROUP, TEMPLATE_OBJECT_TYPE, {
|
||||
name: CLASSIFICATION_FIELD_NAME,
|
||||
type: 'select',
|
||||
target_type: TARGET_TYPE,
|
||||
target_id: '',
|
||||
attrs: {
|
||||
options: levels.map((l) => ({id: '', name: l.name, color: l.color, rank: l.rank})),
|
||||
},
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
permission_options: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
|
||||
// Create channel linked field. permission_values must be set (matching how
|
||||
// the admin console creates it) or the server refuses to let anyone,
|
||||
// including the creator, set a value on it.
|
||||
const channelField = await adminClient.createPropertyField(PROPERTY_GROUP, CHANNEL_OBJECT_TYPE, {
|
||||
name: CHANNEL_LINKED_FIELD_NAME,
|
||||
type: 'select',
|
||||
target_type: TARGET_TYPE,
|
||||
target_id: '',
|
||||
linked_field_id: templateField.id,
|
||||
permission_field: 'admin',
|
||||
permission_values: 'admin',
|
||||
} as Parameters<Client4['createPropertyField']>[2]);
|
||||
|
||||
// Resolve levels with server-assigned IDs
|
||||
const options = (templateField.attrs?.options ?? []) as ClassificationLevel[];
|
||||
const resolvedLevels = options.sort((a, b) => a.rank - b.rank);
|
||||
|
||||
return {templateFieldId: templateField.id, channelFieldId: channelField.id, levels: resolvedLevels};
|
||||
}
|
||||
Reference in New Issue
Block a user