All checks were successful
check / check (push) Successful in 2m16s
Closes #110. CleanupLocalSnapshots wrote three prose lines to stdout with no --json awareness, covering every branch, so `vaultik prune --json | jq` failed on any input. -q never helped either: printlnStdout and stdoutf write straight to v.Stdout and never consult v.UI, which is what SetQuiet affects. It now takes *PruneOptions, symmetric with its sibling phase PruneBlobs, and gates all three writes. Threading opts.JSON was chosen over moving the lines to log.Info, because internal/log/log.go defaults the level to Warn: log.Info would not have relocated them to stderr, it would have deleted them from a plain `vaultik prune`, and "Removing stale local record" narrates the deletion of local index rows. The stale-record count is deliberately not added to PruneBlobsResult - every field there is blob-scoped and produced by the phase that runs after this reconciliation, so adding it would change a published --json schema as a side effect of a stream fix. Note for anyone reading the --json contract: under --json the stale-record removal now produces no signal in either stream. stdout is correctly gated, stderr is level-pinned to Warn because --json sets Quiet, and the count is not in the document. That is inherited behaviour - PruneBlobs' own log.Info calls are equally invisible under --json - not something this change introduced, and it is tracked separately. make build exited 0 and produced nothing: .PHONY listed build with no build: rule, and a phony target with no prerequisites and no recipe is considered already satisfied, which turns what would be a hard error into a silent success. In a repo where `make build` is the documented way to build, a caller checking the exit code concluded the build worked. Now `build: vaultik`, verified in both directions - a clean build produces the binary, a deliberately broken one exits non-zero and produces none. All 19 .PHONY names were audited; build was the only one lacking a rule. TestPhonyTargetsAllHaveRules keeps that true for names added later, so the class is closed rather than the instance.
301 lines
9.4 KiB
Go
301 lines
9.4 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 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": {
|
|
flagConfig, "/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{
|
|
programName, flagConfig, 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)
|
|
}
|