mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
/**
|
|
* Lean helpers for the public-channel ABAC E2E specs.
|
|
*
|
|
* Design rules:
|
|
* - Reuse the existing primitives in `../team_settings/helpers.ts` for
|
|
* channel/policy/user creation and configuration. This file only adds
|
|
* thin wrappers that register cleanup as resources are created.
|
|
* - All test-created resources are tracked in a `CleanupLedger`. The spec's
|
|
* `test.afterEach` drains the ledger so resources are removed whether the
|
|
* test passed, failed, or threw mid-flight.
|
|
* - Cleanup is best-effort: individual failures are swallowed so one stale
|
|
* resource cannot block deletion of the others or mask the test result.
|
|
* - Custom profile attributes are scoped per test (unique names + cleanup).
|
|
* Tests must not lean on a shared `Department` field — the server caps CPA
|
|
* fields at 20, and accumulating shared state has historically saturated
|
|
* that limit and broken every subsequent run.
|
|
*/
|
|
|
|
import type {Client4} from '@mattermost/client';
|
|
import type {UserProfile} from '@mattermost/types/users';
|
|
|
|
import {newTestPassword, getRandomId, type PlaywrightClient4} from '@mattermost/playwright-lib';
|
|
|
|
import {assignChannelsToPolicy, deletePolicy, unassignChannelsFromPolicy} from '../team_settings/helpers';
|
|
|
|
export type CleanupTask = () => Promise<unknown>;
|
|
|
|
export class CleanupLedger {
|
|
private tasks: CleanupTask[] = [];
|
|
|
|
add(task: CleanupTask): void {
|
|
// LIFO: latest registrations run first so deletions respect dependency
|
|
// order (e.g. unassign channels from a policy before deleting the policy).
|
|
this.tasks.unshift(task);
|
|
}
|
|
|
|
async drain(): Promise<void> {
|
|
const tasks = this.tasks;
|
|
this.tasks = [];
|
|
for (let i = 0; i < tasks.length; i++) {
|
|
try {
|
|
await tasks[i]();
|
|
} catch (e: unknown) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
const stack = e instanceof Error ? e.stack : undefined;
|
|
// eslint-disable-next-line no-console
|
|
console.error(`CleanupLedger.drain: cleanup task ${i} failed`, message, stack ?? '');
|
|
// Swallow — cleanup is best-effort and must not mask the test
|
|
// result or stop subsequent cleanups from running.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Issue a server request and surface the response body on non-2xx so the test
|
|
* report shows *what* the server rejected, not just "failed". The shipped
|
|
* helpers in `team_settings/helpers.ts` swallow these — useful in steady
|
|
* state, brutal when something legitimately broke (e.g. CPA field-limit
|
|
* exhaustion silently masquerading as a "field not found" error).
|
|
*/
|
|
async function doFetchOrThrow(client: Client4, url: string, init: RequestInit & {body?: string}): Promise<any> {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${client.getToken()}`,
|
|
...(init.headers || {}),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(`${init.method} ${url} -> ${response.status}: ${text}`);
|
|
}
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Permanently delete a user via the admin API.
|
|
* Permanent deletion requires `ServiceSettings.EnableAPIUserDeletion` to be on
|
|
* server-side; on test deployments where it is off, the call returns 4xx and
|
|
* the cleanup ledger silently swallows it.
|
|
*/
|
|
export async function permanentDeleteUser(client: Client4, userId: string): Promise<void> {
|
|
await (client as any).doFetch(`${client.getBaseRoute()}/users/${userId}?permanent=true`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a per-test custom profile attribute (text type) with a unique name and
|
|
* register cleanup. Returns the field including its server-assigned `id`,
|
|
* which callers can plug straight into a CEL expression via
|
|
* `user.attributes.id_<id>`.
|
|
*
|
|
* Avoids the shared `Department` slot used by other ABAC tests: those compete
|
|
* for one of 20 server-cap'd CPA slots and any leak (a failed test, an
|
|
* abandoned run) cascades into "field not found" or "limit reached" errors
|
|
* across the whole suite.
|
|
*/
|
|
export async function createTrackedAttribute(
|
|
client: Client4,
|
|
baseName: string,
|
|
ledger: CleanupLedger,
|
|
): Promise<{id: string; name: string}> {
|
|
const name = `${baseName}_${getRandomId()}`;
|
|
const field = await doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({name, type: 'text', attrs: {visibility: 'when_set'}}),
|
|
});
|
|
ledger.add(() =>
|
|
doFetchOrThrow(client, `${client.getBaseRoute()}/custom_profile_attributes/fields/${field.id}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
);
|
|
return {id: field.id, name: field.name};
|
|
}
|
|
|
|
/**
|
|
* Set a CPA value on a user, addressing the field by its ID. Avoids the
|
|
* field-name-to-ID lookup in the shared `setUserAttribute` helper, which is
|
|
* unnecessary indirection now that callers hold the field handle directly.
|
|
*/
|
|
export async function setUserAttributeById(client: Client4, userId: string, fieldId: string, value: string) {
|
|
await client.updateUserCustomProfileAttributesValues(userId, {[fieldId]: value});
|
|
}
|
|
|
|
/**
|
|
* Create a public channel and register cleanup for it.
|
|
*/
|
|
export async function createTrackedPublicChannel(client: PlaywrightClient4, teamId: string, ledger: CleanupLedger) {
|
|
const channel = await client.createPublicChannel(teamId);
|
|
ledger.add(() => client.deleteChannel(channel.id));
|
|
return channel;
|
|
}
|
|
|
|
/**
|
|
* Create a parent ABAC policy with the given CEL rule and assign the given
|
|
* channels in one shot. Registers cleanup so the policy (and its inherited
|
|
* channel-scope children) gets removed even if the test bails partway.
|
|
*
|
|
* Why we don't reuse `createParentPolicy` from team_settings/helpers.ts and
|
|
* patch afterwards: that helper hardcodes `expression: 'true'`, and a follow-up
|
|
* PUT to override the rule fails the server's policy-validation pipeline.
|
|
* Stamping the desired expression on the initial create is both simpler and
|
|
* matches what the system console actually does.
|
|
*/
|
|
export async function createPolicyAssignedToChannels(
|
|
client: Client4,
|
|
name: string,
|
|
expression: string,
|
|
channelIds: string[],
|
|
ledger: CleanupLedger,
|
|
) {
|
|
const policy: any = await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
id: '',
|
|
name,
|
|
type: 'parent',
|
|
version: 'v0.2',
|
|
revision: 0,
|
|
rules: [{expression, actions: ['*']}],
|
|
}),
|
|
});
|
|
|
|
// Cleanup is self-sufficient: unassign first, then delete. The LIFO drain
|
|
// order in the spec runs this BEFORE the channel deletes, so the parent
|
|
// still has live child-scope policy rows referencing it — DeleteAccessControlPolicy
|
|
// would silently fail and leak the parent. Unassigning explicitly drops
|
|
// those children regardless of channel cleanup order.
|
|
ledger.add(async () => {
|
|
if (channelIds.length > 0) {
|
|
try {
|
|
await unassignChannelsFromPolicy(client, policy.id, channelIds);
|
|
} catch {
|
|
// Channels may already be gone (their cleanup tasks may have run
|
|
// first), in which case the child policies were auto-removed by
|
|
// cleanupChannelAccessControlPolicy. Either way, best-effort.
|
|
}
|
|
}
|
|
await deletePolicy(client, policy.id);
|
|
});
|
|
|
|
if (channelIds.length > 0) {
|
|
await assignChannelsToPolicy(client, policy.id, channelIds);
|
|
}
|
|
|
|
return policy;
|
|
}
|
|
|
|
/**
|
|
* Flip the auto-add (Active) flag on a channel-scope ABAC policy. Channel-scope
|
|
* policies share the channel's ID, so this is keyed by channelId. Active=true
|
|
* is what makes the access-control sync job auto-add matching users.
|
|
*
|
|
* Children inherit the parent's Active flag at assign time, but the parent
|
|
* default on a fresh `createPolicyAssignedToChannels` is false — flip the
|
|
* child here when the test needs auto-add ON.
|
|
*/
|
|
export async function setChannelPolicyActive(client: Client4, channelId: string, active: boolean): Promise<void> {
|
|
await doFetchOrThrow(client, `${client.getBaseRoute()}/access_control_policies/activate`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({entries: [{id: channelId, active}]}),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trigger an access-control sync job for a single policy and return the job
|
|
* record. The job is queued — call `waitForJobCompletion` to block until it
|
|
* resolves.
|
|
*/
|
|
export async function runAccessControlSyncJob(client: Client4, policyId: string): Promise<any> {
|
|
return client.createJob({
|
|
type: 'access_control_sync' as any,
|
|
data: {policy_id: policyId},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Poll a job until it reaches a terminal status (success / error / canceled).
|
|
* Sync jobs are queued and processed asynchronously; in CI the queue can be
|
|
* idle for several seconds before pickup. The default 60s ceiling matches what
|
|
* we've seen empirically; callers can extend via `timeoutMs` when running on
|
|
* slow servers.
|
|
*/
|
|
export async function waitForJobCompletion(
|
|
client: Client4,
|
|
jobId: string,
|
|
opts: {timeoutMs?: number; pollIntervalMs?: number} = {},
|
|
): Promise<any> {
|
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
const pollIntervalMs = opts.pollIntervalMs ?? 1_000;
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
while (Date.now() < deadline) {
|
|
const job: any = await client.getJob(jobId);
|
|
if (job.status === 'success') {
|
|
return job;
|
|
}
|
|
if (job.status === 'error' || job.status === 'canceled') {
|
|
const detail = job?.data?.message ?? job?.message ?? job?.error ?? JSON.stringify(job?.data ?? job);
|
|
throw new Error(`Job ${jobId} finished with status ${job.status}: ${detail}`);
|
|
}
|
|
await new Promise((res) => setTimeout(res, pollIntervalMs));
|
|
}
|
|
throw new Error(`Job ${jobId} did not reach a terminal status within ${timeoutMs}ms`);
|
|
}
|
|
|
|
/**
|
|
* Create a user, add them to a team, optionally set a single attribute by ID,
|
|
* and bypass the tutorial / onboarding so tests can `pw.testBrowser.login()`
|
|
* straight into the channels view. Cleanup is registered for the user.
|
|
*/
|
|
export async function createTrackedTeamMember(
|
|
adminClient: Client4,
|
|
teamId: string,
|
|
attribute: {fieldId: string; value: string} | undefined,
|
|
ledger: CleanupLedger,
|
|
): Promise<UserProfile & {password: string}> {
|
|
const id = getRandomId();
|
|
const username = `pub${id}`.toLowerCase();
|
|
const password = newTestPassword();
|
|
|
|
const user = await adminClient.createUser(
|
|
{email: `${username}@sample.mattermost.com`, username, password} as UserProfile & {password: string},
|
|
'',
|
|
'',
|
|
);
|
|
ledger.add(() => permanentDeleteUser(adminClient, user.id));
|
|
|
|
await adminClient.savePreferences(user.id, [
|
|
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
|
|
{user_id: user.id, category: 'onboarding', name: 'complete', value: 'true'},
|
|
]);
|
|
await adminClient.addToTeam(teamId, user.id);
|
|
if (attribute) {
|
|
await setUserAttributeById(adminClient, user.id, attribute.fieldId, attribute.value);
|
|
}
|
|
|
|
// Attach the password back to the user object so pw.testBrowser.login() can
|
|
// authenticate — the API response does not include it.
|
|
return {...user, password};
|
|
}
|