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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,800 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for System Console > User Attributes page.
*
* Tests the admin UI for managing Custom Profile Attribute (CPA) field definitions,
* including creating, editing, deleting, and configuring attribute fields.
*
* Related: MM-62558 / PR #30722 (Profile Popup CPA tests pattern reference)
*
* IMPORTANT: All field names must be valid CEL identifiers — matching
* ^[A-Za-z_][A-Za-z0-9_]*$ — because the server validates them against that
* pattern and returns HTTP 422 for any name containing spaces or special chars.
* Use underscores instead of spaces (e.g. 'Test_Department' not 'Test Department').
*/
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, getAdminClient, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
import type {CustomProfileAttribute} from '../../channels/custom_profile_attributes/helpers';
import {
setupCustomProfileAttributeFields,
deleteCustomProfileAttributes,
} from '../../channels/custom_profile_attributes/helpers';
type FieldsMap = Record<string, UserPropertyField>;
interface TestContext {
adminClient: Client4;
systemConsolePage: SystemConsolePage;
}
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
// # Clean up any pre-existing CPA fields to start with a blank slate
try {
const existing = await adminClient.getCustomProfileAttributeFields();
if (existing?.length) {
const existingMap: FieldsMap = {};
for (const f of existing) {
existingMap[f.id] = f;
}
await deleteCustomProfileAttributes(adminClient, existingMap);
}
} catch {
// No fields to clean up
}
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, systemConsolePage};
}
async function getFieldsMap(client: Client4): Promise<FieldsMap> {
const fields: UserPropertyField[] = await client.getCustomProfileAttributeFields();
const map: FieldsMap = {};
for (const field of fields) {
map[field.id] = field;
}
return map;
}
async function cleanupFields(client: Client4, fieldsMap: FieldsMap): Promise<void> {
if (Object.keys(fieldsMap).length > 0) {
await deleteCustomProfileAttributes(client, fieldsMap);
}
}
test.describe('System Console - User Attributes Management', () => {
test.afterAll(async () => {
try {
const {adminClient} = await getAdminClient({skipLog: true});
const fields = (await adminClient.getCustomProfileAttributeFields()) as Array<{name: string}>;
if (!fields.some((f) => f.name === 'Department')) {
await adminClient.createCustomProfileAttributeField({
name: 'Department',
type: 'text',
attrs: {sort_order: 0},
} as any);
}
} catch {
// Best-effort cleanup; if the server is unlicensed or fields API
// is unavailable, ABAC tests will handle their own attribute setup.
}
});
/**
* @objective Verify that navigating to the User Attributes page shows the empty state
* with the Add attribute button and a disabled Save button.
*/
test('navigates to user attributes page and displays empty state', {tag: '@user_attributes'}, async ({pw}) => {
const {systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes via sidebar
await systemConsolePage.sidebar.systemAttributes.userAttributes.click();
// * Verify the page loaded
await sp.toBeVisible();
// * Verify the "Add attribute" link button is visible
await expect(sp.addAttributeButton).toBeVisible();
// * Verify Save button is present but disabled (no changes)
await expect(sp.saveButton).toBeVisible();
await expect(sp.saveButton).toBeDisabled();
});
/**
* @objective Verify that attributes created via API are displayed in the table
* when the User Attributes page loads.
*
* @precondition
* Two custom profile attributes (Department, Role) exist via API setup.
*/
test('displays pre-existing attributes in the table', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Set up attributes via API
const attributes: CustomProfileAttribute[] = [
{name: 'Department', type: 'text'},
{name: 'Role', type: 'text', attrs: {value_type: 'url'}},
];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
// # Navigate to User Attributes page
await sp.goto();
// * Verify attributes appear in the table
await expect(sp.nameInputByValue('Department')).toBeVisible();
await expect(sp.nameInputByValue('Role')).toBeVisible();
await cleanupFields(adminClient, fieldsMap);
});
/**
* @objective Verify creating a new text attribute via the UI and saving it
* persists the field to the server.
*/
test.fixme('creates a new text attribute and saves', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Click "Add attribute"
await sp.addAttribute();
// * Verify a new row with an input appears in the table.
// Use lastNameInput() — not positional nameInput(0) — so concurrent tests
// inserting UAAE/ABAC rows don't shift the index to the wrong field.
const nameInput = sp.lastNameInput();
await expect(nameInput).toBeVisible();
// # Type attribute name (must be a valid CEL identifier — no spaces)
await nameInput.fill('test_department');
await nameInput.blur();
await sp.saveAndWaitForSettled();
// * Verify the field was created by fetching from API
const fieldsMap = await getFieldsMap(adminClient);
const createdField = Object.values(fieldsMap).find((f) => f.name === 'test_department');
expect(createdField).toBeDefined();
expect(createdField!.type).toBe('text');
await cleanupFields(adminClient, fieldsMap);
});
/**
* @objective Verify creating a select attribute with multiple options saves
* the field and its options to the server.
*/
test.fixme('creates a select attribute with options and saves', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Click "Add attribute"
await sp.addAttribute();
// # Type attribute name (must be a valid CEL identifier — no spaces)
const nameInput = sp.lastNameInput();
await nameInput.fill('office_location');
await nameInput.blur();
// # Change type to Select (use selectLastType so the index stays correct
// even when concurrent tests have inserted extra rows)
await sp.selectLastType('Select');
// # Add options
await sp.addOptionsToLast(['Remote', 'Office', 'Hybrid']);
// # Click the name input to blur the react-select and commit all pending option state
await sp.lastNameInput().click();
await sp.saveAndWaitForSettled();
// * Verify field was created with correct type via API
const fieldsMap = await getFieldsMap(adminClient);
const createdField = Object.values(fieldsMap).find((f) => f.name === 'office_location');
expect(createdField).toBeDefined();
expect(createdField!.type).toBe('select');
expect(createdField!.attrs.options).toBeDefined();
expect(createdField!.attrs.options!.length).toBe(3);
await cleanupFields(adminClient, fieldsMap);
});
/**
* @objective Verify editing an existing attribute name and saving persists
* the rename to the server.
*
* @precondition
* A custom profile attribute named "Old_Name" exists via API setup.
*/
test.fixme('edits an existing attribute name and saves', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'old_name', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
// # Navigate to User Attributes page
await sp.goto();
const nameInput = sp.nameInputByValue('old_name');
await expect(nameInput).toBeVisible();
await nameInput.focus();
await nameInput.fill('new_name');
// blur via keyboard — the CSS-attribute locator no longer matches
// after fill() so calling .blur() on it would time out.
await sp.page.keyboard.press('Tab');
await sp.saveAndWaitForSettled();
// * Verify field was updated via API
const updatedMap = await getFieldsMap(adminClient);
expect(Object.values(updatedMap).find((f) => f.name === 'new_name')).toBeDefined();
await cleanupFields(adminClient, {...fieldsMap, ...updatedMap});
});
/**
* @objective Verify deleting a saved attribute via the dot menu removes it
* from the server after confirmation and save.
*
* @precondition
* A custom profile attribute named "To_Delete" exists via API setup.
*/
test.fixme('deletes an attribute via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'to_delete', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
// # Navigate to User Attributes page
await sp.goto();
// * Verify the attribute exists
await expect(sp.nameInputByValue('to_delete')).toBeVisible();
// # Open dot menu for the field
await sp.openDotMenu(fieldId);
// # Click "Delete attribute"
await sp.deleteAttribute();
// # Confirm deletion in modal
await sp.confirmDeletion();
await sp.saveAndWaitForSettled();
// * Verify field was deleted via API
const updatedMap = await getFieldsMap(adminClient);
expect(Object.values(updatedMap).find((f) => f.name === 'to_delete')).toBeUndefined();
await cleanupFields(adminClient, updatedMap);
});
/**
* @objective Verify duplicating an attribute via the dot menu creates a copy
* with a valid name that persists after save.
*
* @precondition
* A custom profile attribute named "Original" exists via API setup.
*/
test.fixme('duplicates an attribute via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'Original', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
// # Navigate to User Attributes page
await sp.goto();
// # Open dot menu for the field
await sp.openDotMenu(fieldId);
// # Click "Duplicate attribute"
await sp.duplicateAttribute();
// * Verify a copy row appeared with "_copy" suffix in the name
await expect(sp.nameInputByValue('Original_copy')).toBeVisible();
// # Rename the copy to a valid CEL identifier (server rejects spaces/parens with 422)
const copyInput = sp.lastNameInput();
await copyInput.fill('Original_copy');
await copyInput.blur();
await sp.saveAndWaitForSettled();
// * Verify both fields exist via API
const updatedMap = await getFieldsMap(adminClient);
expect(Object.values(updatedMap).find((f) => f.name === 'Original')).toBeDefined();
expect(Object.values(updatedMap).find((f) => f.name === 'Original_copy')).toBeDefined();
await cleanupFields(adminClient, updatedMap);
});
/**
* @objective Verify changing attribute visibility to "Always hide" via the
* dot menu persists the hidden state to the server.
*
* @precondition
* A custom profile attribute named "Visibility_Test" exists via API setup.
*/
test.fixme('changes attribute visibility via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'visibility_test', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
// # Navigate to User Attributes page
await sp.goto();
// # Open dot menu
await sp.openDotMenu(fieldId);
// # Select "Always hide" visibility
await sp.setVisibility('Always hide');
await sp.saveAndWaitForSettled();
// * Verify visibility was updated via API
const updatedMap = await getFieldsMap(adminClient);
const updatedField = Object.values(updatedMap).find((f) => f.name === 'visibility_test');
expect(updatedField).toBeDefined();
expect(updatedField!.attrs.visibility).toBe('hidden');
await cleanupFields(adminClient, updatedMap);
});
/**
* @objective Verify toggling "Editable by users" off via the dot menu sets
* the attribute to admin-managed on the server.
*
* @precondition
* A custom profile attribute named "Editable_Test" exists via API setup.
*/
test.fixme('toggles editable by users off via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'editable_test', type: 'text'}];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
const fieldId = Object.keys(fieldsMap)[0];
// # Navigate to User Attributes page
await sp.goto();
// # Open dot menu
await sp.openDotMenu(fieldId);
// # Click "Editable by users" toggle
await sp.toggleEditableByUsers();
// # Wait for the checkbox to reflect the toggled (unchecked) state before dismissing,
// # to avoid a race where Escape fires before the UI registers the change
await expect(systemConsolePage.page.getByRole('menuitemcheckbox', {name: 'Editable by users'})).toHaveAttribute(
'aria-checked',
'false',
);
// # Close the dot menu — it stays open after toggling; backdrop would block Save click
await sp.dismissMenu();
await sp.saveAndWaitForSettled();
// * Verify managed was set to 'admin' (not editable by users) via API
// # Use expect.poll to tolerate brief server-side propagation delay
await expect
.poll(async () => {
const map = await getFieldsMap(adminClient);
return Object.values(map).find((f) => f.name === 'editable_test');
})
.toMatchObject({attrs: {managed: 'admin'}});
await cleanupFields(adminClient, await getFieldsMap(adminClient));
});
/**
* @objective Verify that clearing the auto-derived CEL identifier (Name)
* after entering a Display Name shows the empty-name validation warning
* in both the offending Name cell (red icon) and a banner below the table,
* and that Save remains disabled.
*/
test('shows validation warning for empty attribute name', {tag: '@user_attributes'}, async ({pw}) => {
const {systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Add a new attribute
await sp.addAttribute();
// # Fill Display Name so the Name field auto-derives as snake_case
const displayNameInput = sp.lastDisplayNameInput();
await displayNameInput.fill('Job Title');
await displayNameInput.blur();
// * Verify the Name field auto-populated with the snake_case identifier
const nameInput = sp.lastNameInput();
await expect(nameInput).toHaveValue('job_title');
// # Clear the auto-derived identifier and blur to trigger the empty-name warning
await nameInput.clear();
await nameInput.blur();
// * Verify the in-cell error icon is rendered for the offending row
await expect(sp.identifierValidationError()).toBeVisible();
// * Verify the bottom banner with the title and body copy is rendered
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
await expect(sp.validationBannerByTitle(/missing a Name/)).toBeVisible();
// * Verify Save button is disabled due to validation error
await expect(sp.saveButton).toBeDisabled();
});
/**
* @objective Verify that two pending fields with the same Name surface the
* `name_unique` validation: both rows display the in-cell error icon, a
* single banner appears below the table, and Save stays disabled.
*/
test('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
const {systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const dupName = `dup_${Date.now()}`;
// # Navigate to User Attributes page
await sp.goto();
// # Add the first new attribute with the duplicate name. We commit the
// # value via lastNameInput() here, then resolve the row by value below
// # so subsequent addAttribute() calls don't cause `.last()` to slide
// # onto the second row.
await sp.addAttribute();
await sp.lastNameInput().fill(dupName);
await sp.lastNameInput().blur();
// # Add the second new attribute with the same name (triggers name_unique)
await sp.addAttribute();
const secondNameInput = sp.lastNameInput();
await secondNameInput.fill(dupName);
await secondNameInput.blur();
// * Both rows should display the in-cell error icon
await expect(sp.identifierValidationError()).toHaveCount(2);
// * A single name_unique banner is rendered below the table
await expect(sp.validationBannerByTitle('Attribute names must be unique.')).toHaveCount(1);
await expect(sp.validationBannerByTitle(/share the same Name/)).toBeVisible();
// * Verify Save button is disabled
await expect(sp.saveButton).toBeDisabled();
});
/**
* @objective Verify the `name_taken` validation path: renaming a persisted
* field to free its name, then renaming a second persisted field to take
* the freed name, surfaces the in-cell icon and bottom banner. This is the
* only sequence that reaches the `NameTaken` branch — duplicate-pending
* fields trigger `NameUnique` first.
*
* @precondition
* Two custom profile attributes (`taken_a`, `taken_b`) exist via API setup.
*/
test(
'shows validation warning when a persisted attribute name is taken',
{tag: '@user_attributes'},
async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create two persisted attributes via API
const uid = Date.now();
const nameA = `taken_a_${uid}`;
const nameB = `taken_b_${uid}`;
const freedName = `taken_freed_${uid}`;
const attributes: CustomProfileAttribute[] = [
{name: nameA, type: 'text'},
{name: nameB, type: 'text'},
];
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
try {
// # Navigate to User Attributes page
await sp.goto();
// # Rename A first — frees the original `taken_a_*` name
const inputA = sp.nameInputByValue(nameA);
await expect(inputA).toBeVisible();
await inputA.focus();
await inputA.fill(freedName);
await sp.page.keyboard.press('Tab');
// # Rename B to the now-freed name — reaches the NameTaken branch
const inputB = sp.nameInputByValue(nameB);
await expect(inputB).toBeVisible();
await inputB.focus();
await inputB.fill(nameA);
await sp.page.keyboard.press('Tab');
// * Verify the offending row carries the in-cell error icon
await expect(sp.identifierValidationError()).toHaveCount(1);
// * Verify the bottom banner with the name_taken title and body
await expect(sp.validationBannerByTitle('Attribute name already taken.')).toBeVisible();
await expect(sp.validationBannerByTitle(/already used by another attribute/)).toBeVisible();
// * Verify Save button is disabled
await expect(sp.saveButton).toBeDisabled();
} finally {
await cleanupFields(adminClient, fieldsMap);
}
},
);
/**
* @objective Verify changing an attribute type from Text to Phone via the type
* selector saves the updated value_type to the server.
*
* @precondition
* A text attribute named "Contact_Number" exists via API setup.
*/
test.fixme('changes attribute type from text to phone', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create a text attribute via API
const attributes: CustomProfileAttribute[] = [{name: 'contact_number', type: 'text'}];
await setupCustomProfileAttributeFields(adminClient, attributes);
// # Navigate to User Attributes page
await sp.goto();
// # Select "Phone" type for the Contact_Number field.
// Use selectTypeForField() — resolves the row index by name so concurrent
// UAAE/ABAC rows don't shift the positional index.
await sp.selectTypeForField('contact_number', 'Phone');
await sp.saveAndWaitForSettled();
// * Verify field type was updated via API
const updatedMap = await getFieldsMap(adminClient);
const updatedField = Object.values(updatedMap).find((f) => f.name === 'contact_number');
expect(updatedField).toBeDefined();
expect(updatedField!.type).toBe('text');
expect(updatedField!.attrs.value_type).toBe('phone');
await cleanupFields(adminClient, updatedMap);
});
/**
* @objective Verify creating a multiselect attribute with options saves
* the field and its options to the server.
*/
test('creates a multiselect attribute with options and saves', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Click "Add attribute"
await sp.addAttribute();
// # Type attribute name ('Skills' is a single-word valid CEL identifier)
const nameInput = sp.lastNameInput();
await nameInput.fill('Skills');
await nameInput.blur();
// # Change type to Multi-select
await sp.selectLastType('Multi-select');
// # Add options
await sp.addOptionsToLast(['JavaScript', 'Python', 'Go']);
// # Click the name input to blur the react-select and commit all pending option state
await sp.lastNameInput().click();
await sp.saveAndWaitForSettled();
// * Verify field was created with correct type via API
const fieldsMap = await getFieldsMap(adminClient);
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Skills');
expect(createdField).toBeDefined();
expect(createdField!.type).toBe('multiselect');
expect(createdField!.attrs.options).toBeDefined();
expect(createdField!.attrs.options!.length).toBe(3);
await cleanupFields(adminClient, fieldsMap);
});
/**
* @objective Verify creating multiple text attributes in a single session
* and saving them all at once persists both to the server.
*/
test.fixme('creates multiple text attributes and saves all at once', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Create first attribute (text) — use lastNameInput() after each addAttribute()
await sp.addAttribute();
const firstInput = sp.lastNameInput();
await firstInput.fill('job_title');
await firstInput.blur();
// # Create second attribute (text)
await sp.addAttribute();
const secondInput = sp.lastNameInput();
await secondInput.fill('team_name');
await secondInput.blur();
await sp.saveAndWaitForSettled();
// * Verify both fields were created via API
const fieldsMap = await getFieldsMap(adminClient);
expect(Object.values(fieldsMap).find((f) => f.name === 'job_title')).toBeDefined();
expect(Object.values(fieldsMap).find((f) => f.name === 'team_name')).toBeDefined();
await cleanupFields(adminClient, fieldsMap);
});
/**
* @objective Verify that renaming an attribute and saving persists the change
* after a full page reload.
*
* @precondition
* A custom profile attribute named "Persistent_Field" exists via API setup.
*/
test.fixme('persists attribute changes after page reload', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Create an attribute via API
await setupCustomProfileAttributeFields(adminClient, [{name: 'persistent_field', type: 'text'}]);
// # Navigate to User Attributes page
await sp.goto();
// * Verify attribute exists
await expect(sp.nameInputByValue('persistent_field')).toBeVisible();
// # Edit the name using a value-based locator so concurrent UAAE/ABAC rows
// don't shift a positional index to the wrong field.
const nameInput = sp.nameInputByValue('persistent_field');
await expect(nameInput).toHaveValue('persistent_field');
await nameInput.focus();
await nameInput.fill('updated_persistent');
// blur via keyboard — the value-based locator is stale after fill()
await sp.page.keyboard.press('Tab');
await sp.saveAndWaitForSettled();
// # Reload the page
await sp.goto();
// * Verify the updated name persisted
await expect(sp.nameInputByValue('updated_persistent')).toBeVisible();
await cleanupFields(adminClient, await getFieldsMap(adminClient));
});
/**
* @objective Verify that two distinct name validation errors produce two
* stacked banners below the table (one per type), both offending rows
* carry the in-cell error icon, and that fixing one row removes only the
* matching banner while the other persists.
*/
test('stacks one banner per distinct validation error type', {tag: '@user_attributes'}, async ({pw}) => {
const {systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Row 1: trigger name_required (fill Display Name, clear auto-Name, blur)
await sp.addAttribute();
const firstDisplayName = sp.lastDisplayNameInput();
await firstDisplayName.fill('Job Title');
await firstDisplayName.blur();
const firstNameInput = sp.lastNameInput();
await expect(firstNameInput).toHaveValue('job_title');
await firstNameInput.clear();
await firstNameInput.blur();
// # Row 2: trigger name_invalid_cel (reserved CEL keyword)
await sp.addAttribute();
const secondNameInput = sp.lastNameInput();
await secondNameInput.fill('true');
await secondNameInput.blur();
// * Both banners stack at the bottom of the table
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toBeVisible();
// * Both offending Name cells carry the in-cell error icon
await expect(sp.identifierValidationError()).toHaveCount(2);
// * Save button stays disabled while any banner is present
await expect(sp.saveButton).toBeDisabled();
// # Fix the second row (give it a valid name) — only the CEL banner should disappear
await secondNameInput.fill('valid_name');
await secondNameInput.blur();
await expect(sp.validationBannerByTitle(/Identifier must start with a letter or underscore/)).toHaveCount(0);
await expect(sp.validationBannerByTitle('Please enter an attribute name.')).toBeVisible();
await expect(sp.identifierValidationError()).toHaveCount(1);
await expect(sp.saveButton).toBeDisabled();
});
/**
* @objective Verify that deleting a newly added (unsaved) attribute removes
* the row without a confirmation modal.
*/
test(
'deletes a newly added unsaved attribute without confirmation modal',
{tag: '@user_attributes'},
async ({pw}) => {
const {systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
// # Navigate to User Attributes page
await sp.goto();
// # Add a new attribute
await sp.addAttribute();
// # Type a name — 'Temporary' is a valid single-word CEL identifier.
// Use lastNameInput() so concurrent UAAE/ABAC rows don't shift the index.
const nameInput = sp.lastNameInput();
await nameInput.fill('Temporary');
await nameInput.blur();
// # Open dot menu for the new (pending) field
await sp.openDotMenuForUnsaved();
await sp.deleteAttribute();
// * Verify the row is removed (no confirmation modal for unsaved fields)
await expect(sp.nameInputByValue('Temporary')).not.toBeVisible();
// * Verify Save button returns to disabled state (no net changes)
await expect(sp.saveButton).toBeDisabled({timeout: 10000});
},
);
});

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import type {UserProfile} from '@mattermost/types/users';
import {expect, test, testConfig} from '@mattermost/playwright-lib';
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
import {setupCustomProfileAttributeValuesForUser} from '../../channels/custom_profile_attributes/helpers';
type AdminUser = UserProfile & {password: string};
const IDENTIFIER_VALIDATION_MESSAGE =
'Identifier must start with a letter or underscore and contain only letters, numbers, and underscores. Reserved CEL words are not allowed.';
interface TestContext {
adminClient: Client4;
adminUser: AdminUser;
systemConsolePage: SystemConsolePage;
}
async function createAdminClient(): Promise<{adminClient: Client4; adminUser: AdminUser}> {
const adminClient = new Client4();
adminClient.setUrl(testConfig.baseURL);
const loggedInUser = await adminClient.login(testConfig.adminUsername, testConfig.adminPassword);
const adminUser = {
...loggedInUser,
password: testConfig.adminPassword,
} as AdminUser;
return {adminClient, adminUser};
}
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await createAdminClient();
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, adminUser, systemConsolePage};
}
async function getTownSquareRoute(adminClient: Client4) {
const teams = await adminClient.getMyTeams();
expect(teams.length).toBeGreaterThan(0);
const team = teams[0];
const channel = await adminClient.getChannelByName(team.id, 'town-square');
return {
teamName: team.name,
channelName: channel.name,
};
}
test.describe('System Console - User Attributes display names', () => {
/**
* @objective Verify that a CPA field's display_name is rendered as the user-facing
* label in the user attributes table, the profile popover, account settings, and
* the admin user detail page while the identifier remains unchanged in the API.
*/
test('renders display_name across admin and self-service surfaces', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, adminUser, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const uid = Date.now();
const identifier = `department_${uid}`;
const displayName = `Department ${uid}`;
const attributeValue = 'Engineering';
let createdField: UserPropertyField | undefined;
try {
// # Navigate to User Attributes and create a new field with a display name
await sp.goto();
// * Verify the table exposes both Name and Display Name column headers
await expect(sp.container.getByRole('columnheader', {name: 'Display Name', exact: true})).toBeVisible();
await expect(sp.container.getByRole('columnheader', {name: 'Name', exact: true})).toBeVisible();
// # Add a new row and fill identifier + display name
await sp.addAttribute();
await sp.lastNameInput().fill(identifier);
await sp.lastNameInput().blur();
await sp.lastDisplayNameInput().fill(displayName);
await sp.lastDisplayNameInput().blur();
await sp.saveAndWaitForSettled();
const fields = await adminClient.getCustomProfileAttributeFields();
createdField = fields.find((field) => field.name === identifier);
expect(createdField).toBeDefined();
expect(createdField?.attrs?.display_name).toBe(displayName);
// # Set a value for sysadmin and open the self profile popover in Channels
await setupCustomProfileAttributeValuesForUser(
adminClient,
[{name: identifier, value: attributeValue, type: 'text'}],
{[createdField!.id]: createdField!},
adminUser.id,
);
const {teamName, channelName} = await getTownSquareRoute(adminClient);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(teamName, channelName);
await channelsPage.postMessage(`phase-5-display-name-${uid}`);
const lastPost = await channelsPage.getLastPost();
await channelsPage.openProfilePopover(lastPost);
// * Verify the profile popover and account settings render display_name
await expect(
channelsPage.page.locator(`#user-popover__custom_attributes-title-${createdField!.id}`),
).toHaveText(displayName);
await channelsPage.userProfilePopover.close();
const profileModal = await channelsPage.openProfileModal();
const section = profileModal.container.locator('.section-min').filter({hasText: displayName});
await expect(section).toBeVisible();
const editButton = profileModal.container.locator(`#customAttribute_${createdField!.id}Edit`);
await editButton.scrollIntoViewIfNeeded();
await editButton.click();
const settingsInput = profileModal.container.locator(`#customAttribute_${createdField!.id}`);
await expect(settingsInput).toHaveAttribute('aria-label', displayName);
await profileModal.closeModal();
// # Open the admin user detail page for sysadmin
await systemConsolePage.page.goto(`/admin_console/user_management/user/${adminUser.id}`);
await systemConsolePage.users.userDetail.toBeVisible();
// * Verify the admin user detail label also uses display_name
await expect(
systemConsolePage.page.getByTestId(`user-detail-custom-attribute-label-${createdField!.id}`),
).toContainText(displayName);
} finally {
if (createdField) {
await adminClient.deleteCustomProfileAttributeField(createdField.id).catch(() => undefined);
}
}
});
/**
* @objective Verify that invalid CPA identifiers are blocked client-side before
* any create-field API request is issued, and that a valid identifier clears the
* warning and can be saved successfully.
*/
test('blocks invalid identifiers before API submission', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const {page} = systemConsolePage;
const apiPosts: string[] = [];
const validIdentifier = `my_field_${Date.now()}`;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().includes('/api/v4/custom_profile_attributes/fields')) {
apiPosts.push(request.url());
}
});
try {
// # Add an attribute and exercise invalid identifier inputs in the table.
// Use lastNameInput() — not positional nameInput(0) — so a leftover row from
// a prior test attempt (or a concurrent UAAE/ABAC suite) cannot shift the
// index and cause us to rename someone else's field instead of populating
// the row we just added.
await sp.goto();
await sp.addAttribute();
const invalidIdentifiers = ['in', 'true', 'for'];
for (const invalidIdentifier of invalidIdentifiers) {
await sp.lastNameInput().fill(invalidIdentifier);
await sp.lastNameInput().blur();
// * Verify the in-cell error icon and bottom banner are rendered,
// and Save stays disabled before any POST is issued.
await expect(sp.identifierValidationError()).toBeVisible();
await expect(sp.validationBannerByTitle(IDENTIFIER_VALIDATION_MESSAGE)).toBeVisible();
await expect(sp.validationBannerByTitle(/not a valid identifier/)).toBeVisible();
await expect(sp.saveButton).toBeDisabled();
}
expect(apiPosts).toHaveLength(0);
// # Correct the identifier to a valid CEL-safe name and save it
await sp.lastNameInput().fill(validIdentifier);
await sp.lastNameInput().blur();
// * Verify the warning clears and the field can be created successfully
await expect(sp.identifierValidationError()).not.toBeVisible();
await expect(sp.saveButton).toBeEnabled();
await sp.saveAndWaitForSettled();
const fields = await adminClient.getCustomProfileAttributeFields();
const createdField = fields.find((field) => field.name === validIdentifier);
expect(createdField).toBeDefined();
} finally {
// Look up the field by name rather than relying on a captured `createdField`
// — the assertions above can throw before that variable is assigned, and we
// still want to remove the server-side field so retries start from a clean slate.
const fields = await adminClient.getCustomProfileAttributeFields().catch(() => []);
const leftover = fields.find((field) => field.name === validIdentifier);
if (leftover) {
await adminClient.deleteCustomProfileAttributeField(leftover.id).catch(() => undefined);
}
}
});
});

