diff options
Diffstat (limited to 'internal/store/store_test.go')
| -rw-r--r-- | internal/store/store_test.go | 422 |
1 files changed, 422 insertions, 0 deletions
diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ee1e07d..950c010 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -6,9 +6,11 @@ package store import ( "context" + "io" "os" "os/exec" "path/filepath" + "strings" "testing" "codeberg.org/snonux/geheim/internal/config" @@ -276,6 +278,52 @@ func TestExport(t *testing.T) { } } +// --- TestWalkIndexesInvalidRegex --------------------------------------------- + +// TestWalkIndexesInvalidRegex confirms that WalkIndexes returns an error when +// the search term is not a valid regular expression. +func TestWalkIndexesInvalidRegex(t *testing.T) { + ctx, store, _, _, _ := testSetup(t) + + err := store.WalkIndexes(ctx, "[invalid", func(*Index) error { return nil }) + if err == nil { + t.Error("WalkIndexes with invalid regex: expected error, got nil") + } +} + +// --- TestImportMissingSourceFile --------------------------------------------- + +// TestImportMissingSourceFile confirms that Import returns an error when the +// source file does not exist. +func TestImportMissingSourceFile(t *testing.T) { + ctx, store, _, _, _ := testSetup(t) + + err := store.Import(ctx, "/nonexistent/path/secret.txt", "dest/secret.txt", false) + if err == nil { + t.Error("Import with missing source file: expected error, got nil") + } +} + +// --- TestHashPathEdgeCases --------------------------------------------------- + +// TestHashPathEdgeCases exercises edge inputs: empty string and a lone slash. +func TestHashPathEdgeCases(t *testing.T) { + _, store, _, _, _ := testSetup(t) + + // Empty string — HashPath("") should return sha256("") without panicking. + got := store.HashPath("") + if len(got) != 64 { + t.Errorf("HashPath(\"\") length = %d; want 64", len(got)) + } + + // Leading slash produces an empty first component after split on "/". + // Verify it does not panic and returns a non-empty result. + got2 := store.HashPath("/only") + if got2 == "" { + t.Error("HashPath(\"/only\") returned empty string") + } +} + // --- TestRemoveEntry --------------------------------------------------------- // TestRemoveEntry adds an entry, commits it so that git rm works, then removes @@ -323,3 +371,377 @@ func TestRemoveEntry(t *testing.T) { t.Errorf("expected 0 entries after remove; got %d", count) } } + +// --- TestSearch -------------------------------------------------------------- + +// TestSearch adds two entries, then calls Search with ActionNone and verifies +// both descriptions are returned sorted and printed to stdout. +func TestSearch(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + for _, desc := range []string{"zebra/entry", "apple/entry"} { + if err := store.Add(ctx, desc, "data"); err != nil { + t.Fatalf("Add %q: %v", desc, err) + } + } + + results, err := store.Search(ctx, "", ActionNone, nil) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results; got %d", len(results)) + } + // Search returns results sorted by Description. + if results[0].Description != "apple/entry" || results[1].Description != "zebra/entry" { + t.Errorf("unexpected sort order: %v, %v", results[0].Description, results[1].Description) + } +} + +// --- TestSearchActionCat ----------------------------------------------------- + +// TestSearchActionCat verifies that Search with ActionCat prints decrypted +// content to stdout (we capture os.Stdout via a temp file redirect). +func TestSearchActionCat(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "my/note.txt", "hello cat content\n"); err != nil { + t.Fatalf("Add: %v", err) + } + + // Redirect stdout to capture output. + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("creating pipe: %v", err) + } + os.Stdout = w + + results, err := store.Search(ctx, "note.txt", ActionCat, nil) + + w.Close() + os.Stdout = oldStdout + var buf strings.Builder + io.Copy(&buf, r) + + if err != nil { + t.Fatalf("Search ActionCat: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + // The cat output should contain the decrypted content (tab-prefixed). + if !strings.Contains(buf.String(), "hello cat content") { + t.Errorf("stdout does not contain expected content: %q", buf.String()) + } +} + +// --- TestSearchActionCatBinarySkip ------------------------------------------- + +// TestSearchActionCatBinarySkip confirms that ActionCat prints a skip message +// rather than binary content when the description implies a binary file. +func TestSearchActionCatBinarySkip(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + // A .jpg description is detected as binary by IsBinary(). + if err := store.Add(ctx, "photo.jpg", "\x89PNG\r\n\x1a\n"); err != nil { + t.Fatalf("Add: %v", err) + } + + // Capture stdout to verify the skip message is printed. + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("creating pipe: %v", err) + } + oldStdout := os.Stdout + os.Stdout = w + + results, searchErr := store.Search(ctx, "photo.jpg", ActionCat, nil) + + w.Close() + os.Stdout = oldStdout + var buf strings.Builder + io.Copy(&buf, r) + + if searchErr != nil { + t.Fatalf("Search ActionCat (binary): %v", searchErr) + } + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + // The "binary" warning must be present; the raw bytes must NOT be printed. + if !strings.Contains(buf.String(), "Not displaying") { + t.Errorf("expected binary-skip message; stdout = %q", buf.String()) + } +} + +// --- TestShredAllExported ---------------------------------------------------- + +// TestShredAllExported writes two files to the export dir, calls ShredAllExported, +// and verifies both files have been removed. +func TestShredAllExported(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + + // Write two plaintext files to the export directory. + for _, name := range []string{"secret1.txt", "secret2.txt"} { + p := filepath.Join(cfg.ExportDir, name) + if err := os.WriteFile(p, []byte("sensitive"), 0o600); err != nil { + t.Fatalf("writing export file: %v", err) + } + } + + if err := store.ShredAllExported(ctx); err != nil { + t.Fatalf("ShredAllExported: %v", err) + } + + // Both files should be gone. + for _, name := range []string{"secret1.txt", "secret2.txt"} { + p := filepath.Join(cfg.ExportDir, name) + if _, err := os.Stat(p); err == nil { + t.Errorf("file %q still exists after ShredAllExported", name) + } + } +} + +// --- TestSearchActionExport -------------------------------------------------- + +// TestSearchActionExport verifies that Search with ActionExport writes the +// decrypted content to cfg.ExportDir using the basename of the description. +func TestSearchActionExport(t *testing.T) { + ctx, store, cfg, _, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + wantContent := "exported via search\n" + if err := store.Add(ctx, "docs/report.txt", wantContent); err != nil { + t.Fatalf("Add: %v", err) + } + + results, err := store.Search(ctx, "report.txt", ActionExport, nil) + if err != nil { + t.Fatalf("Search ActionExport: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result; got %d", len(results)) + } + + // ActionExport uses the basename of the description as the export filename. + exportedPath := filepath.Join(cfg.ExportDir, "report.txt") + got, err := os.ReadFile(exportedPath) + if err != nil { + t.Fatalf("reading exported file: %v", err) + } + if string(got) != wantContent { + t.Errorf("exported content = %q; want %q", got, wantContent) + } +} + +// --- TestImportRecursive ----------------------------------------------------- + +// TestImportRecursive creates a directory tree, imports it, then verifies that +// all files appear as indexed entries with the correct descriptions and content. +func TestImportRecursive(t *testing.T) { + ctx, store, cfg, c, _ := testSetup(t) + initGitRepo(t, cfg.DataDir) + + // Build a two-level source tree. + srcRoot := t.TempDir() + files := map[string]string{ + "top.txt": "top level content\n", + "sub/nested.txt": "nested content\n", + } + for rel, content := range files { + full := filepath.Join(srcRoot, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o600); err != nil { + t.Fatalf("writing %q: %v", rel, err) + } + } + + if err := store.ImportRecursive(ctx, srcRoot, "backup"); err != nil { + t.Fatalf("ImportRecursive: %v", err) + } + + // Both files should now be indexed. + found := map[string]*Index{} + if err := store.WalkIndexes(ctx, "", func(idx *Index) error { + found[idx.Description] = idx + return nil + }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + if len(found) != 2 { + t.Fatalf("expected 2 indexed entries; got %d", len(found)) + } + + // Verify content round-trips correctly. + for desc, wantContent := range map[string]string{ + "backup/top.txt": "top level content\n", + "backup/sub/nested.txt": "nested content\n", + } { + idx, ok := found[desc] + if !ok { + t.Errorf("entry %q not found; found: %v", desc, func() []string { + keys := make([]string, 0, len(found)) + for k := range found { + keys = append(keys, k) + } + return keys + }()) + continue + } + d, err := loadData(ctx, filepath.Join(cfg.DataDir, idx.DataFile), c) + if err != nil { + t.Fatalf("loadData for %q: %v", desc, err) + } + if string(d.Content) != wantContent { + t.Errorf("content for %q = %q; want %q", desc, d.Content, wantContent) + } + } +} + +// --- TestReimportAfterExport ------------------------------------------------- + +// TestReimportAfterExport exports an entry, modifies the exported file, then +// reimports it and verifies the updated content is stored in the encrypted .data file. +func TestReimportAfterExport(t *testing.T) { + ctx, store, cfg, c, g := testSetup(t) + initGitRepo(t, cfg.DataDir) + + original := "original content\n" + if err := store.Add(ctx, "editable/note.txt", original); err != nil { + t.Fatalf("Add: %v", err) + } + + // Locate the entry. + var idx *Index + if err := store.WalkIndexes(ctx, "", func(i *Index) error { idx = i; return nil }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + + d, err := loadData(ctx, filepath.Join(cfg.DataDir, idx.DataFile), c) + if err != nil { + t.Fatalf("loadData: %v", err) + } + + // Export to a temp export dir. + if err := d.Export(ctx, cfg.ExportDir, "note.txt"); err != nil { + t.Fatalf("Export: %v", err) + } + + // Simulate editing the exported file. + updated := "updated content after edit\n" + if err := os.WriteFile(d.ExportedPath, []byte(updated), 0o600); err != nil { + t.Fatalf("updating exported file: %v", err) + } + + // Reimport overwrites the encrypted .data with the updated content. + if err := d.ReimportAfterExport(ctx, c, g); err != nil { + t.Fatalf("ReimportAfterExport: %v", err) + } + + // Reload from disk and verify the update was persisted. + reloaded, err := loadData(ctx, filepath.Join(cfg.DataDir, idx.DataFile), c) + if err != nil { + t.Fatalf("loadData after reimport: %v", err) + } + if string(reloaded.Content) != updated { + t.Errorf("reimported content = %q; want %q", reloaded.Content, updated) + } +} + +// --- TestRemoveInteractive --------------------------------------------------- + +// TestRemoveInteractive tests Store.Remove by injecting a strings.Reader as the +// interactive input (answering "y"). After removal WalkIndexes must find no entries. +func TestRemoveInteractive(t *testing.T) { + ctx, store, cfg, _, g := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "interactive/remove", "data to delete"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit(ctx); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Inject "y\n" as user input to confirm deletion. + input := strings.NewReader("y\n") + if err := store.Remove(ctx, "interactive/remove", input); err != nil { + t.Fatalf("Remove: %v", err) + } + + count := 0 + if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil { + t.Fatalf("WalkIndexes after remove: %v", err) + } + if count != 0 { + t.Errorf("expected 0 entries after removal; got %d", count) + } +} + +// --- TestRemoveInteractiveDecline -------------------------------------------- + +// TestRemoveInteractiveDecline confirms that answering "n" leaves the entry intact. +func TestRemoveInteractiveDecline(t *testing.T) { + ctx, store, cfg, _, g := testSetup(t) + initGitRepo(t, cfg.DataDir) + + if err := store.Add(ctx, "keep/this", "important data"); err != nil { + t.Fatalf("Add: %v", err) + } + if err := g.Commit(ctx); err != nil { + t.Fatalf("Commit: %v", err) + } + + // Inject "n\n" — user declines deletion. + input := strings.NewReader("n\n") + if err := store.Remove(ctx, "keep/this", input); err != nil { + t.Fatalf("Remove: %v", err) + } + + count := 0 + if err := store.WalkIndexes(ctx, "", func(*Index) error { count++; return nil }); err != nil { + t.Fatalf("WalkIndexes: %v", err) + } + if count != 1 { + t.Errorf("expected 1 entry after decline; got %d", count) + } +} + +// --- TestCommitIndexSkipsExisting -------------------------------------------- + +// TestCommitIndexSkipsExisting verifies that CommitIndex with force=false is a +// no-op when IndexPath already exists, preserving the original encrypted content. +func TestCommitIndexSkipsExisting(t *testing.T) { + ctx := context.Background() + c := newTestIndexCipher(t) + dir := t.TempDir() + + indexPath := filepath.Join(dir, "existing.index") + sentinel := []byte("original encrypted content") + if err := os.WriteFile(indexPath, sentinel, 0o600); err != nil { + t.Fatalf("writing sentinel: %v", err) + } + + idx := &Index{ + Description: "should not be written", + IndexPath: indexPath, + Hash: strings.Repeat("0", 64), + } + + // force=false must skip writing; passing nil for git since it won't be reached. + if err := idx.CommitIndex(ctx, c, nil, false); err != nil { + t.Errorf("CommitIndex(force=false) with existing file returned error: %v", err) + } + + got, _ := os.ReadFile(indexPath) + if string(got) != string(sentinel) { + t.Errorf("index file was overwritten: got %q; want %q", got, sentinel) + } +} |
