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

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

View File

@@ -0,0 +1,160 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {saveUpgradePhaseLogs, test} from '@mattermost/playwright-lib';
import {
assertProfileImageFetchable,
clearUpgradeBaseline,
ensureDirectChannel,
ensureGroupChannel,
ensureUpgradePlaybooksEnabled,
ensureUpgradePluginActive,
loadUpgradeFromContext,
mergeUpgradeBaseline,
persistUpgradeLicenseFromEnv,
postMessage,
readFileDriverName,
readServerIdentity,
readUpgradeBaseline,
readUpgradeLicenseBaseline,
readUpgradeSchemaBaseline,
seedUpgradeAttachments,
seeder,
uploadUpgradeProfileImage,
} from '../upgrade_fixtures';
/**
* @objective Seeds upgrade actors and content via Client4 / PlaywrightClient4 on the from-image.
* Each test writes a slice of `.upgrade_baseline.json` so the captured state maps clearly to
* what upgrade-to re-verifies. Prefer API clients for all setup/verify.
*/
test.describe.serial('upgrade-from baseline', {tag: ['@upgrade-from']}, () => {
test.afterAll(async () => {
// Snapshot from-image logs before upgrade-swap-to replaces the Mattermost container.
await saveUpgradePhaseLogs('from');
});
test('server identity, actors, and channel ids', async ({pw}) => {
// # Start from a clean baseline for this upgrade-from run
clearUpgradeBaseline();
// # Ensure shared team/users/channels and persist license into Postgres
const {adminClient, user, adminMe, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
await persistUpgradeLicenseFromEnv(adminClient);
// # Capture from-image identity, license, schema migrations, and channel ids for upgrade-to
const fromIdentity = await readServerIdentity(adminClient);
const license = await readUpgradeLicenseBaseline(adminClient);
const schema = await readUpgradeSchemaBaseline(adminClient);
mergeUpgradeBaseline({
serverVersion: fromIdentity.serverVersion,
buildNumber: fromIdentity.buildNumber,
schema,
fileDriverName: await readFileDriverName(adminClient),
license,
userId: user.id,
adminUserId: adminMe.id,
publicChannelId: publicChannel.id,
privateChannelId: privateChannel.id,
});
});
test('user channel messages and dm/gm channel ids', async ({pw}) => {
const {userClient, user, peers, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
// # Post user messages in public/private (including a search marker)
await postMessage(userClient, publicChannel.id, seeder.UPGRADE_PUBLIC_MESSAGE);
await postMessage(userClient, publicChannel.id, seeder.UPGRADE_SEARCH_MESSAGE);
await postMessage(userClient, privateChannel.id, seeder.UPGRADE_PRIVATE_MESSAGE);
// # Create user DM/GM and post messages there
const userDmChannel = await ensureDirectChannel(userClient, user.id, peers[0].id);
await postMessage(userClient, userDmChannel.id, seeder.UPGRADE_DM_MESSAGE);
const userGmChannel = await ensureGroupChannel(userClient, [user.id, peers[1].id, peers[2].id]);
await postMessage(userClient, userGmChannel.id, seeder.UPGRADE_GM_MESSAGE);
// # Record DM/GM channel ids for upgrade-to
mergeUpgradeBaseline({
userDmChannelId: userDmChannel.id,
userGmChannelId: userGmChannel.id,
});
});
test('attachments across channel types', async ({pw, request}) => {
const {adminClient, userClient, user, peers, publicChannel, privateChannel} = await loadUpgradeFromContext(pw);
const userDmChannel = await ensureDirectChannel(userClient, user.id, peers[0].id);
// # Seed file attachments across public/private/DM and verify each is downloadable
const attachments = await seedUpgradeAttachments(request, userClient, adminClient, {
publicId: publicChannel.id,
privateId: privateChannel.id,
userDmId: userDmChannel.id,
});
// # Record attachment post ids for upgrade-to
mergeUpgradeBaseline({attachments});
});
test('profile image and avatar thread post id', async ({pw, request}) => {
const {adminClient, userClient, user, publicChannel} = await loadUpgradeFromContext(pw);
// # Upload user profile image and confirm it is fetchable
await uploadUpgradeProfileImage(userClient, user.id, seeder.UPGRADE_PROFILE_PHOTO_FILE);
// * Verify profile image bytes are served
await assertProfileImageFetchable(request, userClient, user.id);
// # Create an avatar thread (root + reply) whose post id upgrade-to will re-check
await postMessage(adminClient, publicChannel.id, 'upgrade-check avatar separator');
const avatarPost = await postMessage(userClient, publicChannel.id, 'upgrade-check avatar message');
await postMessage(adminClient, publicChannel.id, 'upgrade-check thread reply for avatar footer', avatarPost.id);
mergeUpgradeBaseline({avatarPostId: avatarPost.id});
});
test('admin channel messages and dm/gm channel ids', async ({pw}) => {
const {adminClient, userClient, adminMe, peers, publicChannel, privateChannel} =
await loadUpgradeFromContext(pw);
// # Post admin messages in public/private (including a search marker)
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_PUBLIC_MESSAGE);
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_SEARCH_MESSAGE);
await postMessage(adminClient, privateChannel.id, seeder.UPGRADE_ADMIN_PRIVATE_MESSAGE);
// # Create admin DM/GM and post messages there
const adminDmChannel = await ensureDirectChannel(adminClient, adminMe.id, peers[0].id);
await postMessage(adminClient, adminDmChannel.id, seeder.UPGRADE_ADMIN_DM_MESSAGE);
const adminGmChannel = await ensureGroupChannel(adminClient, [adminMe.id, peers[1].id, peers[2].id]);
await postMessage(adminClient, adminGmChannel.id, seeder.UPGRADE_ADMIN_GM_MESSAGE);
// # Post an admin avatar message for post-upgrade re-check
await postMessage(userClient, publicChannel.id, 'upgrade-check admin avatar separator');
await postMessage(adminClient, publicChannel.id, seeder.UPGRADE_ADMIN_AVATAR_MESSAGE);
// # Record admin DM/GM channel ids for upgrade-to
mergeUpgradeBaseline({
adminDmChannelId: adminDmChannel.id,
adminGmChannelId: adminGmChannel.id,
});
});
test('playbooks plugin enabled', async ({pw}) => {
// # Enable prepackaged playbooks on the from-image (API only — not boot env)
const {adminClient} = await loadUpgradeFromContext(pw);
const playbooks = await ensureUpgradePlaybooksEnabled(adminClient);
// # Record playbooks config/runtime state for upgrade-to
mergeUpgradeBaseline({playbooks});
});
test('demo plugin active', async ({pw, request}) => {
const baseline = readUpgradeBaseline();
test.skip(!baseline.license?.isLicensed, 'Demo plugin requires an enterprise license');
// # Install and enable the demo plugin on the from-image
const {adminClient} = await loadUpgradeFromContext(pw);
await ensureUpgradePluginActive(request, adminClient);
});
});

