diff --git a/.github/workflows/backmerge.yml b/.github/workflows/backmerge.yml index c3fd831..56e11c1 100644 --- a/.github/workflows/backmerge.yml +++ b/.github/workflows/backmerge.yml @@ -34,6 +34,8 @@ jobs: if: steps.check.outputs.needs_pr == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_NAME: ${{ github.workflow }} + RUN_ID: ${{ github.run_id }} run: | MAIN_SHA="$(git rev-parse --short origin/main)" MAIN_SUBJECT="$(git log -1 --format='%s' origin/main)" @@ -44,16 +46,21 @@ jobs: exit 0 fi - gh pr create \ - --base develop \ - --head main \ - --title "chore: back-merge main into develop" \ - --body "Automated back-merge of \`main\` into \`develop\`. + BODY_FILE="$(mktemp)" + cat > "$BODY_FILE" < Note on GPG: the merge commit GitHub creates when you click "Merge pull request" is signed by \`web-flow\`. The locksmith \`make verify\` GPG gate already ignores web-flow merge commits, so this back-merge will not break verification on \`develop\`. + EOF -> **Note on GPG**: the merge commit GitHub creates when you click \"Merge pull request\" is signed by \`web-flow\`. The locksmith \`make verify\` GPG gate already ignores web-flow merge commits, so this back-merge will not break verification on \`develop\`." + gh pr create \ + --base develop \ + --head main \ + --title "chore: back-merge main into develop" \ + --body-file "$BODY_FILE" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c22c915..7575a6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,16 +23,10 @@ jobs: with: go-version-file: go.mod check-latest: false - - - name: Cache Go modules - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', 'go.work', 'go.work.sum') }} - restore-keys: | - ${{ runner.os }}-go- + cache-dependency-path: | + **/go.sum + go.work + go.work.sum - name: Initialize tools and protobuf run: make init @@ -53,16 +47,10 @@ jobs: with: go-version-file: go.mod check-latest: false - - - name: Cache Go modules - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum', 'go.work', 'go.work.sum') }} - restore-keys: | - ${{ runner.os }}-go- + cache-dependency-path: | + **/go.sum + go.work + go.work.sum - name: Initialize tools and protobuf run: make init diff --git a/AGENTS.md b/AGENTS.md index 0f80a13..a50bb35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,16 @@ Invoke these via the agent's skill mechanism. Each skill's or `gh pr create` themselves - they print the commands for the human maintainer to run. +## Superpowers + CodeGraph + +When invoking any superpowers skill (brainstorming, writing-plans, +executing-plans, subagent-driven-development, debugging, +code-review, etc.), use `codegraph_*` tools for code navigation - +not grep or ad-hoc file reads. CodeGraph is faster and returns +structural information (callers, callees, blast radius) that grep +cannot. Dispatched sub-agents inherit this rule. See the CodeGraph +section below for the tool table. + ## CodeGraph diff --git a/CLAUDE.md b/CLAUDE.md index 6e8de7c..9a761d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,13 @@ merging them, and creating the release tag. - Specs: save to `docs/superpowers/specs/YYYY-MM-DD--design.md` - Both directories are gitignored and MUST NOT be committed to git. Never use `git add -f` on these files. They are local working artifacts only. +- **Use CodeGraph for code navigation during superpowers workflows.** + Brainstorming, writing-plans, executing-plans, debugging, and code-review + skills all benefit from `codegraph_*` tools (see the CodeGraph section + above). Prefer `codegraph_search` / `codegraph_context` / + `codegraph_impact` over grep when scoping work, locating call sites, or + estimating blast radius. Sub-agents dispatched from these skills inherit + the same rule. ## Final Verification (mandatory last step in every superpowers plan) diff --git a/internal/bundled/bundle_test.go b/internal/bundled/bundle_test.go index 766c599..243de31 100644 --- a/internal/bundled/bundle_test.go +++ b/internal/bundled/bundle_test.go @@ -92,6 +92,35 @@ func TestOpenFromBytes_NoManifest(t *testing.T) { } } +// TestOpenBundle_Wrapper checks that the OpenBundle wrapper either returns a +// usable bundle or the documented ErrEmptyBundle on builds without populated +// per-platform bytes. Any other error is a regression. +func TestOpenBundle_Wrapper(t *testing.T) { + b, err := OpenBundle() + if err != nil { + if !errors.Is(err, ErrEmptyBundle) { + t.Fatalf("OpenBundle() error = %v, want ErrEmptyBundle or nil", err) + } + return + } + if b == nil { + t.Fatal("OpenBundle() = nil, nil; want non-nil bundle") + } +} + +// TestOpenFromBytes_BadManifestJSON exercises the JSON-decode error branch +// when manifest.json is present but contains invalid JSON. +func TestOpenFromBytes_BadManifestJSON(t *testing.T) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, _ := zw.Create("manifest.json") + w.Write([]byte("{not json")) + zw.Close() + if _, err := openFromBytes(buf.Bytes()); err == nil { + t.Fatal("expected error for invalid manifest JSON") + } +} + func TestFindEntry(t *testing.T) { mf := Manifest{Entries: []Entry{ {Name: "a", Kind: KindPlugin}, diff --git a/internal/bundled/extract_test.go b/internal/bundled/extract_test.go index c076c7b..583a6fa 100644 --- a/internal/bundled/extract_test.go +++ b/internal/bundled/extract_test.go @@ -7,6 +7,8 @@ import ( "errors" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -214,6 +216,222 @@ func TestExtract_PinentryKind(t *testing.T) { } } +// TestExtract_OnExtracted_Called covers the OnExtracted callback branch. +func TestExtract_OnExtracted_Called(t *testing.T) { + dir := t.TempDir() + content := []byte("xyz") + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)}, + }} + data := makeZip(t, mf, map[string][]byte{"p": content}) + b, _ := openFromBytes(data) + var extracted []string + if err := Extract(b, ExtractOptions{ + Names: []string{"p"}, PluginsDir: dir, + OnExtracted: func(name string) { extracted = append(extracted, name) }, + }); err != nil { + t.Fatalf("Extract: %v", err) + } + if len(extracted) != 1 || extracted[0] != "p" { + t.Errorf("OnExtracted not called for p; got %v", extracted) + } +} + +// TestExtract_OnKept_SilentSkip covers the OnKept(false) branch when an +// existing file already matches the bundle's SHA. +func TestExtract_OnKept_SilentSkip(t *testing.T) { + dir := t.TempDir() + content := []byte("same") + if err := os.WriteFile(filepath.Join(dir, "p"), content, 0o755); err != nil { + t.Fatal(err) + } + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)}, + }} + data := makeZip(t, mf, map[string][]byte{"p": content}) + b, _ := openFromBytes(data) + var keptName string + var keptWarn bool + if err := Extract(b, ExtractOptions{ + Names: []string{"p"}, PluginsDir: dir, + OnKept: func(name string, withWarning bool) { + keptName = name + keptWarn = withWarning + }, + }); err != nil { + t.Fatalf("Extract: %v", err) + } + if keptName != "p" || keptWarn { + t.Errorf("OnKept(name=%q, warn=%v), want (p, false)", keptName, keptWarn) + } +} + +// TestFileSHA256_OpenFails exercises the open-error branch when the path is a +// directory (open succeeds on most systems but io.Copy of a directory fails). +// On macOS/Linux opening a directory returns no error but reads fail. +func TestFileSHA256_DirectoryPath(t *testing.T) { + dir := t.TempDir() + _, _, err := FileSHA256(dir) + if err == nil { + t.Skip("opening a directory does not error on this OS; cannot drive hashing error") + } +} + +// TestShortSHA covers both branches: longer than ShortSHALen and shorter. +func TestShortSHA(t *testing.T) { + long := "abcdef0123456789" + if got := ShortSHA(long); got != "abcdef01" { + t.Errorf("ShortSHA(long) = %q, want abcdef01", got) + } + short := "ab" + if got := ShortSHA(short); got != "ab" { + t.Errorf("ShortSHA(short) = %q, want ab", got) + } + exact := "abcdef01" + if got := ShortSHA(exact); got != exact { + t.Errorf("ShortSHA(exact) = %q, want %q", got, exact) + } +} + +// TestDestPathFor_UnknownKind covers the default branch of destPathFor via +// Extract: an entry with an unknown kind returns an error. +func TestExtract_UnknownKind(t *testing.T) { + dir := t.TempDir() + mf := Manifest{Entries: []Entry{ + {Name: "weird", Kind: EntryKind("weird"), SHA256: sha256Hex([]byte("x"))}, + }} + data := makeZip(t, mf, map[string][]byte{"weird": []byte("x")}) + b, _ := openFromBytes(data) + err := Extract(b, ExtractOptions{Names: []string{"weird"}, PluginsDir: dir}) + if err == nil { + t.Fatal("expected error for unknown kind") + } + if !strings.Contains(err.Error(), "unknown entry kind") { + t.Errorf("error = %v, want 'unknown entry kind'", err) + } +} + +// TestExtract_EntryNotInBundle exercises the "bundle has no entry" branch. +func TestExtract_EntryNotInBundle(t *testing.T) { + dir := t.TempDir() + mf := Manifest{Entries: []Entry{ + {Name: "real", Kind: KindPlugin, SHA256: sha256Hex([]byte("x"))}, + }} + data := makeZip(t, mf, map[string][]byte{"real": []byte("x")}) + b, _ := openFromBytes(data) + err := Extract(b, ExtractOptions{Names: []string{"absent"}, PluginsDir: dir}) + if err == nil { + t.Fatal("expected error for missing entry name") + } +} + +// TestExtract_FileSHA256_OpenError exercises FileSHA256's open-error branch via +// Extract: when the destination directory is unreadable, Extract surfaces the +// error. Using a path that contains a non-directory component triggers it. +func TestExtract_DestIsDirectory(t *testing.T) { + // Set PluginsDir to a path whose parent is an existing file (cannot mkdir). + tmp := t.TempDir() + notDir := filepath.Join(tmp, "blocker") + if err := os.WriteFile(notDir, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex([]byte("xyz"))}, + }} + data := makeZip(t, mf, map[string][]byte{"p": []byte("xyz")}) + b, _ := openFromBytes(data) + err := Extract(b, ExtractOptions{ + Names: []string{"p"}, + PluginsDir: filepath.Join(notDir, "subdir"), // mkdir will fail: parent is a file + }) + if err == nil { + t.Fatal("expected error when PluginsDir cannot be created") + } +} + +// TestBundle_Open_Missing covers the "entry not found in bundle" branch of +// (*Bundle).Open. +func TestBundle_Open_Missing(t *testing.T) { + mf := Manifest{Entries: []Entry{{Name: "a", Kind: KindPlugin, SHA256: sha256Hex([]byte("x"))}}} + data := makeZip(t, mf, map[string][]byte{"a": []byte("x")}) + b, _ := openFromBytes(data) + if _, err := b.Open("nonexistent"); err == nil { + t.Fatal("expected error for missing entry") + } +} + +// TestOpenFromBytes_BadZip covers the zip.NewReader error branch. +func TestOpenFromBytes_BadZip(t *testing.T) { + if _, err := openFromBytes([]byte("not-a-zip")); err == nil { + t.Fatal("expected error for non-zip bytes") + } +} + +// TestExtract_DestIsExistingDirectory exercises the writeEntry rename-failure +// branch by making the destination path an existing directory. +func TestExtract_DestIsExistingDirectory(t *testing.T) { + dir := t.TempDir() + // Pre-create a directory at the location where the plugin file would + // land. Rename of tmp to a directory location should fail. + if err := os.MkdirAll(filepath.Join(dir, "p"), 0o755); err != nil { + t.Fatal(err) + } + // Also create a child inside so the directory is non-empty (rename + // would otherwise succeed on some systems when target dir is empty). + if err := os.WriteFile(filepath.Join(dir, "p", "child"), []byte("c"), 0o600); err != nil { + t.Fatal(err) + } + content := []byte("xx") + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)}, + }} + data := makeZip(t, mf, map[string][]byte{"p": content}) + b, _ := openFromBytes(data) + err := Extract(b, ExtractOptions{ + Names: []string{"p"}, + PluginsDir: dir, + // File-exists branch with !ForceOverwrite triggers prompter; nil + // prompter defaults to Keep, but the file SHA differs (it's + // actually a directory entry SHA that won't match) so the path + // proceeds. Use ForceOverwrite to skip prompting. + ForceOverwrite: true, + }) + if err == nil { + t.Skipf("OS allowed rename over non-empty directory (%s); test inconclusive", runtime.GOOS) + } +} + +// TestExtract_DestParentReadOnly exercises the writeEntry OpenFile error +// branch: we make the plugin directory read-only so OpenFile of the tmp file +// fails with EACCES. +func TestExtract_DestParentReadOnly(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions; skip on root") + } + dir := t.TempDir() + pluginsDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + t.Fatal(err) + } + content := []byte("xx") + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex(content)}, + }} + data := makeZip(t, mf, map[string][]byte{"p": content}) + b, _ := openFromBytes(data) + // Make the destination directory read-only. mkdirAll on the same + // directory in writeEntry will succeed (MkdirAll is a no-op for an + // existing dir), but OpenFile of the .tmp file will fail. + if err := os.Chmod(pluginsDir, 0o555); err != nil { + t.Fatal(err) + } + defer os.Chmod(pluginsDir, 0o755) + err := Extract(b, ExtractOptions{Names: []string{"p"}, PluginsDir: pluginsDir}) + if err == nil { + t.Fatal("expected error writing into a read-only directory") + } +} + func TestExtract_BundleSHAFails(t *testing.T) { // Force a sha256 mismatch between manifest and content. dir := t.TempDir() diff --git a/internal/bundled/paths_test.go b/internal/bundled/paths_test.go index 6ff7b0f..4ae4eae 100644 --- a/internal/bundled/paths_test.go +++ b/internal/bundled/paths_test.go @@ -2,6 +2,8 @@ package bundled import ( + "errors" + "os" "path/filepath" "testing" ) @@ -30,6 +32,41 @@ func TestBinDir(t *testing.T) { } } +// TestPathsHomeError exercises the UserHomeDir error branch in PluginsDir, +// BinDir, and PinentryPath by unsetting HOME (POSIX) so UserHomeDir fails. +func TestPathsHomeError(t *testing.T) { + // On macOS/Linux UserHomeDir falls back to /etc/passwd; clearing HOME may + // not actually return an error. We tolerate either outcome. + t.Setenv("HOME", "") + // Call all three to exercise their UserHomeDir branches. + _, errP := PluginsDir() + _, errB := BinDir() + _, errPE := PinentryPath() + if errP == nil && errB == nil && errPE == nil { + t.Skip("UserHomeDir did not fail on empty HOME; cannot drive error branch") + } +} + +// TestExtract_ResolveConflict_PrompterError covers the prompter-returns-error +// branch of resolveConflict (via Extract). +func TestExtract_ResolveConflict_PrompterError(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "p"), []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + newContent := []byte("new") + mf := Manifest{Entries: []Entry{ + {Name: "p", Kind: KindPlugin, SHA256: sha256Hex(newContent)}, + }} + data := makeZip(t, mf, map[string][]byte{"p": newContent}) + b, _ := openFromBytes(data) + prompter := &fakePrompter{err: errors.New("user aborted")} + err := Extract(b, ExtractOptions{Names: []string{"p"}, PluginsDir: dir, Prompter: prompter}) + if err == nil { + t.Fatal("expected error from prompter") + } +} + func TestPinentryPath(t *testing.T) { t.Setenv("HOME", "/fake/home") got, err := PinentryPath() diff --git a/internal/cli/cli_error_paths_test.go b/internal/cli/cli_error_paths_test.go new file mode 100644 index 0000000..c5b3460 --- /dev/null +++ b/internal/cli/cli_error_paths_test.go @@ -0,0 +1,438 @@ +package cli_test + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + locksmithv1 "github.com/lorem-dev/locksmith/gen/proto/locksmith/v1" + "github.com/lorem-dev/locksmith/internal/cli" +) + +// TestPrintError_PlainError covers the PrintError code path for a non-gRPC +// error. We replace os.Stderr with a pipe so we can capture and assert on the +// produced output. +func TestPrintError_PlainError(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stderr + os.Stderr = w + defer func() { os.Stderr = old }() + + t.Setenv("NO_COLOR", "1") + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + cli.PrintError(errors.New("boom")) + _ = w.Close() + <-done + if !strings.Contains(buf.String(), "boom") { + t.Errorf("stderr = %q, want to contain 'boom'", buf.String()) + } +} + +// TestPrintError_GRPCWithHint covers PrintError's hint branch. +func TestPrintError_GRPCWithHint(t *testing.T) { + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stderr + os.Stderr = w + defer func() { os.Stderr = old }() + + t.Setenv("NO_COLOR", "1") + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, r) + close(done) + }() + cli.PrintError(status.Error(codes.NotFound, "missing key")) + _ = w.Close() + <-done + out := buf.String() + if !strings.Contains(out, "missing key") { + t.Errorf("stderr missing message: %q", out) + } + if !strings.Contains(out, "Hint:") { + t.Errorf("stderr missing hint: %q", out) + } +} + +// TestRestart_DaemonNotRunning_AutoStart covers the no-running + !noStart +// branch where daemon.Start is invoked. +func TestRestart_DaemonNotRunning_AutoStart(t *testing.T) { + dir := t.TempDir() + sock := filepath.Join(dir, "ls.sock") + t.Setenv("LOCKSMITH_SOCKET", sock) + t.Setenv("HOME", dir) + + var out bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"restart"}) + // In this hermetic env locksmith binary cannot be located, so Start + // returns an error. The point is that the branch is exercised. + if err := root.Execute(); err == nil { + t.Fatal("expected error since locksmith binary is not on PATH in test env") + } +} + +// TestConfigPinentry_Auto exercises newConfigPinentryCmd's RunE via --auto so +// it does not block on prompts. The outcome depends on whether +// locksmith-pinentry is available in the test environment; the assertion only +// requires that the RunE wrapper completes without panicking. +func TestConfigPinentry_Auto(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + root := cli.NewRootCmd() + root.SetArgs([]string{"config", "pinentry", "--auto", "--no-tui"}) + _ = root.Execute() +} + +// TestVaultList_DaemonError exercises the err-from-VaultList branch via the +// mock daemon returning an error. +func TestVaultList_DaemonErrorMessage(t *testing.T) { + srv := &mockServer{ + vaultListErr: status.Error(codes.Unavailable, "vault is unavailable"), + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + + err := runWithSocket(t, socketPath, "vault", "list") + if err == nil { + t.Fatal("expected error from vault list") + } + if !strings.Contains(err.Error(), "listing vaults") { + t.Errorf("error = %q, want wrap 'listing vaults'", err.Error()) + } +} + +// TestSessionList_WithMultipleSessions exercises the "non-empty sessions" +// marshal branch in newSessionListCmd. +func TestSessionList_WithMultipleSessions(t *testing.T) { + srv := &mockServer{ + sessionListResp: &locksmithv1.SessionListResponse{ + Sessions: []*locksmithv1.SessionInfo{ + {SessionId: "a", ExpiresAt: "2099-01-01T00:00:00Z"}, + {SessionId: "b", ExpiresAt: "2099-01-02T00:00:00Z"}, + }, + }, + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + if err := runWithSocket(t, socketPath, "session", "list"); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +// TestSessionEnsure_ReusesEnvSession covers the reuse-existing-session branch +// in ensureSession (via `session ensure`). +func TestSessionEnsure_ReusesEnvSession(t *testing.T) { + srv := &mockServer{ + sessionListResp: &locksmithv1.SessionListResponse{ + Sessions: []*locksmithv1.SessionInfo{ + {SessionId: "keep-me", ExpiresAt: "2099-01-01T00:00:00Z"}, + }, + }, + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "keep-me") + + var stdout, stderr bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"session", "ensure"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(stdout.String(), "keep-me") { + t.Errorf("stdout = %q, want to contain reused session id", stdout.String()) + } +} + +// TestSessionEnsure_Quiet covers the --quiet success path in +// newSessionEnsureCmd. +func TestSessionEnsure_Quiet(t *testing.T) { + srv := &mockServer{ + sessionStartResp: &locksmithv1.SessionStartResponse{ + SessionId: "sess_quiet", + ExpiresAt: "2099-01-01T00:00:00Z", + }, + } + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + var stdout bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetArgs([]string{"session", "ensure", "--quiet"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(stdout.String(), "sess_quiet") { + t.Errorf("stdout = %q, want sess_quiet", stdout.String()) + } +} + +// TestReload_DefaultSocket exercises dialDaemon's fallback path when +// LOCKSMITH_SOCKET is empty. The default socket does not exist in this +// hermetic HOME, so the gRPC call must fail. +func TestReload_DefaultSocket(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("LOCKSMITH_SOCKET", "") + root := cli.NewRootCmd() + root.SetArgs([]string{"reload"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error since default socket is absent") + } +} + +// TestPluginsUpdate_DryRun_EmptyBundle covers the dryRun branch of +// runPluginsUpdate. Either succeeds (bundle present, diff printed) or returns +// an error (empty bundle); both branches reach the dryRun code path. +func TestPluginsUpdate_DryRun_EmptyBundle(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "locksmith") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), + []byte("vaults:\n v:\n type: gopass\n"), 0o600)) + root := cli.NewRootCmd() + root.SetArgs([]string{"plugins", "update", "--dry-run"}) + _ = root.Execute() +} + +// TestPluginsUpdate_ConfigMissing covers the "loading config" error branch. +func TestPluginsUpdate_ConfigMissing(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + // Do NOT create a config file. + root := cli.NewRootCmd() + root.SetArgs([]string{"plugins", "update"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error when config missing") + } + if !strings.Contains(err.Error(), "loading config") { + t.Errorf("error = %q, want 'loading config'", err.Error()) + } +} + +// TestMCPRun_FromConfig_URLEntry exercises runFromConfig's URL branch via a +// real config file. RunProxy then attempts to connect to an unreachable URL +// and the call returns an error, but the runFromConfig URL branch is covered. +func TestMCPRun_FromConfig_URLEntry(t *testing.T) { + srv := &mockServer{} + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "locksmith") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + cfg := `mcp: + servers: + remote: + url: "http://127.0.0.1:1" + transport: sse + headers: + X-Test: "static" +` + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), + []byte(cfg), 0o600)) + + root := cli.NewRootCmd() + root.SetIn(strings.NewReader("")) + root.SetArgs([]string{"mcp", "run", "--server", "remote"}) + // runFromConfig URL branch is exercised regardless of whether the + // downstream proxy connect succeeds or fails. + _ = root.Execute() +} + +// TestMCPRun_FromConfig_CommandEntry exercises runFromConfig's local-command +// branch. mcp.Run returns nil when stdin closes before any non-empty line, so +// we close stdin immediately. +func TestMCPRun_FromConfig_CommandEntry(t *testing.T) { + srv := &mockServer{} + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "locksmith") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + cfg := `mcp: + servers: + local: + command: ["true"] +` + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), + []byte(cfg), 0o600)) + + root := cli.NewRootCmd() + root.SetIn(strings.NewReader("")) + root.SetArgs([]string{"mcp", "run", "--server", "local"}) + _ = root.Execute() +} + +// TestMCPRun_LocalMode_EmptyStdin exercises runLocalMode with a command. With +// an empty stdin, mcp.Run returns nil immediately. +func TestMCPRun_LocalMode_EmptyStdin(t *testing.T) { + srv := &mockServer{} + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + + root := cli.NewRootCmd() + root.SetIn(strings.NewReader("")) + root.SetArgs([]string{"mcp", "run", "--", "true"}) + _ = root.Execute() +} + +// TestServe_ValidConfig exercises newServeCmd RunE further - log writer setup +// and daemon initialisation - by feeding it a complete-but-failing config +// (invalid log file path so NewLogWriter returns error). +func TestServe_BadLogFile(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cfg := `logging: + level: info + format: text + file: /nonexistent/dir/locksmith.log +` + cfgPath := filepath.Join(home, "config.yaml") + require.NoError(t, os.WriteFile(cfgPath, []byte(cfg), 0o600)) + root := cli.NewRootCmd() + root.SetArgs([]string{"--config", cfgPath, "serve"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for bad log file") + } +} + +// TestServe_InvalidYAML covers the config.Load failure branch in serve. +func TestServe_InvalidYAML(t *testing.T) { + home := t.TempDir() + cfgPath := filepath.Join(home, "config.yaml") + require.NoError(t, os.WriteFile(cfgPath, []byte(":\n bad: ["), 0o600)) + root := cli.NewRootCmd() + root.SetArgs([]string{"--config", cfgPath, "serve"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for malformed YAML") + } +} + +// TestMCPRun_HeaderEmptyName: --header with no value before '=' is rejected +// by parseHeaderArgs (covers the idx < 1 branch). +func TestMCPRun_HeaderEmptyName(t *testing.T) { + root := cli.NewRootCmd() + root.SetArgs([]string{"mcp", "run", "--url", "http://127.0.0.1:1", "--header", "=value"}) + err := root.Execute() + if err == nil { + t.Fatal("expected error for empty header name") + } +} + +// TestMCPRun_FromConfig_UnknownServer: cfg file exists but server not defined. +// Hits LoadServerConfig's error path inside runFromConfig. +func TestMCPRun_FromConfig_UnknownServer(t *testing.T) { + srv := &mockServer{} + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "") + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "locksmith") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), + []byte("mcp:\n servers:\n other:\n command: [\"true\"]\n"), 0o600)) + + root := cli.NewRootCmd() + root.SetArgs([]string{"mcp", "run", "--server", "missing"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error for unknown server") + } +} + +// Tests for dialDaemon-failure RunE branches across multiple commands. +// Each command must return a non-nil error when the daemon socket is absent. + +func TestVaultList_NoDaemon(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", filepath.Join(t.TempDir(), "absent.sock")) + root := cli.NewRootCmd() + root.SetArgs([]string{"vault", "list"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error when daemon socket is absent") + } +} + +func TestVaultHealth_NoDaemon(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", filepath.Join(t.TempDir(), "absent.sock")) + root := cli.NewRootCmd() + root.SetArgs([]string{"vault", "health"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error when daemon socket is absent") + } +} + +func TestSessionStart_NoDaemon(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", filepath.Join(t.TempDir(), "absent.sock")) + root := cli.NewRootCmd() + root.SetArgs([]string{"session", "start"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error when daemon socket is absent") + } +} + +func TestSessionEnd_NoDaemon(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", filepath.Join(t.TempDir(), "absent.sock")) + t.Setenv("LOCKSMITH_SESSION", "abc") + root := cli.NewRootCmd() + root.SetArgs([]string{"session", "end"}) + if err := root.Execute(); err == nil { + t.Fatal("expected error when daemon socket is absent") + } +} + +// TestVersionCmd_DirectExecution sanity-checks the version subcommand reaches +// its RunE through the root command tree. +func TestVersionCmd_ViaRoot(t *testing.T) { + var out bytes.Buffer + root := cli.NewRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"version"}) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if out.Len() == 0 { + t.Error("version produced no output") + } +} diff --git a/internal/cli/mcp_cmd_test.go b/internal/cli/mcp_cmd_test.go index 9916c88..2334bb1 100644 --- a/internal/cli/mcp_cmd_test.go +++ b/internal/cli/mcp_cmd_test.go @@ -1,14 +1,25 @@ package cli_test import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + locksmithv1 "github.com/lorem-dev/locksmith/gen/proto/locksmith/v1" "github.com/lorem-dev/locksmith/internal/cli" ) +func newSessionListWithID(id string) *locksmithv1.SessionListResponse { + return &locksmithv1.SessionListResponse{ + Sessions: []*locksmithv1.SessionInfo{ + {SessionId: id, ExpiresAt: "2099-01-01T00:00:00Z"}, + }, + } +} + func TestMCPRun_MutualExclusivity_URLandDash(t *testing.T) { root := cli.NewRootCmd() root.SetArgs([]string{"mcp", "run", "--url", "https://example.com", "--", "npx", "-y", "foo"}) @@ -48,3 +59,78 @@ func TestMCPRun_InvalidHeaderArg(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "expected Name=template") } + +// TestMCPRun_InvalidEnv_NoEquals exercises parseEnvArgs failure path: when an +// --env value is provided that does not even contain '='. +func TestMCPRun_InvalidEnv_NoEquals(t *testing.T) { + root := cli.NewRootCmd() + root.SetArgs([]string{"mcp", "run", "--env", "=junk", "--", "sh", "-c", "true"}) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "expected VAR=ref") +} + +// TestMCPRun_InvalidEnvRef exercises mcp.ParseRef failure inside parseEnvArgs. +func TestMCPRun_InvalidEnvRef(t *testing.T) { + root := cli.NewRootCmd() + // "{}" is an invalid extended ref form (mcp.ParseRef rejects malformed braces). + root.SetArgs([]string{"mcp", "run", "--env", "FOO={", "--", "sh", "-c", "true"}) + err := root.Execute() + require.Error(t, err) +} + +// TestMCPRun_DaemonDown exercises the dialDaemon failure path within runMCPRun. +// dialDaemon fails when the resolved socket is unreachable. +func TestMCPRun_DaemonDown(t *testing.T) { + t.Setenv("LOCKSMITH_SOCKET", "/tmp/locksmith-mcp-absent.sock") + t.Setenv("LOCKSMITH_SESSION", "") + root := cli.NewRootCmd() + root.SetArgs([]string{"mcp", "run", "--url", "https://example.com"}) + err := root.Execute() + // The error must surface from dialDaemon/ensureMCPSession; either path + // returns non-nil. + require.Error(t, err) +} + +// TestMCPRun_ServerNotInConfig exercises runFromConfig: when --server points to +// an unknown name, mcp.LoadServerConfig returns an error and it is surfaced. +func TestMCPRun_ServerNotInConfig(t *testing.T) { + srv := &mockServer{} + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + + home := t.TempDir() + t.Setenv("HOME", home) + cfgDir := filepath.Join(home, ".config", "locksmith") + require.NoError(t, os.MkdirAll(cfgDir, 0o755)) + // Valid YAML with no mcp.servers entries. + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "config.yaml"), + []byte("vaults: {}\n"), 0o600)) + + root := cli.NewRootCmd() + root.SetArgs([]string{"mcp", "run", "--server", "nonexistent"}) + err := root.Execute() + require.Error(t, err) +} + +// TestMCPRun_ReusesEnvSession exercises ensureMCPSession's "session is valid" +// branch: when LOCKSMITH_SESSION matches an existing session reported by +// SessionList, ensureMCPSession returns that ID without calling SessionStart. +// The proxy then fails (unreachable URL) but we only care about reaching that +// far - the goal is coverage of ensureMCPSession's reuse path. +func TestMCPRun_ReusesEnvSession_ProxyAttempt(t *testing.T) { + srv := &mockServer{} + // SessionList returns one session whose ID matches the env value. + srv.sessionListResp = newSessionListWithID("existing-session-id") + socketPath, cleanup := startMockDaemon(t, srv) + defer cleanup() + t.Setenv("LOCKSMITH_SOCKET", socketPath) + t.Setenv("LOCKSMITH_SESSION", "existing-session-id") + + root := cli.NewRootCmd() + // URL is unroutable so RunProxy will fail; that's fine - we just need + // coverage of the "session matched" branch of ensureMCPSession. + root.SetArgs([]string{"mcp", "run", "--url", "http://127.0.0.1:1"}) + _ = root.Execute() +} diff --git a/internal/cli/plugins_cmd_test.go b/internal/cli/plugins_cmd_test.go index 319d721..bb9ec31 100644 --- a/internal/cli/plugins_cmd_test.go +++ b/internal/cli/plugins_cmd_test.go @@ -59,6 +59,132 @@ func TestPluginsUpdate_EmptyBundle(t *testing.T) { } } +// TestCliPromptOrNil_Force returns nil when force=true. +func TestCliPromptOrNil_Force(t *testing.T) { + if got := cliPromptOrNil(true); got != nil { + t.Errorf("cliPromptOrNil(true) = %v, want nil", got) + } +} + +// TestCliPromptOrNil_NotForce returns a non-nil prompter when force=false. +func TestCliPromptOrNil_NotForce(t *testing.T) { + if got := cliPromptOrNil(false); got == nil { + t.Error("cliPromptOrNil(false) = nil, want non-nil prompter") + } +} + +// TestCliPrompter_AllAnswers exercises each branch of BundleExtractPrompt +// by feeding the answer through stdin. +func TestCliPrompter_AllAnswers(t *testing.T) { + cases := []struct { + input string + want bundled.ConflictResolution + }{ + {"y\n", bundled.Overwrite}, + {"n\n", bundled.Keep}, + {"a\n", bundled.OverwriteAll}, + {"s\n", bundled.KeepAll}, + {"q\n", bundled.Keep}, // default branch + } + p := cliPrompter{} + for _, tc := range cases { + t.Run(strings.TrimSpace(tc.input), func(t *testing.T) { + // Replace stdin with the canned input. + origStdin := os.Stdin + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + if _, werr := w.WriteString(tc.input); werr != nil { + t.Fatal(werr) + } + _ = w.Close() + os.Stdin = r + // Silence the prompt printout. + devnull, _ := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + os.Stdout = devnull + defer func() { + os.Stdin = origStdin + os.Stdout = origStdout + _ = devnull.Close() + }() + + got, err := p.BundleExtractPrompt("plugin", "abcd1234", "efef5678") + if err != nil { + t.Fatalf("BundleExtractPrompt: %v", err) + } + if got != tc.want { + t.Errorf("answer %q: got %v, want %v", tc.input, got, tc.want) + } + }) + } +} + +// TestDiffAction_Missing covers the missing branch of diffAction. +func TestDiffAction_Missing(t *testing.T) { + action, err := diffAction(filepath.Join(t.TempDir(), "nonexistent"), "abc") + if err != nil { + t.Fatalf("diffAction: %v", err) + } + if action != "missing" { + t.Errorf("action = %q, want missing", action) + } +} + +// TestDiffAction_Match covers the match branch. +func TestDiffAction_Match(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + gotSHA, _, err := bundled.FileSHA256(path) + if err != nil { + t.Fatal(err) + } + action, err := diffAction(path, gotSHA) + if err != nil { + t.Fatalf("diffAction: %v", err) + } + if action != "match" { + t.Errorf("action = %q, want match", action) + } +} + +// TestDiffAction_Differ covers the differ branch. +func TestDiffAction_Differ(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x") + if err := os.WriteFile(path, []byte("hello"), 0o600); err != nil { + t.Fatal(err) + } + action, err := diffAction(path, "0000000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Fatalf("diffAction: %v", err) + } + if action != "differ" { + t.Errorf("action = %q, want differ", action) + } +} + +// TestReadSHA_MatchesFileSHA256 is a wrapper sanity check. +func TestReadSHA_MatchesFileSHA256(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x") + if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil { + t.Fatal(err) + } + a, exists, err := readSHA(path) + if err != nil || !exists { + t.Fatalf("readSHA(%s) = (%q, %v, %v)", path, a, exists, err) + } + b, _, _ := bundled.FileSHA256(path) + if a != b { + t.Errorf("readSHA = %q, FileSHA256 = %q", a, b) + } +} + // captureStdout returns whatever was written to os.Stdout during fn. func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/daemon/lifecycle_test.go b/internal/daemon/lifecycle_test.go index 2fcad1c..938881f 100644 --- a/internal/daemon/lifecycle_test.go +++ b/internal/daemon/lifecycle_test.go @@ -52,9 +52,9 @@ func TestStop_KillsListenerAndWaits(t *testing.T) { t.Fatalf("start helper: %v", err) } t.Cleanup(func() { _ = cmd.Process.Kill() }) - waitForFile(t, socketPath, 2*time.Second) + waitForFile(t, socketPath, 10*time.Second) - if err := daemon.Stop(socketPath, 2*time.Second); err != nil { + if err := daemon.Stop(socketPath, 10*time.Second); err != nil { t.Fatalf("Stop: %v", err) } if daemon.IsRunning(socketPath) { diff --git a/internal/initflow/agents/claude/claude_test.go b/internal/initflow/agents/claude/claude_test.go index f8e4767..3568395 100644 --- a/internal/initflow/agents/claude/claude_test.go +++ b/internal/initflow/agents/claude/claude_test.go @@ -58,3 +58,54 @@ func TestReadTemplateForTest(t *testing.T) { } } } + +func TestReadTemplateForTest_MissingReturnsError(t *testing.T) { + if _, err := claude.ReadTemplateForTest("templates/does-not-exist.tmpl"); err == nil { + t.Fatal("expected error for missing template, got nil") + } +} + +func TestInstall_MkdirAllFails(t *testing.T) { + home := t.TempDir() + // Make configDir a path whose parent is a regular file - MkdirAll + // on skillDir will fail because it cannot mkdir under a file. + blocker := filepath.Join(home, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("seed blocker: %v", err) + } + configDir := filepath.Join(blocker, "claude") + if err := claude.Install(home, configDir); err == nil { + t.Fatal("expected MkdirAll error, got nil") + } +} + +func TestInstall_SkillWriteFails(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".claude") + skillsDir := filepath.Join(configDir, "skills") + if err := os.MkdirAll(skillsDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Pre-create locksmith.md as a directory so os.WriteFile fails. + if err := os.Mkdir(filepath.Join(skillsDir, "locksmith.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := claude.Install(home, configDir); err == nil { + t.Fatal("expected write error, got nil") + } +} + +func TestInstall_ClaudeMdUpsertFails(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".claude") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Pre-create CLAUDE.md as a directory so marker.Upsert's WriteFile fails. + if err := os.Mkdir(filepath.Join(configDir, "CLAUDE.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := claude.Install(home, configDir); err == nil { + t.Fatal("expected upsert error, got nil") + } +} diff --git a/internal/initflow/agents/codex/codex_test.go b/internal/initflow/agents/codex/codex_test.go index 780eea2..93eb45c 100644 --- a/internal/initflow/agents/codex/codex_test.go +++ b/internal/initflow/agents/codex/codex_test.go @@ -49,3 +49,35 @@ func TestReadTemplateForTest(t *testing.T) { t.Error("template is empty") } } + +func TestReadTemplateForTest_MissingReturnsError(t *testing.T) { + if _, err := codex.ReadTemplateForTest("templates/missing.tmpl"); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestInstall_MkdirAllFails(t *testing.T) { + home := t.TempDir() + blocker := filepath.Join(home, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("seed blocker: %v", err) + } + configDir := filepath.Join(blocker, "codex") + if err := codex.Install(home, configDir); err == nil { + t.Fatal("expected MkdirAll error, got nil") + } +} + +func TestInstall_UpsertFails(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".codex") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Mkdir(filepath.Join(configDir, "AGENTS.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := codex.Install(home, configDir); err == nil { + t.Fatal("expected upsert error, got nil") + } +} diff --git a/internal/initflow/agents/gemini/gemini_test.go b/internal/initflow/agents/gemini/gemini_test.go index 990d190..9082df4 100644 --- a/internal/initflow/agents/gemini/gemini_test.go +++ b/internal/initflow/agents/gemini/gemini_test.go @@ -49,3 +49,35 @@ func TestReadTemplateForTest(t *testing.T) { t.Error("template is empty") } } + +func TestReadTemplateForTest_MissingReturnsError(t *testing.T) { + if _, err := gemini.ReadTemplateForTest("templates/missing.tmpl"); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestInstall_MkdirAllFails(t *testing.T) { + home := t.TempDir() + blocker := filepath.Join(home, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("seed blocker: %v", err) + } + configDir := filepath.Join(blocker, "gemini") + if err := gemini.Install(home, configDir); err == nil { + t.Fatal("expected MkdirAll error, got nil") + } +} + +func TestInstall_UpsertFails(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".gemini") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Mkdir(filepath.Join(configDir, "GEMINI.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := gemini.Install(home, configDir); err == nil { + t.Fatal("expected upsert error, got nil") + } +} diff --git a/internal/initflow/agents/generic/generic_test.go b/internal/initflow/agents/generic/generic_test.go index be0cb82..f9a1201 100644 --- a/internal/initflow/agents/generic/generic_test.go +++ b/internal/initflow/agents/generic/generic_test.go @@ -55,3 +55,36 @@ func TestReadTemplateForTest(t *testing.T) { t.Error("template is empty") } } + +func TestReadTemplateForTest_MissingReturnsError(t *testing.T) { + if _, err := generic.ReadTemplateForTest("templates/missing.tmpl"); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestInstall_MkdirAllFails(t *testing.T) { + parent := t.TempDir() + // Make homeDir itself a regular file so MkdirAll under it fails. + home := filepath.Join(parent, "fakehome") + if err := os.WriteFile(home, []byte("x"), 0o644); err != nil { + t.Fatalf("seed file: %v", err) + } + if err := generic.Install(home); err == nil { + t.Fatal("expected MkdirAll error, got nil") + } +} + +func TestInstall_WriteFails(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".config", "locksmith") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Pre-create agent-instructions.md as a directory so WriteFile fails. + if err := os.Mkdir(filepath.Join(dir, "agent-instructions.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := generic.Install(home); err == nil { + t.Fatal("expected write error, got nil") + } +} diff --git a/internal/initflow/agents/marker/marker_test.go b/internal/initflow/agents/marker/marker_test.go index 6d7db01..dd93e90 100644 --- a/internal/initflow/agents/marker/marker_test.go +++ b/internal/initflow/agents/marker/marker_test.go @@ -61,3 +61,39 @@ func TestUpsert_CreatesAbsentFile(t *testing.T) { t.Error("file was not created") } } + +// TestUpsert_AppendNoTrailingNewline exercises the sep="\n\n" branch where +// the existing file does not end with a newline. +func TestUpsert_AppendNoTrailingNewline(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "F.md") + _ = os.WriteFile(p, []byte("existing without newline"), 0o644) + block := marker.Start + "\n## locksmith\n" + marker.End + if err := marker.Upsert(p, block); err != nil { + t.Fatalf("Upsert: %v", err) + } + got, _ := os.ReadFile(p) + if !strings.Contains(string(got), "existing without newline\n\n"+marker.Start) { + t.Errorf("expected double-newline separator, got %q", string(got)) + } +} + +// TestUpsert_ReadError exercises the read-error (not ErrNotExist) branch +// by pointing the path at a directory. +func TestUpsert_ReadError(t *testing.T) { + dir := t.TempDir() + // Path is a directory, so ReadFile returns a non-ErrNotExist error. + if err := marker.Upsert(dir, marker.Start+"\n"+marker.End); err == nil { + t.Fatal("expected error reading directory as file") + } +} + +// TestUpsert_WriteError exercises the os.WriteFile error branch by +// targeting a path under a non-existent parent directory. +func TestUpsert_WriteError(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "nope", "child.md") + if err := marker.Upsert(p, marker.Start+"\n"+marker.End); err == nil { + t.Fatal("expected write error for missing parent dir") + } +} diff --git a/internal/initflow/agents/opencode/opencode_test.go b/internal/initflow/agents/opencode/opencode_test.go index ded1c8c..2492bb5 100644 --- a/internal/initflow/agents/opencode/opencode_test.go +++ b/internal/initflow/agents/opencode/opencode_test.go @@ -49,3 +49,35 @@ func TestReadTemplateForTest(t *testing.T) { t.Error("template is empty") } } + +func TestReadTemplateForTest_MissingReturnsError(t *testing.T) { + if _, err := opencode.ReadTemplateForTest("templates/missing.tmpl"); err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestInstall_MkdirAllFails(t *testing.T) { + home := t.TempDir() + blocker := filepath.Join(home, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("seed blocker: %v", err) + } + configDir := filepath.Join(blocker, "opencode") + if err := opencode.Install(home, configDir); err == nil { + t.Fatal("expected MkdirAll error, got nil") + } +} + +func TestInstall_UpsertFails(t *testing.T) { + home := t.TempDir() + configDir := filepath.Join(home, ".config", "opencode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.Mkdir(filepath.Join(configDir, "instructions.md"), 0o755); err != nil { + t.Fatalf("seed dir: %v", err) + } + if err := opencode.Install(home, configDir); err == nil { + t.Fatal("expected upsert error, got nil") + } +} diff --git a/internal/initflow/flow_test.go b/internal/initflow/flow_test.go index 5fa5c37..dd2d309 100644 --- a/internal/initflow/flow_test.go +++ b/internal/initflow/flow_test.go @@ -1023,6 +1023,194 @@ func TestApplyCodexHook_AlreadyPresent_Skips(t *testing.T) { } } +// TestRunInit_Interactive_CodexHookConfirmed exercises the Codex CodexHook prompt +// branch and the applyCodexHook installer path. +func TestRunInit_Interactive_CodexHookConfirmed(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0o755) + + mp := &mockPrompter{ + configDir: filepath.Join(home, ".config", "locksmith"), + vaults: []string{}, + agents: []initflow.DetectedAgent{{Name: "Codex", Detected: true, ConfigDir: codexDir}}, + summaryConfirm: true, + codexHook: true, + } + result, err := initflow.RunInit(initflow.InitOptions{Prompter: mp}) + if err != nil { + t.Fatalf("RunInit() error: %v", err) + } + if !result.CodexHookConfirmed { + t.Error("expected CodexHookConfirmed = true") + } + if !result.CodexHookInstalled { + t.Error("expected CodexHookInstalled = true") + } +} + +// TestRunInit_Interactive_CodexHookDeclined exercises the path where the +// user declines the Codex hook prompt. +func TestRunInit_Interactive_CodexHookDeclined(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0o755) + + mp := &mockPrompter{ + configDir: filepath.Join(home, ".config", "locksmith"), + vaults: []string{}, + agents: []initflow.DetectedAgent{{Name: "Codex", Detected: true, ConfigDir: codexDir}}, + summaryConfirm: true, + codexHook: false, + } + result, err := initflow.RunInit(initflow.InitOptions{Prompter: mp}) + if err != nil { + t.Fatalf("RunInit() error: %v", err) + } + if result.CodexHookInstalled { + t.Error("CodexHookInstalled should be false when declined") + } +} + +// TestRunInit_Interactive_CodexHookError surfaces the prompter error. +func TestRunInit_Interactive_CodexHookError(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0o755) + wantErr := errors.New("codex hook prompt error") + + mp := &mockPrompter{ + configDir: filepath.Join(home, ".config", "locksmith"), + vaults: []string{}, + agents: []initflow.DetectedAgent{{Name: "Codex", Detected: true, ConfigDir: codexDir}}, + summaryConfirm: true, + codexHookErr: wantErr, + } + _, err := initflow.RunInit(initflow.InitOptions{Prompter: mp}) + if !errors.Is(err, wantErr) { + t.Errorf("RunInit() error = %v, want %v", err, wantErr) + } +} + +// TestRunInit_Interactive_CodexHookAlreadyPresent covers the +// "installer.IsInstalled() -> already present" branch. +func TestRunInit_Interactive_CodexHookAlreadyPresent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0o755) + // Pre-seed hooks.json with the canonical command. + existing := map[string]any{ + "hooks": map[string]any{ + "SessionStart": []any{ + map[string]any{ + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "locksmith session ensure --quiet >/dev/null 2>&1 || true", + }, + }, + }, + }, + }, + } + data, _ := json.Marshal(existing) + _ = os.WriteFile(filepath.Join(codexDir, "hooks.json"), data, 0o644) + + mp := &mockPrompter{ + configDir: filepath.Join(home, ".config", "locksmith"), + vaults: []string{}, + agents: []initflow.DetectedAgent{{Name: "Codex", Detected: true, ConfigDir: codexDir}}, + summaryConfirm: true, + codexHook: false, // never consulted + } + result, err := initflow.RunInit(initflow.InitOptions{Prompter: mp}) + if err != nil { + t.Fatalf("RunInit() error: %v", err) + } + if !result.CodexHookAlreadyPresent { + t.Error("expected CodexHookAlreadyPresent = true") + } + if result.CodexHookInstalled { + t.Error("CodexHookInstalled should be false when already present") + } +} + +// TestRunInit_Auto_CodexHook covers the auto-mode Codex hook confirmation branch. +func TestRunInit_Auto_CodexHook(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + stubGopassOnly(t) + codexDir := filepath.Join(home, ".codex") + os.MkdirAll(codexDir, 0o755) + + result, err := initflow.RunInit(initflow.InitOptions{Auto: true, AgentOnly: "codex"}) + if err != nil { + t.Fatalf("RunInit() error: %v", err) + } + // In auto mode with Codex selected, the hook should be confirmed. + if !result.CodexHookConfirmed && !result.CodexHookAlreadyPresent { + t.Error("expected CodexHookConfirmed or AlreadyPresent in auto mode") + } +} + +// TestAgentWriter_Install_ErrorOnUnwritableConfigDir exercises the wrap-error +// branch of Install when the agent ConfigDir cannot be written to. +func TestAgentWriter_Install_ErrorOnUnwritableConfigDir(t *testing.T) { + home := t.TempDir() + // Make a file where the agent expects a directory. + bogus := filepath.Join(home, "bogus") + if err := os.WriteFile(bogus, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + writer := initflow.NewAgentWriter(home) + cases := []struct { + name string + }{ + {"Claude Code"}, + {"Codex"}, + {"Gemini CLI"}, + {"OpenCode"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := writer.Install(initflow.DetectedAgent{ + Name: c.name, + Detected: true, + ConfigDir: filepath.Join(bogus, "child"), // parent is a file + }) + if err == nil { + t.Errorf("expected error for %s install with bogus ConfigDir", c.name) + } + }) + } +} + +// TestAgentWriter_Install_Generic_Error exercises the generic-agent error path. +func TestAgentWriter_Install_Generic_Error(t *testing.T) { + home := t.TempDir() + // Place a file where ~/.config/locksmith would live so the generic installer fails. + cfgRoot := filepath.Join(home, ".config") + if err := os.MkdirAll(cfgRoot, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Make ~/.config/locksmith a regular file so MkdirAll on it fails. + if err := os.WriteFile(filepath.Join(cfgRoot, "locksmith"), []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + writer := initflow.NewAgentWriter(home) + err := writer.Install(initflow.DetectedAgent{ + Name: "SomeRandomAgent", + Detected: true, + }) + if err == nil { + t.Error("expected error for generic install when ~/.config/locksmith is a file") + } +} + func TestApplyDaemonRestart_NoDaemon_Skips(t *testing.T) { socket := filepath.Join(t.TempDir(), "ls.sock") t.Setenv("LOCKSMITH_SOCKET", socket) diff --git a/internal/initflow/hooks/codex/codex_test.go b/internal/initflow/hooks/codex/codex_test.go index 21cd8d6..508e8ee 100644 --- a/internal/initflow/hooks/codex/codex_test.go +++ b/internal/initflow/hooks/codex/codex_test.go @@ -115,6 +115,53 @@ func TestIsInstalled_TrueAfterInstall(t *testing.T) { } } +// TestInstall_MkdirError exercises the MkdirAll error branch by +// pointing the installer at a path whose parent is a regular file. +func TestInstall_MkdirError(t *testing.T) { + tmp := t.TempDir() + parentFile := filepath.Join(tmp, "not-a-dir") + if err := os.WriteFile(parentFile, []byte("x"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + installer := codex.New(filepath.Join(parentFile, "child")) + if err := installer.Install(); err == nil { + t.Fatal("expected MkdirAll error") + } +} + +// TestIsInstalled_HooksKeyMissing covers findSessionStartCmd's +// "no hooks key" branch via a settings file lacking the hooks object. +func TestIsInstalled_HooksKeyMissing(t *testing.T) { + installer, home := makeInstaller(t) + hp := filepath.Join(home, ".codex", "hooks.json") + _ = os.WriteFile(hp, []byte(`{"unrelated":"value"}`), 0o644) + if installer.IsInstalled() { + t.Error("IsInstalled should be false when hooks key absent") + } +} + +// TestIsInstalled_SessionStartNotArray covers the "SessionStart present +// but not an array" branch. +func TestIsInstalled_SessionStartNotArray(t *testing.T) { + installer, home := makeInstaller(t) + hp := filepath.Join(home, ".codex", "hooks.json") + _ = os.WriteFile(hp, []byte(`{"hooks":{"SessionStart":"oops"}}`), 0o644) + if installer.IsInstalled() { + t.Error("IsInstalled should be false when SessionStart is not an array") + } +} + +// TestIsInstalled_EntryNotMap covers the "entry is not a map" continue +// branch and the inner "sub-hook is not a map" continue branch. +func TestIsInstalled_EntryNotMap(t *testing.T) { + installer, home := makeInstaller(t) + hp := filepath.Join(home, ".codex", "hooks.json") + _ = os.WriteFile(hp, []byte(`{"hooks":{"SessionStart":["not-a-map",{"hooks":["scalar",{"command":42}]}]}}`), 0o644) + if installer.IsInstalled() { + t.Error("IsInstalled should be false when no matching command present") + } +} + // helpers. func containsSessionStartCommand(settings map[string]any, cmd string) bool { hk, _ := settings["hooks"].(map[string]any) diff --git a/internal/initflow/hooks/settings_test.go b/internal/initflow/hooks/settings_test.go index e1ce073..56d89c5 100644 --- a/internal/initflow/hooks/settings_test.go +++ b/internal/initflow/hooks/settings_test.go @@ -78,3 +78,53 @@ func TestFindStringInAllow_NoPermissionsKey(t *testing.T) { t.Error("expected false") } } + +// TestFindStringInAllow_NoAllowKey covers the "permissions present but +// allow missing" branch. +func TestFindStringInAllow_NoAllowKey(t *testing.T) { + s := map[string]any{"permissions": map[string]any{}} + if hooks.FindStringInAllow(s, "Bash(x:*)") { + t.Error("expected false when allow missing") + } +} + +// TestReadSettings_ReadError exercises the non-ErrNotExist read error +// branch by pointing the path at a directory. +func TestReadSettings_ReadError(t *testing.T) { + if _, err := hooks.ReadSettings(t.TempDir()); err == nil { + t.Fatal("expected error reading directory as file") + } +} + +// TestReadSettings_NullJSON covers the explicit nil-map normalisation +// branch where the JSON parsed cleanly to nil. +func TestReadSettings_NullJSON(t *testing.T) { + p := filepath.Join(t.TempDir(), "null.json") + _ = os.WriteFile(p, []byte("null"), 0o644) + got, err := hooks.ReadSettings(p) + if err != nil { + t.Fatalf("ReadSettings: %v", err) + } + if got == nil || len(got) != 0 { + t.Errorf("expected empty map, got %v", got) + } +} + +// TestWriteSettings_WriteError exercises the write-error branch by +// targeting a path under a non-existent parent directory. +func TestWriteSettings_WriteError(t *testing.T) { + p := filepath.Join(t.TempDir(), "nope", "out.json") + if err := hooks.WriteSettings(p, map[string]any{"k": "v"}); err == nil { + t.Fatal("expected write error for missing parent dir") + } +} + +// TestWriteSettings_MarshalError exercises the marshalling-error branch +// by passing a value that json.MarshalIndent cannot encode. +func TestWriteSettings_MarshalError(t *testing.T) { + p := filepath.Join(t.TempDir(), "out.json") + // A function cannot be marshalled to JSON. + if err := hooks.WriteSettings(p, map[string]any{"bad": func() {}}); err == nil { + t.Fatal("expected marshalling error") + } +} diff --git a/internal/initflow/huh_prompter_test.go b/internal/initflow/huh_prompter_test.go index ba59bdf..d8db945 100644 --- a/internal/initflow/huh_prompter_test.go +++ b/internal/initflow/huh_prompter_test.go @@ -468,6 +468,48 @@ func TestHuhPrompter_VaultSelection_OnePasswordNotDetected(t *testing.T) { } } +func TestHuhPrompter_CodexHook_Yes(t *testing.T) { + p := newHuhWithInput("y\n") + got, err := p.CodexHook("/home/user/.codex/hooks.json") + if err != nil { + t.Fatalf("CodexHook() error: %v", err) + } + if !got { + t.Error("CodexHook() = false, want true") + } +} + +func TestHuhPrompter_CodexHook_No(t *testing.T) { + p := newHuhWithInput("n\n") + got, err := p.CodexHook("/home/user/.codex/hooks.json") + if err != nil { + t.Fatalf("CodexHook() error: %v", err) + } + if got { + t.Error("CodexHook() = true, want false") + } +} + +// TestHuhPrompter_VaultSelection_PlannedNoteNoPlatform exercises the +// plannedLabel branch where PlatformNote is empty. +func TestHuhPrompter_VaultSelection_PlannedNoteNoPlatform(t *testing.T) { + p := newHuhWithInput("0\n") + vaults := []initflow.DetectedVault{ + {Type: config.VaultGopass, Available: true, Detected: true, Implemented: true}, + // A planned vault without PlatformNote. + {Type: "future-vault", Available: true, Detected: false, Implemented: false}, + } + got, err := p.VaultSelection(vaults) + if err != nil { + t.Fatalf("VaultSelection() error: %v", err) + } + for _, v := range got { + if v == "future-vault" { + t.Errorf("VaultSelection() returned planned backend %q", v) + } + } +} + func TestHuhPrompter_VaultSelection_NoImplemented_Errors(t *testing.T) { p := newHuhWithInput("") vaults := []initflow.DetectedVault{ diff --git a/internal/shellhook/pathhook_test.go b/internal/shellhook/pathhook_test.go index 6c9fac2..5bc31f2 100644 --- a/internal/shellhook/pathhook_test.go +++ b/internal/shellhook/pathhook_test.go @@ -135,6 +135,51 @@ func TestInstallPath_AppendsPreservingContent(t *testing.T) { } } +// TestIsPathInstalled_ReadError exercises the read-error branch by pointing +// rcFile at a directory. +func TestIsPathInstalled_ReadError(t *testing.T) { + dir := t.TempDir() + _, err := shellhook.IsPathInstalled(dir) + if err == nil { + t.Skip("ReadFile on a directory did not error on this OS; cannot drive error branch") + } +} + +// TestInstallPath_OpenFileError exercises the OpenFile-error branch by making +// rcFile read-only. +func TestInstallPath_ReadOnlyRC(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses file permissions; skip on root") + } + rc := filepath.Join(t.TempDir(), ".bashrc") + if err := os.WriteFile(rc, []byte("# stuff\n"), 0o400); err != nil { + t.Fatal(err) + } + err := shellhook.InstallPath(rc, shellhook.ShellBash, "/opt/bin") + if err == nil { + t.Error("expected error opening read-only rc for append") + } +} + +// TestInstallPath_StatErrorNotNotExist exercises the stat-error-but-not- +// not-exist branch. We point rcFile through a non-readable parent directory +// (read-only parent denies stat to non-owners). +func TestInstallPath_StatPermissionError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses directory permissions; skip on root") + } + parent := filepath.Join(t.TempDir(), "denied") + if err := os.Mkdir(parent, 0o000); err != nil { + t.Fatal(err) + } + defer os.Chmod(parent, 0o755) + rc := filepath.Join(parent, "rc") + err := shellhook.InstallPath(rc, shellhook.ShellBash, "/opt/bin") + if err == nil { + t.Skip("OS did not return EACCES on stat through 0000 parent; cannot drive branch") + } +} + func TestInstallPath_Fish_WritesToFishRC(t *testing.T) { dir := filepath.Join(t.TempDir(), ".config", "fish") _ = os.MkdirAll(dir, 0o755) diff --git a/internal/shellhook/shellhook_test.go b/internal/shellhook/shellhook_test.go index 1b81c28..50acdd3 100644 --- a/internal/shellhook/shellhook_test.go +++ b/internal/shellhook/shellhook_test.go @@ -205,6 +205,38 @@ func TestInstall_CreatesFile(t *testing.T) { } } +// TestIsInstalled_ReadError exercises the read-error branch when rcFile is a +// directory (open succeeds, read fails on most POSIX systems). +func TestIsInstalled_ReadError(t *testing.T) { + dir := t.TempDir() + // rcFile points at a directory; os.ReadFile returns an "is a directory" error. + _, err := shellhook.IsInstalled(dir) + if err == nil { + t.Skip("ReadFile on a directory did not error on this OS; cannot drive error branch") + } +} + +// TestInstall_BadPath exercises the OpenFile-error branch by giving Install a +// path whose parent does not exist. +func TestInstall_OpenFileError(t *testing.T) { + bad := filepath.Join(t.TempDir(), "no-such-dir", "rc") + err := shellhook.Install(bad, shellhook.ShellBash) + if err == nil { + t.Fatal("expected error when rcFile parent does not exist") + } +} + +// TestRCFile_NoHome covers the UserHomeDir error branch when HOME is unset +// and UserHomeDir fails. Falls through gracefully when the OS doesn't error. +func TestRCFile_HomeError(t *testing.T) { + t.Setenv("HOME", "") + if _, ok := shellhook.RCFile(shellhook.ShellZsh); !ok { + // We hit the error branch (ok=false). + return + } + t.Skip("UserHomeDir did not fail on empty HOME; cannot drive error branch") +} + func TestInstall_Idempotent(t *testing.T) { // Install does NOT de-duplicate - this test documents that contract. // Callers must check IsInstalled first. diff --git a/plugins/gopass/provider_test.go b/plugins/gopass/provider_test.go index bbcfd73..91ed9b4 100644 --- a/plugins/gopass/provider_test.go +++ b/plugins/gopass/provider_test.go @@ -108,10 +108,14 @@ func TestGopassProvider_HealthCheck_Available_Mocked(t *testing.T) { } func TestGopassProvider_HealthCheck_Installed(t *testing.T) { - if _, err := exec.LookPath("gopass"); err != nil { - t.Skip("gopass not installed") + p := &GopassProvider{ + lookPath: func(string) (string, error) { + return "/usr/local/bin/gopass", nil + }, + runCmd: func(string, ...string) error { + return nil + }, } - p := &GopassProvider{} resp, err := p.HealthCheck(context.Background(), &vaultv1.HealthCheckRequest{}) if err != nil { t.Fatalf("HealthCheck() error: %v", err) @@ -124,10 +128,12 @@ func TestGopassProvider_HealthCheck_Installed(t *testing.T) { } func TestGopassProvider_GetSecret_InvalidPath(t *testing.T) { - if _, err := exec.LookPath("gopass"); err != nil { - t.Skip("gopass not installed") + p := &GopassProvider{ + cmdFactory: func(_ context.Context, name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", + `echo "entry is not in the password store" >&2; exit 1`) + }, } - p := &GopassProvider{} _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ Path: "locksmith-test/nonexistent-key-12345", }) @@ -137,12 +143,19 @@ func TestGopassProvider_GetSecret_InvalidPath(t *testing.T) { } func TestGopassProvider_GetSecret_WithStore(t *testing.T) { - if _, err := exec.LookPath("gopass"); err != nil { - t.Skip("gopass not installed") + // Verify the store prefix is applied: the cmdFactory captures the path + // arg and asserts it carries the "teststore/" prefix. + var gotPath string + p := &GopassProvider{ + cmdFactory: func(_ context.Context, name string, args ...string) *exec.Cmd { + // gopass show -o + if len(args) > 0 { + gotPath = args[len(args)-1] + } + return exec.Command("sh", "-c", + `echo "entry is not in the password store" >&2; exit 1`) + }, } - p := &GopassProvider{} - // This will fail because the path doesn't exist - we just want to verify - // the store prefix is applied (the error message should contain the store prefix) _, err := p.GetSecret(context.Background(), &vaultv1.GetSecretRequest{ Path: "nonexistent-key-12345", Opts: map[string]string{"store": "teststore"}, @@ -150,6 +163,9 @@ func TestGopassProvider_GetSecret_WithStore(t *testing.T) { if err == nil { t.Fatal("GetSecret() expected error for nonexistent key") } + if want := "teststore/nonexistent-key-12345"; gotPath != want { + t.Errorf("path passed to gopass = %q, want %q", gotPath, want) + } } func TestBuildGopassEnv_IncludesSetVars(t *testing.T) { @@ -244,6 +260,31 @@ func TestGopassProvider_GetSecret_GenericError_Mocked(t *testing.T) { } } +func TestGopassProvider_ResolversReturnDefaults(t *testing.T) { + p := &GopassProvider{} + if p.resolveLookPath() == nil { + t.Error("resolveLookPath() returned nil for zero value") + } + if p.resolveRunCmd() == nil { + t.Error("resolveRunCmd() returned nil for zero value") + } + if p.resolveCmdFactory() == nil { + t.Error("resolveCmdFactory() returned nil for zero value") + } + // Exercise the default runCmd closure: invoke it with a command that + // always fails so we don't depend on any external binary. We only care + // that the default factory builds and runs a *exec.Cmd at all. + defaultRun := p.resolveRunCmd() + _ = defaultRun("sh", "-c", "exit 0") + + // Exercise the default cmdFactory closure: build a cmd and run it. + defaultFactory := p.resolveCmdFactory() + cmd := defaultFactory(context.Background(), "sh", "-c", "exit 0") + if cmd == nil { + t.Error("default cmdFactory returned nil cmd") + } +} + func TestInfoCompatibility(t *testing.T) { p := &GopassProvider{} resp, err := p.Info(context.Background(), &vaultv1.InfoRequest{})