check / check (push) Successful in 18m30s
The startup banner moves from stdout to stderr, so a `completion` script, a `config get` value and the hidden `__complete` command print only their own output. `--quiet`, `--cron` and `--json` still suppress it. A failing `remote info`, `prune` or `snapshot remove` under `--json` now reports its error on stderr. Their reporters returned early under `--json`, so the failure reached neither stream. `snapshot verify --quiet` writes no report. A failure is still returned and printed on stderr, with the same exit status. Judgement call: the banner's stream, posted on the issue for the owner. Model: opus-5-5
277 lines
8.2 KiB
Go
277 lines
8.2 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"
|
|
flagConfig = "--config"
|
|
|
|
// programName is argv[0] as the real process receives it. Entry
|
|
// strips it before scanning, so it has to be present.
|
|
programName = "vaultik"
|
|
|
|
// someSnapshotID only fills the positional argument; no test needs
|
|
// the snapshot to exist.
|
|
someSnapshotID = "host_2026-01-01T00:00:00Z"
|
|
)
|
|
|
|
// 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 suppress 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": {
|
|
flagConfig, "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON,
|
|
},
|
|
}
|
|
|
|
// TestJSONInvocationSuppressesBanner checks that every --json
|
|
// invocation suppresses the startup banner, as the README says --json
|
|
// does along with --quiet and --cron. The scan is over the raw argument
|
|
// vector, so each position and spelling of --json is listed.
|
|
func TestJSONInvocationSuppressesBanner(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, argv := range jsonArgumentVectors {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var banner bytes.Buffer
|
|
|
|
emitStartupBanner(argv, &banner)
|
|
|
|
assert.Empty(t, banner.String(),
|
|
"--json suppresses the banner")
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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 banner bytes.Buffer
|
|
|
|
emitStartupBanner(argv, &banner)
|
|
|
|
assert.Contains(t, banner.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. A test that lists the destination must create its directory
|
|
// first, because listing a directory that does not exist is an error.
|
|
// 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 an empty destination directory 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")
|
|
storeDir := filepath.Join(dir, "store")
|
|
|
|
contents := fmt.Sprintf(hermeticConfig,
|
|
filepath.Join(dir, "source"),
|
|
storeDir,
|
|
filepath.Join(dir, "index.sqlite"))
|
|
|
|
require.NoError(t,
|
|
os.WriteFile(configPath, []byte(contents), configFileMode))
|
|
|
|
require.NoError(t, os.Mkdir(storeDir, 0o750))
|
|
|
|
// 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{
|
|
programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
|
|
}
|
|
|
|
stdout := captureProcessStdout(t, func() { _ = 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 commands Entry runs reach os.Stdout directly.
|
|
//
|
|
// 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)
|
|
}
|