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,222 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console autotranslation settings (Site Configuration -> Localization).
* AT-SC-01: Without EA license, feature discovery is shown.
* AT-SC-02: With EA license, Auto-translation toggle is visible and off by default.
* AT-SC-03: Enabling the system toggle expands provider and target language configuration.
* AT-SC-05: Selecting LibreTranslate shows endpoint and API key fields.
* AT-SC-07: Target languages multi-select is visible and usable.
*/
import {expect, test, hasAutotranslationLicense} from '@mattermost/playwright-lib';
test.describe('System Console - Autotranslation (Localization)', () => {
test(
'without EA license, auto-translation shows feature discovery block',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
hasAutotranslationLicense(license.SkuShortName),
'Skipping test - this test requires non-Entry/Advanced license to see feature discovery',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await expect(systemConsolePage.localization.featureDiscoveryBlock).toBeVisible();
await expect(systemConsolePage.localization.autoTranslationSection).not.toBeVisible();
},
);
test(
'with EA license, Auto-translation toggle is visible and off by default',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await expect(systemConsolePage.localization.autoTranslationSection).toBeVisible();
await expect(systemConsolePage.localization.autoTranslationToggle).toBeVisible();
// Check toggle state directly (not via unscoped text search which is flaky)
const isOn = await systemConsolePage.localization.isToggleOn();
expect(isOn).toBe(false);
},
);
test(
'enabling system toggle expands provider and target language configuration',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await systemConsolePage.localization.turnOnAutoTranslation();
await expect(systemConsolePage.localization.providerDropdown).toBeVisible();
await expect(systemConsolePage.localization.targetLanguagesMultiSelect).toBeVisible();
},
);
test(
'selecting LibreTranslate shows endpoint and API key fields',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
await systemConsolePage.localization.turnOnAutoTranslation();
await systemConsolePage.localization.selectTranslationProvider('LibreTranslate');
await expect(systemConsolePage.localization.libreTranslateUrlInput).toBeVisible();
await expect(systemConsolePage.localization.libreTranslateApiKeyInput).toBeVisible();
await expect(systemConsolePage.localization.container.getByText(/LibreTranslate docs/i)).toBeVisible();
},
);
test(
'selecting Mattermost Agents hides LibreTranslate fields and shows plugin guidance',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Open Localization settings in the System Console
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
// # Enable auto-translation and switch the provider to Mattermost Agents
await systemConsolePage.localization.turnOnAutoTranslation();
await systemConsolePage.localization.selectTranslationProvider('Mattermost Agents');
// * Verify Mattermost Agents guidance is shown for the inactive plugin
await expect(systemConsolePage.localization.mattermostAgentsInactiveNotice).toBeVisible();
await expect(systemConsolePage.localization.mattermostAgentsConfigLink).toBeVisible();
await expect(systemConsolePage.localization.mattermostAgentsConfigLink).toHaveAttribute(
'href',
'/admin_console/plugins/plugin_mattermost-ai',
);
// * Verify LibreTranslate-specific inputs are hidden for the Mattermost Agents provider
await expect(systemConsolePage.localization.libreTranslateUrlInput).not.toBeVisible();
await expect(systemConsolePage.localization.libreTranslateApiKeyInput).not.toBeVisible();
},
);
test(
'admin can select multiple target languages',
{
tag: ['@autotranslation', '@system_console'],
},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
const license = await adminClient.getClientLicenseOld();
test.skip(
!hasAutotranslationLicense(license.SkuShortName),
'Skipping test - server does not have Entry or Advanced license',
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
await systemConsolePage.sidebar.siteConfiguration.localization.click();
await systemConsolePage.page.waitForURL(/\/admin_console\/site_config\/localization/);
// Enable autotranslation first
await systemConsolePage.localization.turnOnAutoTranslation();
// Verify multiselect is visible
await expect(systemConsolePage.localization.targetLanguagesMultiSelect).toBeVisible();
await expect(systemConsolePage.localization.container.getByText('Languages allowed')).toBeVisible();
// Select multiple languages (Spanish and French)
const multiSelect = systemConsolePage.localization.targetLanguagesMultiSelect;
const multiSelectInput = multiSelect.locator('input');
const languageOptions = multiSelect.getByRole('option');
// Open dropdown and select Spanish
await multiSelectInput.click();
await languageOptions.filter({hasText: /Español/}).click();
// Dropdown closes after each selection — reopen it, then select French
await multiSelectInput.click();
await languageOptions.filter({hasText: /Français/}).click();
// Close multiselect
await multiSelect.press('Escape');
// Verify both languages are selected by checking for their remove buttons in the multiselect
const spanishChip = multiSelect.getByRole('button', {name: /Remove Español/});
const frenchChip = multiSelect.getByRole('button', {name: /Remove Français/});
await expect(spanishChip).toBeVisible();
await expect(frenchChip).toBeVisible();
},
);
});

