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 }