View File

@@ -0,0 +1,164 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test} from '@mattermost/playwright-lib';
import {
type CustomProfileAttribute,
deleteCustomProfileAttributes,
setupCustomProfileAttributeValuesForUser,
} from '../../channels/custom_profile_attributes/helpers';
function matchingOrder(actual: string[], expected: string[]) {
return actual.filter((value) => expected.includes(value));
}
/**
* @objective Verify navigating to the User Attributes page shows the empty state and disabled Save button.
*/
test('MM-T5745 navigates to user attributes page and displays empty state', {tag: '@user_attributes'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.initSetup();
// Custom profile attribute fields are global, so other specs may have left fields behind
// for reuse. Clear them to exercise the empty state, then restore them in `finally` so
// this test does not permanently wipe fields other specs depend on.
const existing = await adminClient.getCustomProfileAttributeFields();
try {
if (existing.length) {
const fields = Object.fromEntries(existing.map((field) => [field.id, field]));
await deleteCustomProfileAttributes(adminClient, fields);
}
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
// # Navigate to User Attributes via the sidebar
await systemConsolePage.sidebar.systemAttributes.userAttributes.click();
const sp = systemConsolePage.systemProperties;
// * Verify the empty management page and disabled Save button
await sp.toBeVisible();
await expect(sp.addAttributeButton).toBeVisible();
await expect(sp.saveButton).toBeVisible();
await expect(sp.saveButton).toBeDisabled();
} finally {
for (const field of existing) {
try {
await adminClient.createCustomProfileAttributeField({
name: field.name,
type: field.type,
attrs: field.attrs,
});
} catch (error) {
// eslint-disable-next-line no-console
console.log(`Failed to restore custom profile attribute field ${field.name}:`, error);
}
}
}
});
/**
* @objective Verify a system admin can add, edit, and delete a user attribute.
*/
test('MM-T5746 adds, edits, and deletes a user attribute', {tag: '@user_attributes'}, async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, adminUser} = await pw.initSetup();
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sp = systemConsolePage.systemProperties;
const originalName = `attr_${pw.random.id()}`.padEnd(40, '0').slice(0, 40);
const updatedName = `edited_${pw.random.id()}`;
// # Add and save a maximum-length user attribute
await sp.goto();
await sp.addAttribute();
await sp.lastNameInput().fill(originalName);
await sp.lastNameInput().press('Tab');
await sp.saveAndWaitForSettled();
// * Verify the new attribute persisted
let fields = await adminClient.getCustomProfileAttributeFields();
const created = fields.find((field) => field.name === originalName);
expect(created).toBeDefined();
await expect(sp.nameInputByValue(originalName)).toHaveValue(originalName);
// # Rename and save the attribute
await sp.nameInputByValue(originalName).fill(updatedName);
await systemConsolePage.page.keyboard.press('Tab');
await sp.saveAndWaitForSettled();
fields = await adminClient.getCustomProfileAttributeFields();
// * Verify the renamed attribute persisted
const updated = fields.find((field) => field.name === updatedName);
expect(updated).toBeDefined();
// # Delete and save the attribute
await sp.openDotMenu(updated!.id);
await sp.deleteAttribute();
await sp.confirmDeletion();
await sp.saveAndWaitForSettled();
// * Verify the attribute was deleted
fields = await adminClient.getCustomProfileAttributeFields();
expect(fields.find((field) => field.id === updated!.id)).toBeUndefined();
});
/**
* @objective Verify reordering attributes in the System Console updates profile settings and popover order.
*/
test(
'MM-T5766 propagates reordered user attributes to profile settings and popover',
{tag: '@user_attributes'},
async ({pw}) => {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminClient, adminUser, team, user} = await pw.initSetup();
const suffix = pw.random.id();
const labels = [`First ${suffix}`, `Second ${suffix}`, `Favorite Food ${suffix}`];
const fields: Record<string, UserPropertyField> = {};
for (const [index, label] of labels.entries()) {
const field = await adminClient.createCustomProfileAttributeField({
name: `reorder_${index}_${suffix}`,
type: 'text',
attrs: {display_name: label, sort_order: 1000 + index, visibility: 'always'},
} as any);
fields[field.id] = field;
}
const attributes: CustomProfileAttribute[] = labels.map((label, index) => ({
name: `reorder_${index}_${suffix}`,
type: 'text',
value: `Value ${index + 1}`,
attrs: {display_name: label, visibility: 'always'},
}));
await setupCustomProfileAttributeValuesForUser(adminClient, attributes, fields, user.id);
// # Move Favorite Food to the top in the System Console and save
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const sp = systemConsolePage.systemProperties;
await sp.goto();
await sp.reorderButtonByName(`reorder_2_${suffix}`).press('ArrowUp');
await sp.reorderButtonByName(`reorder_2_${suffix}`).press('ArrowUp');
await sp.saveAndWaitForSettled();
const expectedOrder = [labels[2], labels[0], labels[1]];
// * Verify the new order in profile settings
const {channelsPage} = await pw.testBrowser.login(user);
await channelsPage.goto(team.name, 'town-square');
const profileModal = await channelsPage.openProfileModal();
expect(matchingOrder(await profileModal.sectionHeadings.allTextContents(), labels)).toEqual(expectedOrder);
await profileModal.closeModal();
// # Open the user's profile popover
await channelsPage.postMessage(`Reordered attributes ${suffix}`);
const post = await channelsPage.getLastPost();
const popover = await channelsPage.openProfilePopover(post);
// * Verify the new order in the popover
expect(matchingOrder(await popover.attributeHeadings.allTextContents(), labels)).toEqual(expectedOrder);
},
);

