Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

323 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package commands
import (
"context"
dbsql "database/sql"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
)
// dsnFromHelper builds a postgres:// DSN from the test main helper's SqlSettings.
// The helper itself stores the live test postgres DSN.
func dsnFromHelper(t *testing.T) string {
t.Helper()
require.NotNil(t, mainHelper, "mainHelper must be initialized; do not run with -short")
settings := mainHelper.GetSQLSettings()
require.NotNil(t, settings.DataSource)
require.NotEmpty(t, *settings.DataSource)
return *settings.DataSource
}
// --- subprocess (CLI integration) tests ---
func TestDBPingHappyPath(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
output := th.CheckCommand(t, "db", "ping", "--timeout", "30s")
require.Contains(t, output, "Database is reachable",
"expected success log line in command output, got: %s", output)
}
func TestDBPingDirectDSN(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := dsnFromHelper(t)
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping", "--timeout", "30s")
require.NoError(t, err, "command should succeed when DSN is direct postgres URL; output: %s", output)
require.Contains(t, output, "Database is reachable")
}
func TestDBPingTimeoutOnUnreachableDB(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Loopback to a port nothing listens on; connect_timeout=1 keeps each
// attempt short. We allow 2s total with 500ms between attempts so we get
// multiple "Waiting for database" lines.
dsn := "postgres://[email protected]:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err, "command should fail on unreachable DB; output: %s", output)
require.Contains(t, output, "timed out waiting for database",
"expected timeout message in output, got: %s", output)
require.LessOrEqual(t, elapsed, 30*time.Second,
"command should not exceed a generous upper bound; took %s", elapsed)
}
func TestDBPingInvalidDSN(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Passes IsDatabaseDSN (postgres:// prefix) so it takes the direct path.
dsn := "postgres://leakyuser:supersecret@[invalid"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail on malformed DSN; output: %s", output)
require.Contains(t, output, "invalid database DSN",
"expected sanitized DSN parse error; got: %s", output)
require.Contains(t, output, "missing ']' in host",
"expected malformed DSN reason; got: %s", output)
require.NotContains(t, output, "supersecret",
"malformed DSN errors must not leak credentials; got: %s", output)
require.NotContains(t, output, "leakyuser",
"malformed DSN errors must not leak credentials; got: %s", output)
}
func TestDBPingMissingConfigFile(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
// Point --config at a path that does not exist; createFileIfNotExist=false
// inside resolvePingDataSource means NewStoreFromDSN will return an error.
missing := th.TemporaryDirectory() + "/does-not-exist.json"
output, err := th.RunCommandWithOutput(t, "--config", missing, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
require.Error(t, err, "command should fail when --config file does not exist; output: %s", output)
require.Contains(t, output, "failed to load configuration",
"expected config-load error message; got: %s", output)
}
func TestDBPingFlagValidation(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://localhost:1/mattermost?sslmode=disable&connect_timeout=1"
t.Run("zero timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "0s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error; got: %s", output)
})
t.Run("zero retry interval", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "1s", "--retry-interval", "0s")
require.Error(t, err)
require.Contains(t, output, "--retry-interval must be > 0",
"expected retry-interval validation error; got: %s", output)
})
t.Run("negative timeout", func(t *testing.T) {
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "-1s")
require.Error(t, err)
require.Contains(t, output, "--timeout must be > 0",
"expected timeout validation error for negative value; got: %s", output)
})
t.Run("garbage timeout value", func(t *testing.T) {
// cobra refuses to parse "garbage" as a duration; subcommand never runs.
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "garbage")
require.Error(t, err)
// Don't pin to exact text — cobra owns the error string here. Just
// confirm the subcommand's success log is absent.
require.NotContains(t, output, "Database is reachable",
"command should not have run successfully; got: %s", output)
})
}
func TestDBPingRetryIntervalHonored(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://[email protected]:1/mattermost?sslmode=disable&connect_timeout=1"
start := time.Now()
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "2s", "--retry-interval", "500ms")
elapsed := time.Since(start)
require.Error(t, err)
// Loose lower bound: at 500ms intervals we expect at least 2 retries
// (~1s wall clock minimum) before the 2s timeout strikes.
require.GreaterOrEqual(t, elapsed, 1*time.Second,
"expected retries to span at least 1s; got %s", elapsed)
// Loose upper bound: don't exceed several multiples of the configured
// timeout — accommodates CI variance.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected command to bail close to --timeout; took %s", elapsed)
waitingCount := strings.Count(output, "Waiting for database")
require.GreaterOrEqual(t, waitingCount, 2,
"expected at least 2 'Waiting for database' lines; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingShortRetryIntervalProducesMoreAttempts verifies that the
// --retry-interval flag actually controls the cadence (not just the timeout).
func TestDBPingShortRetryIntervalProducesMoreAttempts(t *testing.T) {
th := SetupWithStoreMock(t)
th.SetAutoConfig(false)
dsn := "postgres://[email protected]:1/mattermost?sslmode=disable&connect_timeout=1"
output, err := th.RunCommandWithOutput(t, "--config", dsn, "db", "ping",
"--timeout", "3s", "--retry-interval", "200ms")
require.Error(t, err)
waitingCount := strings.Count(output, "Waiting for database")
// At 200ms intervals over 3s we expect well more than 3 attempts even
// accounting for per-attempt connection overhead.
require.GreaterOrEqual(t, waitingCount, 3,
"expected several retries with short interval; got %d in output:\n%s",
waitingCount, output)
}
// TestDBPingCmdRegistered confirms the new subcommand is wired into the
// existing DbCmd group, so users actually get `mattermost db ping`.
func TestDBPingCmdRegistered(t *testing.T) {
require.Contains(t, DbCmd.Commands(), DBPingCmd,
"DBPingCmd should be registered as a subcommand of DbCmd")
require.Equal(t, "ping", DBPingCmd.Use)
// Flags exist with sensible defaults.
timeoutFlag := DBPingCmd.Flags().Lookup("timeout")
require.NotNil(t, timeoutFlag)
require.Equal(t, dbPingDefaultTimeout.String(), timeoutFlag.DefValue)
intervalFlag := DBPingCmd.Flags().Lookup("retry-interval")
require.NotNil(t, intervalFlag)
require.Equal(t, dbPingDefaultRetryInterval.String(), intervalFlag.DefValue)
}
// --- in-process tests of pingWithRetry / resolvePingDataSource ---
func TestPingWithRetry_SuccessOnFirstAttempt(t *testing.T) {
if testing.Short() {
t.Skip("requires live test database")
}
dsn := dsnFromHelper(t)
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 100*time.Millisecond, logger)
require.NoError(t, err)
}
func TestPingWithRetry_TimeoutAgainstUnreachable(t *testing.T) {
dsn := "postgres://[email protected]:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel()
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 200*time.Millisecond, logger)
elapsed := time.Since(start)
require.Error(t, err)
require.True(t,
errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(err.Error(), "timed out waiting for database"),
"expected deadline-exceeded or timeout error; got %v", err)
// Must have honored the timeout, not just returned immediately.
require.LessOrEqual(t, elapsed, 30*time.Second,
"expected reasonable upper bound; took %s", elapsed)
}
func TestPingWithRetry_ContextCancelImmediately(t *testing.T) {
dsn := "postgres://[email protected]:1/mattermost?sslmode=disable&connect_timeout=1"
db, err := dbsql.Open(model.DatabaseDriverPostgres, dsn)
require.NoError(t, err)
defer db.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before we even start
logger := mlog.CreateConsoleTestLogger(t)
err = pingWithRetry(ctx, db, 1*time.Second, logger)
require.Error(t, err, "cancelled context should produce an error")
}
// In-process tests of resolvePingDataSource. We drive DSN selection via the
// MM_CONFIG environment variable rather than the --config persistent flag
// because the persistent flag is only merged into a subcommand's local
// flagset during cobra's Execute() pipeline; calling resolvePingDataSource
// directly outside Execute means the flag would not be visible.
// MM_CONFIG is consumed by getConfigDSN as the second-precedence source.
func TestResolvePingDataSource_DirectDSN(t *testing.T) {
wanted := "postgres://user:[email protected]:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_DirectDSN_PostgresqlScheme(t *testing.T) {
wanted := "postgresql://user:[email protected]:5432/mm?sslmode=disable"
t.Setenv("MM_CONFIG", wanted)
got, err := resolvePingDataSource(DBPingCmd)
require.NoError(t, err)
require.Equal(t, wanted, got)
}
func TestResolvePingDataSource_MissingFile(t *testing.T) {
missing := t.TempDir() + "/no-such-config.json"
t.Setenv("MM_CONFIG", missing)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration")
}
// TestResolvePingDataSource_PointsAtDirectory verifies that pointing --config
// at a directory (not a JSON file) surfaces a clear, wrapped error. Catches
// regressions where we silently fall through instead of returning the load error.
func TestResolvePingDataSource_PointsAtDirectory(t *testing.T) {
dir := t.TempDir()
t.Setenv("MM_CONFIG", dir)
_, err := resolvePingDataSource(DBPingCmd)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to load configuration",
"expected wrapped error; got %v", err)
}