check / check (push) Waiting to run
When the destination store cannot be reached, `snapshot remove` still removes the snapshot from the local index and warns. The warning went through the UI, which writes to stdout, so under `--json` it landed ahead of the document and `| jq` failed on a command that exited 0. Under `--json` the UI warning is now skipped, and the logger's warning on stderr carries the follow-up. That follow-up, in the warning, the README and the command's help, said `vaultik prune` would finish the cleanup, but `prune` never removes snapshot metadata. They now say to run `vaultik snapshot remove` for the snapshot again once the destination store is reachable. The new test removes a snapshot from the local index against a missing destination directory and checks both streams. Model: opus-5-5
187 lines
5.8 KiB
Go
187 lines
5.8 KiB
Go
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/adrg/xdg"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/vaultik/internal/database"
|
|
)
|
|
|
|
// TestEntryCompletionStdoutIsTheScript runs `vaultik completion bash`,
|
|
// whose stdout the README tells the user to source. The script has to
|
|
// start on the first line.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
|
func TestEntryCompletionStdoutIsTheScript(t *testing.T) {
|
|
code, stdout, _ := runEntry(t, "completion", "bash")
|
|
|
|
require.Equal(t, 0, code)
|
|
|
|
firstLine, _, _ := strings.Cut(stdout, "\n")
|
|
assert.True(t, strings.HasPrefix(firstLine, "# bash completion"),
|
|
"the first line of stdout must be the script's, got %q", firstLine)
|
|
}
|
|
|
|
// TestEntryConfigGetStdoutIsTheValue runs `vaultik config get`, whose
|
|
// stdout a script reads as the value and nothing else.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout and os.Stderr
|
|
func TestEntryConfigGetStdoutIsTheValue(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.yml")
|
|
require.NoError(t, os.WriteFile(configPath,
|
|
[]byte("hostname: test-host\n"), configFileMode))
|
|
|
|
code, stdout, _ := runEntry(t,
|
|
flagConfig, configPath, "config", "get", "hostname")
|
|
|
|
require.Equal(t, 0, code)
|
|
assert.Equal(t, "test-host\n", stdout)
|
|
}
|
|
|
|
// TestEntryJSONFailureIsReportedOnStderr runs each --json command that
|
|
// writes no document when it fails, against a destination it cannot
|
|
// use. The error must reach stderr, and stdout must stay empty.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntryJSONFailureIsReportedOnStderr(t *testing.T) {
|
|
for _, testCase := range []struct {
|
|
name string
|
|
args []string
|
|
wantOnStderr string
|
|
}{
|
|
{
|
|
name: "remote info",
|
|
args: []string{cmdRemote, cmdInfo, flagJSON},
|
|
wantOnStderr: "Failed to get remote info",
|
|
},
|
|
{
|
|
name: "prune",
|
|
args: []string{cmdPrune, flagJSON},
|
|
wantOnStderr: "Prune failed",
|
|
},
|
|
{
|
|
name: "snapshot remove",
|
|
args: []string{cmdSnapshot, cmdRemove, someSnapshotID, flagJSON},
|
|
wantOnStderr: "Failed to remove snapshot",
|
|
},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
configPath := writeUnusableDestinationConfig(t)
|
|
|
|
code, stdout, stderr := runEntry(t,
|
|
append([]string{flagConfig, configPath}, testCase.args...)...)
|
|
|
|
assert.Equal(t, 1, code)
|
|
assert.Empty(t, stdout,
|
|
"a failed --json command has no document to write")
|
|
assert.Contains(t, stderr, testCase.wantOnStderr,
|
|
"the failure must be reported on stderr")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEntrySnapshotRemoveJSONWarningIsOnStderr runs `snapshot remove
|
|
// --json` on a snapshot in the local index, against a destination
|
|
// directory that does not exist. The command removes the snapshot from
|
|
// the local index and still exits 0. Its stdout must hold the document
|
|
// alone, with the warning about the destination store, and the command
|
|
// to run again once it is reachable, on stderr.
|
|
//
|
|
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
|
func TestEntrySnapshotRemoveJSONWarningIsOnStderr(t *testing.T) {
|
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
|
seedStaleSnapshotRecord(t, indexPath)
|
|
|
|
code, stdout, stderr := runEntry(t, flagConfig, configPath,
|
|
cmdSnapshot, cmdRemove, stalePruneSnapshotID, flagJSON)
|
|
|
|
require.Equal(t, 0, code)
|
|
requireExactlyOneJSONDocument(t, stdout)
|
|
assert.Contains(t, stderr,
|
|
"Could not remove snapshot metadata from remote storage")
|
|
assert.Contains(t, stderr,
|
|
"run 'vaultik snapshot remove "+stalePruneSnapshotID+"' again")
|
|
}
|
|
|
|
// writeMissingDestinationConfig builds a config whose destination
|
|
// directory does not exist. Returns the config path and the path of
|
|
// its local index, which is not created here.
|
|
func writeMissingDestinationConfig(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yml")
|
|
indexPath := filepath.Join(dir, "index.sqlite")
|
|
|
|
contents := fmt.Sprintf(hermeticConfig,
|
|
filepath.Join(dir, "source"),
|
|
filepath.Join(dir, "missing-store"),
|
|
indexPath)
|
|
|
|
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)
|
|
|
|
return configPath, indexPath
|
|
}
|
|
|
|
// writeUnusableDestinationConfig builds a config whose destination
|
|
// directory does not exist, which fails `remote info`, and whose local
|
|
// index is bound to another destination, which fails `prune` and
|
|
// `snapshot remove` (a missing destination alone only makes `snapshot
|
|
// remove` warn). Returns the config path.
|
|
func writeUnusableDestinationConfig(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
configPath, indexPath := writeMissingDestinationConfig(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
db, err := database.New(ctx, indexPath)
|
|
require.NoError(t, err)
|
|
|
|
defer func() { require.NoError(t, db.Close()) }()
|
|
|
|
require.NoError(t, database.NewRepositories(db).LocalMeta.Set(ctx,
|
|
database.LocalMetaKeyStorageURL, "file://"+t.TempDir()))
|
|
|
|
return configPath
|
|
}
|
|
|
|
// runEntry runs Entry with args after the program name and returns its
|
|
// exit code and what it wrote to stdout and stderr.
|
|
//
|
|
// Not parallel-safe: it replaces os.Args, os.Stdout and os.Stderr.
|
|
func runEntry(t *testing.T, args ...string) (int, string, string) {
|
|
t.Helper()
|
|
|
|
previousArgs := os.Args
|
|
|
|
t.Cleanup(func() {
|
|
os.Args = previousArgs
|
|
rootFlags = RootFlags{}
|
|
})
|
|
|
|
os.Args = append([]string{programName}, args...)
|
|
|
|
var code int
|
|
|
|
stdout, stderr := captureProcessStdoutAndStderr(t,
|
|
func() { code = Entry() })
|
|
|
|
return code, stdout, stderr
|
|
}
|