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,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PropertyField} from '@mattermost/types/properties';
import type {SystemConsolePage} from '@mattermost/playwright-lib';
const PROPERTY_GROUP = 'access_control';
export type ChannelDisplayLocation = 'display_label_header' | 'display_label_info' | 'display_banner_top';
export type ChannelAttributeConfig = {
displayName: string;
type?: 'Select' | 'Multiselect' | 'Text';
options?: string[];
required?: boolean;
// The menu label, e.g. 'Cannot be changed once set'. Left alone the row keeps
// its default, 'Can be changed at any time'.
changePolicy?: string;
displayLocations?: ChannelDisplayLocation[];
};
/**
* Mirrors slugifyForCEL in webapp utils/properties.ts, which is what the console
* derives the unique name from as the display name is typed.
*/
export function derivedAttributeName(displayName: string): string {
let slug = displayName
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_');
if (/^[0-9]/.test(slug)) {
slug = '_' + slug;
}
return slug.replace(/_+/g, '_').replace(/_+$/, '') || '_copy';
}
/**
* Creates an attribute and its Channels resource entirely through the System
* Console UI, and returns the name the console derived for it.
*/
export async function configureChannelAttribute(
systemConsolePage: SystemConsolePage,
{displayName, type, options = [], required = false, changePolicy, displayLocations = []}: ChannelAttributeConfig,
): Promise<string> {
const {globalAttributes} = systemConsolePage;
const {attributeAppliesToChannels} = globalAttributes;
await globalAttributes.gotoNewAttribute();
await globalAttributes.setDisplayName(displayName);
if (type && type !== 'Text') {
await globalAttributes.selectType(type);
}
if (options.length) {
await globalAttributes.addOptions(options);
}
await attributeAppliesToChannels.addResource();
await attributeAppliesToChannels.setRequired(required);
if (changePolicy) {
await attributeAppliesToChannels.setChangePolicy(changePolicy);
}
await attributeAppliesToChannels.setDisplayLocations(displayLocations);
await globalAttributes.save();
return derivedAttributeName(displayName);
}
export async function findChannelField(adminClient: Client4, name: string): Promise<PropertyField | undefined> {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, 'channel', 'system', undefined, {perPage: 200});
return fields.find((field) => field.name === name && field.delete_at === 0);
}
export async function deleteChannelFieldIfExists(adminClient: Client4, name: string) {
try {
const field = await findChannelField(adminClient, name);
if (field) {
await adminClient.deletePropertyField(PROPERTY_GROUP, 'channel', field.id);
}
} catch {
// May not exist, or the property routes may be unavailable; ignore.
}
}

View File

