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
306 lines
8.8 KiB
Go
306 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
)
|
|
|
|
// TestResult tracks the outcome of a single test case.
|
|
type TestResult struct {
|
|
Name string
|
|
Passed bool
|
|
Detail string
|
|
}
|
|
|
|
// TestRunner orchestrates the full integration test sequence.
|
|
type TestRunner struct {
|
|
cfg Config
|
|
logger *mlog.Logger
|
|
mgr *ServerManager
|
|
clientA *model.Client4
|
|
clientB *model.Client4
|
|
teamA *model.Team
|
|
teamB *model.Team
|
|
remoteID string // single remote cluster connection, reused for all tests
|
|
suffix string // random suffix for unique names across repeated runs
|
|
results []TestResult
|
|
}
|
|
|
|
func NewTestRunner(cfg Config, logger *mlog.Logger) *TestRunner {
|
|
return &TestRunner{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
suffix: model.NewId()[:6],
|
|
}
|
|
}
|
|
|
|
func (tr *TestRunner) pass(name string) {
|
|
tr.results = append(tr.results, TestResult{Name: name, Passed: true})
|
|
tr.logger.Info("PASS", mlog.String("test", name))
|
|
}
|
|
|
|
func (tr *TestRunner) fail(name, detail string) {
|
|
tr.results = append(tr.results, TestResult{Name: name, Passed: false, Detail: detail})
|
|
tr.logger.Error("FAIL", mlog.String("test", name), mlog.String("detail", detail))
|
|
}
|
|
|
|
// Run executes the full test suite.
|
|
func (tr *TestRunner) Run(ctx context.Context) error {
|
|
// Lifecycle management
|
|
if tr.cfg.Manage {
|
|
tr.mgr = NewServerManager(tr.cfg, tr.logger)
|
|
defer tr.mgr.Teardown()
|
|
if err := tr.mgr.Setup(ctx); err != nil {
|
|
return fmt.Errorf("server setup: %w", err)
|
|
}
|
|
}
|
|
|
|
// Provision
|
|
if err := tr.provision(ctx); err != nil {
|
|
return fmt.Errorf("provision: %w", err)
|
|
}
|
|
|
|
// Run test suites
|
|
if err := tr.runMembershipTests(ctx); err != nil {
|
|
tr.logger.Error("Membership tests had errors", mlog.Err(err))
|
|
}
|
|
if err := tr.runPostTests(ctx); err != nil {
|
|
tr.logger.Error("Post tests had errors", mlog.Err(err))
|
|
}
|
|
if err := tr.runReactionTests(ctx); err != nil {
|
|
tr.logger.Error("Reaction tests had errors", mlog.Err(err))
|
|
}
|
|
if err := tr.runOfflineRecoveryTests(ctx); err != nil {
|
|
tr.logger.Error("Offline recovery tests had errors", mlog.Err(err))
|
|
}
|
|
|
|
// Report
|
|
return tr.report()
|
|
}
|
|
|
|
func (tr *TestRunner) provision(ctx context.Context) error {
|
|
var err error
|
|
|
|
// Create admin users and login
|
|
tr.logger.Info("Provisioning admin users...")
|
|
tr.clientA, err = ProvisionAdmin(ctx, tr.cfg.ServerAURL, "mattermost_test",
|
|
tr.cfg.AdminUser, "admin-a@test.local", tr.cfg.AdminPass)
|
|
if err != nil {
|
|
return fmt.Errorf("provision server A: %w", err)
|
|
}
|
|
|
|
tr.clientB, err = ProvisionAdmin(ctx, tr.cfg.ServerBURL, "mattermost_node_test",
|
|
tr.cfg.AdminUser, "admin-b@test.local", tr.cfg.AdminPass)
|
|
if err != nil {
|
|
return fmt.Errorf("provision server B: %w", err)
|
|
}
|
|
|
|
// Upload license
|
|
tr.logger.Info("Uploading license...")
|
|
if err := UploadLicense(ctx, tr.clientA, tr.cfg.LicensePath); err != nil {
|
|
return fmt.Errorf("license server A: %w", err)
|
|
}
|
|
if err := UploadLicense(ctx, tr.clientB, tr.cfg.LicensePath); err != nil {
|
|
return fmt.Errorf("license server B: %w", err)
|
|
}
|
|
|
|
// Wait for remote cluster service to become available after license upload
|
|
if err := tr.waitFor(ctx, 30*time.Second, func() bool {
|
|
_, _, err := tr.clientA.GetRemoteClusters(ctx, 0, 1, model.RemoteClusterQueryFilter{})
|
|
return err == nil
|
|
}); err != nil {
|
|
return fmt.Errorf("remote cluster service not ready on server A: %w", err)
|
|
}
|
|
|
|
// Create teams
|
|
tr.logger.Info("Creating teams...")
|
|
tr.teamA, _, err = tr.clientA.CreateTeam(ctx, &model.Team{
|
|
Name: "team-a-" + tr.suffix,
|
|
DisplayName: "Team A",
|
|
Type: model.TeamOpen,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create team A: %w", err)
|
|
}
|
|
|
|
tr.teamB, _, err = tr.clientB.CreateTeam(ctx, &model.Team{
|
|
Name: "team-b-" + tr.suffix,
|
|
DisplayName: "Team B",
|
|
Type: model.TeamOpen,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create team B: %w", err)
|
|
}
|
|
|
|
// Create a single remote cluster connection for all tests
|
|
tr.logger.Info("Setting up remote cluster connection...")
|
|
invitePassword := model.NewTestPassword()
|
|
invite, _, err := tr.clientA.CreateRemoteCluster(ctx, &model.RemoteClusterWithPassword{
|
|
RemoteCluster: &model.RemoteCluster{
|
|
Name: "server-b-" + tr.suffix,
|
|
DisplayName: "Server B",
|
|
DefaultTeamId: tr.teamA.Id,
|
|
},
|
|
Password: invitePassword,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create remote cluster: %w", err)
|
|
}
|
|
tr.remoteID = invite.RemoteCluster.RemoteId
|
|
|
|
_, _, err = tr.clientB.RemoteClusterAcceptInvite(ctx, &model.RemoteClusterAcceptInvite{
|
|
Name: "server-a-" + tr.suffix,
|
|
DisplayName: "Server A",
|
|
DefaultTeamId: tr.teamB.Id,
|
|
Invite: invite.Invite,
|
|
Password: invitePassword,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("accept invite: %w", err)
|
|
}
|
|
|
|
if err := tr.waitForRemoteOnline(ctx, tr.remoteID); err != nil {
|
|
return fmt.Errorf("remote cluster not online: %w", err)
|
|
}
|
|
tr.logger.Info("Remote cluster connection established")
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupSharedChannel creates a channel on Server A, shares it with Server B via
|
|
// the pre-established remote cluster, and returns both channel IDs.
|
|
func (tr *TestRunner) setupSharedChannel(ctx context.Context, channelName string) (channelA, channelB string, err error) {
|
|
uniqueName := channelName + "-" + tr.suffix
|
|
ch, _, err := tr.clientA.CreateChannel(ctx, &model.Channel{
|
|
TeamId: tr.teamA.Id,
|
|
Name: uniqueName,
|
|
DisplayName: "Shared: " + channelName,
|
|
Type: model.ChannelTypeOpen,
|
|
})
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("create channel: %w", err)
|
|
}
|
|
channelA = ch.Id
|
|
|
|
// Share the channel through the existing remote
|
|
_, err = tr.clientA.InviteRemoteClusterToChannel(ctx, tr.remoteID, channelA)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("share channel: %w", err)
|
|
}
|
|
|
|
// Wait for channel to appear on B
|
|
tr.logger.Info("Waiting for channel to sync to Server B...", mlog.String("channel", channelName))
|
|
if err := tr.waitFor(ctx, 30*time.Second, func() bool {
|
|
channels, _, err := tr.clientB.GetAllSharedChannels(ctx, tr.teamB.Id, 0, 100)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, sc := range channels {
|
|
if sc.ShareName == uniqueName {
|
|
channelB = sc.ChannelId
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}); err != nil {
|
|
return "", "", fmt.Errorf("channel did not appear on server B: %w", err)
|
|
}
|
|
|
|
return channelA, channelB, nil
|
|
}
|
|
|
|
func (tr *TestRunner) waitForRemoteOnline(ctx context.Context, remoteID string) error {
|
|
return tr.waitFor(ctx, 30*time.Second, func() bool {
|
|
rc, _, err := tr.clientA.GetRemoteCluster(ctx, remoteID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return rc.LastPingAt > 0
|
|
})
|
|
}
|
|
|
|
func (tr *TestRunner) waitFor(ctx context.Context, timeout time.Duration, condition func() bool) error {
|
|
deadline := time.After(timeout)
|
|
ticker := time.NewTicker(2 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-deadline:
|
|
return fmt.Errorf("timed out after %s", timeout)
|
|
case <-ticker.C:
|
|
if condition() {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// createTestUser creates a user on Server A, adds them to the team, and returns their ID.
|
|
func (tr *TestRunner) createTestUser(ctx context.Context, username string) (string, error) {
|
|
uniqueName := username + "-" + tr.suffix
|
|
user, _, err := tr.clientA.CreateUser(ctx, &model.User{
|
|
Username: uniqueName,
|
|
Email: uniqueName + "@test.local",
|
|
Password: model.NewTestPassword(),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("create user %s: %w", username, err)
|
|
}
|
|
|
|
_, _, err = tr.clientA.AddTeamMember(ctx, tr.teamA.Id, user.Id)
|
|
if err != nil {
|
|
return "", fmt.Errorf("add user %s to team: %w", username, err)
|
|
}
|
|
|
|
return user.Id, nil
|
|
}
|
|
|
|
// verifyMemberOnB checks whether a user (by username prefix) is a member of a channel on Server B.
|
|
func (tr *TestRunner) verifyMemberOnB(ctx context.Context, channelB, usernamePrefix string) bool {
|
|
users, _, err := tr.clientB.GetUsersInChannel(ctx, channelB, 0, 200, "")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, u := range users {
|
|
if len(u.Username) >= len(usernamePrefix) && u.Username[:len(usernamePrefix)] == usernamePrefix {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// report prints the final summary. Errors go to stderr so that a clean
|
|
// stderr signals success in CI/scripted environments. Returns non-nil
|
|
// on any failure so the process exits with a non-zero code.
|
|
func (tr *TestRunner) report() error {
|
|
passed, failed := 0, 0
|
|
for _, r := range tr.results {
|
|
if r.Passed {
|
|
passed++
|
|
} else {
|
|
failed++
|
|
}
|
|
}
|
|
|
|
tr.logger.Info("=========================================")
|
|
tr.logger.Info(fmt.Sprintf("Results: %d passed, %d failed", passed, failed))
|
|
tr.logger.Info("=========================================")
|
|
|
|
if failed > 0 {
|
|
for _, r := range tr.results {
|
|
if !r.Passed {
|
|
tr.logger.Error("FAIL", mlog.String("test", r.Name), mlog.String("detail", r.Detail))
|
|
}
|
|
}
|
|
return fmt.Errorf("%d test(s) failed", failed)
|
|
}
|
|
return nil
|
|
}
|