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:
leetcrypt
2026-07-15 11:06:42 -07:00
parent 826d804bd9
commit c94c59a043
3 changed files with 347 additions and 5 deletions
+13 -5
View File
@@ -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
+328
View File
@@ -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
}
+6
View File
@@ -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