The file backend listed a destination directory that does not exist as an empty store. With the volume unplugged, snapshot list reported every local snapshot as missing from the store, snapshot remove said it had removed metadata it never reached, and prune dropped every local snapshot record. List and ListStream now fail when the destination directory is missing, so those commands take their existing path for a store that cannot be listed. A missing prefix under an existing directory is still an empty listing, and a first backup still creates the directory. Three tests listed a file:// destination nothing had created; they now create it. Model: opus-5-5
304 lines
9.6 KiB
Go
304 lines
9.6 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. 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 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)
|
|
}
|