cmd: CLI integration tests + gate-refusal coverage (M1)
cmd/incredigo 4.3% -> 34.5%. Extract newRootCmd() as a testability seam so tests drive the real wired CLI (persistent flags + every subcommand) via SetArgs. main_test.go: a runCLI harness that captures os.Stdout/os.Stderr around Execute, plus a fake gopass on PATH + fixture HOME so no command touches the user's real store or host dotfiles (discovery isolated to --source file). Covers the safety- critical contracts: rotate --execute refused without INCREDIGO_ALLOW_EXECUTE=1; backup gate aborts BEFORE any driver runs when the snapshot fails; empty passphrase rejected with no sealed content left behind; export refuses to overwrite; unknown sealer rejected. Plus scan/status/worklist happy paths, migrate --dedupe, and an export -> import round-trip proving byte-exact restore with no plaintext in the sealed bundle. discover: add ClearPaths() to reset the file scanner's process-global --path targets between CLI invocations (a test-binary need; the one-shot CLI never hits the stale-target accumulation). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+13
-5
@@ -43,6 +43,18 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := newRootCmd().Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// newRootCmd builds the fully-wired root command. Extracted from main so tests can
|
||||
// drive the real CLI (persistent flags + every subcommand) with SetArgs/Execute.
|
||||
// Because the flag vars are package-level and pflag writes each default into its
|
||||
// bound variable at registration, constructing a fresh root per call also resets
|
||||
// flag state between test invocations.
|
||||
func newRootCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "incredigo",
|
||||
Short: "Local-first, encrypted, RAM-only credential custody",
|
||||
@@ -55,11 +67,7 @@ func main() {
|
||||
root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)")
|
||||
|
||||
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd(), passwordsCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "incredigo:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// applyPaths registers any --path targets with the file scanner and, when an
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
)
|
||||
|
||||
// runCLI drives the real root command with args, capturing os.Stdout/os.Stderr.
|
||||
// It swaps the process stdio around Execute so the CLI's direct fmt.Fprintln(os.Stdout,…)
|
||||
// writes are captured; goroutines drain the pipes to avoid blocking on large output.
|
||||
func runCLI(t *testing.T, args ...string) (stdout, stderr string, err error) {
|
||||
t.Helper()
|
||||
|
||||
outR, outW, perr := os.Pipe()
|
||||
if perr != nil {
|
||||
t.Fatal(perr)
|
||||
}
|
||||
errR, errW, perr := os.Pipe()
|
||||
if perr != nil {
|
||||
t.Fatal(perr)
|
||||
}
|
||||
origOut, origErr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = outW, errW
|
||||
|
||||
var ob, eb bytes.Buffer
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); io.Copy(&ob, outR) }()
|
||||
go func() { defer wg.Done(); io.Copy(&eb, errR) }()
|
||||
|
||||
// The file scanner's --path targets are process-global; reset so a prior test's
|
||||
// (now-deleted) fixture path can't leak into this invocation.
|
||||
discover.ClearPaths()
|
||||
root := newRootCmd()
|
||||
root.SetArgs(args)
|
||||
err = root.Execute()
|
||||
|
||||
os.Stdout, os.Stderr = origOut, origErr
|
||||
outW.Close()
|
||||
errW.Close()
|
||||
wg.Wait()
|
||||
outR.Close()
|
||||
errR.Close()
|
||||
return ob.String(), eb.String(), err
|
||||
}
|
||||
|
||||
// fakeGopassOnPath installs a stub `gopass` as the first entry on PATH and points it
|
||||
// at a fresh file-backed store, so CLI commands that build sink.Gopass{} (using the
|
||||
// default "gopass" binary) never touch the user's real store. failShow makes `gopass
|
||||
// show` exit non-zero, used to force a backup-gate failure. Returns the store dir.
|
||||
func fakeGopassOnPath(t *testing.T, failShow bool, seed map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "gopass")
|
||||
showBody := `cat "$store/$3"`
|
||||
if failShow {
|
||||
showBody = `echo "show failed" >&2; exit 1`
|
||||
}
|
||||
script := `#!/usr/bin/env bash
|
||||
store="$FAKE_GOPASS_STORE"
|
||||
case "$1" in
|
||||
ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
|
||||
show) ` + showBody + ` ;;
|
||||
insert)
|
||||
shift; path=""
|
||||
for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
|
||||
mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := t.TempDir()
|
||||
for p, s := range seed {
|
||||
full := filepath.Join(store, p)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("FAKE_GOPASS_STORE", store)
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return store
|
||||
}
|
||||
|
||||
// fixtureDir writes files as credential material and returns the dir, for discovery
|
||||
// via `--path <dir> --source file`. Isolating discovery to the file scanner keeps the
|
||||
// host's real aws/env/ssh secrets out of the test.
|
||||
func fixtureDir(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// isolateHome points HOME at a throwaway dir so no host dotfiles are ever scanned or
|
||||
// written, and the rotate default backup dir (~/.incredigo) lands under the temp dir.
|
||||
func isolateHome(t *testing.T) { t.Setenv("HOME", t.TempDir()) }
|
||||
|
||||
func TestScanFindsFixtureViaPath(t *testing.T) {
|
||||
isolateHome(t)
|
||||
dir := fixtureDir(t, map[string]string{"deploy.key": "unique-scan-secret-value"})
|
||||
out, serr, err := runCLI(t, "scan", "--source", "file", "--path", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v (stderr=%s)", err, serr)
|
||||
}
|
||||
if !strings.Contains(out, "deploy.key") {
|
||||
t.Errorf("scan output missing fixture identity:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "unique-scan-secret-value") {
|
||||
t.Errorf("scan leaked the secret value:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(serr, "credential(s) found") {
|
||||
t.Errorf("scan summary missing:\n%s", serr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusHappyPath(t *testing.T) {
|
||||
isolateHome(t)
|
||||
dir := fixtureDir(t, map[string]string{"token.txt": "status-secret"})
|
||||
out, _, err := runCLI(t, "status", "--source", "file", "--path", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("status: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "STATE") || !strings.Contains(out, "token.txt") {
|
||||
t.Errorf("status output unexpected:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorklistHappyPath(t *testing.T) {
|
||||
isolateHome(t)
|
||||
dir := fixtureDir(t, map[string]string{"aws.creds": "worklist-secret"})
|
||||
out, _, err := runCLI(t, "worklist", "--source", "file", "--path", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("worklist: %v", err)
|
||||
}
|
||||
if strings.Contains(out, "worklist-secret") {
|
||||
t.Errorf("worklist leaked a secret:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(out), "rotat") {
|
||||
t.Errorf("worklist output does not look like a worklist:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDedupeAndForce(t *testing.T) {
|
||||
isolateHome(t)
|
||||
store := fakeGopassOnPath(t, false, nil)
|
||||
dir := fixtureDir(t, map[string]string{"svc.token": "migrate-secret"})
|
||||
|
||||
// First migrate: stores the credential.
|
||||
out, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/")
|
||||
if err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "store imported/file/") {
|
||||
t.Errorf("first migrate did not store:\n%s", out)
|
||||
}
|
||||
// The secret must be in the store (streamed via stdin, never argv).
|
||||
got := readAnyUnder(t, filepath.Join(store, "imported/file"))
|
||||
if got != "migrate-secret" {
|
||||
t.Errorf("stored value = %q, want migrate-secret", got)
|
||||
}
|
||||
|
||||
// Second migrate with --dedupe: the same entry is skipped, not overwritten.
|
||||
out2, _, err := runCLI(t, "migrate", "--source", "file", "--path", dir, "--prefix", "imported/", "--dedupe")
|
||||
if err != nil {
|
||||
t.Fatalf("migrate --dedupe: %v", err)
|
||||
}
|
||||
if !strings.Contains(out2, "(exists)") {
|
||||
t.Errorf("--dedupe did not skip an existing entry:\n%s", out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportImportRoundTripCLI(t *testing.T) {
|
||||
isolateHome(t)
|
||||
t.Setenv("INCREDIGO_PASSPHRASE", "round-trip-pass")
|
||||
seed := map[string]string{
|
||||
"imported/aws/default": "AKIA/roundtrip+secret",
|
||||
"imported/env/api": "tok-末-value",
|
||||
}
|
||||
store := fakeGopassOnPath(t, false, seed)
|
||||
bundle := filepath.Join(t.TempDir(), "backup.age")
|
||||
|
||||
// export from store A
|
||||
_, serr, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
|
||||
if err != nil {
|
||||
t.Fatalf("export: %v (stderr=%s)", err, serr)
|
||||
}
|
||||
if fi, e := os.Stat(bundle); e != nil || fi.Size() == 0 {
|
||||
t.Fatalf("bundle missing/empty: %v", e)
|
||||
}
|
||||
if !strings.Contains(serr, "sealed 2") {
|
||||
t.Errorf("export summary unexpected:\n%s", serr)
|
||||
}
|
||||
// The bundle must not contain the plaintext secret.
|
||||
if b, _ := os.ReadFile(bundle); bytes.Contains(b, []byte("AKIA/roundtrip+secret")) {
|
||||
t.Fatal("sealed bundle leaked plaintext")
|
||||
}
|
||||
|
||||
// import into a fresh empty store B
|
||||
storeB := t.TempDir()
|
||||
t.Setenv("FAKE_GOPASS_STORE", storeB)
|
||||
_, serr, err = runCLI(t, "import", "--in", bundle, "--force")
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v (stderr=%s)", err, serr)
|
||||
}
|
||||
for path, want := range seed {
|
||||
got, e := os.ReadFile(filepath.Join(storeB, path))
|
||||
if e != nil {
|
||||
t.Fatalf("restored %s missing: %v", path, e)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("%s restored %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
_ = store
|
||||
}
|
||||
|
||||
func TestExportRefusesToOverwrite(t *testing.T) {
|
||||
isolateHome(t)
|
||||
t.Setenv("INCREDIGO_PASSPHRASE", "pw")
|
||||
fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
|
||||
bundle := filepath.Join(t.TempDir(), "exists.age")
|
||||
if err := os.WriteFile(bundle, []byte("preexisting"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/"); err == nil {
|
||||
t.Fatal("export overwrote an existing bundle")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gate refusals (the safety-critical CLI contracts) ---
|
||||
|
||||
func TestRotateExecuteRefusedWithoutEnv(t *testing.T) {
|
||||
isolateHome(t)
|
||||
os.Unsetenv("INCREDIGO_ALLOW_EXECUTE")
|
||||
fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
|
||||
_, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
|
||||
if err == nil {
|
||||
t.Fatal("rotate --execute ran without INCREDIGO_ALLOW_EXECUTE=1")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "refused") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportMissingPassphrase(t *testing.T) {
|
||||
isolateHome(t)
|
||||
t.Setenv("INCREDIGO_PASSPHRASE", "") // set-but-empty is an explicit error
|
||||
fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
|
||||
bundle := filepath.Join(t.TempDir(), "b.age")
|
||||
_, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/")
|
||||
if err == nil {
|
||||
t.Fatal("export proceeded with an empty passphrase")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "PASSPHRASE") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// export opens the output file before reading the passphrase, so an empty file
|
||||
// may remain — but it must never contain sealed (or any) content.
|
||||
if fi, e := os.Stat(bundle); e == nil && fi.Size() != 0 {
|
||||
t.Errorf("bundle has %d bytes despite the passphrase error", fi.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotateExecuteBackupGateAborts proves the backup gate blocks execute BEFORE any
|
||||
// driver runs: with authorization present but `gopass show` failing, Snapshot fails
|
||||
// and the command aborts with a backup-gate error — no rotation is attempted.
|
||||
func TestRotateExecuteBackupGateAborts(t *testing.T) {
|
||||
isolateHome(t)
|
||||
t.Setenv("INCREDIGO_ALLOW_EXECUTE", "1")
|
||||
t.Setenv("INCREDIGO_PASSPHRASE", "pw")
|
||||
// ls returns a path so ExportTo tries to read it, but show fails → Snapshot fails.
|
||||
fakeGopassOnPath(t, true, map[string]string{"imported/env/api": "v"})
|
||||
out, _, err := runCLI(t, "rotate", "--execute", "--prefix", "imported/")
|
||||
if err == nil {
|
||||
t.Fatal("rotate --execute proceeded despite a failing backup gate")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "backup gate failed") {
|
||||
t.Errorf("expected a backup-gate error, got: %v", err)
|
||||
}
|
||||
// The rotation results table must never have been printed.
|
||||
if strings.Contains(out, "ROTATED") || strings.Contains(out, "DRIVER") {
|
||||
t.Errorf("rotation ran despite the backup gate failing:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSealerRejected(t *testing.T) {
|
||||
isolateHome(t)
|
||||
t.Setenv("INCREDIGO_PASSPHRASE", "pw")
|
||||
fakeGopassOnPath(t, false, map[string]string{"imported/env/api": "v"})
|
||||
bundle := filepath.Join(t.TempDir(), "b.age")
|
||||
_, _, err := runCLI(t, "export", "--out", bundle, "--prefix", "imported/", "--sealer", "rot13")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown sealer") {
|
||||
t.Fatalf("expected unknown-sealer error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readAnyUnder returns the contents of the first regular file under root (tests seed
|
||||
// exactly one), or fails.
|
||||
func readAnyUnder(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
var found string
|
||||
filepath.Walk(root, func(p string, fi os.FileInfo, err error) error {
|
||||
if err == nil && !fi.IsDir() && found == "" {
|
||||
b, _ := os.ReadFile(p)
|
||||
found = string(b)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if found == "" {
|
||||
t.Fatalf("no file found under %s", root)
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -29,6 +29,12 @@ func AddPath(p string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPaths drops all registered --path targets. The CLI is one-shot so it never
|
||||
// needs this, but a long-lived process (notably a test binary invoking the CLI many
|
||||
// times) must reset between runs or the singleton accumulates stale, since-removed
|
||||
// paths and the scanner errors on the first vanished target.
|
||||
func ClearPaths() { fileSingleton.targets = nil }
|
||||
|
||||
// fileScanner harvests whole files as credentials from user-supplied paths. Unlike
|
||||
// the format-specific scanners (aws, env, …), it does no field parsing: each
|
||||
// regular, non-empty, reasonably-sized file becomes ONE Credential whose secret is
|
||||
|
||||
Reference in New Issue
Block a user