@@ -0,0 +1,390 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — applying an attribute to channels.
*
* The Channels resource row writes the channel keys onto a linked channel field,
* and the channel surfaces honour them. Every test here configures through the
* console UI and then checks what a user sees, with no API seeding in between.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {configureChannelAttribute, deleteChannelFieldIfExists, findChannelField} from './applies_to_helpers';
import {
deleteGlobalAttributeFieldIfExists,
requireGlobalAttributesEnabled,
setGlobalAttributesFeatureFlag,
} from './global_attributes_helpers';
test.describe(
'System Console - applying an attribute to channels',
{tag: ['@system_console', '@channel_attributes']},
() => {
// Shares the server-wide GlobalAttributes flag with the sibling spec.
test.describe.configure({mode: 'serial'});
let originalFlagValue: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalFlagValue = FeatureFlags.GlobalAttributes === true;
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (adminClient && originalFlagValue !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalFlagValue);
}
});
/**
* @objective Ensure the Channels row writes every channel key onto a linked channel field.
*/
test('creates a linked channel field carrying the configured keys', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
const displayName = `Program ${suffix}`;
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
// # Apply it to channels, required, shown in the header and the banner
name = await configureChannelAttribute(systemConsolePage, {
displayName,
required: true,
displayLocations: ['display_label_header', 'display_banner_top'],
});
// * The linked channel field carries every configured key
const channelField = await findChannelField(adminClient, name);
expect(channelField).toBeDefined();
expect(channelField?.linked_field_id).toBeTruthy();
expect(channelField?.object_type).toBe('channel');
// The console has no control for this, so the payload has to pin it: the
// server would otherwise default a channel field to any member.
expect(channelField?.permission_values).toBe('admin');
expect(channelField?.attrs?.required).toBe(true);
expect(channelField?.attrs?.actions).toEqual(['display_label_header', 'display_banner_top']);
// * Options stay on the template; a linked field never carries its own
expect(channelField?.attrs?.options).toBeUndefined();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure an attribute configured through the System Console is asked for at
* channel creation and rendered afterwards, with no API seeding anywhere in the path.
*/
test('an attribute configured here is required when creating a channel and shown in its header', async ({
pw,
}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Caveat ${suffix}`,
type: 'Select',
options: ['NOFORN'],
required: true,
displayLocations: ['display_label_header'],
});
// # Create a channel as the same admin, who is a member of a team
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// * The attribute is asked for, because it was marked required here
await expect(channelsPage.page.getByTestId(`channelAttributeRow-${name}`)).toBeVisible();
await modal.fillDisplayName(`Attr Console ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('NOFORN', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value renders as a header chip, because it was designated here
await expect(channelsPage.centerView.header.attributes.chip('NOFORN')).toBeVisible();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the Channel Info display location puts the attribute in Channel Info only.
*/
test('shows a Channel-Info-only attribute in Channel Info and not in the header', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Info ${suffix}`,
type: 'Select',
options: ['INTERNAL'],
required: true,
displayLocations: ['display_label_info'],
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Info ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('INTERNAL', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The inline header slot carries it, the row-below slot does not
await expect(channelsPage.centerView.header.infoAttributes.chip('INTERNAL')).toBeVisible();
await expect(channelsPage.centerView.header.attributes.chip('INTERNAL')).toHaveCount(0);
// * And Channel Info carries it, as it does for every attribute
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('INTERNAL');
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the Banner display location renders a banner and nothing else.
*/
test('renders a Banner-only attribute as a banner, with no chip', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Marking ${suffix}`,
type: 'Select',
options: ['RESTRICTED'],
required: true,
displayLocations: ['display_banner_top'],
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('RESTRICTED', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value drives the banner and stays out of the header
await expect(channelsPage.page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
await expect(channelsPage.centerView.header.attributes.chip('RESTRICTED')).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure an attribute with no display location is stored and shown nowhere.
*/
test('stores a value for an attribute with no display location and renders it nowhere', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Quiet ${suffix}`,
type: 'Select',
options: ['QUIET'],
required: true,
});
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const displayName = `Attr Quiet ${suffix}`;
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(displayName);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('QUIET', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * The value reached the store
const channel = await adminClient.getChannelByName(
team.id,
displayName.toLowerCase().replace(/\s+/g, '-'),
);
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.length).toBeGreaterThan(0);
// * And is rendered on no display surface
await expect(channelsPage.centerView.header.attributes.chip('QUIET')).toHaveCount(0);
await expect(channelsPage.centerView.header.infoAttributes.chip('QUIET')).toHaveCount(0);
await expect(channelsPage.page.getByTestId('channel_banner_container')).toHaveCount(0);
// * Channel Info still lists it: the display locations govern the
// header and banner, never whether an admin can reach a value
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('QUIET');
await expect(info.attributes.editButton(name)).toBeVisible();
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure "Cannot be changed once set" locks the value in Channel Info.
*/
test('locks the value in Channel Info when the change policy forbids changes', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Locked ${suffix}`,
type: 'Select',
options: ['FINAL'],
required: true,
changePolicy: 'Cannot be changed once set',
displayLocations: ['display_label_info'],
});
// * The console wrote both keys: change_policy, and the editable key the
// * channel UI still reads
const channelField = await findChannelField(adminClient, name);
expect(channelField?.attrs?.change_policy).toBe('never');
expect(channelField?.attrs?.editable).toBe(false);
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Locked ${suffix}`);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('FINAL', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// * Once set at creation the value is read-only, even to a system admin
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.lock(name)).toBeVisible();
await expect(info.attributes.editButton(name)).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure the channel-admin tier the console pins reaches the channel
* field, so an attribute configured here is not editable by an ordinary member.
*
* There is no control for this, which is what makes the assertion worth keeping:
* the tier is only correct as long as the payload keeps pinning it.
*/
test('keeps a console-configured attribute out of a plain member reach', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const suffix = pw.random.id();
let name = '';
try {
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
name = await configureChannelAttribute(systemConsolePage, {
displayName: `Restricted ${suffix}`,
type: 'Select',
options: ['SET'],
required: true,
displayLocations: ['display_label_info'],
});
const channelField = await findChannelField(adminClient, name);
expect(channelField?.permission_values).toBe('admin');
const {team, user} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const channelDisplayName = `Attr Restricted ${suffix}`;
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(channelDisplayName);
await channelsPage.page.getByTestId(`channelAttribute-${name}`).click();
await channelsPage.page.getByText('SET', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(
team.id,
channelDisplayName.toLowerCase().replace(/\s+/g, '-'),
);
await adminClient.addToChannel(user.id, channel.id);
// # Look at the same attribute as an ordinary member
const asMember = await pw.testBrowser.login(user);
await asMember.channelsPage.goto(team.name, channel.name);
await asMember.channelsPage.toBeVisible();
// * The member reads the value but is offered no way to change it
const info = await asMember.channelsPage.openChannelInfo();
await expect(info.attributes.chip(name)).toHaveText('SET');
await expect(info.attributes.editButton(name)).toHaveCount(0);
} finally {
await deleteChannelFieldIfExists(adminClient, name);
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
},
);

View File

@@ -0,0 +1,262 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — classification's own attribute page.
*
* Classification's fields are created by the Classification Markings page, so the
* create-only card on the New attribute page can never reach them. This page is the
* only way to configure which resources classification applies to without the API,
* which is what these tests exercise.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {
deleteClassificationFieldsIfExist,
setClassificationMarkingsFeatureFlag,
setupClassificationWithChannelField,
} from '../../channels/channel_classification/helpers';
import {findChannelField} from './applies_to_helpers';
import {requireGlobalAttributesEnabled, setGlobalAttributesFeatureFlag} from './global_attributes_helpers';
test.describe(
'System Console - the classification attribute page',
{tag: ['@system_console', '@channel_attributes']},
() => {
// Shares the server-wide GlobalAttributes and ClassificationMarkings flags with
// its sibling specs.
test.describe.configure({mode: 'serial'});
let originalGlobalAttributes: boolean | undefined;
let originalClassificationMarkings: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalGlobalAttributes = FeatureFlags.GlobalAttributes === true;
originalClassificationMarkings = FeatureFlags.ClassificationMarkings === true;
await setClassificationMarkingsFeatureFlag(adminClient, true);
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (!adminClient) {
return;
}
await deleteClassificationFieldsIfExist(adminClient);
if (originalGlobalAttributes !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalGlobalAttributes);
}
if (originalClassificationMarkings !== undefined) {
await setClassificationMarkingsFeatureFlag(adminClient, originalClassificationMarkings);
}
});
/**
* @objective Verify the definition is shown read-only, with the levels editor a link away.
*/
test('shows the definition read-only and links to Classification Markings', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
// * Every level is listed, and none of them is editable here
for (const level of levels) {
await expect(globalAttributes.classificationLevels).toContainText(level.name);
}
await expect(
systemConsolePage.page.getByTestId('classificationAttribute').getByRole('textbox'),
).toHaveCount(0);
// * The one place levels can be changed is a link away
await expect(globalAttributes.classificationMarkingsLink).toHaveAttribute(
'href',
'/admin_console/site_config/classification_markings',
);
});
/**
* @objective Verify a display location chosen here reaches the channel field and the channel header.
*/
test('applies a header chip to channels once Header is chosen', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team, user} = await pw.initSetup();
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
await globalAttributes.appliesToChannels.setDisplayLocations(['display_label_header']);
await globalAttributes.saveInPlace();
// * The choice landed on the linked channel field
const channelField = await findChannelField(adminClient, 'classification');
expect(channelField?.attrs?.actions).toEqual(['display_label_header']);
// # Give a channel a classification, then look at it as a member
const channel = await adminClient.createChannel({
team_id: team.id,
name: `classification-attr-${pw.random.id()}`,
display_name: 'Classification Attr',
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelField!.id, value: levels[0].id},
] as Parameters<typeof adminClient.patchPropertyValues>[3]);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * The header carries the chip the console asked for
await expect(channelsPage.centerView.header.attributes.chip(levels[0].name)).toBeVisible();
});
/**
* @objective Verify classification is offered at channel creation while optional, and demanded once Required is on.
*
* Optional attributes are otherwise kept off the create dialog. Classification is
* the exception: it has always been offered there, and its own control in that
* dialog is suppressed once the ChannelAttributes flag is on, so the generic
* section has to carry it either way.
*/
test('offers classification at channel creation, and demands it once Required is on', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team} = await pw.initSetup();
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
// * Offered while optional, and Create is not held up by leaving it empty
let modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Optional Classification ${pw.random.id()}`);
await expect(channelsPage.page.getByTestId('channelAttribute-classification')).toBeVisible();
await expect(modal.createButton).toBeEnabled();
await modal.cancel();
// # Mark it required on its attribute page
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.globalAttributes.gotoClassificationAttribute();
await systemConsolePage.globalAttributes.appliesToChannels.setRequired(true);
await systemConsolePage.globalAttributes.saveInPlace();
const asAdmin = await pw.testBrowser.login(adminUser);
await asAdmin.channelsPage.goto(team.name);
await asAdmin.channelsPage.toBeVisible();
modal = await asAdmin.channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Required Classification ${pw.random.id()}`);
// * Now it holds up Create until a level is chosen
await expect(modal.createButton).toBeDisabled();
await asAdmin.channelsPage.page.getByTestId('channelAttribute-classification').click();
await asAdmin.channelsPage.page.getByText(levels[0].name, {exact: true}).click();
await expect(modal.createButton).toBeEnabled();
});
/**
* @objective Verify unticking Banner actually stops the banner.
*
* Classification banners on a field carrying no display locations at all, which is
* what keeps a server that predates this page behaving as it did. A configured
* field has to be obeyed instead, or the Banner checkbox would do nothing.
*/
test('stops bannering when the display locations exclude the banner', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {levels} = await setupClassificationWithChannelField(adminClient);
const {team, user} = await pw.initSetup();
const channelFieldBefore = await findChannelField(adminClient, 'classification');
const channel = await adminClient.createChannel({
team_id: team.id,
name: `classification-banner-${pw.random.id()}`,
display_name: 'Classification Banner',
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelFieldBefore!.id, value: levels[0].id},
] as Parameters<typeof adminClient.patchPropertyValues>[3]);
// # Configure Channel Info only — deliberately not the banner
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
await globalAttributes.gotoClassificationAttribute();
await globalAttributes.appliesToChannels.setDisplayLocations(['display_label_info']);
await globalAttributes.saveInPlace();
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * No banner, even though the channel has a classification
await expect(page.getByTestId('channel_banner_container')).toHaveCount(0);
});
/**
* @objective Verify removing the resource asks first, and that a removal is not undone by an unrelated save.
*/
test('removes the Channels resource only after confirming, and keeps it removed', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
await setupClassificationWithChannelField(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {globalAttributes} = systemConsolePage;
const page = systemConsolePage.page;
await globalAttributes.gotoClassificationAttribute();
// # Ask to remove, then back out
await globalAttributes.appliesToChannels.removeResource();
await page.getByRole('button', {name: 'Cancel'}).click();
// * Nothing was deleted
await expect(globalAttributes.appliesToChannels.row).toBeVisible();
expect(await findChannelField(adminClient, 'classification')).toBeDefined();
// # Ask again and confirm
await globalAttributes.appliesToChannels.removeResource();
await page.getByRole('button', {name: 'Remove and delete values'}).click();
// * The field is gone, and the card offers to add it back
await expect(globalAttributes.appliesToChannels.addResourceButton).toBeVisible();
expect(await findChannelField(adminClient, 'classification')).toBeUndefined();
// * Saving the Classification Markings page does not reinstate it: the field is
// * created on the transition into enabled, not on every save
await page.goto('/admin_console/site_config/classification_markings');
// Save only enables once something changes, so the save under test needs an
// edit -- one with nothing to do with the Channels resource. The cell keeps the
// typing local and only reports it on blur, so the edit has to be committed.
const levelName = page.getByRole('textbox', {name: 'Classification level name'}).first();
await levelName.fill('UNCLASSIFIED EDITED');
await levelName.blur();
await page.getByTestId('saveSetting').click();
await expect(page.getByTestId('saveSetting')).toBeDisabled();
expect(await findChannelField(adminClient, 'classification')).toBeUndefined();
});
},
);

View File

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {PropertyField} from '@mattermost/types/properties';
import {getAdminClient, licenseTier, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
export const GLOBAL_ATTRIBUTES_ADMIN_PATH = '/admin_console/system_attributes/manage_attributes';
// Canonical values: webapp/channels/src/components/admin_console/global_attributes/constants.ts
// (cross-package import not feasible between e2e-tests and webapp)
const PROPERTY_GROUP = 'access_control';
const OBJECT_TYPE = 'template';
const TARGET_TYPE = 'system';
// The three resource object types an Applies-to linked field can use. Also the
// only object types (besides 'template') PropertyField.IsValid allows a linked
// field to carry -- a template field itself is rejected for having a
// linked_field_id ("template fields cannot have a linked field").
// Canonical values: webapp/channels/.../attribute_details/attribute_applies_to_constants.ts
export type ResourceObjectType = 'user' | 'channel' | 'post';
const ALL_RESOURCE_OBJECT_TYPES: ResourceObjectType[] = ['user', 'channel', 'post'];
// Server clamps per_page to this max (see web.PerPageMaximum in server/channels/web/params.go).
// Directory-mode search with no cursor sorts CreateAt ASC, so the default 60-item page only
// returns the oldest fields — request the max to reduce the risk of missing newer ones.
const MAX_PROPERTY_FIELDS_PER_PAGE = 200;
/**
* 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_GLOBALATTRIBUTES).
*/
export async function setGlobalAttributesFeatureFlag(adminClient: Client4, enabled: boolean) {
await adminClient.patchConfig({
FeatureFlags: {
GlobalAttributes: enabled,
},
} as any);
}
/**
* Shared precondition for every test that needs the Attribute Management page actually
* reachable (as opposed to the flag-off gate test, which deliberately doesn't need this):
* skips on a sub-Enterprise license, enables the GlobalAttributes flag, and skips if the
* flag didn't actually take (e.g. env/SplitKey overrides). Returns the admin session.
*/
export async function requireGlobalAttributesEnabled(pw: PlaywrightExtended) {
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
const license = await adminClient.getClientLicenseOld();
test.skip(
licenseTier(license.SkuShortName) < 20,
'Attribute Management requires Enterprise-tier license (SkuShortName enterprise, entry, or advanced). ' +
'Professional is not sufficient—the admin route is hidden and redirects away.',
);
await setGlobalAttributesFeatureFlag(adminClient, true);
await pw.skipIfFeatureFlagNotSet('GlobalAttributes', true);
return {adminUser, adminClient};
}
/**
* Removes any access_control/template field with the given name (clean slate for E2E),
* ignoring failures — the property routes may be unavailable when the feature flag is off,
* or the field may simply not exist yet.
*/
export async function deleteGlobalAttributeFieldIfExists(adminClient: Client4, name: string) {
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
});
for (const field of fields.filter((f) => f.name === name && f.delete_at === 0)) {
await adminClient.deletePropertyField(PROPERTY_GROUP, OBJECT_TYPE, field.id);
}
} catch {
// May not exist, or routes unavailable; ignore.
}
}
/**
* Best-effort cleanup for an Applies-to save: delete linked fields first
* (looked up from the live template, not from the test's success-path locals)
* so the template delete is not 409'd by leftover dependents.
*/
export async function deleteAppliesToAttributeAndLinkedFieldsIfExists(adminClient: Client4, name: string) {
try {
const templates = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
});
for (const template of templates.filter((field) => field.name === name && field.delete_at === 0)) {
const linked = await fetchLinkedFieldsForTemplate(adminClient, template.id);
for (const field of linked) {
await deleteLinkedDependentField(adminClient, field.id, field.object_type as ResourceObjectType);
}
}
} catch {
// Listing may fail if the flag is off; still try the template delete below.
}
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
/**
* Creates an access_control/template property field for E2E seeding. Ensures a clean
* slate first so reruns don't collide with a field left over from a prior failed run.
*/
export async function createGlobalAttributeField(
adminClient: Client4,
name: string,
field: Partial<Parameters<Client4['createPropertyField']>[2]>,
) {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
return adminClient.createPropertyField(PROPERTY_GROUP, OBJECT_TYPE, {
name,
target_type: TARGET_TYPE,
target_id: '',
...field,
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Creates a field that links to `sourceFieldId` (e.g. the template). Two uses:
* seeding an already-saved Applies-to resource for a given `objectType`
* (defaulting to 'user', the one pre-existing call site's shape), and making
* the server refuse to delete the source field -- deletePropertyField counts
* live linked dependents and returns 409 `has_linked_dependents` when any
* exist (server/channels/app/properties/property_field.go), which this is
* the only way to exercise against a real server response. The create flow
* for a fresh Applies-to resource itself is exercised through the UI, not
* this helper.
*/
export async function createLinkedDependentField(
adminClient: Client4,
name: string,
sourceFieldId: string,
type: string,
objectType: ResourceObjectType = 'user',
attrs?: Record<string, unknown>,
) {
return adminClient.createPropertyField(PROPERTY_GROUP, objectType, {
name,
type,
target_type: TARGET_TYPE,
target_id: '',
linked_field_id: sourceFieldId,
...(attrs ? {attrs} : {}),
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Deletes a linked property field, ignoring failures -- mirrors deleteGlobalAttributeFieldIfExists'
* best-effort cleanup style, since a test's own save/rollback assertions may have already deleted it.
* Must run BEFORE deleting the field it points at -- the source delete stays blocked with a 409
* for as long as a live dependent exists.
*/
export async function deleteLinkedDependentField(
adminClient: Client4,
fieldId: string,
objectType: ResourceObjectType = 'user',
) {
try {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, fieldId);
} catch {
// May already be gone (e.g. a prior rollback already deleted it); ignore.
}
}
/**
* Finds every linked field (across all three resource object types) pointing at `templateFieldId`.
* Queries user/channel/post separately -- there is no single "all object types" listing endpoint --
* and requests the max page size per call, since the `user` object type's result page is shared
* with every Custom Profile Attributes field on the server (see deleteGlobalAttributeFieldIfExists'
* own MAX_PROPERTY_FIELDS_PER_PAGE comment) and a freshly-created linked field is exactly the kind
* of newest-row a default ascending-CreateAt page can drop.
*/
export async function fetchLinkedFieldsForTemplate(
adminClient: Client4,
templateFieldId: string,
): Promise<PropertyField[]> {
const results = await Promise.all(
ALL_RESOURCE_OBJECT_TYPES.map((objectType) =>
adminClient.getPropertyFields(PROPERTY_GROUP, objectType, TARGET_TYPE, undefined, {
perPage: MAX_PROPERTY_FIELDS_PER_PAGE,
}),
),
);
return results.flat().filter((field) => field.linked_field_id === templateFieldId && field.delete_at === 0);
}

View File

@@ -0,0 +1,588 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* System Console — Global Attributes access gate and attribute listing.
* Visibility is gated by the GlobalAttributes feature flag AND an Enterprise-tier license.
* Once past the gate, the page lists every access_control/template property field on the server.
*
* Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`.
* Professional-only licenses hide this admin route (React Router redirects away).
*
* Flag-off access-gate tests live only in this file. The form spec assumes the flag is on
* and must not turn it off — both files share a server, and default PW_WORKERS is 1.
*/
import {expect, test, getAdminClient} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
} from '../site_configuration/classification_markings_helpers';
import {
GLOBAL_ATTRIBUTES_ADMIN_PATH,
createGlobalAttributeField,
createLinkedDependentField,
deleteGlobalAttributeFieldIfExists,
deleteLinkedDependentField,
requireGlobalAttributesEnabled,
setGlobalAttributesFeatureFlag,
} from './global_attributes_helpers';
test.describe('System Console - Global Attributes listing', {tag: '@system_console'}, () => {
// Access-gate tests toggle the server-wide GlobalAttributes flag. Stay serial so a
// flag-off assertion cannot race a later listing/delete test in this file.
test.describe.configure({mode: 'serial'});
let originalFlagValue: boolean | undefined;
let originalClassificationFlagValue: boolean | undefined;
test.beforeAll(async () => {
const {adminClient} = await getAdminClient();
const {FeatureFlags} = await adminClient.getConfig();
originalFlagValue = FeatureFlags.GlobalAttributes === true;
originalClassificationFlagValue = FeatureFlags.ClassificationMarkings === true;
});
test.afterAll(async () => {
const {adminClient} = await getAdminClient();
if (adminClient && originalFlagValue !== undefined) {
await setGlobalAttributesFeatureFlag(adminClient, originalFlagValue);
}
if (adminClient && originalClassificationFlagValue !== undefined) {
await setClassificationMarkingsFeatureFlag(adminClient, originalClassificationFlagValue);
}
});
test.describe('access gate', () => {
/**
* @objective Ensure the Attribute Management admin route is unavailable when the feature flag is off.
*/
test('feature flag off hides Attribute Management regardless of license', async ({pw}) => {
const {adminUser, adminClient} = await getAdminClient();
if (!adminUser || !adminClient) {
throw new Error('Failed to get admin user');
}
// # Turn off GlobalAttributes in server config
await setGlobalAttributesFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.GlobalAttributes === true,
'GlobalAttributes stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.',
);
// # Navigate directly to the Attribute Management path
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * User is redirected away from the hidden route (no Route registered)
await expect(systemConsolePage.page).not.toHaveURL(/manage_attributes/);
// * Attribute Management menu entry is not shown in the sidebar
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('Attribute Management'),
).not.toBeVisible();
});
/**
* @objective Ensure the Attribute Management page is reachable and shows its page frame
* once the feature flag is on and the license meets the Enterprise tier.
*/
test('feature flag on with Enterprise+ license shows the page frame', async ({pw}) => {
const {adminUser} = await requireGlobalAttributesEnabled(pw);
// # Log in and open the Attribute Management URL
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * URL stays on the Attribute Management section
await expect(systemConsolePage.page).toHaveURL(/manage_attributes/);
// * Sidebar menu entry and page heading are both visible ("Attribute Management"
// renders in both places, so each is asserted within its own scope)
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('Attribute Management'),
).toBeVisible();
await expect(
systemConsolePage.page.getByTestId('admin-console-header').getByText('Attribute Management'),
).toBeVisible();
// * The old User Attributes sidebar entry is replaced by Attribute Management
await expect(
systemConsolePage.page.getByTestId('admin-sidebar').getByText('User Attributes', {exact: true}),
).not.toBeVisible();
// * Page frame's static subtitle is present (renders regardless of fetch state)
await expect(
systemConsolePage.page.getByText('Define an attribute once, then choose which resources can use it.'),
).toBeVisible();
});
});
test.describe('listing', () => {
/**
* @objective Ensure a real access_control/template attribute renders in the table with
* its display name, type icon+label, source, and options count — across every field
* type the ticket's Type column has a mapping for (Text/Select/Multiselect/Ranked),
* plus one unmapped type (date) to prove the fallback also holds end-to-end.
*/
test('renders one seeded field per type with correct Attribute/Type/Source/Options values', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
// Covers the "Managed here" Source branch (all of these) alongside every Type/Options
// combination reachable through the admin API — the plugin+protected Source branch is
// unit-test-only since the admin API blocks source_plugin_id/protected from non-plugin
// callers, and rank uses type: 'rank' directly (the same type Classification Markings'
// own saveCreateField creates), not the select-based seeding some older e2e helpers use.
// displayName embeds the same per-run timestamp as name — required so the row locator
// below can't collide with another concurrently-running browser project's seeded row
// (this suite's projects share one server/worker pool; see playwright.config.ts).
const seeds = [
{
name: `e2e_global_attribute_text_${timestamp}`,
displayName: `E2E Text Attribute ${timestamp}`,
type: 'text',
attrs: {},
expectedType: 'Text',
expectedOptions: 'Free Text',
},
{
name: `e2e_global_attribute_select_${timestamp}`,
displayName: `E2E Select Attribute ${timestamp}`,
type: 'select',
attrs: {
options: [
{id: '', name: 'Option A'},
{id: '', name: 'Option B'},
],
},
expectedType: 'Select',
expectedOptions: '2 options',
},
{
name: `e2e_global_attribute_multiselect_${timestamp}`,
displayName: `E2E Multiselect Attribute ${timestamp}`,
type: 'multiselect',
attrs: {
options: [
{id: '', name: 'Option A'},
{id: '', name: 'Option B'},
{id: '', name: 'Option C'},
],
},
expectedType: 'Multiselect',
expectedOptions: '3 options',
},
{
name: `e2e_global_attribute_rank_${timestamp}`,
displayName: `E2E Ranked Attribute ${timestamp}`,
type: 'rank',
attrs: {
options: [
{id: '', name: 'Low', rank: 1},
{id: '', name: 'High', rank: 2},
],
},
expectedType: 'Ranked',
expectedOptions: '2 options',
},
{
name: `e2e_global_attribute_date_${timestamp}`,
displayName: `E2E Date Attribute ${timestamp}`,
type: 'date',
attrs: {},
expectedType: 'Other',
expectedOptions: 'Free Text',
},
] as const;
try {
// # Seed every field inside the try block — if creation fails partway
// through (e.g. field 3 of 5), the finally below still cleans up whatever
// was already created instead of leaving it orphaned on the shared server.
for (const seed of seeds) {
await createGlobalAttributeField(adminClient, seed.name, {
type: seed.type,
attrs: {display_name: seed.displayName, ...seed.attrs},
});
}
// # Log in and open the Attribute Management page once every field is seeded
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
for (const seed of seeds) {
// Re-rooted `has` probe on the name cell's testid+text, mirroring the
// established row-lookup pattern (see board_attributes.ts's rowByName).
const row = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: seed.displayName}),
});
// * Each seeded field's display name, type, source, and options render correctly
await expect(row.getByTestId('global-attribute-name')).toHaveText(seed.displayName);
await expect(row.getByTestId('global-attribute-type')).toContainText(seed.expectedType);
// * A leading icon renders alongside the label, including for the
// unmapped "date" type (fallback icon, not a blank cell)
await expect(row.getByTestId('global-attribute-type').locator('svg')).toBeVisible();
await expect(row.getByTestId('global-attribute-source')).toContainText('Managed here');
// * "Managed here" is the one source kind reachable through the admin
// API (plugin/ldap/saml require server-side attrs the API blocks from
// non-plugin callers — those icon mappings are unit-test-only) and it
// renders with no leading icon, unlike plugin/ldap/saml sources
await expect(row.getByTestId('global-attribute-source').locator('svg')).toHaveCount(0);
await expect(row.getByTestId('global-attribute-options')).toContainText(seed.expectedOptions);
}
} finally {
// # Clean up regardless of assertion outcome, so reruns start from a clean slate
for (const seed of seeds) {
await deleteGlobalAttributeFieldIfExists(adminClient, seed.name);
}
}
});
/**
* @objective Ensure the table sorts by the same value shown in the Attribute column
* (display_name, falling back to name) rather than by the hidden internal name.
*/
test('sorts rows by the displayed Attribute value, not the internal field name', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
// Internal names sort z-then-a; display names (what the table actually shows and
// must sort by) sort a-then-z. If the table sorted by the internal name instead,
// these rows would render in the opposite order.
const firstName = `zzz_e2e_sort_${timestamp}`;
const secondName = `aaa_e2e_sort_${timestamp}`;
const firstDisplayName = `Aardvark E2E Sort Attribute ${timestamp}`;
const secondDisplayName = `Zebra E2E Sort Attribute ${timestamp}`;
// No display_name set — the rendered (and sorted-by) value falls back to this
// internal name directly. Chosen to alphabetically land between the two display
// names above, so its position in the rendered order proves the fallback value
// participates in sorting correctly alongside explicit display names, not just
// in isolation (the unit tests already cover the fallback alone).
const thirdName = `mmm_e2e_sort_fallback_${timestamp}`;
try {
// # Seed all three fields inside the try block — if creation fails
// partway through, the finally below still cleans up whatever was
// already created instead of leaving it orphaned on the shared server.
await createGlobalAttributeField(adminClient, firstName, {
type: 'text',
attrs: {display_name: firstDisplayName},
});
await createGlobalAttributeField(adminClient, secondName, {
type: 'text',
attrs: {display_name: secondDisplayName},
});
await createGlobalAttributeField(adminClient, thirdName, {
type: 'text',
attrs: {},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
await systemConsolePage.page.getByTestId('global-attribute-name').getByText(firstDisplayName).waitFor();
// * The fallback field renders under its internal name, since no display_name was set
await expect(
systemConsolePage.page.getByTestId('global-attribute-name').getByText(thirdName),
).toBeVisible();
// * Rendered order follows the displayed Attribute value (Aardvark, then the
// fallback name, then Zebra) — not the internal name (which would put "aaa_..."
// first if sorted that way)
const names = await systemConsolePage.page.getByTestId('global-attribute-name').allTextContents();
const firstIndex = names.indexOf(firstDisplayName);
const thirdIndex = names.indexOf(thirdName);
const secondIndex = names.indexOf(secondDisplayName);
expect(firstIndex).toBeGreaterThanOrEqual(0);
expect(thirdIndex).toBeGreaterThanOrEqual(0);
expect(secondIndex).toBeGreaterThanOrEqual(0);
expect(firstIndex).toBeLessThan(thirdIndex);
expect(thirdIndex).toBeLessThan(secondIndex);
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, firstName);
await deleteGlobalAttributeFieldIfExists(adminClient, secondName);
await deleteGlobalAttributeFieldIfExists(adminClient, thirdName);
}
});
/**
* @objective Ensure a real Classification Markings field (name/object_type/group_id
* matching production's saveCreateField) renders the read-only subtitle and an
* open-in-new link to its own admin page instead of the ordinary dot-menu, and that an
* unrelated field — including one that shares the same 'rank' type — is entirely unaffected.
*/
test(
'renders the Classification Markings row as a read-only open-in-new link, leaving an unrelated rank field unaffected',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
// # The link's destination page is gated by its own independent feature flag
// (ClassificationMarkings), separate from the GlobalAttributes flag gating this
// listing page — both must be on for the link to render.
// Tagged @classification_markings like every other spec that touches this same
// shared server-wide field/flag (classification_markings.spec.ts,
// global_classification_banner.spec.ts) — those specs are NOT otherwise
// concurrency-guarded against each other; the tag is this suite's existing
// (if informal) convention for grouping tests that share this exact resource.
await setClassificationMarkingsFeatureFlag(adminClient, true);
await pw.skipIfFeatureFlagNotSet('ClassificationMarkings', true);
const timestamp = Date.now();
const classificationDisplayName = `E2E Classification Attribute ${timestamp}`;
const unrelatedRankName = `e2e_unrelated_rank_${timestamp}`;
const unrelatedRankDisplayName = `E2E Unrelated Ranked Attribute ${timestamp}`;
try {
// # Clean slate first via the classification-aware helper (not the generic
// deleteGlobalAttributeFieldIfExists): it also removes any linked system/channel
// field first, avoiding server-side deletion-protection errors that leave a
// stale template field behind if another classification spec ran previously.
await deleteClassificationMarkingsFieldIfExists(adminClient);
// # Seed the real classification field: name 'classification', type 'rank',
// matching classification_markings/utils/index.ts's saveCreateField exactly —
// not the select-based shape some older, test-only e2e helpers use.
await createGlobalAttributeField(adminClient, 'classification', {
type: 'rank',
attrs: {
display_name: classificationDisplayName,
options: [
{id: '', name: 'Unclassified', rank: 1},
{id: '', name: 'Secret', rank: 2},
],
},
});
// # Seed an unrelated field that shares the same 'rank' type, to prove the
// predicate keys on name/object_type/group_id, not on type.
await createGlobalAttributeField(adminClient, unrelatedRankName, {
type: 'rank',
attrs: {
display_name: unrelatedRankDisplayName,
options: [
{id: '', name: 'Low', rank: 1},
{id: '', name: 'High', rank: 2},
],
},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const classificationRow = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: classificationDisplayName}),
});
await classificationRow.waitFor();
// * The read-only subtitle renders under the attribute name
await expect(classificationRow.getByText('Read-only')).toBeVisible();
// * The Source column identifies this row's true source, not the generic
// "Managed here" every other native field gets
await expect(classificationRow.getByTestId('global-attribute-source')).toContainText(
'Classification Markings',
);
// * The rightmost cell is an open-in-new link to the Classification Markings
// admin page, not the dot-menu action trigger
const openInNewLink = classificationRow.getByRole('link', {name: 'Open Classification Markings'});
await expect(openInNewLink).toBeVisible();
await expect(openInNewLink).toHaveAttribute('href', CLASSIFICATION_MARKINGS_ADMIN_PATH);
await expect(classificationRow.getByRole('button', {name: 'More actions'})).toHaveCount(0);
// # Clicking the link actually navigates to the Classification Markings page
await openInNewLink.click();
// * Navigation lands on the Classification Markings admin page
await expect(systemConsolePage.page).toHaveURL(new RegExp(CLASSIFICATION_MARKINGS_ADMIN_PATH));
await systemConsolePage.page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
// * An unrelated 'rank'-type field is unaffected: ordinary dot-menu, no subtitle
const unrelatedRow = systemConsolePage.page.locator('tr', {
has: systemConsolePage.page
.getByTestId('global-attribute-name')
.filter({hasText: unrelatedRankDisplayName}),
});
await unrelatedRow.waitFor();
await expect(unrelatedRow.getByRole('button', {name: 'More actions'})).toBeVisible();
await expect(unrelatedRow.getByText('Read-only')).toHaveCount(0);
await expect(unrelatedRow.getByRole('link', {name: 'Open Classification Markings'})).toHaveCount(0);
await expect(unrelatedRow.getByTestId('global-attribute-source')).toContainText('Managed here');
} finally {
await deleteClassificationMarkingsFieldIfExists(adminClient);
await deleteGlobalAttributeFieldIfExists(adminClient, unrelatedRankName);
}
},
);
});
test.describe('delete attribute', () => {
/**
* @objective Ensure the row kebab's Delete action removes the attribute end-to-end:
* the confirmation names the attribute, and confirming drops the row from the table.
*/
test('deletes an attribute from the row menu after confirming, and the row disappears', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_delete_${timestamp}`;
const displayName = `E2E Delete Attribute ${timestamp}`;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Open the row kebab and click Delete
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
// * The confirmation names the specific attribute rather than prompting generically
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
// # Confirm
await page.getByRole('button', {name: 'Delete', exact: true}).click();
// * The row is gone and no error banner appeared
await expect(row).toHaveCount(0);
await expect(page.getByTestId('global-attributes-delete-error')).toHaveCount(0);
// * The delete really hit the server, not just the client store — a fresh
// page load still doesn't show it
await page.reload();
await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toHaveCount(0);
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure cancelling the confirmation is a true no-op — no delete call fires
* and the attribute survives a reload.
*/
test('leaves the attribute in place when the confirmation is cancelled', async ({pw}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_cancel_${timestamp}`;
const displayName = `E2E Cancel Attribute ${timestamp}`;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Open the row kebab, click Delete, then back out
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toBeVisible();
await page.getByRole('button', {name: 'Cancel'}).click();
// * The modal closed and the row survived
await expect(page.getByRole('heading', {name: `Delete ${displayName} attribute`})).toHaveCount(0);
await expect(row).toBeVisible();
// * Nothing was deleted server-side either
await page.reload();
await expect(page.getByTestId('global-attribute-name').filter({hasText: displayName})).toBeVisible();
} finally {
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
/**
* @objective Ensure a server-side 409 (the attribute still has live linked dependents)
* surfaces as the specific "still linked" banner above the table, not the generic error,
* and leaves the row intact. Exercised against a real 409 from the server rather than a
* stubbed rejection.
*/
test('shows the linked-dependents banner and keeps the row when the server refuses the delete', async ({
pw,
}) => {
const {adminUser, adminClient} = await requireGlobalAttributesEnabled(pw);
const timestamp = Date.now();
const name = `e2e_global_attribute_linked_${timestamp}`;
const displayName = `E2E Linked Attribute ${timestamp}`;
const dependentName = `e2e_global_attribute_dependent_${timestamp}`;
let dependentFieldId: string | undefined;
try {
const field = await createGlobalAttributeField(adminClient, name, {
type: 'text',
attrs: {display_name: displayName},
});
// # Point a dependent field at it, which is what makes the server refuse the delete
const dependent = await createLinkedDependentField(adminClient, dependentName, field.id, 'text');
dependentFieldId = dependent.id;
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(GLOBAL_ATTRIBUTES_ADMIN_PATH);
const row = page.locator('tr', {
has: page.getByTestId('global-attribute-name').filter({hasText: displayName}),
});
await expect(row).toBeVisible();
// # Try to delete it
await page.getByTestId(`global-attribute-actions-${field.id}`).click();
await page.locator(`#global-attribute-actions-${field.id}-delete`).click();
await page.getByRole('button', {name: 'Delete', exact: true}).click();
// * The banner explains the blocking dependency instead of the generic failure
const banner = page.getByTestId('global-attributes-delete-error');
await expect(banner).toBeVisible();
await expect(banner).toContainText('other attributes are still linked to it');
await expect(banner).not.toContainText('An error occurred while deleting this attribute');
// * The row survived the rejected delete
await expect(row).toBeVisible();
// # The banner is dismissible
await banner.getByRole('button', {name: 'Close'}).click();
await expect(banner).toHaveCount(0);
} finally {
// Dependent first: the source delete stays blocked while it exists
if (dependentFieldId) {
await deleteLinkedDependentField(adminClient, dependentFieldId);
}
await deleteGlobalAttributeFieldIfExists(adminClient, name);
}
});
});
});