snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* E2E tests for System Console > System Attributes > Session Attributes.
|
||||
*
|
||||
* Covers the seeded session-attribute listing table, per-row TTL/Grace/Enable/
|
||||
* Disable tuning with staged Save, the unsaved-changes navigation guard, and the
|
||||
* ABAC picker polarity (enabled session attributes appear in permission-policy
|
||||
* editors and are absent from membership-policy editors).
|
||||
*
|
||||
* The CEL autocomplete endpoint returns access control group attributes and
|
||||
* does not return session attributes; the webapp fetches the enabled ones separately
|
||||
* and merges them into the permission-policy pickers client-side,
|
||||
* where they generate `user.session.<name>` references.
|
||||
*
|
||||
* The session_attributes Property group is gated server-side: it requires an
|
||||
* Enterprise Advanced license (returns HTTP 501 otherwise) and the
|
||||
* FeatureFlags.SessionAttributes flag. Tests skip cleanly when those are not met.
|
||||
*/
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
|
||||
import {enableABAC, expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {ensureUserAttributes, getUserAttributeFieldByName} from '../abac/support';
|
||||
|
||||
import {
|
||||
findFieldByName,
|
||||
patchSessionAttribute,
|
||||
readSessionAttribute,
|
||||
restoreSessionAttributesToBaseline,
|
||||
setupSessionAttributesTest,
|
||||
} from './support';
|
||||
|
||||
test.describe('System Console - Session Attributes', () => {
|
||||
// Return every seeded session attribute to its captured baseline after each
|
||||
// test. Tests toggle enabled/ttl/grace on various fields, so without this
|
||||
// the listing test's "Disabled by default" assertions would depend on no
|
||||
// other test having enabled a field first. Runs even when a test throws.
|
||||
test.afterEach(async () => {
|
||||
await restoreSessionAttributesToBaseline();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify the gated listing page renders the seeded session
|
||||
* attributes with correct Type, Platform, Status, and Server-source labeling.
|
||||
*/
|
||||
test(
|
||||
'renders the listing table with correct type, status, and server labels',
|
||||
{tag: '@session_attributes'},
|
||||
async ({pw}) => {
|
||||
const {systemConsolePage, fields} = await setupSessionAttributesTest(pw);
|
||||
const sa = systemConsolePage.sessionAttributes;
|
||||
|
||||
// # Navigate to Session Attributes via the sidebar
|
||||
await systemConsolePage.sidebar.systemAttributes.sessionAttributes.click();
|
||||
|
||||
// * Verify the page loaded
|
||||
await sa.toBeVisible();
|
||||
|
||||
const ipAddress = findFieldByName(fields, 'ip_address');
|
||||
const clientIpAddress = findFieldByName(fields, 'client_ip_address');
|
||||
const vpnActive = findFieldByName(fields, 'vpn_active');
|
||||
const networkInterfaceType = findFieldByName(fields, 'network_interface_type');
|
||||
const osVersion = findFieldByName(fields, 'os_version');
|
||||
|
||||
// * Verify a representative row is rendered with its platform icons
|
||||
await expect(sa.row(ipAddress.id)).toBeVisible();
|
||||
await expect(sa.platforms(ipAddress.id)).toBeVisible();
|
||||
|
||||
// * Verify request-derived fields carry the "Server" label
|
||||
await expect(sa.serverLabel(ipAddress.id)).toBeVisible();
|
||||
|
||||
// * Verify a seeded-but-client-native field does NOT carry the Server label
|
||||
await expect(sa.serverLabel(clientIpAddress.id)).toHaveCount(0);
|
||||
|
||||
// * Verify the derived Type column maps text/select fields correctly
|
||||
await expect(sa.type(ipAddress.id)).toContainText('IP');
|
||||
await expect(sa.type(vpnActive.id)).toContainText('Boolean');
|
||||
await expect(sa.type(networkInterfaceType.id)).toContainText('Enum');
|
||||
await expect(sa.type(osVersion.id)).toContainText('Version');
|
||||
|
||||
// * Verify seeded fields render as Disabled by default
|
||||
await expect(sa.status(ipAddress.id)).toContainText('Disabled');
|
||||
await expect(sa.status(ipAddress.id)).toHaveAttribute('data-enabled', 'false');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify staging a TTL preset, a Grace preset, and a one-click
|
||||
* Enable, then Saving, persists the changes after a full page reload.
|
||||
*/
|
||||
test('persists TTL, grace, and enable edits after save and reload', {tag: '@session_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
|
||||
const sa = systemConsolePage.sessionAttributes;
|
||||
|
||||
const field = findFieldByName(fields, 'os_version');
|
||||
|
||||
// # Navigate to Session Attributes page
|
||||
await sa.goto();
|
||||
|
||||
// # Stage a new TTL (1h), a new Grace period (5m), and enable the field
|
||||
await sa.setTtlPreset(field.id, 3600);
|
||||
await sa.setGracePreset(field.id, 300);
|
||||
await sa.enable(field.id);
|
||||
|
||||
// * Verify the staged values are reflected before saving
|
||||
await expect(sa.ttl(field.id)).toHaveText('1h');
|
||||
await expect(sa.grace(field.id)).toHaveText('5m');
|
||||
await expect(sa.status(field.id)).toContainText('Enabled');
|
||||
|
||||
// # Save the staged edits (one PATCH per changed field)
|
||||
await sa.saveAndWaitForSettled();
|
||||
|
||||
// # Reload the page
|
||||
await sa.goto();
|
||||
|
||||
// * Verify the edits persisted in the UI after reload
|
||||
await expect(sa.ttl(field.id)).toHaveText('1h');
|
||||
await expect(sa.grace(field.id)).toHaveText('5m');
|
||||
await expect(sa.status(field.id)).toContainText('Enabled');
|
||||
|
||||
// * Verify the edits persisted server-side via the property API
|
||||
const persisted = await readSessionAttribute(adminClient, field.id);
|
||||
expect(persisted.ttl_seconds).toBe(3600);
|
||||
expect(persisted.grace_period_seconds).toBe(300);
|
||||
expect(persisted.enabled).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify disabling an enabled attribute requires confirming the
|
||||
* modal, and that the disabled state persists after save and reload.
|
||||
*
|
||||
* @precondition
|
||||
* The target session attribute is enabled via API before the test.
|
||||
*/
|
||||
test('disables an enabled attribute via confirmation modal', {tag: '@session_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
|
||||
const sa = systemConsolePage.sessionAttributes;
|
||||
|
||||
const field = findFieldByName(fields, 'client_version');
|
||||
|
||||
// # Enable the field via API so the dot menu offers Disable
|
||||
await patchSessionAttribute(adminClient, field.id, {enabled: true});
|
||||
|
||||
// # Navigate to Session Attributes page
|
||||
await sa.goto();
|
||||
|
||||
// * Verify it shows as Enabled
|
||||
await expect(sa.status(field.id)).toContainText('Enabled');
|
||||
|
||||
// # Open the dot menu and trigger Disable (opens the confirmation modal)
|
||||
await sa.openDisableModal(field.id);
|
||||
|
||||
// * Verify the confirmation modal is shown
|
||||
await expect(sa.disableModal()).toBeVisible();
|
||||
|
||||
// # Confirm the disable
|
||||
await sa.confirmDisable();
|
||||
|
||||
// * Verify the staged status flips to Disabled before saving
|
||||
await expect(sa.status(field.id)).toContainText('Disabled');
|
||||
|
||||
// # Save the staged change
|
||||
await sa.saveAndWaitForSettled();
|
||||
|
||||
// # Reload the page
|
||||
await sa.goto();
|
||||
|
||||
// * Verify the disabled state persisted in the UI and server-side
|
||||
await expect(sa.status(field.id)).toContainText('Disabled');
|
||||
const persisted = await readSessionAttribute(adminClient, field.id);
|
||||
expect(persisted.enabled).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify a staged edit blocks sidebar navigation with a discard
|
||||
* prompt, and that Cancel reverts the staged change.
|
||||
*/
|
||||
test('blocks navigation while dirty and reverts on cancel', {tag: '@session_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage, fields} = await setupSessionAttributesTest(pw);
|
||||
const sa = systemConsolePage.sessionAttributes;
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
const field = findFieldByName(fields, 'ssid');
|
||||
|
||||
// # Navigate to Session Attributes page
|
||||
await sa.goto();
|
||||
|
||||
// # Capture the rendered TTL, then stage a different TTL (24h)
|
||||
const beforeTtl = (await sa.ttl(field.id).textContent())?.trim() ?? '';
|
||||
await sa.setTtlPreset(field.id, 86400);
|
||||
|
||||
// * Verify the edit is staged and Save is enabled
|
||||
await expect(sa.ttl(field.id)).toHaveText('24h');
|
||||
await expect(sa.saveButton).toBeEnabled();
|
||||
|
||||
// # Attempt to navigate away via the sidebar while dirty
|
||||
await systemConsolePage.sidebar.systemAttributes.userAttributes.click();
|
||||
|
||||
// * Verify the unsaved-changes navigation guard appears
|
||||
const discardModal = page.getByRole('dialog').filter({hasText: 'Discard Changes?'});
|
||||
await expect(discardModal).toBeVisible();
|
||||
await expect(discardModal.getByText('You have unsaved changes', {exact: false})).toBeVisible();
|
||||
|
||||
// # Decline the discard prompt to stay on the page
|
||||
await discardModal.getByRole('button', {name: 'Cancel'}).click();
|
||||
await expect(discardModal).toBeHidden();
|
||||
|
||||
// # Cancel the staged edit via the Save Changes panel
|
||||
await sa.cancel();
|
||||
|
||||
// * Verify the TTL reverted and Save returned to disabled
|
||||
await expect(sa.ttl(field.id)).toHaveText(beforeTtl);
|
||||
await expect(sa.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify session attributes are selectable in a permission-policy
|
||||
* editor's attribute picker but absent from a membership-policy editor's picker.
|
||||
*
|
||||
* @precondition
|
||||
* ABAC is enabled, at least one user attribute exists, and a session
|
||||
* attribute is enabled via API.
|
||||
*/
|
||||
test(
|
||||
'appears in permission-policy picker and is absent from membership-policy picker',
|
||||
{tag: '@session_attributes'},
|
||||
async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
const {adminClient, systemConsolePage, fields} = await setupSessionAttributesTest(pw);
|
||||
const page = systemConsolePage.page;
|
||||
|
||||
const sessionField = findFieldByName(fields, 'ip_address');
|
||||
|
||||
// A second session attribute kept DISABLED: the merge lists enabled
|
||||
// attributes only, so this one must never reach the picker.
|
||||
const disabledSessionField = findFieldByName(fields, 'vpn_active');
|
||||
|
||||
// # Ensure prerequisites: a user attribute, ABAC on, one session
|
||||
// attribute enabled and a second one explicitly disabled.
|
||||
await ensureUserAttributes(adminClient, ['Department']);
|
||||
await patchSessionAttribute(adminClient, sessionField.id, {enabled: true});
|
||||
await patchSessionAttribute(adminClient, disabledSessionField.id, {enabled: false});
|
||||
await enableABAC(page);
|
||||
|
||||
// Picker menu items are keyed by field id (`#attribute-<id>`), not by
|
||||
// name, so resolve the user attribute's id the same way the session
|
||||
// attribute ids come from the property-fields API.
|
||||
const userField = await getUserAttributeFieldByName(adminClient, 'Department');
|
||||
|
||||
// ── Permission policy editor: session attribute IS available ──
|
||||
|
||||
// # Open a new permission policy and add an attribute row
|
||||
await page.goto('/admin_console/system_attributes/permission_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByPlaceholder('Add a unique policy name').fill(`PP Session ${pw.random.id()}`);
|
||||
|
||||
await openAttributePicker(page);
|
||||
|
||||
const permissionMenu = page.getByRole('menu', {name: 'Select attribute'});
|
||||
await expect(permissionMenu).toBeVisible();
|
||||
|
||||
// * Verify the SESSION ATTRIBUTES section and the enabled session attribute are present
|
||||
await expect(permissionMenu.getByText('Session attributes', {exact: true})).toBeVisible();
|
||||
await expect(permissionMenu.locator(`#attribute-${sessionField.id}`)).toBeVisible();
|
||||
|
||||
// * Verify the DISABLED session attribute is absent (enabled-only filter)
|
||||
await expect(permissionMenu.locator(`#attribute-${disabledSessionField.id}`)).toHaveCount(0);
|
||||
|
||||
// ── Membership policy editor: session attribute is NOT available ──
|
||||
//
|
||||
// Done before building a permission expression so this navigation
|
||||
// isn't blocked by the unsaved-changes guard.
|
||||
|
||||
// # Open a new membership policy and add an attribute row
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page
|
||||
.locator('#admin\\.access_control\\.policy\\.edit_policy\\.policyName')
|
||||
.fill(`MP Session ${pw.random.id()}`);
|
||||
|
||||
await openAttributePicker(page);
|
||||
|
||||
const membershipMenu = page.getByRole('menu', {name: 'Select attribute'});
|
||||
await expect(membershipMenu).toBeVisible();
|
||||
|
||||
// * Verify the picker loaded user attributes (proves the menu is populated)
|
||||
await expect(membershipMenu.locator(`#attribute-${userField.id}`)).toBeVisible();
|
||||
|
||||
// * Verify session attributes are absent from the membership picker
|
||||
await expect(membershipMenu.getByText('Session attributes', {exact: true})).toHaveCount(0);
|
||||
await expect(membershipMenu.locator(`#attribute-${sessionField.id}`)).toHaveCount(0);
|
||||
|
||||
// ── CEL path: a picked session attribute generates user.session.<name> ──
|
||||
//
|
||||
// Last step: building an expression dirties the form, so any
|
||||
// navigation must already be complete.
|
||||
|
||||
// # Open a fresh permission policy, pick the session attribute, set a value
|
||||
await page.goto('/admin_console/system_attributes/permission_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', {name: 'Add policy'}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByPlaceholder('Add a unique policy name').fill(`PP CEL ${pw.random.id()}`);
|
||||
|
||||
await openAttributePicker(page);
|
||||
|
||||
const celMenu = page.getByRole('menu', {name: 'Select attribute'});
|
||||
await expect(celMenu).toBeVisible();
|
||||
await celMenu.locator(`#attribute-${sessionField.id}`).click();
|
||||
|
||||
const valueInput = page.locator('.values-editor__simple-input').first();
|
||||
await valueInput.click();
|
||||
await valueInput.fill('10.0.0.1');
|
||||
await valueInput.press('Enter');
|
||||
|
||||
// # Switch to Advanced (CEL) mode
|
||||
await page.getByRole('button', {name: 'Switch to Advanced Mode'}).click();
|
||||
|
||||
// * Verify the generated CEL uses the session namespace, not user.attributes
|
||||
await expect(page.locator('.cel-editor')).toContainText('user.session.ip_address');
|
||||
await expect(page.locator('.cel-editor')).not.toContainText('user.attributes.ip_address');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Add an attribute row in the policy table editor and open its attribute picker.
|
||||
* Shared by the permission and membership editor checks since both reuse the
|
||||
* same table-editor attribute selector.
|
||||
*/
|
||||
async function openAttributePicker(page: Page): Promise<void> {
|
||||
const addAttributeButton = page.getByRole('button', {name: /add attribute/i});
|
||||
await expect(addAttributeButton).toBeEnabled({timeout: 15000});
|
||||
await addAttributeButton.click();
|
||||
|
||||
// Adding a row auto-opens the attribute menu. Only click the selector
|
||||
// button to open it when the auto-open didn't fire — clicking while the
|
||||
// menu is already open would land on its backdrop and be intercepted.
|
||||
const menu = page.getByRole('menu', {name: 'Select attribute'});
|
||||
try {
|
||||
await expect(menu).toBeVisible({timeout: 3000});
|
||||
} catch {
|
||||
await page.getByTestId('attributeSelectorMenuButton').first().click();
|
||||
await expect(menu).toBeVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {PropertyField} from '@mattermost/types/properties';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended, SystemConsolePage} from '@mattermost/playwright-lib';
|
||||
|
||||
export const SESSION_ATTRIBUTES_GROUP = 'session_attributes';
|
||||
export const SESSION_ATTRIBUTES_OBJECT_TYPE = 'session';
|
||||
export const SESSION_ATTRIBUTES_TARGET_TYPE = 'system';
|
||||
|
||||
export type SessionAttrPatch = Partial<{
|
||||
enabled: boolean;
|
||||
ttl_seconds: number;
|
||||
grace_period_seconds: number;
|
||||
}>;
|
||||
|
||||
export interface SessionAttributesContext {
|
||||
adminClient: Client4;
|
||||
systemConsolePage: SystemConsolePage;
|
||||
fields: PropertyField[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The seeded session_attributes Property group is gated server-side: requests
|
||||
* return HTTP 501 below an Enterprise Advanced license, and the System Console
|
||||
* page is hidden unless FeatureFlags.SessionAttributes is true. Probe both and
|
||||
* skip (rather than fail) when the environment does not satisfy them, so the
|
||||
* suite reports honestly on stacks that cannot exercise the feature.
|
||||
*/
|
||||
export async function setupSessionAttributesTest(pw: PlaywrightExtended): Promise<SessionAttributesContext> {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
const config = await adminClient.getConfig();
|
||||
test.skip(
|
||||
config.FeatureFlags?.SessionAttributes !== true,
|
||||
'Requires FeatureFlags.SessionAttributes=true on the server',
|
||||
);
|
||||
|
||||
let fields: PropertyField[] = [];
|
||||
try {
|
||||
fields = await getSessionAttributeFields(adminClient);
|
||||
} catch (error) {
|
||||
test.skip(
|
||||
true,
|
||||
`session_attributes property group unavailable (requires Enterprise Advanced license): ${String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
test.skip(fields.length === 0, 'No seeded session attributes returned by the server');
|
||||
|
||||
captureSessionAttributesBaseline(adminClient, fields);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
return {adminClient, systemConsolePage, fields};
|
||||
}
|
||||
|
||||
/**
|
||||
* Baseline snapshot of every seeded session attribute, captured the first time
|
||||
* the suite sets up (before any test mutates state). Used by
|
||||
* restoreSessionAttributesToBaseline so the suite is order-independent: each
|
||||
* test returns the fields to this original state, so a field enabled by one
|
||||
* test cannot leak into another's assertions.
|
||||
*/
|
||||
let baselineFields: PropertyField[] | null = null;
|
||||
let baselineClient: Client4 | null = null;
|
||||
|
||||
function captureSessionAttributesBaseline(client: Client4, fields: PropertyField[]): void {
|
||||
if (!baselineFields) {
|
||||
baselineFields = fields.map((field) => ({...field}));
|
||||
baselineClient = client;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore every seeded session attribute to the baseline captured at suite
|
||||
* start. Call from afterEach so tests that toggle enabled/ttl/grace cannot leak
|
||||
* state into later tests, regardless of execution order.
|
||||
*/
|
||||
export async function restoreSessionAttributesToBaseline(): Promise<void> {
|
||||
if (!baselineClient || !baselineFields) {
|
||||
return;
|
||||
}
|
||||
for (const field of baselineFields) {
|
||||
await restoreSessionAttribute(baselineClient, field);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSessionAttributeFields(client: Client4): Promise<PropertyField[]> {
|
||||
return client.getPropertyFields(
|
||||
SESSION_ATTRIBUTES_GROUP,
|
||||
SESSION_ATTRIBUTES_OBJECT_TYPE,
|
||||
SESSION_ATTRIBUTES_TARGET_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
export function findFieldByName(fields: PropertyField[], name: string): PropertyField {
|
||||
const field = fields.find((f) => f.name === name);
|
||||
expect(field, `Seeded session attribute "${name}" not found`).toBeDefined();
|
||||
return field as PropertyField;
|
||||
}
|
||||
|
||||
export async function patchSessionAttribute(
|
||||
client: Client4,
|
||||
fieldId: string,
|
||||
attrs: SessionAttrPatch,
|
||||
): Promise<PropertyField> {
|
||||
return client.patchPropertyField(SESSION_ATTRIBUTES_GROUP, SESSION_ATTRIBUTES_OBJECT_TYPE, fieldId, {attrs});
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch a single field's tunable attrs from the server (source of truth for
|
||||
* persistence assertions).
|
||||
*/
|
||||
export async function readSessionAttribute(client: Client4, fieldId: string): Promise<SessionAttrPatch> {
|
||||
const fields = await getSessionAttributeFields(client);
|
||||
const field = fields.find((f) => f.id === fieldId);
|
||||
const attrs = (field?.attrs ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
enabled: attrs.enabled === true,
|
||||
ttl_seconds: typeof attrs.ttl_seconds === 'number' ? attrs.ttl_seconds : undefined,
|
||||
grace_period_seconds: typeof attrs.grace_period_seconds === 'number' ? attrs.grace_period_seconds : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a field's tunables to the captured values. Use in a finally block so
|
||||
* each test leaves the seeded fields in their original state and tests stay
|
||||
* independent.
|
||||
*/
|
||||
export async function restoreSessionAttribute(client: Client4, field: PropertyField): Promise<void> {
|
||||
const attrs = (field.attrs ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
await patchSessionAttribute(client, field.id, {
|
||||
enabled: attrs.enabled === true,
|
||||
ttl_seconds: typeof attrs.ttl_seconds === 'number' ? attrs.ttl_seconds : undefined,
|
||||
grace_period_seconds:
|
||||
typeof attrs.grace_period_seconds === 'number' ? attrs.grace_period_seconds : undefined,
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup; ignore if the server rejects (e.g. license downgrade mid-run).
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user