summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/cli_test.go436
1 files changed, 436 insertions, 0 deletions
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
new file mode 100644
index 0000000..250601c
--- /dev/null
+++ b/internal/cli/cli_test.go
@@ -0,0 +1,436 @@
+// Package cli (internal tests) exercise pure package-level helpers and
+// dispatch paths that do not require a full CLI setup (no cipher, no store,
+// no terminal).
+package cli
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/foostore/internal/clipboard"
+ "codeberg.org/snonux/foostore/internal/config"
+ "codeberg.org/snonux/foostore/internal/crypto"
+ "codeberg.org/snonux/foostore/internal/git"
+ "codeberg.org/snonux/foostore/internal/shell"
+ "codeberg.org/snonux/foostore/internal/store"
+)
+
+// testCLI creates a fully wired CLI backed by temporary directories and a real
+// cipher so that dispatch paths that touch the store can be exercised.
+// No git repo is initialised, so tests must not attempt commits.
+func testCLI(t *testing.T) (*CLI, *config.Config) {
+ t.Helper()
+
+ dataDir := t.TempDir()
+ exportDir := t.TempDir()
+ keyDir := t.TempDir()
+ keyFile := filepath.Join(keyDir, "key")
+ if err := os.WriteFile(keyFile, []byte("testkey1234567890"), 0o600); err != nil {
+ t.Fatalf("write key file: %v", err)
+ }
+
+ cfg := &config.Config{
+ DataDir: dataDir,
+ ExportDir: exportDir,
+ KeyFile: keyFile,
+ KeyLength: 32,
+ AddToIV: "Hello world",
+ }
+
+ ciph, err := crypto.NewCipher(keyFile, 32, "testpin", "Hello world")
+ if err != nil {
+ t.Fatalf("NewCipher: %v", err)
+ }
+
+ g := git.New(dataDir)
+ st, err := store.New(cfg, ciph, g)
+ if err != nil {
+ t.Fatalf("store.New: %v", err)
+ }
+
+ // No-op completion function; no readline instance needed for dispatch tests.
+ sh, err := shell.New(func(string) []string { return nil })
+ if err != nil {
+ t.Logf("shell.New: %v (non-TTY, skipping test)", err)
+ t.Skip("shell.New requires a TTY")
+ }
+ t.Cleanup(func() { sh.Close() })
+
+ return &CLI{
+ cfg: cfg,
+ st: st,
+ g: g,
+ clip: clipboard.New("", ""),
+ sh: sh,
+ cipher: ciph,
+ }, cfg
+}
+
+// captureStdout redirects os.Stdout to a pipe, calls fn, then returns what was
+// written and restores the original Stdout.
+func captureStdout(fn func()) string {
+ orig := os.Stdout
+ r, w, err := os.Pipe()
+ if err != nil {
+ return ""
+ }
+ os.Stdout = w
+ fn()
+ w.Close()
+ os.Stdout = orig
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+// captureStderr redirects os.Stderr to a pipe, calls fn, then returns what was
+// written and restores the original Stderr.
+func captureStderr(fn func()) string {
+ orig := os.Stderr
+ r, w, err := os.Pipe()
+ if err != nil {
+ return ""
+ }
+ os.Stderr = w
+ fn()
+ w.Close()
+ os.Stderr = orig
+ var buf bytes.Buffer
+ _, _ = io.Copy(&buf, r)
+ return buf.String()
+}
+
+// TestLogMsg verifies that logMsg writes "> <msg>\n" to stdout.
+func TestLogMsg(t *testing.T) {
+ got := captureStdout(func() { logMsg("hello world") })
+ want := "> hello world\n"
+ if got != want {
+ t.Errorf("logMsg output = %q; want %q", got, want)
+ }
+}
+
+// TestWarn verifies that warn writes "WARN <msg>\n" to stderr.
+func TestWarn(t *testing.T) {
+ got := captureStderr(func() { warn("something bad") })
+ want := "WARN something bad\n"
+ if got != want {
+ t.Errorf("warn output = %q; want %q", got, want)
+ }
+}
+
+// TestPrintHelp verifies that printHelp writes a non-empty help text to stdout
+// containing key command names.
+func TestPrintHelp(t *testing.T) {
+ got := captureStdout(func() { printHelp() })
+ for _, cmd := range []string{"ls", "cat", "add", "import", "sync", "version"} {
+ if !strings.Contains(got, cmd) {
+ t.Errorf("printHelp output missing %q; full output:\n%s", cmd, got)
+ }
+ }
+}
+
+// TestShredFileCli verifies that shredFile removes a temporary file.
+// It uses a temp file so no live data is affected.
+func TestShredFileCli(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "todelete.txt")
+ if err := os.WriteFile(target, []byte("sensitive"), 0o600); err != nil {
+ t.Fatalf("writing temp file: %v", err)
+ }
+
+ ctx := t.Context()
+ if err := shredFile(ctx, target); err != nil {
+ t.Fatalf("shredFile: %v", err)
+ }
+
+ if _, err := os.Stat(target); err == nil {
+ t.Errorf("file %q still exists after shredFile", target)
+ }
+}
+
+// ---- dispatch helpers -------------------------------------------------------
+// The following tests use a bare &CLI{} because the tested code paths in
+// dispatchSimple never dereference any struct fields — they only call package-
+// level helpers (logMsg, warn, printHelp) or read c.lastResult (zero value "").
+
+// TestDispatch_noopCommands runs the stateless dispatchSimple branches and
+// confirms they all return exit code 0.
+func TestDispatch_noopCommands(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+
+ for _, cmd := range []string{"version", "commands", "help", "shell", "exit", "last"} {
+ t.Run(cmd, func(t *testing.T) {
+ // Capture stdout/stderr to avoid noise in test output.
+ _ = captureStdout(func() {
+ _ = captureStderr(func() {
+ ec := c.dispatch(ctx, []string{cmd})
+ if ec != 0 {
+ t.Errorf("dispatch(%q) = %d; want 0", cmd, ec)
+ }
+ })
+ })
+ })
+ }
+}
+
+// TestDispatch_version_output confirms that dispatch("version") emits the
+// version string to stdout.
+func TestDispatch_version_output(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+ out := captureStdout(func() { c.dispatch(ctx, []string{"version"}) })
+ if !strings.Contains(out, "foostore") {
+ t.Errorf("version output %q does not contain 'foostore'", out)
+ }
+}
+
+// TestDispatch_commands_output confirms that dispatch("commands") lists all
+// commands to stdout, one per line.
+func TestDispatch_commands_output(t *testing.T) {
+ ctx := context.Background()
+ c := &CLI{}
+ out := captureStdout(func() { c.dispatch(ctx, []string{"commands"}) })
+ for _, cmd := range []string{"ls", "cat", "add", "sync", "import"} {
+ if !strings.Contains(out, cmd) {
+ t.Errorf("commands output missing %q; output:\n%s", cmd, out)
+ }
+ }
+}
+
+// ---- cmd* error paths -------------------------------------------------------
+// The error paths for missing arguments do not access any struct fields.
+
+// TestCmdAdd_missingArgs verifies cmdAdd returns exit code 1 when no
+// description argument is supplied.
+func TestCmdAdd_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdAdd(context.Background(), []string{"add"})
+ if ec != 1 {
+ t.Errorf("cmdAdd with no args = %d; want 1", ec)
+ }
+}
+
+// TestCmdImport_missingArgs verifies cmdImport returns exit code 1 when no
+// file argument is supplied.
+func TestCmdImport_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdImport(context.Background(), []string{"import"})
+ if ec != 1 {
+ t.Errorf("cmdImport with no args = %d; want 1", ec)
+ }
+}
+
+// TestCmdImportR_missingArgs verifies cmdImportR returns exit code 1 when no
+// directory argument is supplied.
+func TestCmdImportR_missingArgs(t *testing.T) {
+ c := &CLI{}
+ ec := c.cmdImportR(context.Background(), []string{"import_r"})
+ if ec != 1 {
+ t.Errorf("cmdImportR with no args = %d; want 1", ec)
+ }
+}
+
+// ---- store-backed dispatch tests --------------------------------------------
+// These tests use testCLI() which provides a real but empty store.
+
+// TestDispatch_ls_empty confirms that dispatch("ls") on an empty store
+// returns exit code 0 and prints "0 entries".
+func TestDispatch_ls_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ out := captureStdout(func() {
+ ec := c.dispatch(context.Background(), []string{"ls"})
+ if ec != 0 {
+ t.Errorf("dispatch(ls) = %d; want 0", ec)
+ }
+ })
+ if !strings.Contains(out, "0 entries") {
+ t.Errorf("ls output %q does not contain '0 entries'", out)
+ }
+}
+
+// TestDispatch_shred_empty confirms that dispatch("shred") on an empty export
+// dir returns exit code 0.
+func TestDispatch_shred_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"shred"})
+ if ec != 0 {
+ t.Errorf("dispatch(shred) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_emptyArgv confirms that dispatch with an empty argv on an empty
+// store returns exit code 0 (no fzf entries, immediate return).
+func TestDispatch_emptyArgv(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{})
+ if ec != 0 {
+ t.Errorf("dispatch([]) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_search_empty confirms that dispatch("search", "foo") returns
+// exit code 0 when there are no matching entries.
+func TestDispatch_search_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"search", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(search,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_cat_empty confirms that dispatch("cat", "foo") returns exit
+// code 0 when there are no matching entries.
+func TestDispatch_cat_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"cat", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(cat,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_rm_empty confirms that dispatch("rm", "foo") returns exit code
+// 0 when there are no matching entries (nothing to remove).
+func TestDispatch_rm_empty(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"rm", "foo"})
+ if ec != 0 {
+ t.Errorf("dispatch(rm,foo) = %d; want 0", ec)
+ }
+}
+
+// TestDispatch_unknownCommand confirms that an unrecognised command is treated
+// as a search term and returns exit code 0 on an empty store.
+func TestDispatch_unknownCommand(t *testing.T) {
+ c, _ := testCLI(t)
+ ec := c.dispatch(context.Background(), []string{"completelyunknown"})
+ if ec != 0 {
+ t.Errorf("dispatch(unknown) = %d; want 0", ec)
+ }
+}
+
+// ---- readPIN ----------------------------------------------------------------
+
+// TestReadPIN_envVar verifies that readPIN returns the $PIN value immediately
+// without touching the terminal.
+func TestReadPIN_envVar(t *testing.T) {
+ t.Setenv("PIN", "s3cret")
+ pin, err := readPIN()
+ if err != nil {
+ t.Fatalf("readPIN: %v", err)
+ }
+ if pin != "s3cret" {
+ t.Errorf("readPIN = %q; want %q", pin, "s3cret")
+ }
+}
+
+// ---- completionFn -----------------------------------------------------------
+
+// TestCompletionFn_commandsOnly verifies that completionFn (with $PIN unset)
+// returns matching command names and nothing else.
+func TestCompletionFn_commandsOnly(t *testing.T) {
+ t.Setenv("PIN", "") // ensure no store walk
+ c, _ := testCLI(t)
+
+ results := c.completionFn("sy")
+ if len(results) != 1 || results[0] != "sync" {
+ t.Errorf("completionFn(sy) = %v; want [sync]", results)
+ }
+
+ results = c.completionFn("")
+ if len(results) != len(CommandList) {
+ t.Errorf("completionFn('') returned %d results; want %d", len(results), len(CommandList))
+ }
+}
+
+// ---- makeActionFn -----------------------------------------------------------
+
+// TestMakeActionFn_nil verifies that makeActionFn returns nil for actions
+// handled directly by the store (cat, export, pathexport).
+func TestMakeActionFn_nil(t *testing.T) {
+ c, _ := testCLI(t)
+ ctx := context.Background()
+
+ for _, action := range []store.Action{store.ActionCat, store.ActionExport, store.ActionPathExport} {
+ fn := c.makeActionFn(ctx, action)
+ if fn != nil {
+ t.Errorf("makeActionFn(%v) = non-nil; want nil (store handles it internally)", action)
+ }
+ }
+}
+
+// ---- remaining dispatchSearch branches --------------------------------------
+
+// TestDispatch_searchActions exercises all SearchActions entries on an empty
+// store, confirming each returns exit code 0 without panicking.
+func TestDispatch_searchActions(t *testing.T) {
+ c, _ := testCLI(t)
+ ctx := context.Background()
+
+ // These commands go through dispatchSearch/cmdSearchAction.
+ // With an empty store they find no matching entries and return 0.
+ for _, cmd := range []string{"paste", "export", "pathexport", "open", "edit", "get"} {
+ t.Run(cmd, func(t *testing.T) {
+ ec := c.dispatch(ctx, []string{cmd, "nonexistent"})
+ if ec != 0 {
+ t.Errorf("dispatch(%q, nonexistent) = %d; want 0", cmd, ec)
+ }
+ })
+ }
+}
+
+// ---- add/import/import_r with real args (error paths) ----------------------
+
+// TestDispatch_add_noStdinData calls dispatch("add", "desc") when stdin is
+// empty (as in a non-interactive test run). cmdAdd should detect no data and
+// return exit code 1.
+func TestDispatch_add_noStdinData(t *testing.T) {
+ c, _ := testCLI(t)
+ // Redirect stdin to /dev/null so scanner.Scan returns false immediately.
+ null, err := os.Open(os.DevNull)
+ if err != nil {
+ t.Fatalf("open /dev/null: %v", err)
+ }
+ defer null.Close()
+ origStdin := os.Stdin
+ os.Stdin = null
+ defer func() { os.Stdin = origStdin }()
+
+ _ = captureStdout(func() {
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"add", "my/desc"})
+ if ec != 1 {
+ t.Errorf("dispatch(add,desc) with empty stdin = %d; want 1", ec)
+ }
+ })
+ })
+}
+
+// TestDispatch_import_missingFile calls dispatch("import", "/no/such/file")
+// which should return exit code 1 after failing to read the source file.
+func TestDispatch_import_missingFile(t *testing.T) {
+ c, _ := testCLI(t)
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"import", "/no/such/file.txt"})
+ if ec != 1 {
+ t.Errorf("dispatch(import, missing) = %d; want 1", ec)
+ }
+ })
+}
+
+// TestDispatch_importR_missingDir calls dispatch("import_r", "/no/such/dir")
+// which should return exit code 1 after failing to walk the directory.
+func TestDispatch_importR_missingDir(t *testing.T) {
+ c, _ := testCLI(t)
+ _ = captureStderr(func() {
+ ec := c.dispatch(context.Background(), []string{"import_r", "/no/such/dir"})
+ if ec != 1 {
+ t.Errorf("dispatch(import_r, missing) = %d; want 1", ec)
+ }
+ })
+}