View File

@@ -0,0 +1,287 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* E2E tests for ranked Custom Profile Attributes on the System Console
* User Attributes page.
*
* Covers the schema-authoring surface: the "Ranked" attribute type, the
* rank-numbered value chips, the per-chip popover, and the "Edit ranking"
* modal (numeric rank editing with inline duplicate rejection).
*
* Field names must be valid CEL identifiers (^[A-Za-z_][A-Za-z0-9_]*$).
*/
import type {Client4} from '@mattermost/client';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import {expect, test} from '@mattermost/playwright-lib';
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
import {deleteCustomProfileAttributes} from '../../channels/custom_profile_attributes/helpers';
type FieldsMap = Record<string, UserPropertyField>;
interface TestContext {
adminClient: Client4;
systemConsolePage: SystemConsolePage;
}
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
const {adminUser, adminClient} = await pw.initSetup();
// The PropertyFieldRank feature flag is enabled at the server level — via the
// MM_FEATUREFLAGS_PROPERTYFIELDRANK env var in CI (e2e-tests/.ci/server.generate.sh)
// and the server config locally. It cannot be toggled here: without a SplitKey the
// config store marks FeatureFlags read-only, so a patchConfig of FeatureFlags is a
// no-op (and a string value would fail JSON decode with a 400).
// # Start from a clean slate so chip/row indices are predictable
try {
const existing = await adminClient.getCustomProfileAttributeFields();
if (existing?.length) {
const map: FieldsMap = {};
for (const f of existing) {
map[f.id] = f;
}
await deleteCustomProfileAttributes(adminClient, {...map, __ownedIds: new Set(Object.keys(map))} as any);
}
} catch {
// nothing to clean up
}
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, systemConsolePage};
}
/** Creates a ranked CPA field via API. Options are {name, rank} pairs. */
async function createRankField(
adminClient: Client4,
name: string,
options: Array<{name: string; rank: number}>,
): Promise<UserPropertyField> {
return adminClient.createCustomProfileAttributeField({
name,
type: 'rank',
attrs: {sort_order: 0, options},
} as any);
}
async function getFieldsMap(client: Client4): Promise<FieldsMap> {
const fields: UserPropertyField[] = await client.getCustomProfileAttributeFields();
const map: FieldsMap = {};
for (const field of fields) {
map[field.id] = field;
}
return map;
}
async function cleanup(client: Client4): Promise<void> {
const map = await getFieldsMap(client);
await deleteCustomProfileAttributes(client, {...map, __ownedIds: new Set(Object.keys(map))} as any);
}
test.describe('System Console - Ranked User Attributes', () => {
/**
* @objective Creating a Ranked attribute and adding values auto-assigns
* ascending ranks, renders numbered chips in ascending order, and persists
* the field as type `rank` with sequential rank integers.
*/
test('creates a ranked attribute with auto-assigned ranks and saves', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const name = `clearance_${Date.now()}`;
try {
// # Navigate to User Attributes
await sp.goto();
// # Add a new attribute and name it
await sp.addAttribute();
const nameInput = sp.lastNameInput();
await nameInput.fill(name);
await nameInput.blur();
// # Change the type to Ranked
await sp.selectLastType('Rank');
// # Add three values — each auto-assigns the next rank (1, 2, 3)
await sp.addRankValuesToLast(['Unclassified', 'Secret', 'TopSecret']);
// * Chips render left→right in ascending rank order
await expect(sp.rankChips()).toHaveCount(3);
expect(await sp.rankChipLabels()).toEqual(['Unclassified', 'Secret', 'TopSecret']);
// * Each chip carries its rank badge (1, 2, 3)
await expect(sp.rankBadge('Unclassified')).toHaveText('1');
await expect(sp.rankBadge('Secret')).toHaveText('2');
await expect(sp.rankBadge('TopSecret')).toHaveText('3');
await sp.saveAndWaitForSettled();
// * Verify the field persisted as a ranked field with sequential ranks
const created = Object.values(await getFieldsMap(adminClient)).find((f) => f.name === name);
expect(created).toBeDefined();
expect(created!.type).toBe('rank');
const byName = Object.fromEntries((created!.attrs.options ?? []).map((o) => [o.name, o.rank]));
expect(byName).toMatchObject({Unclassified: 1, Secret: 2, TopSecret: 3});
} finally {
await cleanup(adminClient);
}
});
/**
* @objective The Edit ranking modal's add-value inline input rejects a label
* already used by another option: a "Values must be unique." error appears and
* the duplicate cannot be committed until the label is changed.
*
* The modal uses drag-and-drop ordering (rank = position), so duplicate ranks
* are impossible by construction; this test covers the duplicate-label guard on
* the add-value input instead.
*
* @precondition
* A ranked attribute with options Unclassified(1), Secret(2), TopSecret(3) exists.
*/
test('rejects a duplicate label in the Edit ranking modal add input', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const name = `clearance_${Date.now()}`;
try {
const field = await createRankField(adminClient, name, [
{name: 'Unclassified', rank: 1},
{name: 'Secret', rank: 2},
{name: 'TopSecret', rank: 3},
]);
// # Navigate to User Attributes and open the Edit ranking modal
await sp.goto();
await sp.openEditRanking(field.id);
// * Modal shows one row per option
await expect(sp.rankedModalRows()).toHaveCount(3);
// # Open the add-value input and type a label already in use
await sp.addRankedModalValue();
const addInput = sp.rankedModal().locator('.ranked-schema-modal__add-input');
await addInput.fill('Secret');
// * Duplicate-label error appears
await expect(sp.rankedModal().getByText('Values must be unique.')).toBeVisible();
// * Save is still enabled (duplicate prevents commit, not save of existing rows)
await expect(sp.rankedModalSaveButton()).toBeEnabled();
// # Correct the label — duplicate error disappears
await addInput.fill('Confidential');
await expect(sp.rankedModal().getByText('Values must be unique.')).not.toBeVisible();
} finally {
await cleanup(adminClient);
}
});
/**
* @objective Renaming an option through the per-chip popover persists the new
* label while preserving its rank.
*
* @precondition
* A ranked attribute with options Unclassified(1), Secret(2), TopSecret(3) exists.
*/
test('renames a ranked option via the per-chip popover', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const name = `clearance_${Date.now()}`;
try {
await createRankField(adminClient, name, [
{name: 'Unclassified', rank: 1},
{name: 'Secret', rank: 2},
{name: 'TopSecret', rank: 3},
]);
await sp.goto();
// # Open the Secret chip's popover and rename it
await sp.openRankChipPopover('Secret');
const labelInput = sp.rankPopoverLabelInput();
await expect(labelInput).toHaveValue('Secret');
await labelInput.fill('Classified');
await labelInput.press('Enter');
await sp.dismissMenu();
// * The chip now shows the new label, still at rank 2
await expect(sp.rankChip('Classified')).toBeVisible();
await expect(sp.rankBadge('Classified')).toHaveText('2');
await sp.saveAndWaitForSettled();
// * The option rename persisted with its rank unchanged
const updated = Object.values(await getFieldsMap(adminClient)).find((f) => f.name === name);
const renamed = updated!.attrs.options?.find((o) => o.name === 'Classified');
expect(renamed).toBeDefined();
expect(renamed!.rank).toBe(2);
expect(updated!.attrs.options?.some((o) => o.name === 'Secret')).toBe(false);
} finally {
await cleanup(adminClient);
}
});
/**
* @objective Adding a value in the Edit ranking modal persists it with the next
* sequential rank.
*
* The add-value affordance shows an inline text input; blurring the input
* commits the new label as the highest-ranked option.
*
* @precondition
* A ranked attribute with options Unclassified(1), Secret(2) exists.
*/
test('adds a value via the Edit ranking modal', {tag: '@user_attributes'}, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupTest(pw);
const sp = systemConsolePage.systemProperties;
const name = `clearance_${Date.now()}`;
try {
const field = await createRankField(adminClient, name, [
{name: 'Unclassified', rank: 1},
{name: 'Secret', rank: 2},
]);
await sp.goto();
await sp.openEditRanking(field.id);
await expect(sp.rankedModalRows()).toHaveCount(2);
// # Click "Add value" — shows the inline add input (counted as a row)
await sp.addRankedModalValue();
await expect(sp.rankedModalRows()).toHaveCount(3);
// * Save is available (the inline input is not yet a committed row)
await expect(sp.rankedModalSaveButton()).toBeEnabled();
// # Type the new label and commit it by blurring
const addInput = sp.rankedModal().locator('.ranked-schema-modal__add-input');
await addInput.fill('TopSecret');
await addInput.blur();
// * Three committed rows; save remains available
await expect(sp.rankedModalRows()).toHaveCount(3);
await expect(sp.rankedModalSaveButton()).toBeEnabled();
await sp.saveRankedModal();
await sp.saveAndWaitForSettled();
// * The new option persisted at rank 3
const updated = Object.values(await getFieldsMap(adminClient)).find((f) => f.name === name);
const added = updated!.attrs.options?.find((o) => o.name === 'TopSecret');
expect(added).toBeDefined();
expect(added!.rank).toBe(3);
} finally {
await cleanup(adminClient);
}
});
});