package cli //nolint:testpackage // needs access to unexported emitStartupBanner import ( "bytes" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "testing" "github.com/adrg/xdg" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Command words and flags used to build argument vectors below. They are // constants rather than repeated literals so that a rename shows up as a // compile error in one place. const ( cmdSnapshot = "snapshot" cmdList = "list" cmdCreate = "create" cmdVerify = "verify" cmdRemove = "remove" cmdPrune = "prune" cmdRemote = "remote" cmdInfo = "info" flagJSON = "--json" flagQuiet = "--quiet" // someSnapshotID is any snapshot identifier: these tests never run // the command, so it only has to occupy the positional argument. someSnapshotID = "host_2026-01-01T00:00:00Z" ) // placeholderJSONDocument stands in for whatever document a --json // command writes to stdout. `snapshot list --json` with no snapshots // prints exactly this; the other --json commands print an object rather // than an array, but this test is not about their shape. It is about // what is on stdout *before* them, which is the same for all of them // because Entry prints the banner before cobra has parsed anything and // therefore before it can know which command is running. const placeholderJSONDocument = "[]\n" // jsonArgumentVectors are the argument vectors of every --json // invocation the CLI accepts, with the program name stripped exactly as // Entry strips it. Each one must leave stdout untouched by the banner. // //nolint:gochecknoglobals // read-only test fixture shared by two tests var jsonArgumentVectors = map[string][]string{ "snapshot list": {cmdSnapshot, cmdList, flagJSON}, "snapshot verify": {cmdSnapshot, cmdVerify, someSnapshotID, flagJSON}, "snapshot remove": {cmdSnapshot, cmdRemove, someSnapshotID, flagJSON}, "prune": {cmdPrune, flagJSON}, "remote info": {cmdRemote, cmdInfo, flagJSON}, // --json before the subcommand, and with an explicit value: the // scan is positional, so both forms have to be recognized. "json first": {flagJSON, cmdSnapshot, cmdList}, "json with value": {cmdSnapshot, cmdList, flagJSON + "=true"}, // A --json invocation that also carries a flag with a value, so the // scan cannot be fooled by an argument that consumes the next one. "json with config": { "--config", "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON, }, } // TestJSONInvocationStdoutIsExactlyOneDocument is the CLI-layer // regression guard for issue #106: `vaultik snapshot list --json | jq` // must work with no other flags. // // internal/vaultik's TestListSnapshots_JSONStdoutIsOnlyTheDocument // guards the same contract one layer down, but it calls the library // function directly and so cannot see Entry, which is where the // contamination was: the startup banner is written to stdout before // cobra parses anything, and the suppression scan did not know about // --json. The two banner lines and the blank line landed ahead of the // document and `jq` refused the result. // // The document is a constant here because this test is about the // argument vectors, one per --json command; the one that runs a real // command end to end is TestEntryJSONStdoutIsExactlyOneDocument below. func TestJSONInvocationStdoutIsExactlyOneDocument(t *testing.T) { t.Parallel() for name, argv := range jsonArgumentVectors { t.Run(name, func(t *testing.T) { t.Parallel() var stdout bytes.Buffer emitStartupBanner(argv, &stdout) require.Empty(t, stdout.String(), "nothing may reach stdout ahead of a --json document") _, err := stdout.WriteString(placeholderJSONDocument) require.NoError(t, err) requireExactlyOneJSONDocument(t, stdout.String()) }) } } // TestBannerStillPrintedWithoutSuppressingFlag pins the other half of // the contract. Without it, deleting the banner outright would satisfy // the test above, and the banner is wanted on interactive invocations. func TestBannerStillPrintedWithoutSuppressingFlag(t *testing.T) { t.Parallel() for name, argv := range map[string][]string{ "no flags": {cmdSnapshot, cmdList}, "verbose": {cmdSnapshot, cmdList, "--verbose"}, "after the terminator": { cmdSnapshot, "restore", "--", flagJSON, }, } { t.Run(name, func(t *testing.T) { t.Parallel() var stdout bytes.Buffer emitStartupBanner(argv, &stdout) assert.Contains(t, stdout.String(), "starting up at", "the banner belongs on invocations that did not opt out") }) } } // TestBannerSuppressedInArgs covers the suppression scan directly, // including the flags that suppressed the banner before --json joined // them, so that adding --json cannot regress them. func TestBannerSuppressedInArgs(t *testing.T) { t.Parallel() for name, testCase := range map[string]struct { args []string suppressed bool }{ "quiet long": {[]string{cmdSnapshot, cmdCreate, flagQuiet}, true}, "quiet short": {[]string{cmdSnapshot, cmdCreate, "-q"}, true}, "quiet combined": {[]string{cmdSnapshot, cmdCreate, "-qv"}, true}, "cron": {[]string{cmdSnapshot, cmdCreate, "--cron"}, true}, "json": {[]string{cmdSnapshot, cmdList, flagJSON}, true}, "nothing": {[]string{cmdSnapshot, cmdList}, false}, "empty": {nil, false}, "json after dashes": { []string{cmdSnapshot, cmdList, "--", flagJSON}, false, }, "quiet after dashes": { []string{cmdSnapshot, cmdCreate, "--", "-q"}, false, }, } { t.Run(name, func(t *testing.T) { t.Parallel() assert.Equal(t, testCase.suppressed, bannerSuppressedInArgs(testCase.args)) }) } } // hermeticConfig is a complete, valid config that needs no network and // no credentials: file:// storage is exempt from the S3 credential // checks, and FileStorer over a directory that does not exist lists // zero objects without erroring. Chunk, blob and compression settings // are filled in by config.Load. const hermeticConfig = `age_recipients: - age1278m9q7dp3chsh2dcy82qk27v047zywyvtxwnj4cvt0z65jw6a7q5dqhfj snapshots: test: paths: - %s storage_url: file://%s index_path: %s hostname: test-host ` // TestEntryJSONStdoutIsExactlyOneDocument runs the real thing: Entry, // with a real argument vector, over the process's real stdout file // descriptor, all the way through cobra and the fx graph to the // document. It is the assertion the issue asks for — `vaultik snapshot // list --json | jq .` with no other flags — with the pipe replaced by a // decoder. // // `snapshot list` is the command chosen because it is the only --json // command that reaches its document without a populated destination // store: it reads the local index, streams `metadata/` (empty here), // and treats a barren destination as an empty list rather than a // failure. // // Not parallel: it replaces os.Args, os.Stdout and the xdg globals. func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.yml") contents := fmt.Sprintf(hermeticConfig, filepath.Join(dir, "source"), filepath.Join(dir, "store"), filepath.Join(dir, "index.sqlite")) require.NoError(t, os.WriteFile(configPath, []byte(contents), configFileMode)) // The PID lock lives under xdg.DataHome, which xdg resolves at // package init; point it at the temp dir so the test neither // touches nor collides with the real one. t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data")) xdg.Reload() t.Cleanup(xdg.Reload) previousArgs := os.Args t.Cleanup(func() { os.Args = previousArgs rootFlags = RootFlags{} }) os.Args = []string{ "vaultik", "--config", configPath, cmdSnapshot, cmdList, flagJSON, } stdout := captureProcessStdout(t, Entry) requireExactlyOneJSONDocument(t, stdout) var snapshots []any require.NoError(t, json.Unmarshal([]byte(stdout), &snapshots)) assert.Empty(t, snapshots, "a destination store with no snapshots lists none") } // captureProcessStdout redirects the process's own stdout to a pipe for // the duration of fn and returns what was written to it. The redirection // has to be at the file-descriptor level rather than through an injected // writer, because the banner and the JSON encoder reach os.Stdout // independently and the point of the test is that both land in the same // place. // // Not parallel-safe: os.Stdout is process-global. func captureProcessStdout(t *testing.T, fn func()) string { t.Helper() reader, writer, err := os.Pipe() require.NoError(t, err) previous := os.Stdout os.Stdout = writer captured := make(chan string, 1) go func() { var buf bytes.Buffer _, _ = io.Copy(&buf, reader) captured <- buf.String() }() fn() os.Stdout = previous require.NoError(t, writer.Close()) out := <-captured require.NoError(t, reader.Close()) return out } // requireExactlyOneJSONDocument fails unless stdout decodes as a single // JSON value with nothing before or after it — the property that makes // `| jq` work. func requireExactlyOneJSONDocument(t *testing.T, stdout string) { t.Helper() decoder := json.NewDecoder(strings.NewReader(stdout)) var document any err := decoder.Decode(&document) require.NoError(t, err, "stdout must parse as JSON, got:\n%s", stdout) _, err = decoder.Token() require.ErrorIs(t, err, io.EOF, "stdout must hold exactly one JSON document, got:\n%s", stdout) }