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