From c94c59a043ffedd6251940df22251ad6d3ea1bbf Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Wed, 15 Jul 2026 11:06:42 -0700 Subject: [PATCH] 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 --- cmd/incredigo/main.go | 18 +- cmd/incredigo/main_test.go | 328 +++++++++++++++++++++++++++++++++++++ internal/discover/file.go | 6 + 3 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 cmd/incredigo/main_test.go diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index 96f4ab0..ecc8f76 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -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 diff --git a/cmd/incredigo/main_test.go b/cmd/incredigo/main_test.go new file mode 100644 index 0000000..3602838 --- /dev/null +++ b/cmd/incredigo/main_test.go @@ -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 --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 +} diff --git a/internal/discover/file.go b/internal/discover/file.go index c79c0a5..924b078 100644 --- a/internal/discover/file.go +++ b/internal/discover/file.go @@ -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