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 Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (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,192 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
deleteAttributes,
optionId,
purgeAttributes,
readChannelValues,
valueFor,
} from './helpers';
test.describe('Channel attribute assignment', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify every supported attribute control can be assigned while creating a channel.
*/
test('assigns select, multiselect, and text attribute values at channel creation', async ({pw}) => {
await pw.skipIfNoLicense();
// The Properties route gate is evaluated when the API router is built, so
// the flag has to be in the server config before boot.
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// Required, because the create dialog asks only for required attributes.
const program = await createAttribute(adminClient, attributeName('program', suffix), {
options: ['AURORA', 'BOREALIS'],
required: true,
});
const caveats = await createAttribute(adminClient, attributeName('caveats', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
required: true,
});
const note = await createAttribute(adminClient, attributeName('note', suffix), {
type: 'text',
required: true,
});
const userScoped = await createAttribute(adminClient, attributeName('clearance', suffix), {
objectType: 'user',
options: ['SECRET'],
required: true,
});
created.push(program, caveats, note, userScoped);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// Only the channel-scoped attributes belong here; a user attribute in
// the same group must not leak into channel creation.
await expect(page.getByTestId(`channelAttributeRow-${program.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${caveats.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${note.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${userScoped.name}`)).toHaveCount(0);
const displayName = `Attr Channel ${suffix}`;
await modal.fillDisplayName(displayName);
await page.getByTestId(`channelAttribute-${program.name}`).click();
await page.getByText('AURORA', {exact: true}).click();
// The menu closes on each pick, so a multiselect needs reopening to
// add the second value. Two values is what proves the array shape.
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('NOFORN', {exact: true}).click();
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('ORCON', {exact: true}).click();
await page.getByLabel(note.name, {exact: true}).fill('handle with care');
await modal.create();
// The values ride the create request, so the modal closing means the
// channel and its values both landed. The channel header is already
// visible from before, so waiting on it proves nothing.
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
const values = await readChannelValues(adminClient, channel.id);
expect(valueFor(values, program)).toBe(optionId(program, 'AURORA'));
expect(valueFor(values, caveats)).toEqual([optionId(caveats, 'NOFORN'), optionId(caveats, 'ORCON')]);
expect(valueFor(values, note)).toBe('handle with care');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify attribute assignment works for a private channel, not only a public one.
*/
test('assigns attribute values when creating a private channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('private_program', suffix), {
options: ['AURORA', 'BOREALIS'],
required: true,
});
created.push(program);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
const displayName = `Attr Private ${suffix}`;
await modal.fillDisplayName(displayName);
await modal.privateTypeButton.click();
await page.getByTestId(`channelAttribute-${program.name}`).click();
await page.getByText('BOREALIS', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
expect(channel.type).toBe('P');
const values = await readChannelValues(adminClient, channel.id);
expect(valueFor(values, program)).toBe(optionId(program, 'BOREALIS'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the server refuses a channel that is missing a required value.
*/
test('rejects a create that omits a required attribute', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('required_api', suffix), {
options: ['AURORA'],
required: true,
});
created.push(program);
// Straight at the API, which is the point: the requirement holds for
// callers that never see the create dialog.
const name = `attr-api-${suffix}`;
await expect(
adminClient.createChannel({
team_id: team.id,
name,
display_name: `Attr API ${suffix}`,
type: 'O',
}),
).rejects.toThrow();
await expect(adminClient.getChannelByName(team.id, name)).rejects.toThrow();
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,595 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
TEST_LEVELS,
deleteClassificationFieldsIfExist,
setupClassificationWithChannelField,
} from '../channel_classification/helpers';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
attributeToken,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
const BANNER_COLOR = '#1e325c';
test.describe('Channel attribute banner composition', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify an attribute token can be inserted from Channel Settings and previews its resolved value.
*/
test('inserts an attribute token from the Attributes menu and previews the resolved text', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('preview', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-preview-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The composer seeds itself with the banner already on screen, so clear it
// to author from a known template.
// # Insert the attribute
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
// * The token reads as the attribute's label, not its machine name, and
// * the preview resolves it to this channel's value
await expect(configuration.bannerTokenChip(marking.name)).toBeVisible();
await expect(configuration.bannerTextEditor).not.toContainText(attributeToken(marking.name));
await expect(configuration.bannerTokenPreview).toContainText('RESTRICTED');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify every banner-designated attribute shares one banner, and that
* Channel Settings shows them as chips the channel cannot remove.
*/
test('composes one banner from every designated attribute and locks their chips', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('multi_marking', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
sortOrder: 1,
});
const programme = await createAttribute(adminClient, attributeName('multi_programme', suffix), {
type: 'text',
actions: [DISPLAY_BANNER_TOP],
sortOrder: 2,
});
created.push(marking, programme);
const channel = await createChannelForAttributes(adminClient, team, `banner-multi-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
await setChannelValue(adminClient, channel.id, programme, 'AURORA');
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * Both designated attributes share the one banner, in sort order
await expect(channelsPage.page.getByTestId('channel_banner_text')).toHaveText('SECRET · AURORA');
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * The composer opens showing what the banner is made of
await expect(configuration.bannerTokenChip(marking.name)).toBeVisible();
await expect(configuration.bannerTokenChip(programme.name)).toBeVisible();
// * Designated attributes cannot be taken out of the banner
await expect(configuration.bannerTokenChipRemove(marking.name)).toHaveCount(0);
await expect(configuration.bannerTokenChipRemove(programme.name)).toHaveCount(0);
// * Seeding those chips is not an edit, so the tab opens clean
await expect(configuration.container.getByTestId('SaveChangesPanel__save-btn')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the banner section reads as enabled when an attribute drives the
* banner, even though banner_info stays disabled for that channel.
*/
test('shows the banner section as on when an attribute drives the banner', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('driven', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-driven-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * The channel shows a banner, so its settings must not claim it is off
await expect(channelsPage.page.getByTestId('channel_banner_text')).toContainText('SECRET');
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await expect(configuration.container.getByTestId('channelBannerToggle-button')).toHaveAttribute(
'aria-pressed',
'true',
);
await expect(configuration.bannerTextEditor).toBeVisible();
// * Opening the tab changes nothing, so it must not offer to save
await expect(configuration.container.getByTestId('SaveChangesPanel__save-btn')).toHaveCount(0);
// * The preview still shows what members see, from the value alone
await expect(configuration.bannerTokenPreview).toContainText('SECRET');
// * banner_info itself stays disabled: the value is what renders the banner
const stored = await adminClient.getChannel(channel.id);
expect(stored.banner_info?.enabled ?? false).toBe(false);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner authored as custom text plus a token renders both in the channel.
*/
test('saves a banner mixing custom text with a token', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('mixed', suffix), {
options: ['NOFORN'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {NOFORN: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-mixed-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'NOFORN'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.typeBannerText('Handling: ');
await configuration.insertBannerToken(marking.name);
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
// * The channel banner shows the literal and the resolved token together
await channelsPage.centerView.assertChannelBanner('Handling: NOFORN', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a separator between two tokens is dropped when one of them has no value.
*/
test('tidies the separator when one of two tokens is unset', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('tidy_set', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
sortOrder: 0,
});
// Deliberately left without a value on this channel.
const program = await createAttribute(adminClient, attributeName('tidy_unset', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_INFO],
sortOrder: 1,
});
created.push(marking, program);
const channel = await createChannelForAttributes(adminClient, team, `banner-tidy-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'SECRET'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
await configuration.typeBannerText(' · ');
await configuration.insertBannerToken(program.name);
// * The stranded separator is collapsed, not rendered next to nothing
await expect(configuration.bannerTokenPreview).toContainText('SECRET');
await expect(configuration.bannerTokenPreview).not.toContainText('·');
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
await channelsPage.centerView.assertChannelBanner('SECRET', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a template whose tokens are all unset says so rather than previewing a blank line.
*/
test('says so when every token in the template is unset', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('empty_preview', suffix), {
options: ['UNUSED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {UNUSED: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-empty-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.insertBannerToken(marking.name);
// * The preview names the empty result instead of rendering nothing
await expect(configuration.bannerTokenPreview).toContainText('no values are set');
await settings.close();
// * And no banner is rendered from a template that resolves to nothing
await channelsPage.centerView.assertChannelBannerNotVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner keeps resolving after the attribute's display name changes,
* because tokens key off the machine name.
*/
test('keeps resolving after the attribute display name changes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('renamed', suffix), {
options: ['ORCON'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {ORCON: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `banner-rename-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'ORCON'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
await configuration.enableChannelBanner();
await configuration.clearBannerText();
await configuration.insertBannerToken(marking.name);
await configuration.setChannelBannerBackgroundColor(BANNER_COLOR.replace('#', ''));
await configuration.save();
await settings.close();
// * Exactly what was authored is persisted, tokens unresolved
const saved = await adminClient.getChannel(channel.id);
expect(saved.banner_info?.text).toBe(attributeToken(marking.name));
await channelsPage.centerView.assertChannelBanner('ORCON', BANNER_COLOR);
// # Rename the attribute as an administrator would
await adminClient.patchPropertyField('access_control', 'channel', marking.id, {
attrs: {...marking.attrs, display_name: `Renamed ${suffix}`},
} as never);
// * The authored token still resolves, because it keys off the machine name
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await channelsPage.centerView.assertChannelBanner('ORCON', BANNER_COLOR);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify classification is composed as one attribute among many once channel
* attributes are on, rather than through its own dedicated controls.
*/
test('treats classification as one attribute among many', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const classification = await createAttribute(adminClient, attributeName('classification', suffix), {
options: ['SECRET'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {SECRET: BANNER_COLOR},
});
created.push(classification);
const channel = await createChannelForAttributes(adminClient, team, `banner-class-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, classification, optionId(classification, 'SECRET'));
const {page, channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * The dedicated classification controls are gone
await expect(page.locator('#channelClassificationToggle')).toHaveCount(0);
await expect(page.getByTestId('channelClassificationLevel')).toHaveCount(0);
// * The same marking is offered as an ordinary banner token instead
await configuration.enableChannelBanner();
await configuration.bannerTokenButton.click();
await expect(page.getByTestId(`bannerAttributeToken-${classification.name}`)).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify that a non-classification banner attribute leaves the color picker
* editable and the banner toggle unlocked.
*/
test('color picker is editable and toggle is unlocked when a non-classification attribute drives the banner', async ({
pw,
}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('colour_editable', suffix), {
options: ['ORCON'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {ORCON: BANNER_COLOR},
});
created.push(marking);
const channel = await createChannelForAttributes(adminClient, team, `colour-editable-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'ORCON'));
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * Color picker is editable — no classification in the banner
await expect(
configuration.container.locator('#channel_banner_banner_background_color_picker-inputColorValue'),
).toBeEnabled();
// * Toggle is also not locked
await expect(configuration.container.getByTestId('channelBannerToggle-button')).not.toBeDisabled();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify that when classification is banner-designated, Channel Settings
* locks the color picker to the selected level's color and the user cannot override it.
*/
test('color picker is locked to the classification level color when classification is banner-designated', async ({
pw,
}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
// Provision the classification template + channel-linked field ourselves:
// this test must not depend on state left behind by other spec files.
const {channelFieldId, levels} = await setupClassificationWithChannelField(adminClient, TEST_LEVELS);
const level = levels.find((l) => l.color);
if (!level) {
throw new Error('setupClassificationWithChannelField did not return a coloured level');
}
try {
// Designate classification for the banner
await adminClient.patchPropertyField('access_control', 'channel', channelFieldId, {
attrs: {actions: ['display_banner_top']},
} as never);
const channel = await createChannelForAttributes(adminClient, team, `class-banner-${suffix}`);
await adminClient.addToChannel(adminUser.id, channel.id);
await adminClient.patchPropertyValues('access_control', 'channel', channel.id, [
{field_id: channelFieldId, value: level.id},
] as never);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
const colorInput = configuration.container.locator(
'#channel_banner_banner_background_color_picker-inputColorValue',
);
// * Color picker is disabled — the level colour is authoritative
await expect(colorInput).toBeDisabled();
// * It shows the classification level's colour
await expect(colorInput).toHaveValue(level.color.toUpperCase());
} finally {
await deleteClassificationFieldsIfExist(adminClient);
}
});
/**
* @objective Verify that when a required attribute is banner-designated, the banner
* toggle in Channel Settings is locked and cannot be turned off.
*/
test('banner toggle is disabled when a required attribute designates the banner', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, adminUser, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marker = await createAttribute(adminClient, attributeName('req_banner', suffix), {
options: ['RESTRICTED'],
required: true,
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: BANNER_COLOR},
});
created.push(marker);
// Required attributes must be seeded at channel creation time
const channel = await createChannelForAttributes(adminClient, team, `req-banner-${suffix}`, undefined, [
{field_id: marker.id, value: optionId(marker, 'RESTRICTED')},
]);
await adminClient.addToChannel(adminUser.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const settings = await channelsPage.openChannelSettings();
const configuration = await settings.openConfigurationTab();
// * Toggle is locked — a required attribute mandates the banner
await expect(configuration.container.getByTestId('channelBannerToggle-button')).toBeDisabled();
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,595 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
test.describe('Channel attribute display and editing', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a designated attribute renders in both header slots, and that an
* undesignated one still reaches the Channel Info panel, read-only, for a channel member.
*/
test('shows a designated attribute in both header slots and Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('shown', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
optionColors: {AURORA: '#1e325c'},
});
// Undesignated: never a chip, but still reachable in Channel Info.
const hidden = await createAttribute(adminClient, attributeName('undesignated', suffix), {
options: ['QUIET'],
});
created.push(program, hidden);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-display-${suffix}`,
display_name: `Attr Display ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AURORA'));
await setChannelValue(adminClient, channel.id, hidden, optionId(hidden, 'QUIET'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// Visible to an ordinary member, not just whoever set it. Designated for
// both slots, so it renders in both.
const {attributes, infoAttributes} = channelsPage.centerView.header;
await expect(attributes.chip('AURORA')).toBeVisible();
await expect(infoAttributes.chip('AURORA')).toBeVisible();
await expect(attributes.chip('QUIET')).toHaveCount(0);
await expect(infoAttributes.chip('QUIET')).toHaveCount(0);
// Channel Info lists what the channel holds regardless of display
// designation -- it is the only surface a value can be edited from, so a
// display setting must not be able to strand one.
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId('channelInfoAttributes')).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeRow-${program.name}`)).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeRow-${hidden.name}`)).toBeVisible();
// A member reads, never writes.
await expect(page.getByTestId(`channelInfoAttributeEdit-${hidden.name}`)).toHaveCount(0);
await expect(page.getByTestId('channelInfoAddAttributeButton')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a channel admin can reach and fill an attribute that is designated
* for no display location at all.
*
* Channel Info is the only surface a value can be edited from, so gating it on the
* display locations would leave a required attribute permanently unset with no way to
* fix it. The panel therefore lists by role, not by designation.
*/
test('keeps an undesignated required attribute editable in Channel Info for a channel admin', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// The channel comes first: a required attribute is refused at create time
// unless the call carries a value for it. Marking an attribute required
// after the fact is what leaves an existing channel incomplete, which is
// the case that needs a way out.
const channel = await createChannelForAttributes(adminClient, team, `nodisplay-${suffix}`);
const channelAdmin = await pw.createNewUserProfile(adminClient, {prefix: 'chanadmin'});
await adminClient.addToTeam(team.id, channelAdmin.id);
await adminClient.addToChannel(channelAdmin.id, channel.id);
await adminClient.updateChannelMemberSchemeRoles(channel.id, channelAdmin.id, true, true);
await adminClient.addToChannel(user.id, channel.id);
const undesignated = await createAttribute(adminClient, attributeName('nodisplay', suffix), {
options: ['FILLED'],
actions: [],
required: true,
});
created.push(undesignated);
// # Look at the incomplete channel 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 is told nothing: an unset required attribute is not theirs
// to fix, and an empty row they cannot fill only reads as a broken channel
await asMember.channelsPage.openChannelInfo();
await expect(asMember.page.getByTestId(`channelInfoAttributeRow-${undesignated.name}`)).toHaveCount(0);
// # Look at the same channel as the channel admin
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// * No chips on either header slot, because nothing was designated
await expect(channelsPage.centerView.header.attributes.container).toHaveCount(0);
await expect(channelsPage.centerView.header.infoAttributes.container).toHaveCount(0);
// * But the row is there, saying the channel is incomplete, and it can be filled
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.unset(undesignated.name)).toBeVisible();
await info.attributes.select(undesignated.name, 'FILLED');
await expect(info.attributes.chip(undesignated.name)).toHaveText('FILLED');
// * And once filled it reaches the member too, still read-only
const asMemberAgain = await pw.testBrowser.login(user);
await asMemberAgain.channelsPage.goto(team.name, channel.name);
await asMemberAgain.channelsPage.toBeVisible();
const memberInfo = await asMemberAgain.channelsPage.openChannelInfo();
await expect(memberInfo.attributes.chip(undesignated.name)).toHaveText('FILLED');
await expect(memberInfo.attributes.editButton(undesignated.name)).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify chips collapse into +N at a narrow viewport without displacing the header controls.
*/
test('collapses overflowing chips into +N without moving header controls', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-overflow-${suffix}`,
display_name: `Attr Overflow ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
for (let i = 0; i < 5; i++) {
// The inline slot: the one that shares its row with the header controls,
// so it is the only one where yielding space is an invariant.
const field = await createAttribute(adminClient, attributeName(`overflow${i}`, suffix), {
options: [`LONG_VALUE_NUMBER_${i}`],
actions: [DISPLAY_LABEL_INFO],
sortOrder: i,
});
created.push(field);
await setChannelValue(adminClient, channel.id, field, optionId(field, `LONG_VALUE_NUMBER_${i}`));
}
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const infoButton = page.locator('#channel-info-btn');
const row = channelsPage.centerView.header.infoAttributes.visibleRow;
await expect(page.getByTestId('attributeChip').first()).toBeVisible();
const wideX = (await infoButton.boundingBox())?.x ?? 0;
// The invariant, at every width: the row never scrolls. A chip clipped by
// overflow:hidden with no +N beside it is a marking silently hidden.
// Narrow, but still desktop: below 768px the header switches to the mobile
// layout and the icon row is not rendered at all.
await page.setViewportSize({width: 800, height: 800});
await expect(page.getByTestId('channelAttributeLabelsOverflow-info')).toBeVisible();
// Fewer chips shown than exist, and the remainder is reachable.
const shown = await page.getByTestId('attributeChip').count();
expect(shown).toBeGreaterThan(0);
expect(shown).toBeLessThan(5);
// The row yields space rather than claiming it, so the controls after it
// are not pushed further right as the window narrows.
const narrowX = (await infoButton.boundingBox())?.x ?? 0;
expect(narrowX).toBeLessThanOrEqual(wideX);
// The row is bounded by its container: whatever it cannot show goes to
// the popover rather than spilling across the header.
const spill = await row.evaluate((el: HTMLElement) => {
const parent = el.parentElement!.parentElement!;
return el.getBoundingClientRect().right - parent.getBoundingClientRect().right;
});
expect(spill).toBeLessThanOrEqual(1);
await page.getByTestId('channelAttributeLabelsOverflow-info').click();
await expect(page.getByTestId('channelAttributeLabelsPopover-info')).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a locked attribute renders read-only with its reason, and that the lock is enforced server-side.
*
* `editable: false` with no change_policy reads as "never", so the lock is a
* server-side invariant and not only a hidden pencil.
*/
test('renders a locked attribute read-only and validates the lock key server-side', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const locked = await createAttribute(adminClient, attributeName('locked', suffix), {
options: ['FIXED', 'OTHER'],
actions: [DISPLAY_LABEL_INFO],
editable: false,
});
created.push(locked);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-locked-${suffix}`,
display_name: `Attr Locked ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, locked, optionId(locked, 'FIXED'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
// Shown with the reason: hiding it looks like a missing marking.
await expect(page.getByTestId(`channelInfoAttributeRow-${locked.name}`)).toBeVisible();
await expect(page.getByTestId(`channelInfoAttributeEdit-${locked.name}`)).toHaveCount(0);
// The key is type-validated, so a truthy string cannot smuggle past it.
await expect(
adminClient.patchPropertyField('access_control', 'channel', locked.id, {
attrs: {...locked.attrs, editable: 'yes'},
} as never),
).rejects.toThrow();
// Refused for a system admin too: the lock is a property of the attribute,
// not a permission tier.
await expect(setChannelValue(adminClient, channel.id, locked, optionId(locked, 'OTHER'))).rejects.toThrow();
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.find((value) => value.field_id === locked.id)?.value).toBe(optionId(locked, 'FIXED'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify an optional attribute is absent from creation, addable from Channel Info, and produces no chip.
*/
test('adds an optional attribute from Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const optional = await createAttribute(adminClient, attributeName('optional', suffix), {
options: ['LATER'],
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
const displayName = `Attr Optional ${suffix}`;
await modal.fillDisplayName(displayName);
// Optional attributes are not asked for at creation.
await expect(page.getByTestId(`channelAttributeRow-${optional.name}`)).toHaveCount(0);
await modal.create();
await expect(modal.container).not.toBeVisible();
await page.locator('#channel-info-btn').click();
await page.getByTestId('channelInfoAddAttributeButton').click();
await page.getByText(optional.name, {exact: false}).last().click();
await page.getByTestId(`channelAttributeEdit-${optional.name}`).click();
await page.getByText('LATER', {exact: true}).click();
await expect(page.getByTestId(`channelInfoAttributeRow-${optional.name}`)).toContainText('LATER');
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
await expect
.poll(async () => {
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
return values?.find((value) => value.field_id === optional.id)?.value;
})
.toBe(optionId(optional, 'LATER'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a banner-designated attribute drives the channel banner.
*/
test('renders a banner from a banner-designated attribute', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('banner', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: '#c8102e'},
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-banner-${suffix}`,
display_name: `Attr Banner ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// Falls back to the value name, reproducing today's classification banner.
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a multiselect banner attribute set at channel creation banners the new channel immediately.
*/
test('banners a multiselect attribute filled while creating the channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// Required so the create dialog asks for it, which is how a channel gets
// a banner value before anyone has opened Channel Settings.
const caveats = await createAttribute(adminClient, attributeName('bannermulti', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
required: true,
actions: [DISPLAY_BANNER_TOP, DISPLAY_LABEL_HEADER],
});
created.push(caveats);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner Multi ${suffix}`);
// The menu closes on each pick, so the second value needs it reopened.
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('NOFORN', {exact: true}).click();
await page.getByTestId(`channelAttribute-${caveats.name}`).click();
await page.getByText('ORCON', {exact: true}).click();
await modal.create();
await expect(modal.container).not.toBeVisible();
// No reload, no Channel Settings visit: creation alone has to banner it.
await expect(page.getByTestId('channel_banner_text')).toContainText('NOFORN, ORCON');
// Selections may each carry a different colour, so none of them wins and
// the banner falls back rather than rendering transparent.
await expect(page.getByTestId('channel_banner_container')).toHaveCSS(
'background-color',
'rgb(221, 221, 221)',
);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a text banner attribute banners the channel with the string it stores.
*/
test('banners a text attribute filled while creating the channel', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('bannertext', suffix), {
type: 'text',
required: true,
actions: [DISPLAY_BANNER_TOP],
});
created.push(note);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
await modal.fillDisplayName(`Attr Banner Text ${suffix}`);
await page.getByLabel(note.name, {exact: true}).fill('HANDLE WITH CARE');
await modal.create();
await expect(modal.container).not.toBeVisible();
// A text attribute has no options, so the stored string is the banner.
await expect(page.getByTestId('channel_banner_text')).toContainText('HANDLE WITH CARE');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a user without the setter tier sees values but no editing affordance.
*/
test('hides editing from a user without the setter tier', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// admin tier resolves to manage_channel_roles, which a member lacks.
const adminOnly = await createAttribute(adminClient, attributeName('admin_tier', suffix), {
options: ['SET'],
actions: [DISPLAY_LABEL_INFO],
permissionValues: 'admin',
});
created.push(adminOnly);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-tier-${suffix}`,
display_name: `Attr Tier ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, adminOnly, optionId(adminOnly, 'SET'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId(`channelInfoAttributeRow-${adminOnly.name}`)).toContainText('SET');
await expect(page.getByTestId(`channelInfoAttributeEdit-${adminOnly.name}`)).toHaveCount(0);
// Asserted per attribute rather than on the button as a whole: the button
// is shared, so any other attribute this user *can* set would keep it on
// screen and say nothing about this one.
const addButton = page.getByTestId('channelInfoAddAttributeButton');
if (await addButton.count()) {
await addButton.click();
await expect(page.getByTestId(`channelInfoAddAttribute-${adminOnly.name}`)).toHaveCount(0);
}
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify every surface reverts when the feature flag is off.
*/
test('renders no attribute surfaces with the flag off', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', false);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
const marking = await createAttribute(adminClient, attributeName('flagoff', suffix), {
options: ['HIDDEN'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-flagoff-${suffix}`,
display_name: `Attr FlagOff ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'HIDDEN'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// The value stays in the database; only the surfaces disappear.
await expect(page.getByTestId('channelAttributeLabels-header')).toHaveCount(0);
await expect(page.getByTestId('channelAttributeLabels-info')).toHaveCount(0);
await expect(page.getByText('HIDDEN')).toHaveCount(0);
await page.locator('#channel-info-btn').click();
await expect(page.getByTestId('channelInfoAttributes')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,519 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import type {Client4} from '@mattermost/client';
import type {Team} from '@mattermost/types/teams';
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
attributeName,
createAttribute,
createChannelForAttributes,
deleteAttributes,
optionId,
purgeAttributes,
readChannelValues,
setChannelValue,
valueFor,
} from './helpers';
// Editing in Channel Info is admin-only, whatever an attribute's own setter tier
// says: useIsChannelAttributeAdmin gates the pencil, canSet only narrows further.
async function promoteToChannelAdmin(
pw: PlaywrightExtended,
adminClient: Client4,
team: Team,
channelId: string,
prefix: string,
) {
const channelAdmin = await pw.createNewUserProfile(adminClient, {prefix});
await adminClient.addToTeam(team.id, channelAdmin.id);
await adminClient.addToChannel(channelAdmin.id, channelId);
await adminClient.updateChannelMemberSchemeRoles(channelId, channelAdmin.id, true, true);
return channelAdmin;
}
test.describe('Channel attribute editing', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a text value can be changed from Channel Info and commits on Enter.
*/
test('edits a text attribute inline and commits on Enter', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('note', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-text-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-note-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'first draft');
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// # Replace the existing value and commit with Enter
const info = await channelsPage.openChannelInfo();
await expect(info.attributes.chip(note.name)).toHaveText('first draft');
await info.attributes.setText(note.name, 'second draft', 'enter');
// * The new value replaces the old one, in the panel and in the store
await expect(info.attributes.chip(note.name)).toHaveText('second draft');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('second draft');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a text edit commits on blur but is abandoned on Escape.
*/
test('commits a text edit on blur and abandons it on Escape', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('commit', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-commit-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-commit-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'original');
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// # Type a new value and click away
await info.attributes.setText(note.name, 'blurred', 'blur');
await expect(info.attributes.chip(note.name)).toHaveText('blurred');
// # Type again, then abandon with Escape
await info.attributes.setText(note.name, 'discarded', 'escape');
// * Escape leaves the committed value untouched
await expect(info.attributes.chip(note.name)).toHaveText('blurred');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('blurred');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a multiselect value can gain and lose options after it is first set.
*/
test('adds and removes a multiselect option, and the header chips follow', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const caveats = await createAttribute(adminClient, attributeName('caveats', suffix), {
type: 'multiselect',
options: ['NOFORN', 'ORCON'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(caveats);
const channel = await createChannelForAttributes(adminClient, team, `edit-multi-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-caveats-${suffix}`,
);
await setChannelValue(adminClient, channel.id, caveats, [optionId(caveats, 'NOFORN')]);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// # Add a second option to the existing one
await info.attributes.select(caveats.name, 'ORCON');
// * Both options are stored, in the order they were picked
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), caveats);
})
.toEqual([optionId(caveats, 'NOFORN'), optionId(caveats, 'ORCON')]);
// # Remove the first one from the still-open editor
await info.attributes.deselect(caveats.name, 'NOFORN');
// * Only the remaining option survives, and the header agrees
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), caveats);
})
.toEqual([optionId(caveats, 'ORCON')]);
await expect(channelsPage.centerView.header.attributes.chip('ORCON')).toBeVisible();
await expect(channelsPage.centerView.header.attributes.chip('NOFORN')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a failed value write is reported in the row and does not discard the stored value.
*/
test('surfaces an inline error when the value write fails and keeps the previous value', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const note = await createAttribute(adminClient, attributeName('failing', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(note);
const channel = await createChannelForAttributes(adminClient, team, `edit-fail-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-fail-${suffix}`,
);
await setChannelValue(adminClient, channel.id, note, 'kept');
const {page, channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.route('**/api/v4/properties/groups/access_control/channel/values/**', (route) => {
if (route.request().method() !== 'PATCH') {
return route.continue();
}
return route.fulfill({status: 500, body: '{"message":"forced failure"}'});
});
const info = await channelsPage.openChannelInfo();
await info.attributes.setText(note.name, 'never saved', 'enter');
// * The row says the write failed, and keeps the edit open so it can be
// * retried without retyping
await expect(info.attributes.error(note.name)).toBeVisible();
await expect(info.attributes.editor(note.name)).toHaveValue('never saved');
await page.unroute('**/api/v4/properties/groups/access_control/channel/values/**');
// * Nothing was stored, so the previous value stands
expect(valueFor(await readChannelValues(adminClient, channel.id), note)).toBe('kept');
// # Retry now that the write succeeds
await info.attributes.editor(note.name).press('Enter');
await expect(info.attributes.chip(note.name)).toHaveText('never saved');
await expect
.poll(async () => {
return valueFor(await readChannelValues(adminClient, channel.id), note);
})
.toBe('never saved');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a locked attribute can be filled once and is read-only afterwards.
*/
test('fills a locked attribute once, after which it is read-only', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
// Channel first: the server refuses a channel that misses a required
// attribute, so the only way to reach a required-but-unset value is for the
// attribute to become required after the channel exists. That is also how a
// real server gets there, when an admin marks an attribute required later.
const channel = await createChannelForAttributes(adminClient, team, `edit-lock-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-lock-${suffix}`,
);
// The lock bites only once a value exists, so an empty one is still fillable.
const marking = await createAttribute(adminClient, attributeName('locked_once', suffix), {
options: ['FINAL'],
actions: [DISPLAY_LABEL_INFO],
editable: false,
required: true,
});
created.push(marking);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * A required attribute with no value shows as unset and is still editable
await expect(info.attributes.unset(marking.name)).toBeVisible();
await expect(info.attributes.lock(marking.name)).toHaveCount(0);
// # Set it for the first time
await info.attributes.select(marking.name, 'FINAL');
await expect(info.attributes.chip(marking.name)).toHaveText('FINAL');
// * Once set, the row is locked: no pencil, and the lock icon appears
await expect(info.attributes.lock(marking.name)).toBeVisible();
await expect(info.attributes.editButton(marking.name)).toHaveCount(0);
expect(valueFor(await readChannelValues(adminClient, channel.id), marking)).toBe(
optionId(marking, 'FINAL'),
);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the admin setter tier admits a channel admin and excludes a plain member.
*/
test('lets a channel admin edit an admin-tier attribute that a member cannot', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const adminOnly = await createAttribute(adminClient, attributeName('admin_edit', suffix), {
options: ['SET'],
actions: [DISPLAY_LABEL_INFO],
permissionValues: 'admin',
});
created.push(adminOnly);
const channel = await createChannelForAttributes(adminClient, team, `edit-tier-${suffix}`);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-tier-${suffix}`,
);
await adminClient.addToChannel(user.id, channel.id);
// # Look at it as the channel admin
const asAdmin = await pw.testBrowser.login(channelAdmin);
await asAdmin.channelsPage.goto(team.name, channel.name);
await asAdmin.channelsPage.toBeVisible();
const adminInfo = await asAdmin.channelsPage.openChannelInfo();
// Added rather than edited: an unset optional attribute has no row until
// it has a value.
// * The channel admin clears the admin tier and can set it
await adminInfo.attributes.add(adminOnly.name, 'SET');
await expect(adminInfo.attributes.chip(adminOnly.name)).toHaveText('SET');
// # 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();
const memberInfo = await asMember.channelsPage.openChannelInfo();
// * The member sees the value but is offered no way to change it
await expect(memberInfo.attributes.chip(adminOnly.name)).toHaveText('SET');
await expect(memberInfo.attributes.editButton(adminOnly.name)).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify Channel Info management (unset required rows, Add Attribute,
* and the pencil) is available to a channel admin regardless of an attribute's
* own setter tier.
*/
test('shows a channel admin every attribute, including unset ones, and lets them add or edit', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
// Member-tier on purpose: management stays admin-only in Channel Info
// even for an attribute a plain member is otherwise allowed to set.
const required = await createAttribute(adminClient, attributeName('req', suffix), {
actions: [DISPLAY_LABEL_INFO],
options: ['DRAFT'],
required: true,
});
created.push(required);
const optional = await createAttribute(adminClient, attributeName('opt', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const channel = await createChannelForAttributes(
adminClient,
team,
`edit-admin-lists-${suffix}`,
undefined,
[{field_id: required.id, value: optionId(required, 'DRAFT')}],
);
const channelAdmin = await promoteToChannelAdmin(
pw,
adminClient,
team,
channel.id,
`chanadmin-lists-${suffix}`,
);
const {channelsPage} = await pw.testBrowser.login(channelAdmin);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * A required attribute that already has a value is listed and editable
await expect(info.attributes.chip(required.name)).toHaveText('DRAFT');
await expect(info.attributes.editButton(required.name)).toBeVisible();
// * An unset optional attribute is reachable through Add Attribute, and
// * becomes editable once added
await expect(info.attributes.row(optional.name)).toHaveCount(0);
await info.attributes.add(optional.name);
await info.attributes.editor(optional.name).fill('drafted');
await info.attributes.editor(optional.name).press('Enter');
await expect(info.attributes.chip(optional.name)).toHaveText('drafted');
await expect(info.attributes.editButton(optional.name)).toBeVisible();
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a plain member only sees attributes that already carry a
* value, always read-only, with no Add Attribute affordance.
*/
test('shows a plain member only what is already set, read-only, with no way to manage attributes', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
const required = await createAttribute(adminClient, attributeName('req', suffix), {
actions: [DISPLAY_LABEL_INFO],
options: ['DRAFT'],
required: true,
});
created.push(required);
const optional = await createAttribute(adminClient, attributeName('opt', suffix), {
type: 'text',
actions: [DISPLAY_LABEL_INFO],
});
created.push(optional);
const channel = await createChannelForAttributes(
adminClient,
team,
`edit-member-hides-${suffix}`,
undefined,
[{field_id: required.id, value: optionId(required, 'DRAFT')}],
);
await adminClient.addToChannel(user.id, channel.id);
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const info = await channelsPage.openChannelInfo();
// * The required attribute's value is visible, but there is no pencil
await expect(info.attributes.chip(required.name)).toHaveText('DRAFT');
await expect(info.attributes.editButton(required.name)).toHaveCount(0);
// * The unset optional attribute has no row, and there is no way to add one
await expect(info.attributes.row(optional.name)).toHaveCount(0);
await expect(info.attributes.addButton).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import {expect, test} from '@mattermost/playwright-lib';
import {
DISPLAY_BANNER_TOP,
DISPLAY_LABEL_HEADER,
DISPLAY_LABEL_INFO,
assertNoForeignRequiredAttributes,
attributeName,
createAttribute,
deleteAttributes,
optionId,
purgeAttributes,
setChannelValue,
} from './helpers';
test.describe('Channel attribute lifecycle', {tag: ['@channel_attributes']}, () => {
test.describe.configure({mode: 'serial'});
/**
* @objective Verify a required attribute is asked for at creation and blocks it until filled.
*/
test('blocks channel creation until a required attribute is filled', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const required = await createAttribute(adminClient, attributeName('mandatory', suffix), {
options: ['ALPHA'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
required: true,
});
const optional = await createAttribute(adminClient, attributeName('discretionary', suffix), {
options: ['BETA'],
actions: [DISPLAY_LABEL_INFO],
});
created.push(required, optional);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
const modal = await channelsPage.openNewChannelModal();
// Required is asked for; optional is not.
await expect(page.getByTestId(`channelAttributeRow-${required.name}`)).toBeVisible();
await expect(page.getByTestId(`channelAttributeRow-${optional.name}`)).toHaveCount(0);
const displayName = `Attr Required ${suffix}`;
await modal.fillDisplayName(displayName);
// The dialog blocks submission so the user finds out here; the server
// refuses the same create independently.
await expect(modal.createButton).toBeDisabled();
await page.getByTestId(`channelAttribute-${required.name}`).click();
await page.getByText('ALPHA', {exact: true}).click();
await expect(modal.createButton).toBeEnabled();
await modal.create();
await expect(modal.container).not.toBeVisible();
const channel = await adminClient.getChannelByName(team.id, displayName.toLowerCase().replace(/\s+/g, '-'));
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
expect(values?.find((value) => value.field_id === required.id)?.value).toBe(optionId(required, 'ALPHA'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a value changed elsewhere reaches an open session without a reload.
*/
test('updates an open session when a value changes, and when it is cleared', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const program = await createAttribute(adminClient, attributeName('live', suffix), {
options: ['BEFORE', 'AFTER'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(program);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-live-${suffix}`,
display_name: `Attr Live ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, program, optionId(program, 'BEFORE'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const chips = page.getByTestId('attributeChip');
await expect(chips.filter({hasText: 'BEFORE'}).first()).toBeVisible();
// Changed elsewhere, this session untouched.
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AFTER'));
await expect(chips.filter({hasText: 'AFTER'}).first()).toBeVisible();
await expect(chips.filter({hasText: 'BEFORE'})).toHaveCount(0);
// The shape most easily got wrong: a clear returns a null-valued row,
// not a delete event.
await setChannelValue(adminClient, channel.id, program, null);
await expect(page.getByTestId('attributeChip')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a required attribute left unset after creation is visible and recoverable from Channel Info.
*/
test('shows a required attribute as unset and lets it be filled from Channel Info', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
// The channel comes first, because creating one that misses a required
// attribute is refused now. What remains reachable is an attribute that
// becomes required after the fact, which is how an existing channel ends up
// short of its own requirement.
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-unset-${suffix}`,
display_name: `Attr Unset ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
// The unset-required row and its edit path are admin-only in Channel Info,
// so recovering it needs a channel admin, not a plain member.
await adminClient.updateChannelMemberSchemeRoles(channel.id, user.id, true, true);
const required = await createAttribute(adminClient, attributeName('unfilled', suffix), {
options: ['RECOVERED'],
actions: [DISPLAY_LABEL_INFO],
required: true,
});
created.push(required);
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
await page.locator('#channel-info-btn').click();
// The empty row is both the signal and the retry path.
const row = page.getByTestId(`channelInfoAttributeRow-${required.name}`);
await expect(row).toBeVisible();
await expect(row).toContainText('Not set');
// No chip while unset.
await expect(page.getByTestId('attributeChip')).toHaveCount(0);
await page.getByTestId(`channelInfoAttributeEdit-${required.name}`).click();
await page.getByTestId(`channelAttributeEdit-${required.name}`).click();
await page.getByText('RECOVERED', {exact: true}).click();
await expect(row).toContainText('RECOVERED');
await expect
.poll(async () => {
const values = await adminClient.getPropertyValues('access_control', 'channel', channel.id);
return values?.find((value) => value.field_id === required.id)?.value;
})
.toBe(optionId(required, 'RECOVERED'));
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the banner renders attribute tokens, and that a manual banner text still wins.
*/
test('renders a token banner and honours a manual override', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('bannertoken', suffix), {
options: ['RESTRICTED'],
actions: [DISPLAY_BANNER_TOP],
optionColors: {RESTRICTED: '#c8102e'},
sortOrder: 0,
});
const program = await createAttribute(adminClient, attributeName('bannerprogram', suffix), {
options: ['AURORA'],
actions: [DISPLAY_LABEL_INFO],
sortOrder: 1,
});
created.push(marking, program);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-token-${suffix}`,
display_name: `Attr Token ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'RESTRICTED'));
await setChannelValue(adminClient, channel.id, program, optionId(program, 'AURORA'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
// No manual text: falls back to the value name.
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED');
// A two-attribute template resolves against this channel.
await adminClient.patchChannel(channel.id, {
banner_info: {
enabled: true,
text: `{{${marking.name}}} · {{${program.name}}}`,
background_color: '#c8102e',
},
} as never);
await expect(page.getByTestId('channel_banner_text')).toContainText('RESTRICTED · AURORA');
// A literal passes through untouched.
await adminClient.patchChannel(channel.id, {
banner_info: {enabled: true, text: 'Handle with care', background_color: '#c8102e'},
} as never);
await expect(page.getByTestId('channel_banner_text')).toContainText('Handle with care');
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify a direct message shows no attribute chips even when values exist through the API.
*/
test('shows no chips on a direct message', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('dm', suffix), {
options: ['PRIVATE'],
actions: [DISPLAY_LABEL_HEADER, DISPLAY_LABEL_INFO],
});
created.push(marking);
const other = await pw.createNewUserProfile(adminClient);
await adminClient.addToTeam(team.id, other.id);
const dm = await adminClient.createDirectChannel([user.id, other.id]);
// Not blocked at the API level, but no DM surface displays them.
await setChannelValue(adminClient, dm.id, marking, optionId(marking, 'PRIVATE'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name);
await channelsPage.toBeVisible();
await page.goto(`/${team.name}/messages/@${other.username}`);
await expect(page.getByTestId('channelAttributeLabels-header')).toHaveCount(0);
await expect(page.getByTestId('channelAttributeLabels-info')).toHaveCount(0);
await expect(page.getByText('PRIVATE')).toHaveCount(0);
} finally {
await deleteAttributes(adminClient, created);
}
});
/**
* @objective Verify the configured colour reaches the chip, since a marking's colour is part of how it is read.
*/
test('applies the configured colour to a chip', async ({pw}) => {
await pw.skipIfNoLicense();
await pw.skipIfFeatureFlagNotSet('ChannelAttributes', true);
const {adminClient, user, team} = await pw.initSetup();
const suffix = pw.random.id();
const created: PropertyField[] = [];
try {
await purgeAttributes(adminClient);
await assertNoForeignRequiredAttributes(adminClient);
const marking = await createAttribute(adminClient, attributeName('coloured', suffix), {
options: ['DARKBG'],
actions: [DISPLAY_LABEL_HEADER],
optionColors: {DARKBG: '#1e325c'},
});
created.push(marking);
const channel = await adminClient.createChannel({
team_id: team.id,
name: `attr-colour-${suffix}`,
display_name: `Attr Colour ${suffix}`,
type: 'O',
} as Parameters<typeof adminClient.createChannel>[0]);
await adminClient.addToChannel(user.id, channel.id);
await setChannelValue(adminClient, channel.id, marking, optionId(marking, 'DARKBG'));
const {page, channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, channel.name);
await channelsPage.toBeVisible();
const chip = page.getByTestId('attributeChip').filter({hasText: 'DARKBG'}).first();
await expect(chip).toBeVisible();
await expect(chip).toHaveCSS('background-color', 'rgb(30, 50, 92)');
// Contrast is derived, so a dark background must produce light text.
await expect(chip).toHaveCSS('color', 'rgb(255, 255, 255)');
} finally {
await deleteAttributes(adminClient, created);
}
});
});

View File

@@ -0,0 +1,212 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {Team} from '@mattermost/types/teams';
// Channel attributes share the access_control group with Classification Markings.
export const GROUP = 'access_control';
export const TARGET_TYPE = 'system';
// Prefix on every field these specs create, so cleanup never touches real ones.
export const FIELD_PREFIX = 'chanattr_e2e';
// Mirrors the allow-list in model/property_field_attrs_validation.go.
export const DISPLAY_LABEL_HEADER = 'display_label_header';
export const DISPLAY_LABEL_INFO = 'display_label_info';
export const DISPLAY_BANNER_TOP = 'display_banner_top';
type CreateOptions = {
objectType?: 'channel' | 'user';
type?: 'select' | 'multiselect' | 'text';
options?: string[];
required?: boolean;
// Omit to leave unset, which reads as editable. Pass false to lock.
editable?: boolean;
// Undesignated attributes are stored but never shown.
actions?: string[];
// Defaults to member, so an ordinary channel member can set a value.
permissionValues?: 'none' | 'sysadmin' | 'admin' | 'member';
sortOrder?: number;
optionColors?: Record<string, string>;
};
// The server validates field names as CEL identifiers, so anything that is not
// a word character has to be folded away before it reaches the API.
export function attributeName(suffix: string, uniqueId: string): string {
return `${FIELD_PREFIX}_${suffix}_${uniqueId}`.replace(/[^A-Za-z0-9_]/g, '_');
}
export async function createAttribute(
adminClient: Client4,
name: string,
{
objectType = 'channel',
type,
options = [],
required,
editable,
actions,
permissionValues = 'member',
sortOrder,
optionColors,
}: CreateOptions = {},
): Promise<PropertyField> {
const field: Record<string, unknown> = {
name,
type: type ?? (options.length ? 'select' : 'text'),
target_type: TARGET_TYPE,
target_id: '',
permission_field: 'admin',
permission_values: permissionValues,
permission_options: 'admin',
};
const attrs: Record<string, unknown> = {};
if (options.length) {
attrs.options = options.map((optionName) => ({
id: '',
name: optionName,
...(optionColors?.[optionName] ? {color: optionColors[optionName]} : {}),
}));
}
if (required !== undefined) {
attrs.required = required;
}
if (editable !== undefined) {
attrs.editable = editable;
}
if (actions) {
attrs.actions = actions;
}
if (sortOrder !== undefined) {
attrs.sort_order = sortOrder;
}
if (Object.keys(attrs).length) {
field.attrs = attrs;
}
return adminClient.createPropertyField(GROUP, objectType, field as Parameters<Client4['createPropertyField']>[2]);
}
// Sets up channel state without driving the UI.
export async function setChannelValue(
client: Client4,
channelId: string,
field: PropertyField,
value: unknown,
): Promise<void> {
await client.patchPropertyValues(GROUP, 'channel', channelId, [
{field_id: field.id, value} as Parameters<Client4['patchPropertyValues']>[3][number],
]);
}
export async function createChannelForAttributes(
adminClient: Client4,
team: Team,
suffix: string,
displayName = `Attr ${suffix}`,
propertyValues?: Array<{field_id: string; value: unknown}>,
): Promise<Channel> {
return adminClient.createChannel({
team_id: team.id,
name: `attr-${suffix}`.toLowerCase(),
display_name: displayName,
type: 'O',
// Required attributes are refused server-side, so a channel seeded for a
// spec has to carry them in the create call.
...(propertyValues?.length ? {property_values: propertyValues} : {}),
} as Channel);
}
export async function setBannerTemplate(
client: Client4,
channelId: string,
text: string,
backgroundColor = '#1E325C',
): Promise<void> {
await client.patchChannel(channelId, {
banner_info: {enabled: true, text, background_color: backgroundColor},
} as Partial<Channel>);
}
// Mirrors attributeToken() in components/channel_attributes/banner_template.ts.
export function attributeToken(name: string): string {
return `{{${name}}}`;
}
export function optionId(field: PropertyField, name: string): string {
const options = (field.attrs?.options ?? []) as Array<{id: string; name: string}>;
const match = options.find((option) => option.name === name);
if (!match) {
throw new Error(`option ${name} not found on ${field.name}`);
}
return match.id;
}
// Values are typed loosely because a multiselect stores an array where a select
// stores a single option id. The endpoint answers a bare null, not an empty
// array, when a channel has no values at all.
export async function readChannelValues(client: Client4, channelId: string): Promise<Array<PropertyValue<unknown>>> {
const values = await client.getPropertyValues<unknown>(GROUP, 'channel', channelId);
return values ?? [];
}
export function valueFor(values: Array<PropertyValue<unknown>>, field: PropertyField): unknown {
return values.find((value) => value.field_id === field.id)?.value;
}
export async function deleteAttributes(adminClient: Client4, fields: PropertyField[]): Promise<void> {
for (const field of fields) {
try {
await adminClient.deletePropertyField(GROUP, field.object_type, field.id);
} catch {} // eslint-disable-line no-empty
}
}
// Best-effort cleanup of fields left behind by an interrupted run.
export async function purgeAttributes(adminClient: Client4): Promise<void> {
for (const objectType of ['channel', 'user'] as const) {
try {
const fields = await adminClient.getPropertyFields(GROUP, objectType, TARGET_TYPE);
const stale = (fields ?? []).filter(
(field) => field.name.startsWith(FIELD_PREFIX) && field.delete_at === 0,
);
await deleteAttributes(adminClient, stale);
} catch {} // eslint-disable-line no-empty
}
}
/**
* Throws when the server carries a required channel attribute these specs did not
* create.
*
* A stray one breaks every fixture that creates a channel — as a 400 from the API,
* as a 30s timeout on a disabled Create button here — and neither failure names the
* cause. purgeAttributes cannot clear them: it is scoped to FIELD_PREFIX so it never
* deletes a field someone meant to keep.
*/
export async function assertNoForeignRequiredAttributes(adminClient: Client4): Promise<void> {
const fields = await adminClient.getPropertyFields(GROUP, 'channel', TARGET_TYPE, undefined, {perPage: 200});
const foreign = (fields ?? []).filter(
(field) => field.delete_at === 0 && field.attrs?.required === true && !field.name.startsWith(FIELD_PREFIX),
);
if (foreign.length) {
const names = foreign.map((field) => field.name).join(', ');
throw new Error(
`Required channel attributes not owned by these specs are present: ${names}. ` +
'Channel creation cannot complete until each has a value. Delete them, or turn off Required, and re-run.',
);
}
}