All checks were successful
check / check (push) Successful in 2m31s
The banner is printed to stdout before cobra parses, and bannerSuppressedInArgs recognised only --quiet, -q and --cron. So every --json document was preceded by two banner lines and a blank one, and `vaultik snapshot list --json | jq` failed. Passing opts.JSON as extraQuiet did not help: that calls UI.SetQuiet in an fx OnStart hook, long after Entry has printed. The raw-argv scan is extended rather than the banner moved after parsing. root.go documents that the banner must survive cobra rejecting its arguments and --help, and no single post-parse location covers those paths. The subcommand-versus-persistent distinction does not decide it: --cron is already in the suppression list and is itself subcommand-only, existing on snapshot create alone, so this adds another instance of an accepted imprecision rather than a new kind. The error directions are asymmetric - a false positive loses a decorative banner, a false negative corrupts a document - so the scan errs toward suppression, which is also why --json=false suppresses, exactly as --quiet=false already does. Four of the five --json commands now pipe into jq cleanly with no other flags: snapshot list, snapshot verify, snapshot remove, remote info. prune does not, because pruneLocalSnapshots writes three prose lines to stdout with no --json awareness. That reproduces identically before this change and -q never suppressed it either, since printlnStdout and stdoutf bypass v.UI entirely. Tracked as #108. Also fixed: TTYHandler's human-readable byte formatting did not survive grouping, because the key check compared against the bare attribute name and a grouped record presents it qualified. AGENTS.md policy 9 keyed the log format on stdout's TTY-ness, which #82 made false by moving the logger to stderr; it now names the log stream. Vaultik.Stderr keeps its field with the comment amended to say outright that nothing writes to it, and the dead listEnv.stderr is removed.
296 lines
9.3 KiB
Go
296 lines
9.3 KiB
Go
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)
|
|
}
|