diff options
Diffstat (limited to 'internal/cli')
| -rw-r--r-- | internal/cli/cli_test.go | 436 |
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) + } + }) +} |