View File

@@ -0,0 +1,599 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — Classification markings (Enterprise-tier license + ClassificationMarkings feature flag).
* Covers admin UI for presets, save validation, preset-change confirmation, and custom preset detection.
*
* Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`.
* Professional-only licenses hide this admin route (React Router redirects to /admin_console/about/license).
*/
import type {Page} from '@playwright/test';
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
async function selectClassificationPreset(page: Page, optionLabel: string) {
await page.getByTestId('classificationPreset').click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await menu.getByText(optionLabel, {exact: true}).click();
}
test.describe('System Console - Classification markings', () => {
test.beforeAll(async () => {
const {adminClient} = await getAdminClient({skipLog: true});
await setClassificationMarkingsFeatureFlag(adminClient, true);
const config = await adminClient.getConfig();
test.skip(
config.FeatureFlags?.ClassificationMarkings !== true &&
config.FeatureFlags?.ClassificationMarkings !== 'true',
'ClassificationMarkings feature flag is off (probably overridden by env); skipping.',
);
});
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(
licenseTier(license.SkuShortName) < 20,
'Classification markings requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). Professional/trial Professional is not sufficient—the admin route is hidden and redirects to /admin_console/about/license.',
);
// Skip if the custom_profile_attributes property group is absent on this server.
// The group must exist (seeded by the server) before classification markings can be saved;
// the API returns "The specified property group was not found." otherwise.
try {
await adminClient.getPropertyFields('custom_profile_attributes', 'template', 'system');
} catch {
test.skip(
true,
'custom_profile_attributes property group not found on this server; skipping classification markings save tests.',
);
}
});
/**
* @objective Ensure the classification markings admin route is unavailable when the feature flag is off (when the server allows disabling it).
*/
test(
'MM-T6201 classification markings: feature flag off redirects away from admin URL',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Turn off ClassificationMarkings in server config
await setClassificationMarkingsFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.ClassificationMarkings === true,
'ClassificationMarkings stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.',
);
// # Open system console and navigate directly to the classification markings path
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// * User is redirected away from the hidden route (no Route registered)
await expect(systemConsolePage.page).not.toHaveURL(/classification_markings/);
// * Classification markings page title is not shown
await expect(systemConsolePage.page.getByText('Classification Markings').first()).not.toBeVisible();
},
);
/**
* @objective Ensure the classification markings page is reachable when the feature flag is on.
*/
test(
'MM-T6202 classification markings: feature flag on loads configuration page',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and clear any existing classification field
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
// # Log in and open the classification markings URL
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// * URL stays on the classification markings section
await expect(systemConsolePage.page).toHaveURL(/classification_markings/);
// * Page title is visible
await expect(systemConsolePage.page.getByText('Classification Markings').first()).toBeVisible();
},
);
/**
* @objective Validate that enabling classification without any levels shows a save error.
*/
test(
'MM-T6203 classification markings: save fails when enabled with zero levels',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable feature flag and ensure no classification field exists
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// # Enable classification markings without choosing a preset or adding levels
await systemConsolePage.page.locator('input[name="classificationEnabled"][value="true"]').click();
await systemConsolePage.page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(
systemConsolePage.page.getByText(/At least one classification level is required/i),
).toBeVisible();
},
);
/**
* @objective Verify selecting a built-in preset and saving creates the classification field successfully.
*/
// Skipped: master regression. The CM frontend creates a property field on the
// `custom_profile_attributes` group, registered as PSAv1 in server.go, but
// the API rejects v1 groups since #36171 ("PSAv2 generic APIs blacklist").
// Re-enable once the CPA group is migrated to v2 (or v1 template/system
// creates are allowed, mirroring the patch-route FIXME at properties.go:299).
test.skip(
'MM-T6204 classification markings: select NATO preset and save',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and start from no classification field
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
// # Enable markings and choose NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
const firstLevelNameInput = page.getByLabel('Classification level name').first();
// * Preset levels appear in the table
await expect(firstLevelNameInput).toBeVisible();
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
// # Save
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * No server error and first level name is unchanged after save
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
},
);
/**
* @objective When a classification field already exists, changing preset shows a warning modal; confirming applies the new preset.
*/
// Skipped: see MM-T6204 — saving a preset hits the v2_group_not_found check.
test.skip(
'MM-T6205 classification markings: preset change shows confirm modal then applies',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and clear field, then prepare saved UK levels
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'UK (GSCP)');
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * UK preset first level is present
await expect(page.getByLabel('Classification level name').first()).toHaveValue('OFFICIAL');
// # Select a different preset while a field exists on the server
await selectClassificationPreset(page, 'United States');
// * Warning modal appears with expected copy
await expect(page.getByText('Change classification preset?')).toBeVisible();
await expect(
page.getByText(/Changing the classification preset will affect all existing classifications/i),
).toBeVisible();
// # Confirm preset change
await page.getByRole('button', {name: 'Change preset'}).click();
// * Modal closes and US preset first level is shown
await expect(page.getByText('Change classification preset?')).not.toBeVisible();
await expect(page.getByLabel('Classification level name').first()).toHaveValue('UNCLASSIFIED');
},
);
/**
* @objective After saving a preset, deleting a level switches the preset dropdown to Custom and save still succeeds.
*/
// Skipped: see MM-T6204 — saving a preset hits the v2_group_not_found check.
test.skip(
'MM-T6206 classification markings: delete level switches to custom and saves',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Enable flag and save Canada preset as baseline
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'Canada');
await page.getByRole('button', {name: 'Save', exact: true}).click();
await expect(page.getByLabel('Classification level name').first()).toHaveValue('PROTECTED A');
// # Remove one level from the saved preset
await page.getByRole('button', {name: 'Delete level'}).first().click();
const presetControl = page.getByTestId('classificationPreset');
// * Preset selection switches to custom
await expect(presetControl).toContainText('Custom classification levels');
// # Save custom levels
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * No error and preset remains custom
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(presetControl).toContainText('Custom classification levels');
},
);
/**
* @objective Global Classification Indicators section appears when classification is enabled.
*/
test(
'MM-T6207 classification markings: global banner section visible when enabled',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * Global Classification Indicators section should NOT be visible before enabling
await expect(page.getByText('Global Classification Indicators')).not.toBeVisible();
// # Enable classification markings
await page.locator('input[name="classificationEnabled"][value="true"]').click();
// * The section now appears
await expect(page.getByText('Global Classification Indicators')).toBeVisible();
await expect(page.getByText('Configure the global classification banner')).toBeVisible();
await expect(page.getByText('Global Classification Banner', {exact: true})).toBeVisible();
},
);
/**
* @objective Enabling the global banner shows placement and level controls; saving a level
* persists the configuration and loads it back correctly on page reload.
*/
test(
'MM-T6208 classification markings: enable global banner, select level, save, and reload',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings and select NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
await expect(page.getByLabel('Classification level name').first()).toHaveValue('NATO UNCLASSIFIED');
// # Enable the global banner
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// * Placement and level controls appear
await expect(page.getByText('Banner visibility')).toBeVisible();
await expect(page.getByText('Global classification level')).toBeVisible();
// # Select "Top and bottom" for placement
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
// # Pick the first level (NATO UNCLASSIFIED) from the level dropdown
await page.getByTestId('globalBannerLevel').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('NATO UNCLASSIFIED', {exact: true}).click();
// # Save
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
// Wait for the save to fully complete: the button becomes disabled once
// the async persistLevels flow finishes and hasChanges resets to false.
// networkidle alone is unreliable because there is a JS processing gap
// between the re-fetch GETs and the subsequent POST/PATCH calls.
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload the page
await page.reload();
await page.waitForLoadState('networkidle');
// * Banner configuration persisted: enabled, top_and_bottom, NATO UNCLASSIFIED selected.
// The linked field + property value fetch is async; wait for the level dropdown
// (only rendered when the banner is enabled) as the hydration signal.
await expect(page.getByTestId('globalBannerLevel')).toContainText('NATO UNCLASSIFIED', {timeout: 30000});
await expect(page.locator('input[name="globalBannerEnabled"][value="true"]')).toBeChecked();
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked();
},
);
/**
* @objective Placement and level controls remain editable after a banner level has been
* persisted — the write-once lock behavior no longer exists.
*/
test(
'MM-T6209 classification markings: global banner placement and level remain editable after save',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed a field and banner config via API (skip the UI save step)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'natounclassified0000000000', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'nato-restricted', name: 'NATO RESTRICTED', color: '#FFD700', rank: 2},
],
{levelId: 'natounclassified0000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * No locked notice should be present
await expect(page.getByText(/Global classification placement and level are locked/)).not.toBeVisible();
// * Placement inputs are editable
await expect(page.locator('input[name="globalBannerPlacement"]').first()).not.toBeDisabled();
// * Delete buttons for saved level rows are not disabled by a lock
await expect(page.getByRole('button', {name: 'Delete level'}).first()).not.toBeDisabled();
// # Switch placement to "top_and_bottom" and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No server error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload and verify the new placement persisted
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked({
timeout: 30000,
});
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective When the level currently referenced by the global banner is removed, saving
* surfaces a validation error forcing the admin to pick a valid level.
*/
test(
'MM-T6210 classification markings: deleting referenced banner level blocks save until resolved',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed two levels and a banner pointing at the first one
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Delete the level used by the banner (first row)
await page.getByRole('button', {name: 'Delete level'}).first().click();
// # Try to save — banner still references the deleted level
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(
page.getByText(/The global classification banner is configured with a level that no longer exists/i),
).toBeVisible();
// # Pick a valid replacement level (click the react-select control directly)
await page.getByTestId('globalBannerLevel').locator('.DropDown__control').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('CONFIDENTIAL', {exact: true}).click();
// # Save again
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error and banner now references the replacement level
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(page.getByTestId('globalBannerLevel')).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Verify that modifying a preset's levels (rename, delete, add) automatically
* switches the dropdown to "Custom classification levels", and selecting a real preset
* again removes the Custom option from the dropdown.
*/
test(
'MM-T6212 classification markings: modifying a preset switches dropdown to Custom',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings and select NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
const presetControl = page.getByTestId('classificationPreset');
await expect(presetControl).toContainText('NATO');
// # Rename the first level — this should switch to Custom
const firstLevelInput = page.getByLabel('Classification level name').first();
await firstLevelInput.clear();
await firstLevelInput.fill('MY CUSTOM LEVEL');
// * Preset dropdown should now show "Custom classification levels"
await expect(presetControl).toContainText('Custom classification levels');
// # Open the preset dropdown and verify "Custom classification levels" is listed
await presetControl.click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await expect(menu.getByText('Custom classification levels', {exact: true})).toBeVisible();
// # Select a real preset (Canada) — should show the confirmation modal
await menu.getByText('Canada', {exact: true}).click();
await expect(page.getByText('Change classification preset?')).toBeVisible();
// # Confirm the preset change
await page.getByRole('button', {name: 'Change preset'}).click();
// * Dropdown now shows Canada, no longer Custom
await expect(presetControl).toContainText('Canada');
// # Open the dropdown again and verify Custom is no longer listed
await presetControl.click();
const menuAfterSwitch = page.locator('.DropDown__menu');
await expect(menuAfterSwitch).toBeVisible();
await expect(menuAfterSwitch.getByText('Custom classification levels', {exact: true})).not.toBeVisible();
// # Close menu by pressing Escape
await page.keyboard.press('Escape');
// # Delete a level from the Canada preset
await page.getByRole('button', {name: 'Delete level'}).first().click();
// * Should switch back to Custom
await expect(presetControl).toContainText('Custom classification levels');
},
);
/**
* @objective Validate that saving with global banner enabled but no level selected shows an error.
*/
test(
'MM-T6211 classification markings: save fails when global banner enabled without a level',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings, select NATO preset (provides levels)
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
// # Enable the global banner without picking a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// # Try to save — no level selected in the dropdown
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
});

View File

@@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts
// (cross-package import not feasible between e2e-tests and webapp)
const PROPERTY_GROUP = 'access_control';
const OBJECT_TYPE = 'template';
const LINKED_OBJECT_TYPE = 'system';
const TARGET_TYPE = 'system';
const SYSTEM_FIELD_TARGET_ID = ''; // target_type 'system' requires empty target_id on the field
const CLASSIFICATION_FIELD_NAME = 'classification';
const LINKED_CLASSIFICATION_FIELD_NAME = 'classification';
const DISPLAY_BANNER_TOP = 'display_banner_top';
const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom';
export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/classification_markings';
/**
* Toggle via System Console config API. On servers without SplitKey, feature flags are
* read-only from config (see server/config/store.go); effective values come from env
* (e.g. MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS). E2E docker sets that env in server.generate.sh.
*/
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
await adminClient.patchConfig({
FeatureFlags: {
ClassificationMarkings: enabled,
},
} as any);
}
/**
* Removes the classification property field and its linked system field if present
* (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors.
*/
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
// Delete channel linked fields first (created by channel classification tests).
try {
const channelFields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', TARGET_TYPE, '');
for (const f of channelFields.filter((f) => f.name === 'classification' && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', f.id);
}
} catch {
// May not exist; ignore.
}
// Clean up both the current 'system' object type and the legacy 'user' object type
// to handle stale data from earlier versions of the feature.
for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) {
try {
const linkedFields = await adminClient.getPropertyFields(
PROPERTY_GROUP,
objectType,
TARGET_TYPE,
SYSTEM_FIELD_TARGET_ID,
);
const matchingLinkedFields = linkedFields.filter(
(f) => f.name === LINKED_CLASSIFICATION_FIELD_NAME && f.delete_at === 0 && f.linked_field_id,
);
for (const f of matchingLinkedFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
}
} catch {
// Linked field may not exist; ignore.
}
}
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE);
const matchingFields = fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0);
for (const f of matchingFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, OBJECT_TYPE, f.id);
}
} catch {
// Property routes may be unavailable when the feature flag is off; ignore.
}
}
export type SetupGlobalBannerOptions = {
/** Level ID that the global banner should display */
levelId: string;
enabled?: boolean;
placement?: 'top' | 'top_and_bottom';
};
/**
* Creates (or recreates) the classification property field with the provided levels.
* The template field stores classification levels (attrs.options) only.
*/
export async function setupClassificationField(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
) {
// Ensure a clean slate first.
await deleteClassificationMarkingsFieldIfExists(adminClient);
return adminClient.createPropertyField(PROPERTY_GROUP, OBJECT_TYPE, {
name: CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: '',
attrs: {
options: levels.map((l) => ({id: l.id ?? '', name: l.name, color: l.color, rank: l.rank})),
},
permission_field: 'admin',
permission_values: 'admin',
permission_options: 'admin',
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Creates the classification template field, the linked system classification field
* (with attrs.actions encoding banner placement), and upserts the system property value
* (the option ID of the selected classification level) — the full three-layer setup.
*
* Data model:
* Template field → attrs.options (levels)
* Linked field → attrs.actions (display_banner_top / display_banner_bottom)
* Property value → value = option_id (UUID from template attrs.options)
*/
export async function setupClassificationFieldWithGlobalBanner(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
bannerOpts: SetupGlobalBannerOptions,
) {
// 1. Create the template field (levels only).
const templateField = await setupClassificationField(adminClient, levels);
const enabled = bannerOpts.enabled ?? true;
// Resolve the option ID for the requested level (only needed when enabled).
let optionId = '';
if (enabled && bannerOpts.levelId) {
const options = (templateField.attrs?.options ?? []) as Array<{id: string; name: string}>;
const matchedOption = options.find((o) => o.id === bannerOpts.levelId);
if (!matchedOption) {
const available = options.map((o) => `${o.name} (${o.id})`).join(', ');
throw new Error(
`setupClassificationFieldWithGlobalBanner: unknown level ID "${bannerOpts.levelId}". ` +
`Available options on template field ${templateField.id}: [${available}]`,
);
}
optionId = matchedOption.id;
}
const actions: string[] = [];
if (enabled) {
actions.push(DISPLAY_BANNER_TOP);
if (bannerOpts.placement === 'top_and_bottom') {
actions.push(DISPLAY_BANNER_BOTTOM);
}
}
// 2. Create the linked system classification field.
// type, options, and permissions are inherited from the source template by the server.
const linkedField = await adminClient.createPropertyField(PROPERTY_GROUP, LINKED_OBJECT_TYPE, {
name: LINKED_CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: SYSTEM_FIELD_TARGET_ID,
linked_field_id: templateField.id,
attrs: {actions},
} as Parameters<Client4['createPropertyField']>[2]);
// 3. Upsert the system property value via the dedicated system endpoint.
if (enabled) {
if (!optionId) {
throw new Error(
`setupClassificationFieldWithGlobalBanner: resolved optionId is empty for level "${bannerOpts.levelId}". ` +
'The server may not have assigned an ID to the option.',
);
}
if (typeof (adminClient as any).patchSystemPropertyValues !== 'function') {
throw new Error('adminClient.patchSystemPropertyValues is not available — rebuild @mattermost/client');
}
await (adminClient as any).patchSystemPropertyValues(PROPERTY_GROUP, [
{field_id: linkedField.id, value: optionId},
]);
}
return {templateField, linkedField};
}

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Global Classification Banner — end-to-end tests.
* Validates that the banner component renders (or does not render) correctly
* based on property field attrs: feature flag, enabled state, level selection, placement.
*
* These tests sit next to the classification_markings admin-page tests because
* they share the same helpers and feature-flag gating.
*/
import type {Page} from '@playwright/test';
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
const TOP_BANNER_SELECTOR = '[data-testid="global-classification-banner-top"]';
const BOTTOM_BANNER_SELECTOR = '[data-testid="global-classification-banner-bottom"]';
async function selectClassificationPreset(page: Page, optionLabel: string) {
await page.getByTestId('classificationPreset').click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await menu.getByText(optionLabel, {exact: true}).click();
}
test.describe('Global Classification Banner', () => {
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(licenseTier(license.SkuShortName) < 20, 'Classification markings requires Enterprise-tier license.');
});
/**
* @objective Banner does not render when the ClassificationMarkings feature flag is off.
*/
test(
'MM-T6220 global banner: not rendered when feature flag is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.ClassificationMarkings === true,
'Feature flag cannot be disabled in this environment.',
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Restore the flag for subsequent tests
await setClassificationMarkingsFeatureFlag(adminClient, true);
},
);
/**
* @objective Banner does not render when classifications are enabled and configured,
* but the global banner toggle is still disabled.
*/
test(
'MM-T6221 global banner: not rendered when global banner toggle is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Set up classification levels but keep the global banner disabled
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: '', enabled: false, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Enabling the global banner without selecting a level prevents saving.
*/
test(
'MM-T6222 global banner: save fails when enabled without selecting a level',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Enable classification markings and select a preset to have levels
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'United States');
// Enable global banner without selecting a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// Try to save
await page.getByRole('button', {name: 'Save', exact: true}).click();
// Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
/**
* @objective After full setup, the top banner renders with the correct level name,
* background color, and contrasting text color.
*/
test(
'MM-T6223 global banner: renders at top with correct text and color after full setup',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('SECRET');
await expect(topBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)'); // #C8102E
// Bottom banner should NOT be visible (placement is top only)
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Selecting "Top and bottom" placement renders both banners.
*/
test(
'MM-T6224 global banner: top and bottom banners render when placement is top_and_bottom',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
const bottomBanner = channelsPage.page.locator(BOTTOM_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('TOP SECRET');
await expect(bottomBanner).toBeVisible();
await expect(bottomBanner).toContainText('TOP SECRET');
// Both should have the same background color
await expect(topBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)'); // #FCE83A
await expect(bottomBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner also renders on the admin console page.
*/
test(
'MM-T6225 global banner: renders on the admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlconfidential00000000000', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
{levelId: 'lvlconfidential00000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.page.waitForLoadState('networkidle');
const topBanner = systemConsolePage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner disappears after the admin disables it and saves.
*/
test(
'MM-T6226 global banner: disappears after being disabled via admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlrestricted0000000000000', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
{levelId: 'lvlrestricted0000000000000', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Banner should be visible initially
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
// Disable the global banner
await page.locator('input[name="globalBannerEnabled"][value="false"]').click();
const saveBtn = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn.click();
await expect(saveBtn).toBeDisabled({timeout: 30000});
// Banner should no longer be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Switching placement from top to top_and_bottom makes the bottom banner appear.
*/
test(
'MM-T6227 global banner: switching placement to top_and_bottom shows bottom banner',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 1}],
{
levelId: 'lvlsecret00000000000000000',
enabled: true,
placement: 'top',
},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Initially only top banner
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Switch placement to top_and_bottom and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveBtn2 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn2.click();
await expect(saveBtn2).toBeDisabled({timeout: 30000});
// Both banners should now be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Disabling classification markings entirely removes the banner even
* if the global banner was previously configured.
*/
test(
'MM-T6228 global banner: cleared when classification markings are disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top_and_bottom'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Both banners should be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
// Disable classification markings entirely
await page.locator('input[name="classificationEnabled"][value="false"]').click();
const saveBtn3 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn3.click();
await expect(saveBtn3).toBeDisabled({timeout: 30000});
// Banners should be gone
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Changes made by an admin propagate to a non-admin user's banner
* in real-time without requiring a page reload.
*/
test(
'MM-T6230 global banner: propagates to non-admin users via websocket',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminClient, user} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
// Login the non-admin user
const {channelsPage: userChannelsPage} = await pw.testBrowser.login(user);
await userChannelsPage.goto();
await userChannelsPage.toBeVisible();
const userTopBanner = userChannelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(userTopBanner).toBeVisible();
await expect(userTopBanner).toContainText('UNCLASSIFIED');
// Admin changes the banner level
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvlsecret00000000000000000', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvlsecret00000000000000000', enabled: true, placement: 'top'},
);
// The non-admin user should see the updated banner via websocket
await expect(userTopBanner).toContainText('SECRET');
await expect(userTopBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Text color adapts for readability: dark text on light background,
* white text on dark background.
* Color is now derived from the level's color in attrs.options (not stored separately).
*/
test(
'MM-T6229 global banner: text color contrasts with background for readability',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Light background (#FFFFFF) — text should be dark (#000000)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvlunclassified00000000000', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
{levelId: 'lvlunclassified00000000000', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(0, 0, 0)');
// Dark background (#000000) — text should be white (#FFFFFF)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvltopsecret00000000000000', name: 'TOP SECRET', color: '#000000', rank: 1}],
{levelId: 'lvltopsecret00000000000000', enabled: true, placement: 'top'},
);
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(255, 255, 255)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
});