mattermost-mobile-upstream/detox/e2e/support/server_api/system.ts
Pablo Vélez 7df4a0ab97
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
Mm 68219 show placeholder for redacted files (#9719)
* MM-68219 - Add redacted files placeholder and related functionality

* add feature flag control and e2e tests

* adjust placeholder design, polish code, fix permalink preview issue

* polish code and add missing test

* use import path with right path

* gate post refetch behind the feature flag, refetch the open thread and avoid observeCount

* restore filesInfo for the permalink files prop added upstream

* fix stale redaction on background servers, consume the flag on every fetch path and share the viewport emitter

* replace the in-memory redaction flag with a persisted per-post epoch so ABAC file decisions survive restarts and stale posts stay hidden

* raise the redaction epoch on every resync, capture the epoch counter, only invalidate channels on role changes, and store the newer epoch when a re-verified post is otherwise unchanged

* store superseded responses unverified instead of dropping them, keep the since-fetch when re-verifying, verify permalink embeds against the linked channel, and capture the websocket epoch on arrival

* nvalidate file decisions when a user attribute field changes or all its values are cleared

* batch channel invalidations, close file viewers only when affected, reset the epoch on unstamped redaction changes, and only  re-verify the newest page's attachment posts

* re-check older channel posts a page at a time instead of one request per post

* skip the unstamped epoch lookup for posts with no files, redacted count or permalink

* keep the attribute-view retry after later invalidations, retry linked posts unless refused for the current epoch, and stop older payloads from rolling back edits via the redaction check

* MM-70905 - disable and grey out the attachment button when an ABAC pe… (#10195)

* MM-70905 - disable and grey out the attachment button when an ABAC permission policy denies uploads in the channel

* only back off render permission fetches when the server request fails, and log other errors without retrying

* remove the disableServer helper and have removeServer do both steps

* split defaultAllowed into allowWhenUnenforced and allowWhenNotEvaluated, and treat render permissions as unenforced  on servers without the decisions API

* MM-69627: Add playbook task requirements support on mobile (#10035)

* MM-69627: Add playbook task requirements support on mobile

Allow filling required task fields when checking off checklist items during
a run, gated by the same Playbooks beta setting used on web.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address PR review feedback for task requirements

Propagate settings write failures, harden fill-requirements save/title handling, and add missing unit coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix TypeScript errors in new unit tests

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Use defineMessages for fill requirements titles

Keep i18n extraction able to find the header titles after moving them to the route.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Address review feedback for task requirements

Update checklist items locally when saving requirement values instead of
refetching the run, add missing query/navigation tests, and apply small
callback/reduce cleanups from review.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Fix task requirements review follow-ups

Propagate local checklist write failures, clear a stale task-requirements
flag when playbooks is unsupported, and cap fill values at the desktop/
server max length.

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69627: Move requirement field into its own component

Keep one component per file on the fill-requirements screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* MM-69122: Add file element support to interactive dialogs (#9898)

* Implement Interactive Dialog - file upload

* update demo plugin version for e2etests

* fix and add e2etests

* review fixes

* Fix version bump checkout when a tag shares the branch name (#10229)

* Fix Android crashes caused by animated custom emoji (#10030)

* Route GIF decoding through Glide

- Keep APNG4Android for APNG, animated WebP, and animated AVIF support.
- Prepend Glide's GIF decoders to avoid APNG4Android's native GIF overflow.

* Clear Expo image resources with Glide targets

- Detach drawables when Glide clears their custom target.
- Prevent recycled GIF frame resources from remaining attached to Expo image views.

* Preserve foreground image during GIF target recycling

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>

* detox - fix clobbering of feature flags that cause failures in other suites.  Also small fix of a incorrectly set test-id

---------

Co-authored-by: Asaad Mahmood <asaadmahmood@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Scott Bishel <scott.bishel@mattermost.com>
Co-authored-by: Nuno Simões <nuno.simoes@mattermost.com>
Co-authored-by: MangelSpec <74370284+MangelSpec@users.noreply.github.com>
Co-authored-by: Nate Schlossberg <nate.schlossberg@mattermost.com>
2026-10-15 15:59:13 -07:00

582 lines
21 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import path from 'path';
import {timeouts, wait} from '@support/utils';
import {withTransportRetry} from '@support/utils/transport_retry';
import jestExpect from 'expect';
import client from './client';
import {apiUploadFile, getResponseFromError} from './common';
// ****************************************************************
// System
// See https://api.mattermost.com/#tag/system
//
/* eslint-disable no-console */
// Exported API function should have the following:
// - documented using JSDoc
// - meaningful description
// - match the referenced API endpoints
// - parameter/s defined by `@param`
// - return value defined by `@return`
// ****************************************************************
/**
* Check system health.
* @param {string} baseUrl - the base server URL
*/
export const apiCheckSystemHealth = async (baseUrl: string): Promise<any> => {
const result = await apiPingServerStatus(baseUrl);
// apiPingServerStatus returns {data} on success, {error, status} on failure.
// Guard against the error path so callers get a descriptive error, not a TypeError.
if (!result || result.error || !result.data) {
const detail = result?.error
? JSON.stringify(result.error)
: 'No response from server';
throw new Error(`apiCheckSystemHealth: server at "${baseUrl}" is not healthy. ${detail}`);
}
const {data} = result;
jestExpect(data.status).toEqual('OK');
jestExpect(data.database_status).toEqual('OK');
jestExpect(data.filestore_status).toEqual('OK');
};
/**
* Send a test email.
* See https://api.mattermost.com/#operation/TestEmail
* @param {string} baseUrl - the base server URL
* @return {Object} returns response on success or {error, status} on error
*/
export const apiEmailTest = async (baseUrl: string): Promise<any> => {
try {
return await client.post(`${baseUrl}/api/v4/email/test`);
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get client license.
* See https://api.mattermost.com/#operation/GetClientLicense
* @param {string} baseUrl - the base server URL
* @return {Object} returns {license} on success or {error, status} on error
*/
export const apiGetClientLicense = async (baseUrl: string): Promise<any> => {
try {
const response = await client.get(`${baseUrl}/api/v4/license/client?format=old`);
return {license: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Get client config (old format), same shape as the mobile app uses for About.
* @param {string} baseUrl - the base server URL
* @return {Object} returns {config} on success or {error, status} on failure
*/
export const apiGetClientConfigOld = async (baseUrl: string): Promise<any> => {
try {
const response = await client.get(`${baseUrl}/api/v4/config/client?format=old`);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Max post length in runes. Computed by the server and published only in the client config —
* `ServiceSettings.MaxPostSize` does not exist, so never read it from the admin config.
* @param {string} baseUrl - the base server URL
* @return {number} the server's MaxPostSize
*/
export const apiGetMaxPostSize = async (baseUrl: string): Promise<number> => {
const {config, error} = await apiGetClientConfigOld(baseUrl);
if (error) {
throw new Error(`apiGetMaxPostSize: could not read client config: ${JSON.stringify(error)}`);
}
const maxPostSize = Number(config?.MaxPostSize);
if (!Number.isInteger(maxPostSize) || maxPostSize <= 0) {
throw new Error(`apiGetMaxPostSize: client config MaxPostSize is ${JSON.stringify(config?.MaxPostSize)}`);
}
return maxPostSize;
};
/**
* Wait for a client configuration flag to reach the expected value.
* @param {string} baseUrl - the base server URL
* @param {string} flagKey - client configuration key
* @param {string} expectedValue - expected client configuration value
* @param {Object} options - polling attempts and interval
* @return {boolean} true when the expected value is observed
*/
export const waitForClientConfigFlag = async (
baseUrl: string,
flagKey: string,
expectedValue: string,
options: {maxAttempts?: number; pollMs?: number; acceptAbsentAsEnabled?: boolean} = {},
): Promise<boolean> => {
const maxAttempts = options.maxAttempts ?? 60;
const pollMs = options.pollMs ?? timeouts.ONE_SEC;
const acceptAbsentAsEnabled = options.acceptAbsentAsEnabled === true && expectedValue === 'true';
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// eslint-disable-next-line no-await-in-loop -- client config propagation is asynchronous
const {config, error} = await apiGetClientConfigOld(baseUrl);
if (!error && config) {
const value = config[flagKey];
if (String(value) === expectedValue) {
return true;
}
// Mobile treats a missing ExperimentalViewArchivedChannels as enabled
// (only explicit 'false' turns the feature off). Failed fetches are
// not "absent" — keep polling until a successful payload arrives.
if (acceptAbsentAsEnabled && (value === undefined || value === null || value === '')) {
return true;
}
}
if (attempt < maxAttempts - 1) {
// eslint-disable-next-line no-await-in-loop
await wait(pollMs);
}
}
return false;
};
/**
* Get configuration.
* See https://api.mattermost.com/#operation/GetConfig
* @param {string} baseUrl - the base server URL
* @return {Object} returns {config} on success or {error, status} on error
*/
export const apiGetConfig = async (baseUrl: string): Promise<any> => {
try {
const response = await client.get(`${baseUrl}/api/v4/config`);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Update server configuration.
* See https://api.mattermost.com/#operation/UpdateConfig
* @param {string} baseUrl - the base server URL
* @param {Object} newConfig - partial configuration object to merge with current config
* @return {Object} returns {config} on success or {error, status} on error
*/
export const apiUpdateConfig = async (baseUrl: string, newConfig: any): Promise<any> => {
// A config patch is idempotent: replaying the same partial config converges on
// the same server state.
return withTransportRetry(async () => {
try {
const response = await client.put(`${baseUrl}/api/v4/config/patch`, newConfig);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
}, {idempotent: true, label: 'apiUpdateConfig'});
};
/**
* Replace server configuration with a complete config object.
*
* Do not use this against a shared test server: it reverts every setting another
* shard changed since the config was read. Use apiPatchConfig instead.
* @param {string} baseUrl - the base server URL
* @param {Object} config - complete server configuration
* @return {Object} returns {config} on success or {error, status} on error
*/
export const apiReplaceConfig = async (baseUrl: string, config: any): Promise<any> => {
try {
const response = await client.put(`${baseUrl}/api/v4/config`, config);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Patch server configuration (partial update; server merges with current config).
* See https://api.mattermost.com/#operation/PatchConfig
* @param {string} baseUrl - the base server URL
* @param {Object} patchConfig - partial configuration object (same struct keys as server Config)
* @return {Object} returns {config} on success or {error, status} on error
*/
export const apiPatchConfig = async (baseUrl: string, patchConfig: any): Promise<any> => {
// Idempotent for the same reason as apiUpdateConfig above.
return withTransportRetry(async () => {
try {
const response = await client.put(`${baseUrl}/api/v4/config/patch`, patchConfig);
return {config: response.data};
} catch (err) {
return getResponseFromError(err);
}
}, {idempotent: true, label: 'apiPatchConfig'});
};
/**
* Turn feature flags on without touching the others, and only where needed.
*
* FeatureFlags fields are plain bools, so the server cannot tell an omitted flag from a
* false one: patching {FeatureFlags: {A: true}} sets every other flag to false. (Only servers
* with a Split key accept flag writes at all, as the cloud test servers do; elsewhere the
* server ignores them.) This reads the current flags and patches them back in full, and
* skips the write when every named flag is already on or absent from this server version.
* @param {string} baseUrl - the base server URL
* @param {string[]} names - the flags to turn on, e.g. ['PermissionPolicies']
* @return {Object} returns {config} on success or {error, status} on error
*/
export const apiEnableFeatureFlags = async (baseUrl: string, names: string[]): Promise<any> => {
const {config, error, status} = await apiGetConfig(baseUrl);
if (error) {
return {error, status};
}
const current = config.FeatureFlags ?? {};
const toEnable = names.filter((name) => name in current && current[name] !== true);
if (!toEnable.length) {
return {config};
}
const enabled = Object.fromEntries(toEnable.map((name) => [name, true]));
return apiPatchConfig(baseUrl, {
FeatureFlags: {...current, ...enabled},
});
};
/**
* Get remote clusters (connected workspaces).
* See https://api.mattermost.com/#tag/remote_cluster
* Requires system admin. Use before tests that need "no connected workspaces" (e.g. TC-MOB-03).
* @param {string} baseUrl - the base server URL
* @param {Object} options - optional query (only_confirmed, etc.)
* @return {Object} returns {remotes: Array<{remote_id: string, ...}>} on success or {error, status} on error
*/
export const apiGetRemoteClusters = async (
baseUrl: string,
options: {onlyConfirmed?: boolean; excludePlugins?: boolean} = {},
): Promise<{remotes?: Array<{remote_id: string}>; error?: {message: string}; status?: number}> => {
try {
const params = new URLSearchParams();
if (options.onlyConfirmed !== undefined) {
params.set('only_confirmed', String(options.onlyConfirmed));
}
if (options.excludePlugins !== undefined) {
params.set('exclude_plugins', String(options.excludePlugins));
}
const qs = params.toString() ? `?${params.toString()}` : '';
const response = await client.get(`${baseUrl}/api/v4/remotecluster${qs}`);
return {remotes: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Delete a remote cluster.
* See https://api.mattermost.com/#tag/remote_cluster
* Requires system admin.
* @param {string} baseUrl - the base server URL
* @param {string} remoteId - the remote cluster id
* @return {Object} returns on success or {error, status} on error
*/
export const apiDeleteRemoteCluster = async (
baseUrl: string,
remoteId: string,
): Promise<{error?: {message: string}; status?: number}> => {
try {
await client.delete(`${baseUrl}/api/v4/remotecluster/${encodeURIComponent(remoteId)}`);
return {};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Delete all remote clusters on the server.
* Call as admin (e.g. after User.apiAdminLogin) so tests can assert "No connected workspaces" (e.g. TC-MOB-03).
* @param {string} baseUrl - the base server URL
*/
export const apiDeleteAllRemoteClusters = async (baseUrl: string): Promise<void> => {
const result = await apiGetRemoteClusters(baseUrl, {});
if (result.error || !result.remotes) {
return;
}
for (const r of result.remotes) {
// eslint-disable-next-line no-await-in-loop
await apiDeleteRemoteCluster(baseUrl, r.remote_id);
}
};
/**
* Create a remote cluster (connected workspace). Requires system admin.
* See https://api.mattermost.com/#tag/remote_cluster
*/
export const apiCreateRemoteCluster = async (
baseUrl: string,
payload: {
name: string;
display_name: string;
default_team_id: string;
password: string;
},
): Promise<Record<string, unknown> & {error?: {message: string}; status?: number}> => {
try {
const response = await client.post(`${baseUrl}/api/v4/remotecluster`, payload);
return response.data ?? {};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Ensures at least one confirmed remote cluster exists (matches web e2e ensureConfirmedRemote).
* Enables remote cluster service when creating. Call as admin after EnableSharedChannels is on.
*/
export const apiEnsureAtLeastOneConfirmedRemoteCluster = async (baseUrl: string, teamId: string): Promise<void> => {
const confirmed = await apiGetRemoteClusters(baseUrl, {
onlyConfirmed: true,
excludePlugins: true,
});
const list = Array.isArray(confirmed.remotes) ? confirmed.remotes : [];
if (list.length > 0) {
return;
}
await apiPatchConfig(baseUrl, {
ConnectedWorkspacesSettings: {
EnableRemoteClusterService: true,
},
});
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const result = await apiCreateRemoteCluster(baseUrl, {
name: `e2e-remote-${suffix}`,
display_name: `E2E Remote ${suffix}`,
default_team_id: teamId,
password: `e2e-remote-pwd-${suffix}`,
});
if (result.error) {
throw new Error(`apiCreateRemoteCluster failed: ${result.error.message ?? 'unknown error'}`);
}
};
/**
* Deletes all remotes then creates exactly one (known name/display_name for e2e taps by text or id).
* Requires admin session.
*/
export const apiEnsureSingleRemoteCluster = async (
baseUrl: string,
teamId: string,
remote: {name: string; display_name: string; password: string},
): Promise<void> => {
await apiDeleteAllRemoteClusters(baseUrl);
await apiPatchConfig(baseUrl, {
ConnectedWorkspacesSettings: {
EnableRemoteClusterService: true,
},
});
const result = await apiCreateRemoteCluster(baseUrl, {
name: remote.name,
display_name: remote.display_name,
default_team_id: teamId,
password: remote.password,
});
if (result.error) {
throw new Error(`apiEnsureSingleRemoteCluster failed: ${result.error.message ?? 'unknown error'}`);
}
};
/**
* Check that plugin uploads are enabled, fail if not.
* @param {string} baseUrl - the base server URL
* @return {Promise<void>} throws error if plugin uploads are disabled
*/
export const shouldHavePluginUploadEnabled = async (baseUrl: string): Promise<void> => {
const {config} = await apiGetConfig(baseUrl);
const isUploadEnabled = config.PluginSettings.EnableUploads;
if (!isUploadEnabled) {
throw new Error('Plugin uploads must be enabled for this test to run. Set PluginSettings.EnableUploads=true');
}
jestExpect(isUploadEnabled).toEqual(true);
};
/**
* Ping server status.
* See https://api.mattermost.com/#operation/GetPing
* @param {string} baseUrl - the base server URL
* @return {Object} returns {data} on success or {error, status} on error
*/
export const apiPingServerStatus = async (baseUrl: string): Promise<any> => {
try {
const response = await client.get(`${baseUrl}/api/v4/system/ping?get_server_status=true`);
return {data: response.data};
} catch (err) {
return getResponseFromError(err);
}
};
/**
* Require server license to successfully continue.
* @param {string} baseUrl - the base server URL
* @return {Object} returns {license} on success or fail when no license
*/
export const apiRequireLicense = async (baseUrl: string): Promise<any> => {
const {license} = await getClientLicense(baseUrl);
if (license.IsLicensed !== 'true') {
console.error('Server has no Enterprise license.');
}
jestExpect(license.IsLicensed).toEqual('true');
return {license};
};
/**
* Require server license with specific feature to successfully continue.
* @param {string} baseUrl - the base server URL
* @param {string} key - feature, e.g. LDAP
* @return {Object} returns {license} on success or fail when no license or no license to specific feature.
*/
export const apiRequireLicenseForFeature = async (baseUrl: string, key = ''): Promise<any> => {
const {license} = await getClientLicense(baseUrl);
if (license.IsLicensed !== 'true') {
console.error('Server has no Enterprise license.');
}
jestExpect(license.IsLicensed).toEqual('true');
let hasLicenseKey = false;
for (const [k, v] of Object.entries(license)) {
if (k === key && v === 'true') {
hasLicenseKey = true;
break;
}
}
if (!hasLicenseKey) {
console.error(`Server has no license for "${key}" feature.`);
}
jestExpect(hasLicenseKey).toEqual(true);
return {license};
};
/**
* Require SMTP server to be running.
* @param {string} baseUrl - the base server URL
*/
export const apiRequireSMTPServer = async (baseUrl: string) => {
const {status} = await apiEmailTest(baseUrl);
jestExpect(status).toEqual(200);
};
/**
* Upload server license with file expected at "/detox/e2e/support/fixtures/mattermost-license.txt"
* See https://api.mattermost.com/#operation/UploadLicenseFile
* @param {string} baseUrl - the base server URL
* @return {Object} returns response on success or {error, status} on error
*/
export const apiUploadLicense = async (baseUrl: string): Promise<any> => {
const absFilePath = path.resolve(__dirname, '../../support/fixtures/mattermost-license.txt');
return apiUploadFile('license', absFilePath, {url: `${baseUrl}/api/v4/license`, method: 'POST'});
};
// DISABLED: Do not request trial license in tests — tests should run with Free edition only.
// Keeping code commented out as reference; remove this function if trial licensing is never needed.
// /**
// * Request a trial Enterprise license from the Mattermost license server.
// * See https://api.mattermost.com/#operation/RequestTrialLicense
// * @param {string} baseUrl - the base server URL
// * @return {Object} returns response on success or {error, status} on error
// */
// export const apiRequestTrialLicense = async (baseUrl: string): Promise<any> => {
// try {
// const response = await client.post(`${baseUrl}/api/v4/trial-license`, {
// users: 1000,
// terms_accepted: true,
// receive_emails_accepted: true,
// contact_name: 'E2E Test',
// contact_email: 'admin@example.mattermost.com',
// company_name: 'Mattermost E2E',
// company_country: 'US',
// company_size: 'ONE_TO_50',
// });
// return {data: response.data};
// } catch (err) {
// return getResponseFromError(err);
// }
// };
/**
* Get client license.
* If no license, try to upload a license file first.
* Does NOT request trial license — tests run with Free edition features only.
* @param {string} baseUrl - the base server URL
* @return {Object} returns {license} on success or the unlicensed state
*/
export const getClientLicense = async (baseUrl: string): Promise<any> => {
const licenseResponse = await apiGetClientLicense(baseUrl);
if (licenseResponse.error) {
return licenseResponse;
}
const {license} = licenseResponse;
if (license?.IsLicensed === 'true') {
return {license};
}
// Try uploading a license file first (e.g. from fixtures)
const uploadResponse = await apiUploadLicense(baseUrl);
if (!uploadResponse.error) {
const out = await apiGetClientLicense(baseUrl);
if (out.license?.IsLicensed === 'true') {
return {license: out.license};
}
}
// Do not request trial license — tests should work with Free edition only
return {license};
};
export const System = {
apiCheckSystemHealth,
apiCreateRemoteCluster,
apiDeleteAllRemoteClusters,
apiEnsureSingleRemoteCluster,
apiDeleteRemoteCluster,
apiEnsureAtLeastOneConfirmedRemoteCluster,
apiEmailTest,
apiGetClientConfigOld,
apiGetMaxPostSize,
apiGetClientLicense,
apiGetConfig,
apiGetRemoteClusters,
apiPatchConfig,
apiEnableFeatureFlags,
apiPingServerStatus,
apiReplaceConfig,
waitForClientConfigFlag,
// apiRequestTrialLicense, // DISABLED: Do not request trial license in tests
apiRequireLicense,
apiRequireLicenseForFeature,
apiRequireSMTPServer,
apiUpdateConfig,
apiUploadLicense,
getClientLicense,
shouldHavePluginUploadEnabled,
};
export default System;