Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
213 lines
7.4 KiB
TypeScript
213 lines
7.4 KiB
TypeScript
// 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.',
|
|
);
|
|
}
|
|
}
|