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,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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user