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