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,191 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective Add Members to Team modal (System Console) blocks non-qualifying candidates in strict mode
|
||||
* @reference MM-69100
|
||||
*/
|
||||
|
||||
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
enableTeamMembershipABACConfig,
|
||||
ensureDepartmentAttribute,
|
||||
createPrivateTeam,
|
||||
createPublicTeam,
|
||||
createTeamMembershipPolicy,
|
||||
setUserAttribute,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../../../channels/team_settings/helpers';
|
||||
|
||||
import {enableTeamMembershipPolicies} from './helpers';
|
||||
|
||||
test.describe('ABAC - Add members to team (System Console)', {tag: ['@abac', '@team_membership']}, () => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
let createdTeamIds: string[] = [];
|
||||
let createdUserIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({pw}) => {
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
for (const id of createdTeamIds) {
|
||||
try {
|
||||
await adminClient.deleteTeam(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdTeamIds = [];
|
||||
for (const id of createdUserIds) {
|
||||
try {
|
||||
await adminClient.updateUserActive(id, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdUserIds = [];
|
||||
});
|
||||
|
||||
test('MM-68846-T7 PRIVATE governed team blocks non-qualifying candidates inline', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, adminUser} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Create private team with Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # Create eng1 (Engineering) and mkt1 (Marketing) — not added to team yet
|
||||
const createUser = async (dept: string, label: string) => {
|
||||
const uid = `${suffix}${label}`;
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
first_name: dept,
|
||||
last_name: label,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
return user;
|
||||
};
|
||||
|
||||
const eng1 = await createUser('Engineering', `eng1${suffix}`);
|
||||
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
|
||||
createdUserIds.push(eng1.id, mkt1.id);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
|
||||
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
|
||||
|
||||
// # Log in as system admin and navigate to the per-team console page
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(`/admin_console/user_management/teams/${team.id}`);
|
||||
|
||||
// # Click "Add Members" button
|
||||
await page.locator('#addTeamMembers').click();
|
||||
|
||||
const addModal = page.locator('#addUsersToTeamModal');
|
||||
await expect(addModal).toBeVisible({timeout: 10000});
|
||||
|
||||
// MultiSelect uses React-Select which renders the input as a combobox role, not
|
||||
// a plain <input placeholder="...">. Use getByRole to match the accessible name.
|
||||
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
|
||||
await expect(searchInput).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Search for eng1 — qualifying candidate
|
||||
await searchInput.pressSequentially(eng1.username.slice(0, 8));
|
||||
const eng1Row = addModal.locator('.more-modal__row', {hasText: eng1.username});
|
||||
await expect(eng1Row).toBeVisible({timeout: 10000});
|
||||
|
||||
// * eng1's row is NOT disabled
|
||||
await expect(eng1Row).not.toHaveClass(/more-modal__row--disabled/);
|
||||
await expect(eng1Row.locator('.more-modal__error')).not.toBeVisible();
|
||||
|
||||
// # Search for mkt1 — non-qualifying candidate
|
||||
await searchInput.clear();
|
||||
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
|
||||
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
|
||||
await expect(mkt1Row).toBeVisible({timeout: 10000});
|
||||
|
||||
// * mkt1's row is disabled with the blocking message
|
||||
await expect(mkt1Row).toHaveClass(/more-modal__row--disabled/);
|
||||
await expect(mkt1Row.locator('.more-modal__error')).toHaveText('Does not meet membership requirements');
|
||||
});
|
||||
|
||||
test('MM-68846-T8 PUBLIC governed team does NOT block candidates (advisory)', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, adminUser} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Create public team with Engineering policy
|
||||
const team = await createPublicTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # Create eng1 and mkt1
|
||||
const createUser = async (dept: string, label: string) => {
|
||||
const uid = `${suffix}${label}`;
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
first_name: dept,
|
||||
last_name: label,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
return user;
|
||||
};
|
||||
|
||||
const eng1 = await createUser('Engineering', `eng1${suffix}`);
|
||||
const mkt1 = await createUser('Marketing', `mkt1${suffix}`);
|
||||
createdUserIds.push(eng1.id, mkt1.id);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
|
||||
|
||||
// # Advisory policy (public team)
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
|
||||
|
||||
// # Log in as system admin
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto(`/admin_console/user_management/teams/${team.id}`);
|
||||
|
||||
// # Click "Add Members" button
|
||||
await page.locator('#addTeamMembers').click();
|
||||
|
||||
const addModal = page.locator('#addUsersToTeamModal');
|
||||
await expect(addModal).toBeVisible({timeout: 10000});
|
||||
|
||||
const searchInput = addModal.getByRole('combobox', {name: 'Search for people'});
|
||||
await expect(searchInput).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Search for mkt1 — non-qualifying but advisory = no block
|
||||
await searchInput.pressSequentially(mkt1.username.slice(0, 8));
|
||||
const mkt1Row = addModal.locator('.more-modal__row', {hasText: mkt1.username});
|
||||
await expect(mkt1Row).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Row is NOT disabled
|
||||
await expect(mkt1Row).not.toHaveClass(/more-modal__row--disabled/);
|
||||
await expect(mkt1Row.locator('.more-modal__error')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
setupCustomProfileAttributeFields,
|
||||
setupCustomProfileAttributeValuesForUser,
|
||||
} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
|
||||
import {createUserForABAC, ensureUserAttributes} from '../support';
|
||||
|
||||
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
|
||||
|
||||
/**
|
||||
* ABAC — Team directory hiding
|
||||
*
|
||||
* Enforcement is mode-dependent. A private (non-open-invite) governed team is
|
||||
* strict: it is surfaced into the directory only for non-members who satisfy the
|
||||
* policy and omitted for everyone else. A public (open-invite) governed team is
|
||||
* advisory: it stays visible to all. Asserted at the API layer (each user's own
|
||||
* client) so it proves server-side filtering, not just DOM hiding — exercising the
|
||||
* full api4 → app filter → store-listing chain, including the governed-team
|
||||
* listing widening that surfaces private governed teams to qualifying users.
|
||||
*/
|
||||
test.describe('ABAC - Team directory hiding', {tag: ['@abac', '@team_membership']}, () => {
|
||||
// Each test cleans up the resources it creates, even on failure. initSetup's
|
||||
// own team/users are owned by the framework and left alone.
|
||||
let cleanupClient: Client4 | undefined;
|
||||
const createdTeamIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
const createdPolicyIds: string[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
const client = cleanupClient;
|
||||
cleanupClient = undefined;
|
||||
const policyIds = createdPolicyIds.splice(0);
|
||||
const teamIds = createdTeamIds.splice(0);
|
||||
const userIds = createdUserIds.splice(0);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const base = client.getBaseRoute();
|
||||
const headers = {Authorization: `Bearer ${client.getToken()}`};
|
||||
for (const id of policyIds) {
|
||||
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
|
||||
}
|
||||
for (const id of teamIds) {
|
||||
await client.deleteTeam(id).catch(() => {});
|
||||
}
|
||||
// Hard user deletion requires server config; deactivating is the API-safe cleanup.
|
||||
for (const id of userIds) {
|
||||
await client.updateUserActive(id, false).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-68846-T1 - omits a policy-governed team from the directory for non-qualifying users', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient, team} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
// Two non-members: one qualifies (Engineering), one does not (Sales).
|
||||
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Engineering', type: 'text'},
|
||||
]);
|
||||
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Sales', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(qualUser.id, nonQualUser.id);
|
||||
|
||||
// A private (non-open-invite) governed team is strict: ABAC surfaces it into
|
||||
// the directory only for users who qualify and hides it from everyone else.
|
||||
// The policy never mutates allow_open_invite.
|
||||
await adminClient.patchTeam({id: team.id, allow_open_invite: false} as any);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Directory Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [team.id]);
|
||||
|
||||
// Control: a private team with NO policy. The listing widening must surface
|
||||
// only governed teams, so this one stays invisible to both users regardless
|
||||
// of qualification — guarding against the OR clause over-widening.
|
||||
const ungoverned = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Ungoverned ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
allow_open_invite: false,
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(ungoverned.id);
|
||||
|
||||
// ABAC evaluates against a materialized view that refreshes on an interval;
|
||||
// wait until the qualifying user is visible to the evaluator before asserting.
|
||||
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
|
||||
|
||||
// The child policy row is written to the master DB; the directory query
|
||||
// reads from a read replica. Poll policy_enforced until the replica has
|
||||
// caught up so the EXISTS subquery in GetAllPage widens correctly.
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(team.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
message: 'team should show policy_enforced=true before asserting directory visibility',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// API layer: proves the server filters the listing, not just the DOM.
|
||||
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
|
||||
const {client: nonQualClient} = await pw.makeClient({
|
||||
username: nonQualUser.username,
|
||||
password: nonQualUser.password,
|
||||
});
|
||||
|
||||
// Paginate to collect all visible teams — busy CI environments accumulate
|
||||
// many public teams across test runs, so a single page-0 fetch misses teams
|
||||
// that land beyond per_page=200. Use batch.length === 0 (not < per_page) as
|
||||
// the stop condition: the server applies FilterNonQualifyingTeamsForUser
|
||||
// after fetching a full DB page, so a batch can legitimately come back
|
||||
// smaller than per_page while more pages still exist in the DB.
|
||||
const allTeamIds = async (client: Client4): Promise<string[]> => {
|
||||
const ids: string[] = [];
|
||||
let page = 0;
|
||||
while (true) {
|
||||
const batch = (await client.getTeams(page, 200)) as Team[];
|
||||
if (batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
ids.push(...batch.map((t) => t.id));
|
||||
page++;
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
|
||||
const qualIds = await allTeamIds(qualClient);
|
||||
const nonQualIds = await allTeamIds(nonQualClient);
|
||||
|
||||
expect(qualIds).toContain(team.id);
|
||||
expect(nonQualIds).not.toContain(team.id);
|
||||
|
||||
// The ungoverned private team is never surfaced, qualified or not.
|
||||
expect(qualIds).not.toContain(ungoverned.id);
|
||||
expect(nonQualIds).not.toContain(ungoverned.id);
|
||||
|
||||
// The boundary is asserted at the API layer. The /select_team directory page
|
||||
// additionally filters joinable teams to open-invite teams client-side, so it
|
||||
// never renders a private governed team regardless of the server decision —
|
||||
// surfacing those in the directory UI is separate webapp work.
|
||||
});
|
||||
|
||||
test('MM-68846-T2 - hides a governed team from a non-qualifying system admin in the directory while the management listing stays complete', async ({
|
||||
pw,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
// Give the admin a present-but-non-matching Department so the policy
|
||||
// deterministically denies them. A missing attribute is not the same as a
|
||||
// wrong one — the expression must evaluate to false, not error on an absent
|
||||
// key — so the admin gets "Sales" against an "Engineering" policy.
|
||||
await setupCustomProfileAttributeValuesForUser(
|
||||
adminClient,
|
||||
[{name: 'Department', value: 'Sales', type: 'text'}],
|
||||
attributeFieldsMap,
|
||||
adminUser.id,
|
||||
);
|
||||
|
||||
// A private (non-open-invite), policy-governed team the admin is NOT a member
|
||||
// of: create it as admin (the creator is auto-joined), then remove the admin
|
||||
// so the directory treats them as a non-member non-qualifying browser. Private
|
||||
// → strict, so for_directory hiding applies to the admin too.
|
||||
const governed = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Governed ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
allow_open_invite: false,
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(governed.id);
|
||||
await adminClient.removeFromTeam(governed.id, adminUser.id);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Admin Directory Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [governed.id]);
|
||||
|
||||
const base = adminClient.getBaseRoute();
|
||||
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
|
||||
const listDirectoryTeamIds = async (forDirectory: boolean): Promise<string[]> => {
|
||||
const url = `${base}/teams?page=0&per_page=200${forDirectory ? '&for_directory=true' : ''}`;
|
||||
const resp = await fetch(url, {headers});
|
||||
const teams = (await resp.json()) as Team[];
|
||||
return teams.map((t) => t.id);
|
||||
};
|
||||
|
||||
// The assignment becomes visible through a materialized attribute view and a
|
||||
// read replica, so poll the directory listing until the governed team drops
|
||||
// out for the admin (for_directory) rather than asserting on the first read.
|
||||
// A transient fetch error counts as "not ready yet" so the poll keeps going.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return (await listDirectoryTeamIds(true)).includes(governed.id);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
message: 'governed team should be hidden from the admin directory listing',
|
||||
},
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
// Without for_directory the admin stays exempt, so the System Console listing
|
||||
// is still complete.
|
||||
expect(await listDirectoryTeamIds(false)).toContain(governed.id);
|
||||
|
||||
// UI: with the backend confirmed above, the team-selection directory hides it
|
||||
// for the admin too.
|
||||
const adminSession = await pw.testBrowser.login(adminUser);
|
||||
await adminSession.page.goto('/select_team');
|
||||
await adminSession.page.waitForLoadState('networkidle');
|
||||
await expect(adminSession.page.getByText(governed.display_name, {exact: false})).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('MM-68846-T3 - keeps a public governed team visible to a non-qualifying user (advisory mode)', async ({
|
||||
pw,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
// A user who does NOT satisfy the policy (Sales against an Engineering rule).
|
||||
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Sales', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(nonQualUser.id);
|
||||
|
||||
// A public (open-invite) governed team: the policy is advisory, so it stays
|
||||
// in the directory for everyone regardless of qualification — no hiding.
|
||||
// open-invite is set via patch (the create payload does not persist it).
|
||||
const publicGoverned = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Public Governed ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(publicGoverned.id);
|
||||
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Advisory Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
|
||||
|
||||
// Confirm enforcement is actually live before asserting advisory behavior —
|
||||
// otherwise "visible" is indistinguishable from a plain ungoverned public team
|
||||
// and a regression to strict-on-public would go undetected.
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// Advisory: despite being governed, the public team stays visible to the
|
||||
// non-qualifying user — the policy never hides it.
|
||||
const {client: nonQualClient} = await pw.makeClient({
|
||||
username: nonQualUser.username,
|
||||
password: nonQualUser.password,
|
||||
});
|
||||
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
|
||||
expect(nonQualTeams.map((t) => t.id)).toContain(publicGoverned.id);
|
||||
});
|
||||
|
||||
test('MM-68846-T4 - join gate forks on privacy mode: advisory public admits, strict private denies', async ({
|
||||
pw,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
// Present-but-wrong attribute (Sales against an Engineering rule) so the PDP
|
||||
// evaluates the user to false rather than erroring on a missing key.
|
||||
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Sales', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(nonQualUser.id);
|
||||
|
||||
// The admin drives the readiness poll below; give them the same present-but-
|
||||
// wrong attribute so the private team deterministically drops from their
|
||||
// for_directory listing once enforcement is live.
|
||||
await setupCustomProfileAttributeValuesForUser(
|
||||
adminClient,
|
||||
[{name: 'Department', value: 'Sales', type: 'text'}],
|
||||
attributeFieldsMap,
|
||||
adminUser.id,
|
||||
);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const newGovernedTeam = async (open: boolean) => {
|
||||
const t = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Governed ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(t.id);
|
||||
if (open) {
|
||||
// open-invite is set via patch (the create payload does not persist it).
|
||||
await adminClient.patchTeam({id: t.id, allow_open_invite: true} as any);
|
||||
}
|
||||
// The creator is auto-joined, and members always retain directory
|
||||
// visibility — remove the admin so they read as a non-member browser.
|
||||
await adminClient.removeFromTeam(t.id, adminUser.id);
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Join Gate ${open ? 'Public' : 'Private'} ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [t.id]);
|
||||
return t;
|
||||
};
|
||||
|
||||
const publicGoverned = await newGovernedTeam(true);
|
||||
const privateGoverned = await newGovernedTeam(false);
|
||||
|
||||
// The private team's enforcement reaches the join gate through a materialized
|
||||
// view and a read replica. Poll the admin's for_directory listing until the
|
||||
// team drops out: the admin always has it in-candidate (list_private_teams),
|
||||
// so it dropping out positively confirms the policy is live AND the PDP
|
||||
// denies — exactly the state the join gate evaluates. Polling the
|
||||
// non-qualifying user's listing would be ambiguous (absent both before the
|
||||
// policy propagates and after it is hidden).
|
||||
const base = adminClient.getBaseRoute();
|
||||
const headers = {Authorization: `Bearer ${adminClient.getToken()}`};
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
const resp = await fetch(`${base}/teams?page=0&per_page=200&for_directory=true`, {headers});
|
||||
return ((await resp.json()) as Team[]).map((t) => t.id).includes(privateGoverned.id);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{timeout: 60_000, intervals: [1000, 2000, 5000, 5000, 5000]},
|
||||
)
|
||||
.toBe(false);
|
||||
|
||||
// Confirm the public team is genuinely governed before asserting advisory
|
||||
// admission — otherwise a "successful join" would just be a plain public team
|
||||
// and a regression to strict-on-public would slip through.
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// Advisory: the gate is skipped on a governed public team, so the
|
||||
// non-qualifying user is admitted.
|
||||
const member = await adminClient.addToTeam(publicGoverned.id, nonQualUser.id);
|
||||
expect(member.user_id).toBe(nonQualUser.id);
|
||||
|
||||
// Strict: the same user is denied on the private team — directory visibility
|
||||
// never translates into join access, and no role bypasses the gate.
|
||||
let denied = false;
|
||||
try {
|
||||
await adminClient.addToTeam(privateGoverned.id, nonQualUser.id);
|
||||
} catch {
|
||||
denied = true;
|
||||
}
|
||||
expect(denied).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {setupCustomProfileAttributeFields} from '../../../channels/custom_profile_attributes/helpers';
|
||||
import {waitForAttributeViewToInclude} from '../../../channels/team_settings/helpers';
|
||||
import {createUserForABAC, ensureUserAttributes} from '../support';
|
||||
|
||||
import {assignTeamsToPolicy, createTeamMembershipParentPolicy, enableTeamMembershipPolicies} from './helpers';
|
||||
|
||||
/**
|
||||
* ABAC — Team directory "Recommended" tag
|
||||
*
|
||||
* A public (open-invite) governed team is advisory: it stays visible to everyone,
|
||||
* and a non-member who satisfies the policy is surfaced a transient, per-viewer
|
||||
* "recommended" hint. The flag is fail-secure (a non-qualifying viewer is never
|
||||
* tagged) and is never set on private governed teams (those are hidden, not
|
||||
* recommended). Asserted at the API layer so it proves the server-side annotation
|
||||
* across the full api4 → app filter/annotate → store-listing chain, plus a UI check
|
||||
* that the chip renders in the team-selection directory.
|
||||
*
|
||||
* @reference MM-69100
|
||||
*/
|
||||
test.describe('ABAC - Team directory recommended tag', {tag: ['@abac', '@team_membership']}, () => {
|
||||
let cleanupClient: Client4 | undefined;
|
||||
const createdTeamIds: string[] = [];
|
||||
const createdUserIds: string[] = [];
|
||||
const createdPolicyIds: string[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
const client = cleanupClient;
|
||||
cleanupClient = undefined;
|
||||
const policyIds = createdPolicyIds.splice(0);
|
||||
const teamIds = createdTeamIds.splice(0);
|
||||
const userIds = createdUserIds.splice(0);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const base = client.getBaseRoute();
|
||||
const headers = {Authorization: `Bearer ${client.getToken()}`};
|
||||
for (const id of policyIds) {
|
||||
await fetch(`${base}/access_control_policies/${id}`, {method: 'DELETE', headers}).catch(() => {});
|
||||
}
|
||||
for (const id of teamIds) {
|
||||
await client.deleteTeam(id).catch(() => {});
|
||||
}
|
||||
for (const id of userIds) {
|
||||
await client.updateUserActive(id, false).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test('MM-69100-T1 - recommends a public governed team to a qualifying non-member only', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Engineering', type: 'text'},
|
||||
]);
|
||||
const nonQualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Sales', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(qualUser.id, nonQualUser.id);
|
||||
|
||||
// A public (open-invite) governed team: advisory, so it stays visible to all;
|
||||
// qualifying non-members get the recommended hint. open-invite is set via patch
|
||||
// (the create payload does not persist it).
|
||||
const publicGoverned = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Public Governed ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(publicGoverned.id);
|
||||
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Recommended Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
// API layer: the qualifying non-member's listing tags the team recommended;
|
||||
// the non-qualifying user's listing does not (fail-secure).
|
||||
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
|
||||
const {client: nonQualClient} = await pw.makeClient({
|
||||
username: nonQualUser.username,
|
||||
password: nonQualUser.password,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const teams = (await qualClient.getTeams(0, 200)) as Team[];
|
||||
return teams.find((t) => t.id === publicGoverned.id)?.recommended === true;
|
||||
},
|
||||
{
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
message: 'public governed team should be recommended to the qualifying user',
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const nonQualTeams = (await nonQualClient.getTeams(0, 200)) as Team[];
|
||||
const nonQualRow = nonQualTeams.find((t) => t.id === publicGoverned.id);
|
||||
expect(nonQualRow).toBeDefined(); // advisory: still visible
|
||||
expect(Boolean(nonQualRow?.recommended)).toBe(false);
|
||||
});
|
||||
|
||||
test('MM-69100-T2 - never recommends a private governed team, even to a qualifying user', async ({pw}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Engineering', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(qualUser.id);
|
||||
|
||||
// A private (non-open-invite) governed team: strict. It is surfaced to the
|
||||
// qualifying user (directory widening) but must never carry the recommended tag.
|
||||
const privateGoverned = await adminClient.createTeam({
|
||||
name: `team-${pw.random.id()}`,
|
||||
display_name: `Private Governed ${pw.random.id()}`,
|
||||
type: 'O',
|
||||
allow_open_invite: false,
|
||||
email: `${pw.random.id()}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(privateGoverned.id);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Private Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [privateGoverned.id]);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(privateGoverned.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const {client: qualClient} = await pw.makeClient({username: qualUser.username, password: qualUser.password});
|
||||
|
||||
// The qualifying user can see the private governed team (strict widening), but it
|
||||
// is never recommended — recommendation is a public-team-only, advisory concept.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const teams = (await qualClient.getTeams(0, 200)) as Team[];
|
||||
return teams.some((t) => t.id === privateGoverned.id);
|
||||
},
|
||||
{
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
message: 'qualifying user should see the private governed team before asserting recommendation',
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const teams = (await qualClient.getTeams(0, 200)) as Team[];
|
||||
const row = teams.find((t) => t.id === privateGoverned.id);
|
||||
expect(Boolean(row?.recommended)).toBe(false);
|
||||
});
|
||||
|
||||
test('MM-69100-T3 - renders the Recommended chip in the team-selection directory for a qualifying user', async ({
|
||||
pw,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminClient} = await pw.initSetup();
|
||||
cleanupClient = adminClient;
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureUserAttributes(adminClient);
|
||||
const attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, [
|
||||
{name: 'Department', type: 'text'},
|
||||
]);
|
||||
|
||||
const qualUser = await createUserForABAC(adminClient, attributeFieldsMap, [
|
||||
{name: 'Department', value: 'Engineering', type: 'text'},
|
||||
]);
|
||||
createdUserIds.push(qualUser.id);
|
||||
|
||||
// Prefix with "AA" so the team sorts near the top of most environments.
|
||||
// The UI check in Part 2 below walks InfiniteScroll regardless, so an
|
||||
// exact page-0 guarantee is not required.
|
||||
const teamId = pw.random.id();
|
||||
const publicGoverned = await adminClient.createTeam({
|
||||
name: `team-${teamId}`,
|
||||
display_name: `AA Recommended${teamId}`,
|
||||
type: 'O',
|
||||
email: `${teamId}@example.com`,
|
||||
} as any);
|
||||
createdTeamIds.push(publicGoverned.id);
|
||||
await adminClient.patchTeam({id: publicGoverned.id, allow_open_invite: true} as any);
|
||||
|
||||
const expression = 'user.attributes.Department == "Engineering"';
|
||||
const policy = await createTeamMembershipParentPolicy(
|
||||
adminClient,
|
||||
`Recommended UI Policy ${pw.random.id()}`,
|
||||
expression,
|
||||
);
|
||||
createdPolicyIds.push(policy.id);
|
||||
await assignTeamsToPolicy(adminClient, policy.id, [publicGoverned.id]);
|
||||
|
||||
// Wait for the materialized attribute view to include the qualifying user
|
||||
// before asserting policy propagation — the PDP reads from this view.
|
||||
await waitForAttributeViewToInclude(adminClient, expression, [qualUser.id], 45_000);
|
||||
|
||||
// The child policy row is written to the master DB; the directory query
|
||||
// reads from a read replica. Poll policy_enforced until the replica has
|
||||
// caught up so the EXISTS subquery in GetAllPage widens correctly.
|
||||
await expect
|
||||
.poll(async () => (await adminClient.getTeam(publicGoverned.id)).policy_enforced, {
|
||||
timeout: 60_000,
|
||||
intervals: [1000, 2000, 5000, 5000, 5000],
|
||||
message: 'team should show policy_enforced=true before asserting recommended',
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
const {page} = await pw.testBrowser.login(qualUser);
|
||||
await page.goto('/select_team');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const chip = page
|
||||
.locator('.signup-team-dir')
|
||||
.filter({hasText: publicGoverned.display_name})
|
||||
.getByLabel('Recommended based on your attributes');
|
||||
|
||||
await expect(chip).toBeVisible({timeout: 15000});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Client4} from '@mattermost/client';
|
||||
|
||||
/**
|
||||
* Enable team-membership ABAC end to end: the umbrella attribute-based access
|
||||
* control setting plus the `TeamMembershipAccessControl` feature flag that gates
|
||||
* the per-team System Console section. Both must be on for the team page to
|
||||
* render the membership-policy toggle — mirroring the server enforcement gate.
|
||||
*/
|
||||
export async function enableTeamMembershipPolicies(client: Client4): Promise<void> {
|
||||
await client.patchConfig({
|
||||
AccessControlSettings: {
|
||||
EnableAttributeBasedAccessControl: true,
|
||||
EnableUserManagedAttributes: true,
|
||||
},
|
||||
FeatureFlags: {
|
||||
TeamMembershipAccessControl: true,
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parent membership policy that can be assigned to a TEAM.
|
||||
*
|
||||
* Team assignment runs `child.Inherit(parent)` with the child at v0.3, which
|
||||
* requires the parent to also be v0.3 — so (unlike the channel-side
|
||||
* createParentPolicy, which is v0.2) this helper stamps v0.3 and a membership
|
||||
* action. Returns the created policy (carries `id` and `name`).
|
||||
*/
|
||||
export async function createTeamMembershipParentPolicy(
|
||||
client: Client4,
|
||||
name: string,
|
||||
expression: string,
|
||||
): Promise<{id: string; name: string}> {
|
||||
return (client as any).doFetch(`${client.getBaseRoute()}/access_control_policies`, {
|
||||
method: 'put',
|
||||
body: JSON.stringify({
|
||||
id: '',
|
||||
name,
|
||||
type: 'parent',
|
||||
version: 'v0.3',
|
||||
revision: 0,
|
||||
rules: [{expression, actions: ['membership']}],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an access_control_team_sync job and poll that specific job until it
|
||||
* finishes. Polling by ID (not list position) avoids a race where older jobs
|
||||
* occupy jobs[0] and the newly created one is never checked.
|
||||
*
|
||||
* Both `success` and `warning` are terminal completions: the worker reports
|
||||
* `warning` (not `success`) whenever the mass-removal guardrail trips — i.e. a
|
||||
* sync that drops >50% of a team. The sync still ran to completion, so callers
|
||||
* that exercise removals must accept it. The final status is returned so a
|
||||
* caller can assert on it (e.g. expecting the warning state).
|
||||
*/
|
||||
export async function triggerSyncJobAndPoll(
|
||||
client: Client4,
|
||||
policyId = '',
|
||||
timeoutMs = 90_000,
|
||||
pollIntervalMs = 3_000,
|
||||
): Promise<string> {
|
||||
// Scope the sync to the team's policy (team policies are keyed by team id),
|
||||
// mirroring the product trigger createAccessControlTeamSyncJob({policy_id}).
|
||||
// A scoped sync also chains a scoped channel sync, so the chained job is
|
||||
// created deterministically rather than skipped by the unscoped dedupe.
|
||||
const body: {type: string; data?: {policy_id: string}} = {type: 'access_control_team_sync'};
|
||||
if (policyId) {
|
||||
body.data = {policy_id: policyId};
|
||||
}
|
||||
const job: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const jobId: string = job.id;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
const current: any = await (client as any).doFetch(`${client.getBaseRoute()}/jobs/${jobId}`, {method: 'GET'});
|
||||
if (current.status === 'success' || current.status === 'warning') {
|
||||
return current.status;
|
||||
}
|
||||
if (current.status === 'error') {
|
||||
throw new Error(`access_control_team_sync job failed: ${JSON.stringify(current)}`);
|
||||
}
|
||||
}
|
||||
throw new Error('Timed out waiting for access_control_team_sync job to finish');
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a parent policy to teams via the REST API (the `team_ids` field).
|
||||
* Mirrors `assignChannelsToPolicy` from team_settings/helpers — same endpoint,
|
||||
* different resource list.
|
||||
*/
|
||||
export async function assignTeamsToPolicy(client: Client4, policyId: string, teamIds: string[]): Promise<void> {
|
||||
const url = `${client.getBaseRoute()}/access_control_policies/${policyId}/assign`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${client.getToken()}`},
|
||||
body: JSON.stringify({team_ids: teamIds}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`assignTeamsToPolicy failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective API-level enforcement gate: non-qualifying users are rejected when joining a private
|
||||
* ABAC-governed team; the same call is allowed on a public (advisory) team.
|
||||
* @reference MM-69100
|
||||
*/
|
||||
|
||||
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
createPrivateTeam,
|
||||
createPublicTeam,
|
||||
createTeamMembershipPolicy,
|
||||
ensureDepartmentAttribute,
|
||||
enableTeamMembershipABACConfig,
|
||||
setUserAttribute,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../../../channels/team_settings/helpers';
|
||||
|
||||
// Raw fetch wrapper — returns status + body so tests can assert on both success and rejection
|
||||
// without doFetch swallowing the error.
|
||||
async function addTeamMemberRaw(
|
||||
token: string | null,
|
||||
baseRoute: string,
|
||||
teamId: string,
|
||||
userId: string,
|
||||
): Promise<{status: number; body: any}> {
|
||||
const res = await fetch(`${baseRoute}/teams/${teamId}/members`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', Authorization: `Bearer ${token}`},
|
||||
body: JSON.stringify({team_id: teamId, user_id: userId}),
|
||||
});
|
||||
let body: any = {};
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
// empty body is fine
|
||||
}
|
||||
return {status: res.status, body};
|
||||
}
|
||||
|
||||
// Log in via raw REST to obtain the user's OWN session token, so a self-join can be
|
||||
// attempted with the requesting user's session rather than the admin's (which would
|
||||
// bypass the attribute gate). The token is returned in the response 'Token' header.
|
||||
async function loginRaw(baseRoute: string, loginId: string, password: string): Promise<string> {
|
||||
const res = await fetch(`${baseRoute}/users/login`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({login_id: loginId, password}),
|
||||
});
|
||||
return res.headers.get('Token') ?? '';
|
||||
}
|
||||
|
||||
test.describe('ABAC - Join enforcement gate (API level)', {tag: ['@abac', '@team_membership']}, () => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
let createdTeamIds: string[] = [];
|
||||
let createdUserIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({pw}) => {
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
for (const id of createdTeamIds) {
|
||||
try {
|
||||
await adminClient.deleteTeam(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdTeamIds = [];
|
||||
for (const id of createdUserIds) {
|
||||
try {
|
||||
await adminClient.updateUserActive(id, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdUserIds = [];
|
||||
});
|
||||
|
||||
test('MM-69100-T8 non-qualifying user is rejected at the API level on a private ABAC team', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const suffix = pw.random.id();
|
||||
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Create private team governed by the Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # Create eng1 (qualifies) and mkt1 (does not qualify)
|
||||
const makeUser = async (dept: string, label: string) => {
|
||||
const uid = `${label}${suffix}`;
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
return user;
|
||||
};
|
||||
const eng1 = await makeUser('Engineering', 'eng1gate');
|
||||
const mkt1 = await makeUser('Marketing', 'mkt1gate');
|
||||
createdUserIds.push(eng1.id, mkt1.id);
|
||||
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
|
||||
|
||||
// Wait for the materialized view to reflect both users before hitting the gate
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
|
||||
|
||||
const token = adminClient.getToken();
|
||||
const baseRoute = adminClient.getBaseRoute();
|
||||
|
||||
// * Non-qualifying user (Marketing) is rejected with 403
|
||||
const mktResult = await addTeamMemberRaw(token, baseRoute, team.id, mkt1.id);
|
||||
expect(mktResult.status).toBe(403);
|
||||
|
||||
// * Error message is generic — must NOT leak policy name, attribute name or CEL expression
|
||||
const errMsg: string = mktResult.body?.message ?? '';
|
||||
expect(errMsg).not.toMatch(/Engineering/i);
|
||||
expect(errMsg).not.toMatch(/Department/i);
|
||||
expect(errMsg).not.toMatch(/policy/i);
|
||||
expect(errMsg.length).toBeGreaterThan(0);
|
||||
|
||||
// * mkt1 is NOT a member of the team
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
|
||||
const memberIds = members.map((m: any) => m.user_id);
|
||||
expect(memberIds).not.toContain(mkt1.id);
|
||||
|
||||
// * Qualifying user (Engineering) is accepted with 201 (Created)
|
||||
const engResult = await addTeamMemberRaw(token, baseRoute, team.id, eng1.id);
|
||||
expect(engResult.status).toBe(201);
|
||||
|
||||
// * eng1 is now a member
|
||||
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
|
||||
const memberIdsAfter = membersAfter.map((m: any) => m.user_id);
|
||||
expect(memberIdsAfter).toContain(eng1.id);
|
||||
});
|
||||
|
||||
test('MM-69100-T9 non-qualifying user is allowed at the API level on a public ABAC team (advisory)', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const suffix = pw.random.id();
|
||||
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Create public team — advisory mode, same Engineering policy
|
||||
const team = await createPublicTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
const uid = `mkt2gate${suffix}`;
|
||||
const mkt2 = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, mkt2.id, 'Department', 'Marketing');
|
||||
createdUserIds.push(mkt2.id);
|
||||
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
|
||||
|
||||
const token = adminClient.getToken();
|
||||
const baseRoute = adminClient.getBaseRoute();
|
||||
|
||||
// * Non-qualifying user is allowed on a public ABAC team — advisory, no gate
|
||||
const result = await addTeamMemberRaw(token, baseRoute, team.id, mkt2.id);
|
||||
expect(result.status).toBe(201);
|
||||
|
||||
// * mkt2 is a member despite not matching the policy
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
|
||||
const memberIds = members.map((m: any) => m.user_id);
|
||||
expect(memberIds).toContain(mkt2.id);
|
||||
});
|
||||
|
||||
test('MM-69100-T10 qualifying user self-joins a private ABAC team by attribute match; non-qualifying is rejected', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const suffix = pw.random.id();
|
||||
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Private team governed by the Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # Two regular users (no join_private_teams role): eng qualifies, mkt does not.
|
||||
// Capture their passwords so we can log in as them and self-join.
|
||||
const makeUser = async (dept: string, label: string) => {
|
||||
const uid = `${label}${suffix}`;
|
||||
const password = newTestPassword();
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
password,
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
createdUserIds.push(user.id);
|
||||
return {user, password};
|
||||
};
|
||||
const eng = await makeUser('Engineering', 'engself');
|
||||
const mkt = await makeUser('Marketing', 'mktself');
|
||||
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', false);
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng.user.id]);
|
||||
|
||||
const baseRoute = adminClient.getBaseRoute();
|
||||
|
||||
// # Qualifying user logs in and joins themselves with their OWN session
|
||||
const engToken = await loginRaw(baseRoute, eng.user.username, eng.password);
|
||||
expect(engToken.length).toBeGreaterThan(0);
|
||||
|
||||
// * Attribute match authorizes the self-join even without the join_private_teams role
|
||||
const engResult = await addTeamMemberRaw(engToken, baseRoute, team.id, eng.user.id);
|
||||
expect(engResult.status).toBe(201);
|
||||
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
|
||||
expect(members.map((m: any) => m.user_id)).toContain(eng.user.id);
|
||||
|
||||
// # Non-qualifying user attempts the same self-join with their own session
|
||||
const mktToken = await loginRaw(baseRoute, mkt.user.username, mkt.password);
|
||||
expect(mktToken.length).toBeGreaterThan(0);
|
||||
|
||||
// * Rejected — the attribute gate applies to the requesting user's session too
|
||||
const mktResult = await addTeamMemberRaw(mktToken, baseRoute, team.id, mkt.user.id);
|
||||
expect(mktResult.status).toBe(403);
|
||||
|
||||
const membersAfter: any[] = await adminClient.getTeamMembers(team.id, 0, 100);
|
||||
expect(membersAfter.map((m: any) => m.user_id)).not.toContain(mkt.user.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective A team sync removal cascades to private channel membership.
|
||||
*
|
||||
* When a user is removed from a private ABAC-governed team, the server calls
|
||||
* LeaveTeam which synchronously removes that user from every private channel
|
||||
* in the team. This spec exercises the full path end-to-end:
|
||||
*
|
||||
* policy update → team sync → team removal → channel cascade (LeaveTeam)
|
||||
*
|
||||
* Two users start in both the team and a private channel. Updating the policy
|
||||
* to exclude one of them removes that user from the team AND the channel, while
|
||||
* the qualifying user is unaffected.
|
||||
*
|
||||
* Assertions run at two layers:
|
||||
* 1. API — direct membership queries confirm the server state.
|
||||
* 2. UI — browser logins confirm what each user actually sees rendered.
|
||||
*
|
||||
* @reference MM-69100
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, newTestPassword, test, verifyUserInChannel} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
createPrivateChannel,
|
||||
createPrivateTeam,
|
||||
createPublicTeam,
|
||||
createTeamMembershipPolicy,
|
||||
enableTeamMembershipABACConfig,
|
||||
ensureDepartmentAttribute,
|
||||
setUserAttribute,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../../../channels/team_settings/helpers';
|
||||
|
||||
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
|
||||
|
||||
test.describe('ABAC - Team Sync Channel Cascade', {tag: ['@abac', '@team_membership']}, () => {
|
||||
test.setTimeout(240000);
|
||||
|
||||
let createdTeamIds: string[] = [];
|
||||
let createdUserIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({pw}) => {
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
for (const id of createdTeamIds) {
|
||||
await adminClient.deleteTeam(id).catch(() => {});
|
||||
}
|
||||
createdTeamIds = [];
|
||||
for (const id of createdUserIds) {
|
||||
await adminClient.updateUserActive(id, false).catch(() => {});
|
||||
}
|
||||
createdUserIds = [];
|
||||
});
|
||||
|
||||
/**
|
||||
* When the sync job removes a non-qualifying user from a private team, the
|
||||
* underlying LeaveTeam call also removes them from every private channel in
|
||||
* that team. Validated at both the API layer (membership queries) and the UI
|
||||
* layer (browser logins for each affected user).
|
||||
*/
|
||||
test('MM-69100-T_cascade - team sync removal cascades to private channel membership', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
if (!adminClient) {
|
||||
throw new Error('Admin client not available');
|
||||
}
|
||||
const suffix = pw.random.id();
|
||||
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Private team (allow_open_invite=false → strict ABAC enforcement)
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
const {adminUser} = await pw.getAdminClient();
|
||||
if (adminUser) {
|
||||
await adminClient.removeFromTeam(team.id, adminUser.id).catch(() => {});
|
||||
}
|
||||
|
||||
// # Private channel inside the governed team — the cascade target
|
||||
const privateChannel = await createPrivateChannel(adminClient, team.id);
|
||||
|
||||
// # Public home team for mkt1. A user removed from their only team lands
|
||||
// # on a "Team not found" error and cannot navigate anywhere meaningful.
|
||||
// # homeTeam gives mkt1 a valid context for the UI assertions below.
|
||||
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
|
||||
createdTeamIds.push(homeTeam.id);
|
||||
|
||||
// # Create eng1 (Engineering) and mkt1 (Marketing) — track passwords for browser login
|
||||
const eng1Password = newTestPassword();
|
||||
const eng1 = await adminClient.createUser(
|
||||
{
|
||||
email: `eng1${suffix}@sample.mattermost.com`,
|
||||
username: `eng1${suffix}`,
|
||||
password: eng1Password,
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, eng1.id, 'Department', 'Engineering');
|
||||
|
||||
const mkt1Password = newTestPassword();
|
||||
const mkt1 = await adminClient.createUser(
|
||||
{
|
||||
email: `mkt1${suffix}@sample.mattermost.com`,
|
||||
username: `mkt1${suffix}`,
|
||||
password: mkt1Password,
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
|
||||
await adminClient.addToTeam(homeTeam.id, mkt1.id);
|
||||
|
||||
createdUserIds.push(eng1.id, mkt1.id);
|
||||
|
||||
// Gate on the materialized attribute view — ABAC sync reads from a Postgres
|
||||
// view that refreshes at most once every 30 s. Freshly-written attributes
|
||||
// are not visible until the next refresh tick.
|
||||
await waitForAttributeViewToInclude(
|
||||
adminClient,
|
||||
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
|
||||
[eng1.id, mkt1.id],
|
||||
);
|
||||
|
||||
// =========================================================
|
||||
// Phase 1 — policy admits both; sync auto-adds both to team
|
||||
// =========================================================
|
||||
|
||||
await createTeamMembershipPolicy(
|
||||
adminClient,
|
||||
team.id,
|
||||
'user.attributes.Department == "Engineering" || user.attributes.Department == "Marketing"',
|
||||
true, // active = auto-add
|
||||
);
|
||||
|
||||
await triggerSyncJobAndPoll(adminClient, team.id);
|
||||
|
||||
// * [API] Both are team members after first sync
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id);
|
||||
return members.map((m: any) => m.user_id);
|
||||
},
|
||||
{timeout: 15000, intervals: [500, 1000, 2000], message: 'both users should be auto-added to team'},
|
||||
)
|
||||
.toEqual(expect.arrayContaining([eng1.id, mkt1.id]));
|
||||
|
||||
// Team sync adds users to the team (and town-square) only — not to
|
||||
// arbitrary private channels. Add both manually so the cascade has something to remove.
|
||||
await adminClient.addToChannel(eng1.id, privateChannel.id);
|
||||
await adminClient.addToChannel(mkt1.id, privateChannel.id);
|
||||
|
||||
// * [API] Both in private channel — baseline confirmed
|
||||
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
|
||||
expect(await verifyUserInChannel(adminClient, mkt1.id, privateChannel.id)).toBe(true);
|
||||
|
||||
// =========================================================
|
||||
// Phase 2 — policy narrows to Engineering; mkt1 loses access
|
||||
// =========================================================
|
||||
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
|
||||
|
||||
// Strict mode: sync removes mkt1 from team; LeaveTeam cascades to channels
|
||||
await triggerSyncJobAndPoll(adminClient, team.id);
|
||||
|
||||
// * [API] mkt1 removed from team
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id);
|
||||
return members.map((m: any) => m.user_id);
|
||||
},
|
||||
{timeout: 15000, intervals: [500, 1000, 2000], message: 'mkt1 should be removed from team'},
|
||||
)
|
||||
.not.toContain(mkt1.id);
|
||||
|
||||
// * [API] mkt1 cascade-removed from private channel
|
||||
await expect
|
||||
.poll(() => verifyUserInChannel(adminClient, mkt1.id, privateChannel.id), {
|
||||
timeout: 15000,
|
||||
intervals: [500, 1000, 2000],
|
||||
message: 'mkt1 should be cascade-removed from private channel',
|
||||
})
|
||||
.toBe(false);
|
||||
|
||||
// * [API] eng1 still in team and channel
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id);
|
||||
return members.map((m: any) => m.user_id);
|
||||
},
|
||||
{timeout: 10000, intervals: [500, 1000], message: 'eng1 should remain in team'},
|
||||
)
|
||||
.toContain(eng1.id);
|
||||
expect(await verifyUserInChannel(adminClient, eng1.id, privateChannel.id)).toBe(true);
|
||||
|
||||
// =========================================================
|
||||
// UI — mkt1: governed team gone from sidebar; channel inaccessible
|
||||
// =========================================================
|
||||
|
||||
const mkt1WithPassword = {...mkt1, password: mkt1Password};
|
||||
const {page: mkt1Page} = await pw.testBrowser.login(mkt1WithPassword);
|
||||
const mkt1ChannelsPage = new ChannelsPage(mkt1Page);
|
||||
|
||||
// Land on homeTeam so we have a valid context to inspect the sidebar from
|
||||
await mkt1ChannelsPage.goto(homeTeam.name, 'town-square');
|
||||
await mkt1ChannelsPage.toBeVisible();
|
||||
|
||||
// * Navigating to the governed team URL redirects mkt1 away (no membership)
|
||||
await mkt1Page.goto(`/${team.name}/channels/town-square`);
|
||||
await mkt1Page.waitForLoadState('networkidle');
|
||||
await expect(mkt1Page).not.toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
|
||||
|
||||
// =========================================================
|
||||
// UI — eng1: governed team and private channel still accessible
|
||||
// =========================================================
|
||||
|
||||
const eng1WithPassword = {...eng1, password: eng1Password};
|
||||
const {page: eng1Page} = await pw.testBrowser.login(eng1WithPassword);
|
||||
const eng1ChannelsPage = new ChannelsPage(eng1Page);
|
||||
|
||||
// * eng1 can access the governed team — URL confirms they are on it
|
||||
// (team sidebar icon only renders when the user belongs to multiple teams,
|
||||
// so we assert the route instead of the sidebar element)
|
||||
await eng1ChannelsPage.goto(team.name, 'town-square');
|
||||
await eng1ChannelsPage.toBeVisible();
|
||||
await expect(eng1Page).toHaveURL(new RegExp(`/${team.name}/`), {timeout: 10000});
|
||||
|
||||
// * Private channel is accessible to eng1
|
||||
await eng1ChannelsPage.goto(team.name, privateChannel.name);
|
||||
await eng1ChannelsPage.toBeVisible();
|
||||
await expect(eng1Page.locator('#channelHeaderTitle', {hasText: privateChannel.display_name})).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective Sync Job Details modal shows per-team added/removed counts and the mass-removal warning
|
||||
* @reference MM-69100
|
||||
*/
|
||||
|
||||
import {expect, newTestPassword, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
enableTeamMembershipABACConfig,
|
||||
ensureDepartmentAttribute,
|
||||
createPrivateTeam,
|
||||
createTeamMembershipPolicy,
|
||||
setUserAttribute,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../../../channels/team_settings/helpers';
|
||||
|
||||
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
|
||||
|
||||
test.describe('ABAC - Sync Job Details Modal', {tag: ['@abac', '@team_membership']}, () => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
let createdTeamIds: string[] = [];
|
||||
let createdUserIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({pw}) => {
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
for (const id of createdTeamIds) {
|
||||
try {
|
||||
await adminClient.deleteTeam(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdTeamIds = [];
|
||||
for (const id of createdUserIds) {
|
||||
try {
|
||||
await adminClient.updateUserActive(id, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdUserIds = [];
|
||||
});
|
||||
|
||||
test('MM-69100-T4 sync job details shows per-team member counts and section title', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Private team + active Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # The creator is auto-added; drop them so the member set is exactly the
|
||||
// # two users below — keeps the removal count deterministic and below the
|
||||
// # >50% mass-removal guardrail.
|
||||
await adminClient.removeFromTeam(team.id, adminUser.id);
|
||||
|
||||
// # eng1 is a qualifying member; mkt1 is non-qualifying (will be removed by sync)
|
||||
const createUser = async (dept: string, label: string, addToTeam: boolean) => {
|
||||
const uid = `${suffix}${label}`;
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
if (addToTeam) {
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
}
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
return user;
|
||||
};
|
||||
|
||||
const eng1 = await createUser('Engineering', `eng1${suffix}`, true);
|
||||
const mkt1 = await createUser('Marketing', `mkt1${suffix}`, true);
|
||||
createdUserIds.push(eng1.id, mkt1.id);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
|
||||
|
||||
// # Active policy (strict mode removes non-qualifiers)
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
|
||||
|
||||
// # Trigger sync and wait for completion
|
||||
await triggerSyncJobAndPoll(adminClient, team.id);
|
||||
|
||||
// # Log in and navigate to sync jobs page
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * Section title is "Membership sync jobs" (capital S verified in en.json)
|
||||
await expect(page.getByText(/Membership sync jobs/i)).toBeVisible({timeout: 10000});
|
||||
|
||||
// # Open the most recent job's details (the channel sync chained from the team sync)
|
||||
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
|
||||
await expect(jobRow).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Row exposes a visible "View details" affordance (MM-69827 finding 12)
|
||||
await expect(jobRow.getByText('View details')).toBeVisible();
|
||||
|
||||
await jobRow.click();
|
||||
|
||||
const detailsModal = page.locator('#job-details-modal');
|
||||
await expect(detailsModal).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Switch to the Teams tab where the team membership changes are surfaced
|
||||
await detailsModal.getByRole('button', {name: 'Teams'}).click();
|
||||
|
||||
// * Team row present for our team
|
||||
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
|
||||
await expect(teamRow).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Removed count shows -1 (mkt1 removed)
|
||||
await expect(teamRow.locator('.changes-cell .removed')).toHaveText(/-1/);
|
||||
|
||||
// # Click team row to open the user drill-down modal
|
||||
await teamRow.click();
|
||||
await expect(page.locator('#user-list-modal-dialog')).toBeVisible({timeout: 10000});
|
||||
});
|
||||
|
||||
test('MM-69827-T1 Membership Sync Jobs names the channel scope and points to team sync when team ABAC is on', async ({
|
||||
pw,
|
||||
}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * Button names its scope so a green success isn't mistaken for a team re-sync
|
||||
await expect(page.getByRole('button', {name: /Run Channel Sync/i})).toBeVisible({timeout: 10000});
|
||||
|
||||
// * A note points admins to where team-membership sync actually lives
|
||||
await expect(page.getByText(/This syncs channel membership only/i)).toBeVisible({timeout: 10000});
|
||||
await expect(page.getByText(/Team Settings/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('MM-69100-T5 sync job details shows mass-removal warning when >50% of members are removed', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminUser, adminClient} = await pw.getAdminClient();
|
||||
if (!adminUser) {
|
||||
throw new Error('Admin user not found');
|
||||
}
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Private team + active Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # The creator is auto-added; drop them so the member set is exactly the
|
||||
// # four users below (1 qualifying + 3 not), giving a clean 75% removal.
|
||||
await adminClient.removeFromTeam(team.id, adminUser.id);
|
||||
|
||||
// # Add 1 qualifying member and 3 non-qualifying members so >50% are removed
|
||||
const createAndAdd = async (dept: string, label: string) => {
|
||||
const uid = `${suffix}${label}`;
|
||||
const user = await adminClient.createUser(
|
||||
{
|
||||
email: `${uid}@sample.mattermost.com`,
|
||||
username: uid,
|
||||
password: newTestPassword(),
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
await adminClient.addToTeam(team.id, user.id);
|
||||
await setUserAttribute(adminClient, user.id, 'Department', dept);
|
||||
return user;
|
||||
};
|
||||
|
||||
const eng1 = await createAndAdd('Engineering', `eng${suffix}`);
|
||||
const mkt1 = await createAndAdd('Marketing', `mkt1${suffix}`);
|
||||
const mkt2 = await createAndAdd('Marketing', `mkt2${suffix}`);
|
||||
const mkt3 = await createAndAdd('Marketing', `mkt3${suffix}`);
|
||||
createdUserIds.push(eng1.id, mkt1.id, mkt2.id, mkt3.id);
|
||||
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [eng1.id]);
|
||||
|
||||
// # Active policy — 3 of 4 members removed (75%)
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
|
||||
|
||||
// # Trigger sync and wait for completion
|
||||
await triggerSyncJobAndPoll(adminClient, team.id);
|
||||
|
||||
// # Navigate to sync jobs page
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
const {page} = systemConsolePage;
|
||||
await page.goto('/admin_console/system_attributes/membership_policies');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// # Open the most recent job's details (the channel sync chained from the team sync)
|
||||
const jobRow = page.locator('.job-table__access-control tr.clickable').first();
|
||||
await expect(jobRow).toBeVisible({timeout: 10000});
|
||||
await jobRow.click();
|
||||
|
||||
const detailsModal = page.locator('#job-details-modal');
|
||||
await expect(detailsModal).toBeVisible({timeout: 15000});
|
||||
|
||||
// # Switch to the Teams tab where the team membership changes are surfaced
|
||||
await detailsModal.getByRole('button', {name: 'Teams'}).click();
|
||||
|
||||
const teamRow = page.getByTestId(`TeamRow-${team.name}`);
|
||||
await expect(teamRow).toBeVisible({timeout: 10000});
|
||||
|
||||
// * Mass-removal warning indicator present (>50% removed)
|
||||
await expect(teamRow.locator('.mass-removal-warning')).toBeVisible({timeout: 10000});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* @objective A real team sync job creates the addition/removal system posts, which are
|
||||
* rendered correctly in the affected user's DM from the system bot.
|
||||
* @reference MM-69100
|
||||
*/
|
||||
|
||||
import {ChannelsPage, expect, newTestPassword, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
enableTeamMembershipABACConfig,
|
||||
ensureDepartmentAttribute,
|
||||
createPublicTeam,
|
||||
createPrivateTeam,
|
||||
createTeamMembershipPolicy,
|
||||
setUserAttribute,
|
||||
waitForAttributeViewToInclude,
|
||||
} from '../../../channels/team_settings/helpers';
|
||||
|
||||
import {enableTeamMembershipPolicies, triggerSyncJobAndPoll} from './helpers';
|
||||
|
||||
const SYSTEM_BOT_USERNAME = 'system-bot';
|
||||
|
||||
test.describe('ABAC - Sync System Messages', {tag: ['@abac', '@team_membership']}, () => {
|
||||
test.setTimeout(120000);
|
||||
|
||||
let createdTeamIds: string[] = [];
|
||||
let createdUserIds: string[] = [];
|
||||
|
||||
test.afterEach(async ({pw}) => {
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
for (const id of createdTeamIds) {
|
||||
try {
|
||||
await adminClient.deleteTeam(id);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdTeamIds = [];
|
||||
for (const id of createdUserIds) {
|
||||
try {
|
||||
await adminClient.updateUserActive(id, false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
createdUserIds = [];
|
||||
});
|
||||
|
||||
test('MM-69100-T6 qualifying non-member receives an addition DM after a sync job', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Public team + active Engineering policy
|
||||
const team = await createPublicTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # Create joiner with Engineering attribute — NOT yet a team member
|
||||
const joinerUid = `joiner${suffix}`;
|
||||
const joinerPassword = newTestPassword();
|
||||
const joiner = await adminClient.createUser(
|
||||
{
|
||||
email: `${joinerUid}@sample.mattermost.com`,
|
||||
username: joinerUid,
|
||||
password: joinerPassword,
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
createdUserIds.push(joiner.id);
|
||||
await setUserAttribute(adminClient, joiner.id, 'Department', 'Engineering');
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Engineering"', [joiner.id]);
|
||||
|
||||
// # Active policy — auto-add will add joiner on next sync
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
|
||||
|
||||
// # Trigger sync and wait for it to complete
|
||||
await triggerSyncJobAndPoll(adminClient);
|
||||
|
||||
// * joiner is now a team member
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id);
|
||||
const joinerMember = members.find((m: any) => m.user_id === joiner.id);
|
||||
expect(joinerMember).toBeDefined();
|
||||
|
||||
// # Locate the system-bot user
|
||||
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
|
||||
expect(botUsers.length).toBeGreaterThan(0);
|
||||
const systemBot = botUsers[0];
|
||||
|
||||
// # Get or create the DM channel between joiner and system-bot
|
||||
const dmChannel = await adminClient.createDirectChannel([joiner.id, systemBot.id]);
|
||||
expect(dmChannel).toBeDefined();
|
||||
|
||||
// # Log in as joiner and navigate to the DM
|
||||
const joinerWithPassword = {...joiner, password: joinerPassword};
|
||||
const {page} = await pw.testBrowser.login(joinerWithPassword);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(team.name, dmChannel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Addition system message rendered in the DM (scoped to this run's team name
|
||||
// to avoid matching accumulated messages from prior test runs in the same DM).
|
||||
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const expectedText = new RegExp(
|
||||
`You have been added to ${escapedName} because you now meet the membership requirements`,
|
||||
'i',
|
||||
);
|
||||
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
|
||||
test('MM-69100-T7 removed member receives a removal DM after a sync job', async ({pw}) => {
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient} = await pw.getAdminClient();
|
||||
const suffix = pw.random.id();
|
||||
await enableTeamMembershipABACConfig(adminClient);
|
||||
await enableTeamMembershipPolicies(adminClient);
|
||||
await ensureDepartmentAttribute(adminClient);
|
||||
|
||||
// # Private team + active Engineering policy
|
||||
const team = await createPrivateTeam(adminClient, suffix);
|
||||
createdTeamIds.push(team.id);
|
||||
|
||||
// # A second, non-governed team that mkt1 keeps after being removed from the
|
||||
// governed one. A user with zero teams lands on a "Team Not Found" page and
|
||||
// can't reach the system-bot DM, so they need a team context to view it.
|
||||
const homeTeam = await createPublicTeam(adminClient, `home${suffix}`);
|
||||
createdTeamIds.push(homeTeam.id);
|
||||
|
||||
// # mkt1 has Marketing — does not match Engineering rule; IS a current member
|
||||
const mkt1Uid = `mkt1${suffix}`;
|
||||
const mkt1Password = newTestPassword();
|
||||
const mkt1 = await adminClient.createUser(
|
||||
{
|
||||
email: `${mkt1Uid}@sample.mattermost.com`,
|
||||
username: mkt1Uid,
|
||||
password: mkt1Password,
|
||||
} as any,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
createdUserIds.push(mkt1.id);
|
||||
await adminClient.addToTeam(team.id, mkt1.id);
|
||||
await adminClient.addToTeam(homeTeam.id, mkt1.id);
|
||||
await setUserAttribute(adminClient, mkt1.id, 'Department', 'Marketing');
|
||||
await waitForAttributeViewToInclude(adminClient, 'user.attributes.Department == "Marketing"', [mkt1.id]);
|
||||
|
||||
// # Active policy (strict) — mkt1 will be removed
|
||||
await createTeamMembershipPolicy(adminClient, team.id, 'user.attributes.Department == "Engineering"', true);
|
||||
|
||||
await triggerSyncJobAndPoll(adminClient, team.id);
|
||||
|
||||
// * mkt1 is no longer a team member
|
||||
const members: any[] = await adminClient.getTeamMembers(team.id);
|
||||
const mkt1Member = members.find((m: any) => m.user_id === mkt1.id);
|
||||
expect(mkt1Member).toBeUndefined();
|
||||
|
||||
// # Ensure the DM channel between mkt1 and system-bot exists before logging in
|
||||
const botUsers: any[] = await adminClient.getProfilesByUsernames([SYSTEM_BOT_USERNAME]);
|
||||
const systemBot = botUsers[0];
|
||||
const dmChannel = await adminClient.createDirectChannel([mkt1.id, systemBot.id]);
|
||||
|
||||
// # Log in as mkt1 and open the system-bot DM from the retained team's context
|
||||
const mkt1WithPassword = {...mkt1, password: mkt1Password};
|
||||
const {page} = await pw.testBrowser.login(mkt1WithPassword);
|
||||
const channelsPage = new ChannelsPage(page);
|
||||
await channelsPage.goto(homeTeam.name, dmChannel.name);
|
||||
await channelsPage.toBeVisible();
|
||||
|
||||
// * Removal system message rendered (scoped to this run's team name).
|
||||
const escapedName = team.display_name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const expectedText = new RegExp(
|
||||
`You have been removed from ${escapedName} because you no longer meet the membership requirements`,
|
||||
'i',
|
||||
);
|
||||
await expect(page.locator('.post--system__access-control').filter({hasText: expectedText})).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user