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) }