View File

@@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {expect, getPluginStatus, saveUpgradePhaseLogs, test} from '@mattermost/playwright-lib';
import {
assertChannelContainsMessage,
assertSearchFinds,
assertUpgradeFileBackendMatches,
assertUpgradeLicenseMatches,
assertUpgradePlaybooksMatches,
assertUpgradeSchemaMatches,
channelIdForAttachmentSeed,
ensureUpgradeTeam,
loadUpgradeToContext,
readServerIdentity,
seeder,
} from '../upgrade_fixtures';
/**
* @objective Re-verifies upgrade actors and content via Client4 after swap to the to-image.
* Each test checks a slice of `.upgrade_baseline.json` written by upgrade-from.
*/
test.describe.serial('upgrade-to baseline', {tag: ['@upgrade-to']}, () => {
test.afterAll(async () => {
// Snapshot to-image logs (includes post-swap migrations + verification traffic).
await saveUpgradePhaseLogs('to');
});
test('server identity upgraded and license matches baseline', async ({pw}) => {
const {adminClient, baseline} = await loadUpgradeToContext(pw);
// # Read the running to-image identity
const toIdentity = await readServerIdentity(adminClient);
// * Verify server version/build changed from the from-image baseline
expect(`${toIdentity.serverVersion}+${toIdentity.buildNumber}`).not.toBe(
`${baseline.serverVersion}+${baseline.buildNumber}`,
);
// * Verify license state still matches the from-image baseline after swap
await assertUpgradeLicenseMatches(adminClient, baseline);
});
test('schema migrations match baseline', async ({pw}) => {
const {adminClient, baseline} = await loadUpgradeToContext(pw);
test.skip(!baseline.schema, 'From-image did not expose GET /api/v4/system/schema/version');
// * Verify migrations are present and did not regress vs the from-image baseline
const schema = await assertUpgradeSchemaMatches(adminClient, baseline);
// eslint-disable-next-line no-console
console.log(
`upgrade-to: schema versionMax=${schema?.versionMax} count=${schema?.count} (from ${baseline.schema?.versionMax}/${baseline.schema?.count})`,
);
});
test('user channel messages match baseline', async ({pw}) => {
const {userClient, adminClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
const team = await ensureUpgradeTeam(adminClient);
// * Verify user public/private/DM/GM messages still exist
await assertChannelContainsMessage(userClient, publicChannelId, seeder.UPGRADE_PUBLIC_MESSAGE);
await assertChannelContainsMessage(userClient, privateChannelId, seeder.UPGRADE_PRIVATE_MESSAGE);
await assertChannelContainsMessage(userClient, baseline.userDmChannelId, seeder.UPGRADE_DM_MESSAGE);
await assertChannelContainsMessage(userClient, baseline.userGmChannelId, seeder.UPGRADE_GM_MESSAGE);
// * Verify search still finds the user search marker
await assertSearchFinds(userClient, team.id, seeder.UPGRADE_SEARCH_MESSAGE);
});
test('attachments and file backend match baseline', async ({pw, request}) => {
const {adminClient, userClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
// * Verify each seeded attachment post message still exists
for (const seed of seeder.UPGRADE_ATTACHMENT_SEEDS) {
const channelId = channelIdForAttachmentSeed(seed.channel, {
publicId: publicChannelId,
privateId: privateChannelId,
userDmId: baseline.userDmChannelId,
});
const client = seed.author === 'user' ? userClient : adminClient;
await assertChannelContainsMessage(client, channelId, seed.message);
}
// * Verify attachments/profile image remain downloadable on the active file backend
await assertUpgradeFileBackendMatches(request, adminClient, userClient, baseline);
});
test('profile image and avatar thread match baseline', async ({pw}) => {
const {userClient, baseline, publicChannelId} = await loadUpgradeToContext(pw);
// * Verify the avatar root post survived the upgrade
const avatarPost = await userClient.getPost(baseline.avatarPostId);
expect(avatarPost.id).toBe(baseline.avatarPostId);
expect(avatarPost.message).toContain('upgrade-check avatar message');
// * Verify the thread reply is still under that root post
const threadPosts = await userClient.getPosts(publicChannelId, 0, 60);
const threadReply = Object.values(threadPosts.posts).find(
(post) => post.root_id === baseline.avatarPostId && post.message.includes('thread reply'),
);
expect(threadReply).toBeDefined();
});
test('admin channel messages match baseline', async ({pw}) => {
const {adminClient, baseline, publicChannelId, privateChannelId} = await loadUpgradeToContext(pw);
const team = await ensureUpgradeTeam(adminClient);
// * Verify admin public/private/DM/GM messages still exist
await assertChannelContainsMessage(adminClient, publicChannelId, seeder.UPGRADE_ADMIN_PUBLIC_MESSAGE);
await assertChannelContainsMessage(adminClient, publicChannelId, seeder.UPGRADE_ADMIN_AVATAR_MESSAGE);
await assertChannelContainsMessage(adminClient, privateChannelId, seeder.UPGRADE_ADMIN_PRIVATE_MESSAGE);
await assertChannelContainsMessage(adminClient, baseline.adminDmChannelId, seeder.UPGRADE_ADMIN_DM_MESSAGE);
await assertChannelContainsMessage(adminClient, baseline.adminGmChannelId, seeder.UPGRADE_ADMIN_GM_MESSAGE);
// * Verify search still finds the admin search marker
await assertSearchFinds(adminClient, team.id, seeder.UPGRADE_ADMIN_SEARCH_MESSAGE);
});
test('playbooks plugin matches baseline', async ({pw}) => {
const {adminClient, baseline} = await loadUpgradeToContext(pw);
test.skip(!baseline.playbooks, 'From-image did not record playbooks plugin state');
// * Verify playbooks config/runtime state survived the swap (read-only — no re-enable)
await assertUpgradePlaybooksMatches(adminClient, baseline);
});
test('mmctl talks to the upgraded server', async ({pw}) => {
// * Verify mmctl still works against the upgraded server, including unlicensed team runs
const mmctlResult = await pw.runMmctl(['version']);
expect(mmctlResult.exitCode).toBe(0);
});
test('demo plugin matches baseline', async ({pw}) => {
const {adminClient, baseline} = await loadUpgradeToContext(pw);
test.skip(!baseline.license?.isLicensed, 'Demo plugin requires an enterprise license');
// * Verify the demo plugin is still installed and active after upgrade (read-only)
await expect
.poll(() => getPluginStatus(adminClient, seeder.UPGRADE_PLUGIN_ID), {timeout: 30000})
.toEqual({
isInstalled: true,
isActive: true,
});
});
});

View File

@@ -0,0 +1,824 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Fixed seed data shared across separate upgrade-from and upgrade-to processes (see `seeder`).
import fs from 'node:fs';
import path from 'node:path';
import type {Client4} from '@mattermost/client';
import type {Channel} from '@mattermost/types/channels';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import type {APIRequestContext} from '@playwright/test';
import {
expect,
getFileData,
getFileFromAsset,
getPluginStatus,
listAzuriteBlobNames,
listMattermostDataFiles,
listMinioObjectKeys,
playbooksPluginId,
testConfig,
} from '@mattermost/playwright-lib';
const BASELINE_PATH = path.resolve(process.cwd(), '.upgrade_baseline.json');
/** Attachment files from `asset/`, spread across channel types in upgrade-from. */
export type UpgradeAttachmentSeed = {
fileName: string;
message: string;
author: 'user' | 'admin';
channel: 'public' | 'private' | 'dm';
};
/** Fixed seed data shared across upgrade-from and upgrade-to processes. */
export const seeder = {
UPGRADE_TEAM_NAME: 'upgrade-test',
UPGRADE_PUBLIC_CHANNEL_NAME: 'upgrade-public',
UPGRADE_PRIVATE_CHANNEL_NAME: 'upgrade-private',
UPGRADE_USER: {
username: 'upgradetestuser',
email: 'upgradetestuser@sample.mattermost.com',
password: 'Upgradetestuser123!',
first_name: 'Upgrade',
last_name: 'User',
} as UserProfile,
UPGRADE_PEER_USERS: [
{
username: 'upgradetestpeer1',
email: 'upgradetestpeer1@sample.mattermost.com',
password: 'Upgradetestpeer123!',
},
{
username: 'upgradetestpeer2',
email: 'upgradetestpeer2@sample.mattermost.com',
password: 'Upgradetestpeer123!',
},
{
username: 'upgradetestpeer3',
email: 'upgradetestpeer3@sample.mattermost.com',
password: 'Upgradetestpeer123!',
},
].map((u) => u as UserProfile),
UPGRADE_PUBLIC_MESSAGE: 'upgrade-check public channel message',
UPGRADE_PRIVATE_MESSAGE: 'upgrade-check private channel message',
UPGRADE_DM_MESSAGE: 'upgrade-check direct message',
UPGRADE_GM_MESSAGE: 'upgrade-check group message',
UPGRADE_SEARCH_MESSAGE: 'upgrade-check searchable-marker-abc123',
UPGRADE_PROFILE_PHOTO_FILE: 'mattermost-icon_128x128.png',
UPGRADE_ADMIN_ATTACHMENT_MESSAGE: 'upgrade-check admin attachment message',
UPGRADE_ATTACHMENT_SEEDS: [
{
fileName: 'small-image.png',
message: 'upgrade-check public png attachment',
author: 'user',
channel: 'public',
},
{
fileName: 'sample_text_file.txt',
message: 'upgrade-check private text attachment',
author: 'user',
channel: 'private',
},
{
fileName: 'mattermost.png',
message: 'upgrade-check admin attachment message',
author: 'admin',
channel: 'public',
},
{
fileName: 'image-400x40.jpg',
message: 'upgrade-check dm jpeg attachment',
author: 'user',
channel: 'dm',
},
] as UpgradeAttachmentSeed[],
UPGRADE_ADMIN_PUBLIC_MESSAGE: 'upgrade-check admin public channel message',
UPGRADE_ADMIN_PRIVATE_MESSAGE: 'upgrade-check admin private channel message',
UPGRADE_ADMIN_DM_MESSAGE: 'upgrade-check admin direct message',
UPGRADE_ADMIN_GM_MESSAGE: 'upgrade-check admin group message',
UPGRADE_ADMIN_SEARCH_MESSAGE: 'upgrade-check admin-searchable-marker-xyz789',
UPGRADE_ADMIN_AVATAR_MESSAGE: 'upgrade-check admin avatar message',
UPGRADE_PLUGIN_ID: 'com.mattermost.demo-plugin',
UPGRADE_PLUGIN_BUNDLE_URL:
'https://github.com/mattermost/mattermost-plugin-demo/releases/download/v0.11.0/mattermost-plugin-demo-v0.11.0.tar.gz',
UPGRADE_PLUGIN_BUNDLE_PATH: path.resolve(process.cwd(), '.upgrade_plugin_bundle.tar.gz'),
};
/** Prepackaged plugin exercised across upgrade-from → upgrade-to (enabled in the from-spec). */
export const UPGRADE_PREPACKAGED_PLUGIN_ID = playbooksPluginId;
export function channelIdForAttachmentSeed(
channel: UpgradeAttachmentSeed['channel'],
channels: {publicId: string; privateId: string; userDmId: string},
): string {
switch (channel) {
case 'public':
return channels.publicId;
case 'private':
return channels.privateId;
case 'dm':
return channels.userDmId;
default: {
const exhaustive: never = channel;
throw new Error(`Unknown attachment seed channel: ${exhaustive}`);
}
}
}
/** Looks up the shared upgrade-test team, creating it on first (from-phase) run. */
export async function ensureUpgradeTeam(adminClient: Client4) {
try {
return await adminClient.getTeamByName(seeder.UPGRADE_TEAM_NAME);
} catch {
return adminClient.createTeam({
name: seeder.UPGRADE_TEAM_NAME,
display_name: 'Upgrade Test',
type: 'O',
} as any);
}
}
/** Looks up a shared upgrade-test user by username, creating (and adding to the team) if new. */
export async function ensureUpgradeUser(adminClient: Client4, teamId: string, spec: UserProfile) {
let user: UserProfile;
try {
const existing = await adminClient.getUserByUsername(spec.username);
user = {...existing, password: spec.password} as UserProfile;
} catch {
const created = await adminClient.createUser(spec, '', '');
await adminClient.addToTeam(teamId, created.id);
user = {...created, password: spec.password} as UserProfile;
}
await adminClient.savePreferences(user.id, [
{user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'},
{user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'},
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'},
{user_id: user.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'},
]);
return user;
}
/** Looks up a shared upgrade-test channel by name, creating it under the team if new. */
export async function ensureUpgradeChannel(adminClient: Client4, teamId: string, name: string, type: 'O' | 'P') {
try {
return await adminClient.getChannelByName(teamId, name);
} catch {
return adminClient.createChannel({
team_id: teamId,
name,
display_name: name,
type,
} as any);
}
}
/** Adds the user to the channel only when not already a member (avoids "already a member" warns). */
export async function ensureChannelMember(adminClient: Client4, userId: string, channelId: string): Promise<void> {
try {
await adminClient.getChannelMember(channelId, userId);
} catch {
await adminClient.addToChannel(userId, channelId);
}
}
/** Creates or returns an existing DM between the two users. */
export async function ensureDirectChannel(client: Client4, userId: string, peerUserId: string): Promise<Channel> {
return client.createDirectChannel([userId, peerUserId]);
}
/** Creates or returns an existing GM among the given users (includes the acting user). */
export async function ensureGroupChannel(client: Client4, userIds: string[]): Promise<Channel> {
return client.createGroupChannel(userIds);
}
/** Creates a text post in the given channel. */
export async function postMessage(client: Client4, channelId: string, message: string, rootId?: string): Promise<Post> {
return client.createPost({
channel_id: channelId,
message,
...(rootId ? {root_id: rootId} : {}),
});
}
/** Uploads an asset file and creates a post that attaches it. */
export async function postWithAttachment(
client: Client4,
channelId: string,
message: string,
fileName: string,
): Promise<Post> {
const formData = new FormData();
formData.set('channel_id', channelId);
formData.set('files', getFileFromAsset(fileName), fileName);
const uploaded = await client.uploadFile(formData);
const fileId = uploaded.file_infos[0].id;
return client.createPost({
channel_id: channelId,
message,
file_ids: [fileId],
});
}
export type UpgradeAttachmentBaseline = {
postId: string;
fileName: string;
author: 'user' | 'admin';
};
/** Seeds attachment posts from `seeder.UPGRADE_ATTACHMENT_SEEDS` and verifies each upload on the active file backend. */
export async function seedUpgradeAttachments(
request: APIRequestContext,
userClient: Client4,
adminClient: Client4,
channels: {publicId: string; privateId: string; userDmId: string},
): Promise<UpgradeAttachmentBaseline[]> {
const clientFor = (author: 'user' | 'admin') => (author === 'user' ? userClient : adminClient);
const baseline: UpgradeAttachmentBaseline[] = [];
for (const seed of seeder.UPGRADE_ATTACHMENT_SEEDS) {
const client = clientFor(seed.author);
const post = await postWithAttachment(
client,
channelIdForAttachmentSeed(seed.channel, channels),
seed.message,
seed.fileName,
);
await verifyPostAttachmentDownloadable(request, client, post.id, seed.fileName);
baseline.push({postId: post.id, fileName: seed.fileName, author: seed.author});
}
return baseline;
}
/** Asserts a channel's recent posts include the given message text. */
export async function assertChannelContainsMessage(client: Client4, channelId: string, message: string): Promise<void> {
const postList = await client.getPosts(channelId, 0, 60);
const found = Object.values(postList.posts).some((post) => post.message.includes(message));
expect(found).toBe(true);
}
/** Asserts team search returns a post containing the given terms. */
export async function assertSearchFinds(client: Client4, teamId: string, terms: string): Promise<void> {
const results = await client.searchPosts(teamId, terms, false);
const found = Object.values(results.posts).some((post) => post.message.includes(terms));
expect(found).toBe(true);
}
/** Uploads a profile image for the given user via Client4. */
export async function uploadUpgradeProfileImage(client: Client4, userId: string, fileName: string): Promise<void> {
await client.uploadProfileImage(userId, getFileFromAsset(fileName));
}
/**
* Asserts a user's profile picture returns non-empty bytes.
* Uses Playwright's `request` fixture (not raw fetch / browser navigation).
*/
export async function assertProfileImageFetchable(
request: APIRequestContext,
client: Client4,
userId: string,
): Promise<void> {
const user = await client.getUser(userId);
const url = client.getProfilePictureUrl(userId, user.last_picture_update || 0);
const response = await request.get(url, {
headers: {Authorization: `Bearer ${client.getToken()}`},
});
expect(response.ok()).toBe(true);
expect((await response.body()).byteLength).toBeGreaterThan(0);
}
export type ServerIdentity = {
serverVersion: string;
buildNumber: string;
};
/** Reads FileSettings.DriverName from the running server (local, amazons3, azureblob, …). */
export async function readFileDriverName(client: Client4): Promise<string> {
const config = await client.getConfig();
return config.FileSettings?.DriverName || 'local';
}
/** Reads server Version + BuildNumber from the client config API. */
export async function readServerIdentity(client: Client4): Promise<ServerIdentity> {
// Prime X-Version-Id on the client session.
await client.getMe();
const config = await client.getClientConfig();
return {
serverVersion: config.Version || client.getServerVersion(),
buildNumber: config.BuildNumber || '',
};
}
/** License state captured on the from-image for post-upgrade comparison. */
export type UpgradeLicenseBaseline = {
isLicensed: boolean;
skuShortName?: string;
skuName?: string;
isTrial?: boolean;
users?: string;
};
/**
* MM_LICENSE at container boot sets an in-memory license only (LoadLicense env path).
* Persist it to Postgres as well so license state is dual-sourced (env + DB) across the swap.
* upgrade-swap-to keeps the same host MM_LICENSE as upgrade-from; upgrade-to compares baseline.
*/
export async function persistUpgradeLicenseFromEnv(client: Client4): Promise<void> {
const licenseKey = process.env.MM_LICENSE?.trim();
if (!licenseKey) {
return;
}
const file = new File([licenseKey], 'license.mattermost', {type: 'application/octet-stream'});
await client.uploadLicense(file);
}
/** Reads the current server license into a baseline-friendly shape. */
export async function readUpgradeLicenseBaseline(client: Client4): Promise<UpgradeLicenseBaseline> {
const license = await client.getClientLicenseOld();
if (license.IsLicensed !== 'true') {
return {isLicensed: false};
}
return {
isLicensed: true,
skuShortName: license.SkuShortName,
skuName: license.SkuName,
isTrial: license.IsTrial === 'true',
users: license.Users,
};
}
/** Asserts license state after upgrade matches what was recorded on the from-image. */
export async function assertUpgradeLicenseMatches(client: Client4, baseline: UpgradeBaseline): Promise<void> {
const current = await readUpgradeLicenseBaseline(client);
if (!baseline.license) {
expect(current.isLicensed).toBe(true);
return;
}
expect(current.isLicensed).toBe(baseline.license.isLicensed);
if (!baseline.license.isLicensed) {
return;
}
expect(current.skuShortName).toBe(baseline.license.skuShortName);
expect(current.skuName).toBe(baseline.license.skuName);
expect(current.isTrial).toBe(baseline.license.isTrial);
expect(current.users).toBe(baseline.license.users);
}
/** Schema migration snapshot from GET /api/v4/system/schema/version (db_migrations). */
export type UpgradeSchemaBaseline = {
versionMax: number;
count: number;
};
/**
* Reads applied schema migrations. Returns undefined when the from-image lacks the endpoint
* or returns an empty list — upgrade-to then skips the schema assert.
*/
export async function readUpgradeSchemaBaseline(client: Client4): Promise<UpgradeSchemaBaseline | undefined> {
try {
const migrations = await client.getAppliedSchemaMigrations();
if (!migrations.length) {
return undefined;
}
return {
versionMax: Math.max(...migrations.map((m) => m.version)),
count: migrations.length,
};
} catch {
return undefined;
}
}
/**
* Asserts schema migrations did not regress after the to-image swap.
* Skips when the from-phase could not record a schema baseline.
* Returns the to-image snapshot for logging.
*/
export async function assertUpgradeSchemaMatches(
client: Client4,
baseline: UpgradeBaseline,
): Promise<UpgradeSchemaBaseline | undefined> {
if (!baseline.schema) {
return undefined;
}
const current = await readUpgradeSchemaBaseline(client);
expect(current).toBeDefined();
expect(current!.count).toBeGreaterThan(0);
expect(current!.versionMax).toBeGreaterThanOrEqual(baseline.schema.versionMax);
expect(current!.count).toBeGreaterThanOrEqual(baseline.schema.count);
return current;
}
/** Asserts the server reports a licensed Enterprise deployment. */
export async function assertLicensed(client: Client4): Promise<void> {
const license = await readUpgradeLicenseBaseline(client);
expect(license.isLicensed).toBe(true);
}
/**
* Upgrade harness defaults via patchConfig (not MM_* boot env) so later tests can override
* without a container restart. Call before creating users / seeding files.
* Patches are applied in sections so a from-image rejection fails clearly on that section.
*/
export async function ensureUpgradeServerConfig(client: Client4): Promise<void> {
await client.patchConfig({
EmailSettings: {
SendEmailNotifications: true,
},
FileSettings: {
EnablePublicLink: true,
},
ServiceSettings: {
SiteURL: testConfig.internalBaseURL,
},
} as Parameters<typeof client.patchConfig>[0]);
await client.patchConfig({
AccessControlSettings: {
EnableAttributeBasedAccessControl: false,
EnableUserManagedAttributes: false,
},
} as Parameters<typeof client.patchConfig>[0]);
// No PluginStates here — patchConfig replaces that map wholesale, which would undo whatever
// the specs enabled. Plugin enablement is per-id via enablePlugin, and recorded in the
// baseline. EnableUploads is likewise absent: SERVER_ENV_BASELINE owns it and the API 403s.
await client.patchConfig({
PluginSettings: {
Enable: true,
AllowInsecureDownloadURL: true,
},
} as Parameters<typeof client.patchConfig>[0]);
}
/** Installs (if needed) and enables the upgrade demo plugin; asserts it is active. */
export async function ensureUpgradePluginActive(request: APIRequestContext, client: Client4): Promise<void> {
// Only the plugin's own settings — PluginStates is left alone so this does not undo the
// playbooks enablement the from-spec just recorded in the baseline.
await client.patchConfig({
PluginSettings: {
Plugins: {
[seeder.UPGRADE_PLUGIN_ID]: {
username: 'demouser',
channelname: 'demo_plugin',
lastname: 'User',
},
},
},
} as Parameters<typeof client.patchConfig>[0]);
const bundlePath = await ensurePluginBundleDownloaded(request);
const status = await getPluginStatus(client, seeder.UPGRADE_PLUGIN_ID);
if (!status.isInstalled) {
const fileData = getFileData(bundlePath);
await client.uploadPlugin(fileData, true);
}
if (!status.isActive) {
await client.enablePlugin(seeder.UPGRADE_PLUGIN_ID);
}
// Activation is asynchronous server-side, so poll rather than assert immediately.
await expect
.poll(() => getPluginStatus(client, seeder.UPGRADE_PLUGIN_ID), {timeout: 30000})
.toEqual({
isInstalled: true,
isActive: true,
});
}
/** Snapshot of a prepackaged plugin's config + runtime state for upgrade-to comparison. */
export type UpgradePrepackagedPluginBaseline = {
pluginId: string;
configEnabled: boolean;
isInstalled: boolean;
isActive: boolean;
version: string;
};
/** Reads PluginStates + getPlugins for a prepackaged plugin id. */
export async function readUpgradePrepackagedPluginBaseline(
client: Client4,
pluginId: string = UPGRADE_PREPACKAGED_PLUGIN_ID,
): Promise<UpgradePrepackagedPluginBaseline> {
const config = await client.getConfig();
const plugins = await client.getPlugins();
const active = plugins.active.find((p) => p.id === pluginId);
const inactive = plugins.inactive.find((p) => p.id === pluginId);
const manifest = active || inactive;
return {
pluginId,
configEnabled: Boolean(config.PluginSettings?.PluginStates?.[pluginId]?.Enable),
isInstalled: Boolean(manifest),
isActive: Boolean(active),
version: manifest?.version || '',
};
}
/**
* Enables the upgrade prepackaged plugin (playbooks) via the enablePlugin API — not boot env.
* Returns the observed state for the upgrade baseline.
*/
export async function ensureUpgradePlaybooksEnabled(client: Client4): Promise<UpgradePrepackagedPluginBaseline> {
const initial = await getPluginStatus(client, UPGRADE_PREPACKAGED_PLUGIN_ID);
expect(initial.isInstalled).toBe(true);
if (!initial.isActive) {
await client.enablePlugin(UPGRADE_PREPACKAGED_PLUGIN_ID);
}
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
const current = await getPluginStatus(client, UPGRADE_PREPACKAGED_PLUGIN_ID);
if (current.isActive) {
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
const baseline = await readUpgradePrepackagedPluginBaseline(client);
expect(baseline.isInstalled).toBe(true);
expect(baseline.isActive).toBe(true);
expect(baseline.configEnabled).toBe(true);
return baseline;
}
/**
* Asserts playbooks plugin state after upgrade matches the from-image baseline.
* Read-only — does not re-enable or patchConfig PluginStates.
*/
export async function assertUpgradePlaybooksMatches(client: Client4, baseline: UpgradeBaseline): Promise<void> {
expect(baseline.playbooks).toBeDefined();
const current = await readUpgradePrepackagedPluginBaseline(client, baseline.playbooks!.pluginId);
expect(current.pluginId).toBe(baseline.playbooks!.pluginId);
expect(current.isInstalled).toBe(baseline.playbooks!.isInstalled);
expect(current.isActive).toBe(baseline.playbooks!.isActive);
expect(current.configEnabled).toBe(baseline.playbooks!.configEnabled);
}
/** Downloads the demo plugin bundle via Playwright `request` (not raw fetch). */
export async function ensurePluginBundleDownloaded(request: APIRequestContext): Promise<string> {
if (fs.existsSync(seeder.UPGRADE_PLUGIN_BUNDLE_PATH)) {
return seeder.UPGRADE_PLUGIN_BUNDLE_PATH;
}
const attempts = 3;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= attempts; attempt++) {
let response: Awaited<ReturnType<APIRequestContext['get']>> | undefined;
try {
response = await request.get(seeder.UPGRADE_PLUGIN_BUNDLE_URL);
if (response.ok()) {
fs.writeFileSync(seeder.UPGRADE_PLUGIN_BUNDLE_PATH, await response.body());
return seeder.UPGRADE_PLUGIN_BUNDLE_PATH;
}
lastError = new Error(
`Failed to download plugin bundle (attempt ${attempt}/${attempts}): ${response.status()} ${response.statusText()}`,
);
} catch (error) {
lastError =
error instanceof Error
? error
: new Error(`Failed to download plugin bundle (attempt ${attempt}/${attempts}): ${String(error)}`);
} finally {
await response?.dispose();
}
if (attempt < attempts) {
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
throw lastError ?? new Error('Failed to download plugin bundle');
}
export type UpgradeFromContext = {
adminClient: Client4;
userClient: Client4;
adminMe: UserProfile;
user: UserProfile;
peers: UserProfile[];
publicChannel: Channel;
privateChannel: Channel;
};
/** Process-local cache so serial upgrade tests do not re-GET/re-add membership every time. */
let upgradeFromContextCache: UpgradeFromContext | undefined;
export function clearUpgradeFromContextCache(): void {
upgradeFromContextCache = undefined;
}
/**
* Idempotently ensures shared upgrade actors/channels and returns API clients for from-phase tests.
*
* `patchConfig: false` skips the config patch for the to-phase, where writing settings before
* asserting them would repair anything the upgrade dropped instead of reporting it.
*/
export async function loadUpgradeFromContext(
pw: {
getAdminClient: () => Promise<{adminClient: Client4}>;
makeClient: (user: UserProfile) => Promise<{client: Client4 | undefined}>;
},
{patchConfig = true}: {patchConfig?: boolean} = {},
): Promise<UpgradeFromContext> {
if (upgradeFromContextCache) {
return upgradeFromContextCache;
}
const {adminClient} = await pw.getAdminClient();
if (patchConfig) {
await ensureUpgradeServerConfig(adminClient);
}
const team = await ensureUpgradeTeam(adminClient);
const user = await ensureUpgradeUser(adminClient, team.id, seeder.UPGRADE_USER);
const peers = await Promise.all(
seeder.UPGRADE_PEER_USERS.map((peer) => ensureUpgradeUser(adminClient, team.id, peer)),
);
const publicChannel = await ensureUpgradeChannel(adminClient, team.id, seeder.UPGRADE_PUBLIC_CHANNEL_NAME, 'O');
const privateChannel = await ensureUpgradeChannel(adminClient, team.id, seeder.UPGRADE_PRIVATE_CHANNEL_NAME, 'P');
// Membership only on first ensure — re-calling addToChannel every test logs "already a member".
await ensureChannelMember(adminClient, user.id, publicChannel.id);
await ensureChannelMember(adminClient, user.id, privateChannel.id);
const adminMe = await adminClient.getMe();
await ensureChannelMember(adminClient, adminMe.id, publicChannel.id);
await ensureChannelMember(adminClient, adminMe.id, privateChannel.id);
const {client: userClient} = await pw.makeClient(user);
expect(userClient).toBeTruthy();
upgradeFromContextCache = {
adminClient,
userClient: userClient!,
adminMe,
user,
peers,
publicChannel,
privateChannel,
};
return upgradeFromContextCache;
}
export type UpgradeBaseline = {
serverVersion: string;
buildNumber: string;
/** Highest db_migrations.Version (+ count) on the from-image; upgrade-to asserts it did not regress. */
schema?: UpgradeSchemaBaseline;
/** FileSettings.DriverName active when from-phase seeded attachments/profile images. */
fileDriverName: string;
/** License on the from-image; upgrade-to asserts it still matches after swap (same env as from). */
license: UpgradeLicenseBaseline;
userId: string;
adminUserId: string;
publicChannelId: string;
privateChannelId: string;
userDmChannelId: string;
userGmChannelId: string;
adminDmChannelId: string;
adminGmChannelId: string;
attachments: UpgradeAttachmentBaseline[];
avatarPostId: string;
/** Prepackaged playbooks state from upgrade-from; upgrade-to asserts it without re-enabling. */
playbooks?: UpgradePrepackagedPluginBaseline;
};
export type UpgradeToContext = UpgradeFromContext & {
baseline: UpgradeBaseline;
publicChannelId: string;
privateChannelId: string;
};
/** Loads shared upgrade actors/clients plus the from-phase baseline for upgrade-to verification. */
export async function loadUpgradeToContext(pw: {
getAdminClient: () => Promise<{adminClient: Client4}>;
makeClient: (user: UserProfile) => Promise<{client: Client4 | undefined}>;
}): Promise<UpgradeToContext> {
const baseline = readUpgradeBaseline();
// Read-only: verification must observe the config the upgrade produced, not a repaired one.
const ctx = await loadUpgradeFromContext(pw, {patchConfig: false});
return {
...ctx,
baseline,
publicChannelId: baseline.publicChannelId || ctx.publicChannel.id,
privateChannelId: baseline.privateChannelId || ctx.privateChannel.id,
};
}
/** Persists the from-phase's captured state, for upgrade-to to compare against. */
export function writeUpgradeBaseline(baseline: UpgradeBaseline): void {
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2), 'utf-8');
}
/** Removes any prior baseline before a fresh upgrade-from run. */
export function clearUpgradeBaseline(): void {
clearUpgradeFromContextCache();
if (fs.existsSync(BASELINE_PATH)) {
fs.unlinkSync(BASELINE_PATH);
}
}
/** Merges partial baseline fields as each upgrade-from test completes. */
export function mergeUpgradeBaseline(partial: Partial<UpgradeBaseline>): UpgradeBaseline {
const existing: Partial<UpgradeBaseline> = fs.existsSync(BASELINE_PATH)
? JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'))
: {};
const merged = {...existing, ...partial} as UpgradeBaseline;
writeUpgradeBaseline(merged);
return merged;
}
export function readUpgradeBaseline(): UpgradeBaseline {
if (!fs.existsSync(BASELINE_PATH)) {
throw new Error(`${BASELINE_PATH} not found — did the upgrade-from project run first?`);
}
return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
}
/**
* Confirms a post's attachment is served by the active file backend.
* Metadata via Client4; binary download via Playwright `request` (not raw fetch / browser UI).
*/
export async function verifyPostAttachmentDownloadable(
request: APIRequestContext,
client: Client4,
postId: string,
fileName: string,
): Promise<void> {
const post = await client.getPost(postId);
expect(post.file_ids?.length).toBeGreaterThan(0);
const fileInfos = await Promise.all(post.file_ids!.map((fileId) => client.getFileInfo(fileId)));
const fileInfo = fileInfos.find((info) => info.name === fileName);
expect(fileInfo).toBeDefined();
const downloadResponse = await request.get(client.getFileUrl(fileInfo!.id, 0), {
headers: {Authorization: `Bearer ${client.getToken()}`},
});
expect(downloadResponse.ok()).toBe(true);
expect((await downloadResponse.body()).byteLength).toBeGreaterThan(0);
}
/**
* Re-checks file-backed upgrade data after the to-image swap. Uses whatever
* FileSettings.DriverName the server reports — never restarts or patchConfig's the driver.
* API download checks apply to every backend; optional sidecar/object-store probes match the driver.
*/
export async function assertUpgradeFileBackendMatches(
request: APIRequestContext,
adminClient: Client4,
userClient: Client4,
baseline: UpgradeBaseline,
): Promise<void> {
const currentDriver = await readFileDriverName(adminClient);
if (baseline.fileDriverName) {
expect(currentDriver).toBe(baseline.fileDriverName);
}
const attachments = baseline.attachments ?? ([] as UpgradeAttachmentBaseline[]);
for (const {postId, fileName, author} of attachments) {
const client = author === 'user' ? userClient : adminClient;
await verifyPostAttachmentDownloadable(request, client, postId, fileName);
}
await assertProfileImageFetchable(request, userClient, baseline.userId);
switch (currentDriver) {
case 'local':
expect(listMattermostDataFiles().length).toBeGreaterThan(0);
break;
case 'amazons3':
if (testConfig.testcontainersServices.includes('minio')) {
expect((await listMinioObjectKeys()).length).toBeGreaterThan(0);
}
break;
case 'azureblob':
if (testConfig.testcontainersServices.includes('azurite')) {
expect((await listAzuriteBlobNames()).length).toBeGreaterThan(0);
}
break;
default:
break;
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {duration, expect, getUpgradeToServerImage, test as setup} from '@mattermost/playwright-lib';
import {readServerIdentity, readUpgradeBaseline} from './upgrade_fixtures';
setup('swap to to-image', async ({pw}) => {
// Image pull + container restart + migrations can exceed the default 1m test timeout.
setup.setTimeout(duration.four_min);
const toImage = getUpgradeToServerImage();
const baseline = readUpgradeBaseline();
try {
// # Swap to the to-image with the same boot env as upgrade-from (including host MM_LICENSE).
// Plugin enablement is not forced via env — baseline compare in upgrade-to covers that.
await pw.upgradeServerImage(toImage);
// * Confirm the running server is no longer the from-image identity captured in the baseline
const {adminClient} = await pw.getAdminClient();
const identity = await readServerIdentity(adminClient);
expect(`${identity.serverVersion}+${identity.buildNumber}`).not.toBe(
`${baseline.serverVersion}+${baseline.buildNumber}`,
);
// eslint-disable-next-line no-console
console.log(`upgrade-swap-to: running server version=${identity.serverVersion} build=${identity.buildNumber}`);
} finally {
// Capture to-image logs even if the swap/identity assert fails (overwritten by upgrade-to
// afterAll on success). Guarded: a log-capture error here would otherwise replace the
// swap failure this block exists to surface.
try {
await pw.saveUpgradePhaseLogs('to');
} catch (error) {
// eslint-disable-next-line no-console
console.log(`upgrade-swap-to: failed to capture phase logs: ${error}`);
}
}
});