package main
import (
"bytes"
"errors"
"os"
"os/exec"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testArgvEnv turns a re-executed copy of this test binary into the migrate
// binary itself: TestMain sees it, replaces os.Args with its whitespace-split
// value and calls main().
//
// Running main() in a subprocess is the only way to observe what main() does.
// It reads the config off the filesystem through config.LoadConfig, the record
// under test is written by a library past every seam a unit test could reach,
// and the run ends in os.Exit — which takes the process with it. What is under
// test is what an operator finds in the upgrade log, so the test has to read
// that log.
const testArgvEnv = "DOLTSRHT_MIGRATE_TEST_ARGV"
func TestMain(m *testing.M) {
if argv, ok := os.LookupEnv(testArgvEnv); ok {
os.Args = append([]string{"doltsrht-migrate"}, strings.Fields(argv)...)
main()
os.Exit(0)
}
os.Exit(m.Run())
}
// runMain runs main() in a subprocess whose working directory is empty — no
// config.ini, no ./migrations — and returns everything the process wrote and the
// status it exited with.
//
// Stdout and stderr are interleaved into one buffer because that is how a
// package manager's upgrade log shows them, and the question this asks is what
// an operator finds there.
func runMain(t *testing.T, argv string) (output string, code int) {
t.Helper()
exe, err := os.Executable()
require.NoError(t, err, "locate the test binary to re-execute")
cmd := exec.Command(exe)
cmd.Dir = t.TempDir()
cmd.Env = append(os.Environ(), testArgvEnv+"="+argv)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err = cmd.Run()
var exit *exec.ExitError
switch {
case err == nil:
code = 0
case errors.As(err, &exit):
code = exit.ExitCode()
default:
require.NoError(t, err, "run %s %s", exe, argv)
}
return buf.String(), code
}
// testPassword is a fake credential no configuration on this instance holds. It
// is written into the DSN the test passes and looked for in everything the
// process wrote; a real one would put the thing under test into the test log.
const testPassword = "HUNTER2SECRET"
// TestMainDoesNotPrintTheConnectionString pins the reason main() installs a
// logger at all.
//
// brant reports a migration directory it cannot open with
// `slog.Error("failed to create provider", "datasource", a.DataSourceName, ...)`
// — the connection string, password and all, at ERR level. A missing directory
// is not an exotic state: it is what an installed package is in until its first
// migration ships. Two things have to hold for the password not to reach the
// journal, and this asserts through both: the key "datasource" must be in the
// instance's mask list (sr-ht-ecore's, which did not know that spelling until it
// was added), and a handler carrying that list must be slog's default by the
// time brant writes, or the record goes to Go's built-in stderr handler and no
// list of any kind applies.
func TestMainDoesNotPrintTheConnectionString(t *testing.T) {
out, code := runMain(t,
"up --dir /nonexistent --dsn postgresql://u:"+testPassword+"@localhost/x")
// The run must actually reach brant and fail there, or the assertions below
// would pass over an empty log.
assert.NotZero(t, code, "the missing directory must still be a failed run: %s", out)
assert.Contains(t, out, "failed to create provider",
"the leaking record must have been written at all: %s", out)
assert.NotContains(t, out, testPassword, "the DSN reached the log in the clear: %s", out)
assert.Contains(t, out, "datasource=***", "%s", out)
}