Files
lazysecrets/internal/ui/app/persist_test.go
T
keb 10dc747f0f feat: keep what was loaded between runs, and start where you left off (#2)
Starting lazysecrets again now shows what you had loaded at once, and puts you back where you were.

- **Cache file** `~/.cache/lazysecrets/cache.json` (0600, written atomically): the Sources lists, vaults/scopes, secrets' metadata, Describe results and each list's cursor. **Never values**, and never repos (they're on disk anyway). Loaded on start; lists older than 2 minutes reload in the background as before. Saved every 30s when changed, and on quit.
- **Housekeeping:** entries not loaded for 30 days are dropped, as is everything from accounts no longer signed in (every cache key starts with the account ID). The file has a format version; another version is ignored and replaced. `cache: false` turns it off; `--demo` never uses it.
- **Sources are cached too:** Azure lists subscriptions over the network, so without this the Sources pane would still wait.
- **Go to** searches everything cached straight away, and its background loading now also refreshes lists from an earlier run instead of skipping them.
- **Where you left off:** the state file remembers the selected source, vault or secret per folder (the `--dir` or working directory, 100 most recent folders), and the next start in that folder goes back there quietly (no error if it's gone). Starting on a link skips it.

Docs: `cache:` in config.md, a Cache section, the place in State, and the README Privacy section now mentions the cache file.

Tests: cache round trip with a backend too slow to answer (lists and cursor come from the file), no value in the file, file mode 0600, old and signed-out entries dropped, back where it was left, not for another folder or a link.

This touches `app.go` near PR #1's changes (Model fields, the Update switch), so whichever merges second needs a small conflict fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #2
2026-10-11 09:53:32 +02:00

111 lines
3.5 KiB
Go

package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3", "space")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Fatalf("value not revealed:\n%s", d.screen())
}
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "correct-horse") {
t.Error("a value is in the cache file")
}
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
t.Errorf("cache file mode %v", fi.Mode().Perm())
}
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
}
d2.keys("3")
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
c := d.m.cache.containers
if _, ok := c[az.ID()+"|recent"]; !ok {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
}
}
func TestBackWhereItWasLeft(t *testing.T) {
t.Parallel()
st, _ := store.Load("")
dir := "/work/project"
d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
d.keys("3", "j", "j")
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) {
t.Fatalf("place %+v", p)
}
d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
// Another folder starts as usual; so does opening a link.
d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0))
if _, _, o := d3.selected(); o == secret {
t.Errorf("another folder went to %s too", o)
}
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0))
if d4.m.nav != nil && d4.m.nav.quiet {
t.Error("a link was overridden by where it was left")
}
}