package app import ( "cmp" "context" "errors" "os" "strings" "testing" "time" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/golden" "git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/clipboard" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" ) func TestMain(m *testing.M) { time.Local = time.UTC // golden output formats times in local time os.Exit(m.Run()) } // driver runs a Model synchronously: every command is executed and its // message fed back until the program is idle. Commands that don't return // promptly (spinner and cursor-blink ticks) are abandoned. type driver struct { t *testing.T m Model wait time.Duration // how long a command may take before it's dropped (default 50ms) } func newDriver(t *testing.T, providers ...provider.Provider) *driver { t.Helper() if len(providers) == 0 { providers = []provider.Provider{fake.Azure(0), fake.Databricks(0)} } return newDriverOpts(t, Options{}, providers...) } func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *driver { t.Helper() if len(providers) == 0 { providers = []provider.Provider{fake.Azure(0), fake.Databricks(0)} } if opts.Clipboard == nil { opts.Clipboard = &fakeClipboard{} } if opts.OpenURL == nil { // never launch a real browser from tests opts.OpenURL = func(string) error { return nil } } m := New(providers, opts) m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) } d := &driver{t: t, m: m} d.send(tea.WindowSizeMsg{Width: 100, Height: 33}) // 30 for the main panes, 3 for status d.run(m.Init()) return d } // fakeClipboard records writes instead of touching the real clipboard. type fakeClipboard struct { content string cleared [][32]byte } func (c *fakeClipboard) Write(s string) tea.Cmd { c.content = s return nil } func (c *fakeClipboard) Read() (string, error) { return c.content, nil } func (c *fakeClipboard) Clear(fp [32]byte) tea.Cmd { c.cleared = append(c.cleared, fp) if clipboard.Fingerprint(c.content) == fp { c.content = "" } return nil } func (d *driver) clip() *fakeClipboard { return d.m.clip.(*fakeClipboard) } func (d *driver) send(msg tea.Msg) { d.t.Helper() next, cmd := d.m.Update(msg) d.m = next.(Model) d.run(cmd) } func (d *driver) run(cmd tea.Cmd) { d.t.Helper() if cmd == nil { return } ch := make(chan tea.Msg, 1) go func() { ch <- cmd() }() var msg tea.Msg select { case msg = <-ch: case <-time.After(cmp.Or(d.wait, 50*time.Millisecond)): return } switch msg := msg.(type) { case nil, spinner.TickMsg: case tea.BatchMsg: for _, c := range msg { d.run(c) } default: d.send(msg) } } func (d *driver) keys(keys ...string) { d.t.Helper() for _, k := range keys { d.send(keyPress(k)) } } func (d *driver) typeText(s string) { d.t.Helper() for _, r := range s { d.send(tea.KeyPressMsg{Code: r, Text: string(r)}) } } func (d *driver) screen() string { return ansi.Strip(d.m.View().Content) } // sourceCount counts source rows, leaving out group headers. func (d *driver) sourceCount() int { n := 0 for _, p := range d.m.providers { if !d.m.collapsed[p.ID()] { n += len(d.m.sourceResults[p.ID()].sources) } } return n } func (d *driver) selected() (src, container, secret string) { s, _ := d.m.selectedSource() c, _ := d.m.containers.Selected() x, _ := d.m.secrets.Selected() return s.Name, c.Name, x.Name } func TestStartupCascadesToFirstSecret(t *testing.T) { t.Parallel() d := newDriver(t) src, c, s := d.selected() if src != "platform-prod" || c != "kv-app-prod" || s != "db-password" { t.Fatalf("selected = %q/%q/%q", src, c, s) } if got := d.sourceCount(); got != 4 { t.Errorf("sources = %d, want 4 (2 Azure + 2 Databricks)", got) } if d.m.anyLoading() { t.Error("still loading after idle") } } func TestGolden(t *testing.T) { t.Parallel() d := newDriver(t) golden.RequireEqual(t, d.screen()) } func TestIcons(t *testing.T) { t.Parallel() const azure, dbx = "\U000F0805", "\U000F0F58" plain := newDriver(t).screen() if strings.Contains(plain, azure) || strings.Contains(plain, dbx) { t.Error("icons shown without the option") } d := newDriverOpts(t, Options{Icons: true}) screen := d.screen() if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") { t.Errorf("icons missing:\n%s", screen) } d.keys("3") if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") { t.Errorf("detail source line missing icon:\n%s", d.screen()) } } func TestObjectTypeTabs(t *testing.T) { t.Parallel() d := newDriver(t) if got := d.m.secrets.Tabs; strings.Join(got, ",") != "Secrets,Keys,Certs" { t.Fatalf("tabs = %v", got) } d.keys("3", "]") if d.m.objType != provider.TypeKey { t.Fatalf("objType = %s after ]", d.m.objType) } if _, _, s := d.selected(); s != "signing-key" { t.Fatalf("selected = %q", s) } screen := d.screen() for _, want := range []string{"key type RSA 2048", "ops sign, verify", "space to show the public key"} { if !strings.Contains(screen, want) { t.Errorf("missing %q:\n%s", want, screen) } } d.keys("space") if !strings.Contains(d.screen(), "BEGIN PUBLIC KEY") { t.Errorf("public key not shown:\n%s", d.screen()) } d.keys("]") // certificates d.keys("3", "j") if _, _, s := d.selected(); s != "old-tls" { t.Fatalf("selected = %q", s) } if !strings.Contains(d.screen(), "(expired)") { t.Errorf("expired certificate not flagged:\n%s", d.screen()) } d.keys("]") // wraps to secrets if d.m.objType != provider.TypeSecret { t.Fatalf("objType = %s, want wrap to Secret", d.m.objType) } d.keys("[") if d.m.objType != provider.TypeCertificate { t.Fatalf("objType = %s, want wrap back to Certificate", d.m.objType) } } func TestTabErrorsArePerType(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("2", "j") // kv-shared-prod: secrets readable, keys forbidden if d.m.secrets.Err != nil { t.Fatalf("secrets tab errored: %v", d.m.secrets.Err) } d.keys("]") if d.m.secrets.Err == nil || !strings.Contains(d.screen(), "list keys") { t.Fatalf("keys tab should show permission error:\n%s", d.screen()) } d.keys("]") if d.m.secrets.Err != nil || !strings.Contains(d.screen(), "no certificates") { t.Fatalf("certs tab should be empty without error:\n%s", d.screen()) } } func TestTabPreferenceSurvivesDatabricks(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "]") // Keys d.keys("1", "j", "j", "j") // Databricks prod: secrets only if d.m.objType != provider.TypeSecret || len(d.m.secrets.Tabs) != 1 { t.Fatalf("Databricks: objType = %s, tabs = %v", d.m.objType, d.m.secrets.Tabs) } if !strings.Contains(d.screen(), "[3] Secrets") { t.Errorf("single-type title missing:\n%s", d.screen()) } d.keys("3", "]", "1") // no-op without tabs d.keys("k", "k", "k") // back to Azure prod if d.m.objType != provider.TypeKey { t.Fatalf("objType = %s, want Keys restored", d.m.objType) } } func TestCopyWithoutRevealing(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{ClipboardClear: 30 * time.Second}) clock := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) d.m.now = func() time.Time { return clock } d.keys("y") if got := d.clip().content; got != "correct-horse-battery-staple" { t.Fatalf("clipboard = %q", got) } if strings.Contains(d.screen(), "correct-horse-battery-staple") { t.Error("copying revealed the value") } if !strings.Contains(d.screen(), "copied db-password · clipboard clears in 30s") { t.Errorf("no countdown:\n%s", d.screen()) } if strings.Contains(d.screen(), "copying") { t.Errorf("stale progress message:\n%s", d.screen()) } // The countdown survives key presses and ticks down. d.keys("j") clock = clock.Add(2500 * time.Millisecond) d.send(clipTickMsg{token: d.m.clipToken}) if !strings.Contains(d.screen(), "clipboard clears in 28s") { t.Errorf("countdown not updated:\n%s", d.screen()) } // A newer copy restarts the countdown; ticks for the old one are ignored. old := d.m.clipToken d.keys("y") // api-key clock = clock.Add(time.Minute) d.send(clipTickMsg{token: old}) if d.clip().content == "" { t.Fatal("stale tick cleared the clipboard") } d.send(clipTickMsg{token: d.m.clipToken}) if d.clip().content != "" { t.Errorf("clipboard not cleared: %q", d.clip().content) } if strings.Contains(d.screen(), "clears in") || !strings.Contains(d.screen(), "clipboard cleared") { t.Errorf("countdown not replaced by cleared notice:\n%s", d.screen()) } } func TestCopyReferenceCancelsCountdown(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{ClipboardClear: 30 * time.Second}) d.keys("y", "Y") if strings.Contains(d.screen(), "clears in") { t.Errorf("countdown kept after the value was replaced on the clipboard:\n%s", d.screen()) } } func TestCopyRevealedValueAndReference(t *testing.T) { t.Parallel() d := newDriver(t) // ClipboardClear 0: no timer d.keys("space", "y") if d.clip().content != "correct-horse-battery-staple" { t.Fatalf("clipboard = %q", d.clip().content) } if strings.Contains(d.screen(), "clears in") { t.Error("clear notice shown with clearing disabled") } d.keys("Y") if want := "@Microsoft.KeyVault(SecretUri=https://kv-app-prod.vault.azure.net/secrets/db-password)"; d.clip().content != want { t.Errorf("reference = %q", d.clip().content) } } // expiring fails object listing with an AuthError until signed in. type expiring struct { provider.Provider signedIn *bool } func (e expiring) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) { if !*e.signedIn { return nil, &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("AADSTS700082")} } return e.Provider.Objects(ctx, c, t) } func TestSignInFlow(t *testing.T) { t.Parallel() signedIn := false d := newDriver(t, expiring{fake.Azure(0), &signedIn}) if d.m.auth == nil { t.Fatal("auth error not noticed") } if !strings.Contains(d.screen(), "press L to run az login") { t.Errorf("no sign-in hint:\n%s", d.screen()) } if !d.m.keys.Login.Enabled() { t.Error("L not enabled") } next, cmd := d.m.Update(keyPress("L")) if cmd == nil { t.Fatal("L did not start az login") } d.m = next.(Model) signedIn = true d.send(loginDoneMsg{}) if d.m.auth != nil || d.m.keys.Login.Enabled() { t.Error("auth state not cleared after sign-in") } if _, _, s := d.selected(); s != "db-password" { t.Errorf("objects not reloaded after sign-in, selected %q", s) } } func TestSignInFailureKeepsHint(t *testing.T) { t.Parallel() signedIn := false d := newDriver(t, expiring{fake.Azure(0), &signedIn}) d.send(loginDoneMsg{err: errors.New("exit status 1")}) if d.m.auth == nil || !strings.Contains(d.screen(), "sign-in failed: exit status 1") { t.Errorf("failure not reported:\n%s", d.screen()) } } // describing adds an attribute in Describe. type describing struct{ provider.Provider } func (describing) Describe(_ context.Context, o provider.Object) (provider.Object, error) { o.Attrs = append(o.Attrs, provider.Attr{Label: "described", Value: o.Name}) return o, nil } func TestDescribeUpdatesDetail(t *testing.T) { t.Parallel() d := newDriver(t, describing{fake.Azure(0)}) d.keys("3") // the fourth pane follows focus: the secret if !strings.Contains(d.screen(), "described db-password") { t.Fatalf("describe result not shown:\n%s", d.screen()) } d.send(describeMsg{req: -1, obj: provider.Object{ID: "x"}}) d.keys("3", "j") if !strings.Contains(d.screen(), "described api-key") { t.Errorf("describe not refreshed on selection change:\n%s", d.screen()) } } func TestOpenInBrowser(t *testing.T) { t.Parallel() var opened []string open := func(url string) error { opened = append(opened, url); return nil } d := newDriverOpts(t, Options{OpenURL: open}) d.keys("o") // subscription d.keys("2", "o") // vault d.keys("3", "]", "o") // key: its vault's key list d.keys("1", "j", "j", "j") // Databricks prod d.keys("o") // workspace d.keys("2", "o") // Databricks-backed scope: nothing to open if !strings.Contains(d.screen(), "no web page for app") { t.Errorf("missing no-page notice:\n%s", d.screen()) } d.keys("j", "o") // AKV-backed scope: its vault want := []string{ "https://portal.azure.com/#resource/subscriptions/sub-prod/overview", "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview", "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/keys", "https://adb-1111111111111111.11.azuredatabricks.net", "https://portal.azure.com/#resource/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview", } if strings.Join(opened, "\n") != strings.Join(want, "\n") { t.Errorf("opened:\n%s\nwant:\n%s", strings.Join(opened, "\n"), strings.Join(want, "\n")) } } func TestOpenOverSSHCopiesLink(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{OpenURL: func(string) error { return browser.ErrRemote }}) d.keys("o") if d.clip().content != "https://portal.azure.com/#resource/subscriptions/sub-prod/overview" { t.Errorf("clipboard = %q", d.clip().content) } if !strings.Contains(d.screen(), "can't open a browser over SSH") { t.Errorf("no explanation:\n%s", d.screen()) } } func TestVersions(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("v") if d.m.focus != paneSecrets || !strings.Contains(d.screen(), "[3] Versions of db-password") { t.Fatalf("versions view not shown:\n%s", d.screen()) } screen := d.screen() for _, want := range []string{"3f9c2a 2026-09-01 current", "91d3e0 2026-03-01 expired", "0a77c4 2025-09-01 disabled"} { if !strings.Contains(screen, want) { t.Errorf("missing row %q:\n%s", want, screen) } } // space reads the selected version, not the latest. d.keys("j", "space") if !strings.Contains(d.screen(), "previous-password") || strings.Contains(d.screen(), "correct-horse") { t.Errorf("wrong version revealed:\n%s", d.screen()) } // esc returns to the list with the cursor on the object. d.keys("esc") if d.m.versionsOf != nil || !strings.Contains(d.screen(), "[3] Secrets - Keys - Certs") { t.Fatalf("versions view not closed:\n%s", d.screen()) } if _, _, s := d.selected(); s != "db-password" { t.Errorf("cursor = %q after closing versions", s) } if strings.Contains(d.screen(), "previous-password") { t.Error("old version's value still shown after closing versions") } } func TestVersionHistoryInDetail(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3") // the fourth pane follows focus: the secret screen := d.screen() for _, want := range []string{ "versions 3 · v to browse", "▸ 3f9c2a 2026-09-01 current", // the version shown above " 91d3e0 2026-03-01 expired", " 0a77c4 2025-09-01 disabled", } { if !strings.Contains(screen, want) { t.Errorf("missing %q:\n%s", want, screen) } } d.keys("3", "j") // api-key: only its current version if !strings.Contains(d.screen(), "versions 1") || strings.Contains(d.screen(), "v to browse") { t.Errorf("wrong history for api-key:\n%s", d.screen()) } d.keys("1", "j", "j", "j") // Databricks: unsupported, so no section at all if strings.Contains(d.screen(), "versions ") { t.Errorf("versions shown for Databricks:\n%s", d.screen()) } } func TestVersionCountPrimesVersionsView(t *testing.T) { t.Parallel() p := &counting{Provider: fake.Azure(0)} d := newDriver(t, p) before := p.versions d.keys("v") if p.versions != before { t.Errorf("v refetched versions already counted: %d → %d", before, p.versions) } } func TestBackspaceGoesBack(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("v") d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) if d.m.versionsOf != nil { t.Fatal("backspace did not close the versions view") } // While typing a filter, backspace deletes a character instead. d.keys("/") d.typeText("apx") d.send(tea.KeyPressMsg{Code: tea.KeyBackspace}) if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { t.Errorf("backspace in filter: filtering=%v visible=%d", d.m.secrets.Filtering(), d.m.secrets.Len()) } } func TestVersionsClosedByNavigation(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("v", "]") // switching tab leaves the versions view if d.m.versionsOf != nil || d.m.objType != provider.TypeKey { t.Errorf("tab switch: versionsOf=%v objType=%s", d.m.versionsOf, d.m.objType) } d.keys("[", "v", "2", "j") // another vault if d.m.versionsOf != nil { t.Error("vault switch did not close the versions view") } } func TestVersionsUnsupported(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("1", "j", "j", "j", "v") // Databricks if d.m.versionsOf != nil { t.Error("versions view stayed open") } if !strings.Contains(d.screen(), "versions aren't supported for Databricks") { t.Errorf("no notice:\n%s", d.screen()) } if _, _, s := d.selected(); s != "storage-key" { t.Errorf("selection lost: %q", s) } } func TestNavigationReloadsChildren(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("j") // platform-dev if _, c, s := d.selected(); c != "kv-app-dev" || s != "db-password" { t.Fatalf("after j on sources: %q/%q", c, s) } d.keys("j") // Databricks group header: nothing below it if _, c, _ := d.selected(); c != "" || d.m.containers.Title != "[2] Scopes" { t.Errorf("header selected but containers shown: %q / %q", c, d.m.containers.Title) } d.keys("j") // Databricks prod if d.m.containers.Title != "[2] Scopes" { t.Errorf("containers title = %q", d.m.containers.Title) } if _, c, s := d.selected(); c != "app" || s != "storage-key" { t.Fatalf("after j to Databricks: %q/%q", c, s) } } func TestPermissionErrorShownInPane(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("2", "G") // kv-restricted if d.m.secrets.Err == nil { t.Fatal("expected secrets error") } if !strings.Contains(d.screen(), "403 Forbidden") { t.Error("error not rendered") } if !strings.Contains(d.screen(), "name kv-restricted") { t.Error("detail should show the container summary") } } func TestRevealAndHideOnSelectionChange(t *testing.T) { t.Parallel() d := newDriver(t) const plain = "correct-horse-battery-staple" if strings.Contains(d.screen(), plain) { t.Fatal("value visible before reveal") } d.keys("3", "space") if !strings.Contains(d.screen(), plain) { t.Fatalf("value not revealed:\n%s", d.screen()) } d.keys("space") if strings.Contains(d.screen(), plain) { t.Fatal("value still visible after hide") } d.keys("space", "j") if strings.Contains(d.screen(), plain) { t.Fatal("value still visible after moving to another secret") } if d.m.detail.State() != 0 { t.Error("value state not reset after selection change") } } func TestRevealFromEveryPane(t *testing.T) { t.Parallel() const plain = "correct-horse-battery-staple" for _, p := range []string{"1", "2", "3", "4"} { d := newDriver(t) d.keys(p, "space") if !strings.Contains(d.screen(), plain) { t.Errorf("pane %s: value not revealed", p) } d.keys("space") if strings.Contains(d.screen(), plain) { t.Errorf("pane %s: value not hidden", p) } } } func TestRevealOnAKVBackedDatabricksScope(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("j", "j", "j", "2", "j") // Databricks prod → kv-app-prod scope if _, c, _ := d.selected(); c != "kv-app-prod" { t.Fatalf("container = %q", c) } if !strings.Contains(d.screen(), "Azure Key Vault-backed: kv-app-prod") { t.Errorf("scope backing not shown with a secret selected:\n%s", d.screen()) } d.keys("3", "space") if !strings.Contains(d.screen(), "correct-horse-battery-staple") { t.Fatalf("value not revealed:\n%s", d.screen()) } } func TestFilter(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") d.typeText("api") if d.m.secrets.Len() != 1 { t.Fatalf("visible = %d, want 1", d.m.secrets.Len()) } d.keys("enter") if _, _, s := d.selected(); s != "api-key" { t.Fatalf("selected = %q", s) } // While filtering, q is text rather than quit. d.keys("/") _, cmd := d.m.Update(keyPress("q")) if cmd != nil { if _, quit := cmd().(tea.QuitMsg); quit { t.Fatal("q quit while filtering") } } d.keys("esc") // leave filter mode, clearing the query d.keys("/") d.typeText("api") d.keys("enter", "esc") // apply, then esc clears the applied filter if d.m.secrets.Len() != 3 { t.Errorf("esc should clear the applied filter, visible = %d", d.m.secrets.Len()) } if _, _, s := d.selected(); s != "api-key" { t.Errorf("clearing the filter should keep the selection, got %q", s) } } func TestMoveWhileFiltering(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") d.typeText("a") // all three secrets contain an a if d.m.secrets.Len() != 3 { t.Fatalf("visible = %d", d.m.secrets.Len()) } _, _, initial := d.selected() // kept from before filtering: the middle row var seen []string for _, k := range []rune{'j', 'j', 'k'} { d.send(tea.KeyPressMsg{Code: k, Mod: tea.ModCtrl}) _, _, s := d.selected() seen = append(seen, s) } if !d.m.secrets.Filtering() { t.Fatal("ctrl+j/k left filter mode") } // down to the bottom, stay there, back up to where it started if seen[0] == initial || seen[1] != seen[0] || seen[2] != initial { t.Errorf("from %q, ctrl+j, ctrl+j, ctrl+k selected %v", initial, seen) } if !strings.Contains(d.screen(), "ctrl+j/n down") { t.Errorf("filter help missing ctrl+j:\n%s", d.screen()) } } func TestStaleResponsesDropped(t *testing.T) { t.Parallel() d := newDriver(t) before := d.m.containers.Len() d.send(containersMsg{req: -1, items: nil}) if d.m.containers.Len() != before { t.Error("stale containersMsg was applied") } d.send(valueMsg{req: -1, value: provider.NewSecretValue([]byte("stale"))}) if strings.Contains(d.screen(), "stale") { t.Error("stale valueMsg was applied") } } type failingSources struct{ provider.Provider } func (failingSources) Sources(context.Context) ([]provider.Source, error) { return nil, errors.New("run az login") } func TestProviderErrorDoesNotHideOthers(t *testing.T) { t.Parallel() d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0)) if d.sourceCount() != 2 { t.Fatalf("sources = %d, want the 2 Databricks ones", d.sourceCount()) } if !strings.Contains(d.screen(), "Azure: run az login") { t.Errorf("provider error not in status line:\n%s", d.screen()) } } func TestFocusKeys(t *testing.T) { t.Parallel() d := newDriver(t) for _, tc := range []struct { key string want pane }{ {"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail}, {"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers}, {"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail}, // l: what's selected, on the right; h: back to it. {"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail}, } { d.keys(tc.key) if d.m.focus != tc.want { t.Fatalf("after %q focus = %d, want %d", tc.key, d.m.focus, tc.want) } } } func TestQuit(t *testing.T) { t.Parallel() for _, k := range []string{"q", "ctrl+c"} { d := newDriver(t) _, cmd := d.m.Update(keyPress(k)) if cmd == nil { t.Fatalf("%s: no command", k) } if _, ok := cmd().(tea.QuitMsg); !ok { t.Errorf("%s did not quit", k) } } } func TestTooSmall(t *testing.T) { t.Parallel() d := newDriver(t) d.send(tea.WindowSizeMsg{Width: 30, Height: 8}) if !strings.Contains(d.screen(), "Terminal too small") { t.Error("expected too-small message") } } // leftEdge is the width of the left column: the column of the first // top-right corner on the first screen line. func (d *driver) leftEdge() int { first := strings.Split(d.screen(), "\n")[0] return len([]rune(first[:strings.Index(first, "╮")])) + 1 } func TestMoveDivider(t *testing.T) { t.Parallel() d := newDriver(t) // 100 wide: default left column 33 start := d.leftEdge() if start != 33 { t.Fatalf("default left width = %d", start) } d.send(tea.KeyPressMsg{Code: tea.KeyRight, Mod: tea.ModCtrl}) d.keys(">") if got := d.leftEdge(); got != start+2*splitStep { t.Errorf("after ctrl+→ and >: %d, want %d", got, start+2*splitStep) } d.keys("=") if got := d.leftEdge(); got != start { t.Errorf("= didn't reset: %d", got) } // Clamped, and the other direction responds at once after a limit. for range 20 { d.keys("<") } if got := d.leftEdge(); got != minLeftW { t.Errorf("min width = %d, want %d", got, minLeftW) } d.keys(">") if got := d.leftEdge(); got != minLeftW+splitStep { t.Errorf("after limit, > gave %d", got) } for range 30 { d.keys(">") } if got := d.leftEdge(); got != 100-minDetailW { t.Errorf("max width = %d, want %d", got, 100-minDetailW) } // Focus doesn't matter: it's the divider that moves. d.keys("=", "4", ">") if got := d.leftEdge(); got != start+splitStep { t.Errorf("from the detail pane: %d", got) } } func TestCopyAndRevealWhileFiltering(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3", "/") d.typeText("api") d.send(tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl}) if d.clip().content != "sk_live_51HxDEMO0000000000000000" { t.Errorf("ctrl+y copied %q", d.clip().content) } d.send(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) if !strings.Contains(d.screen(), "sk_live_51HxDEMO") { t.Error("ctrl+r didn't reveal") } if !d.m.secrets.Filtering() || d.m.secrets.Len() != 1 { t.Error("left filter mode or changed the query") } d.send(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl}) if strings.Contains(d.screen(), "sk_live_51HxDEMO") { t.Error("second ctrl+r didn't hide") } } func TestCtrlNP(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3") d.send(tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) if _, _, s := d.selected(); s != "api-key" { t.Errorf("ctrl+n: %q", s) } d.keys("/") d.typeText("e") d.send(tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}) // moves, doesn't type if !d.m.secrets.Filtering() || d.m.secrets.HasFilter() && strings.Contains(d.screen(), "/ep") { t.Error("ctrl+p typed into the filter") } d.keys("esc", "?") before := d.m.prompt.cursor d.send(tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) if d.m.prompt.cursor == before { t.Error("ctrl+n didn't move in the menu") } } func TestRevealedValueHides(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{RevealTimeout: 30 * time.Second, HideOnBlur: true}) clock := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) d.m.now = func() time.Time { return clock } d.keys("space") if !strings.Contains(d.screen(), "correct-horse-battery-staple") || !strings.Contains(d.screen(), "hides in 30s") { t.Fatalf("not revealed with a countdown:\n%s", d.screen()) } clock = clock.Add(12 * time.Second) d.send(revealTickMsg{d.m.revealToken}) if !strings.Contains(d.screen(), "hides in 18s") { t.Errorf("countdown:\n%s", d.screen()) } clock = clock.Add(18 * time.Second) d.send(revealTickMsg{d.m.revealToken}) if strings.Contains(d.screen(), "correct-horse") { t.Errorf("still shown after the timeout:\n%s", d.screen()) } // Losing focus hides it at once. d.keys("space") d.send(tea.BlurMsg{}) if strings.Contains(d.screen(), "correct-horse") { t.Errorf("still shown after losing focus:\n%s", d.screen()) } // A tick from the earlier reveal does nothing. d.send(revealTickMsg{d.m.revealToken - 1}) } func TestRevealTimeoutOff(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{}) d.keys("space") d.send(tea.BlurMsg{}) if !strings.Contains(d.screen(), "correct-horse-battery-staple") || strings.Contains(d.screen(), "hides in") { t.Errorf("hidden though both are off:\n%s", d.screen()) } } func TestExpiryMarkers(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}) now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) d.m.now = func() time.Time { return now } o := func(exp time.Time) provider.Object { return provider.Object{Name: "x", Expires: exp} } for _, c := range []struct { exp time.Time want string }{ {now.Add(-time.Hour), "✗ expired"}, {now.Add(12*24*time.Hour + time.Hour), "⚠ 12d"}, {now.Add(5 * time.Hour), "⚠ 5h"}, {now.Add(60 * 24 * time.Hour), ""}, {time.Time{}, ""}, } { got := ansi.Strip(d.m.objectRender()(d.m.st, o(c.exp), 40)) if c.want == "" && strings.ContainsAny(got, "✗⚠") || c.want != "" && !strings.Contains(got, c.want) { t.Errorf("expires %v: %q, want %q", c.exp, got, c.want) } } if w := d.m.expiryWord(o(now.Add(-time.Hour))); w != "expired" { t.Errorf("filter word %q", w) } } func TestSourcesFavouritesTab(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("j", "f") // star platform-dev d.keys("]") if !d.m.sourcesFavs || !strings.Contains(d.screen(), "Favourites") { t.Fatalf("not on the Favourites tab:\n%s", d.screen()) } var names []string for _, r := range d.m.sources.Items() { names = append(names, r.source.Name) } if strings.Join(names, ",") != "platform-dev" { t.Errorf("favourites tab rows = %v", names) } if src, _, _ := d.selected(); src != "platform-dev" { t.Errorf("cursor on %q, want the starred source", src) } d.keys("[") if d.m.sourcesFavs || len(d.m.sources.Items()) < 4 { t.Errorf("back on Sources: favs=%v, %d rows", d.m.sourcesFavs, len(d.m.sources.Items())) } } func TestRebindingShowsEverywhere(t *testing.T) { t.Parallel() km := keys.Default() if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"z"}, "versions": {}}}); err != nil { t.Fatal(err) } d := newDriverOpts(t, Options{Keys: &km}) d.keys("3") scr := d.screen() if !strings.Contains(scr, "z to reveal") || strings.Contains(scr, "space to reveal") { t.Errorf("detail hint doesn't follow the binding:\n%s", scr) } if !strings.Contains(lastLine(scr), "z reveal/hide") { t.Errorf("help bar: %q", lastLine(scr)) } if strings.Contains(scr, "to browse") { t.Errorf("hint for unbound versions:\n%s", scr) } d.keys("space") if strings.Contains(d.screen(), "correct-horse") { t.Error("space still reveals") } d.keys("z") if !strings.Contains(d.screen(), "correct-horse-battery-staple") { t.Errorf("z doesn't reveal:\n%s", d.screen()) } } // failingValue fails to read values with a multi-line error, like sops. type failingValue struct{ provider.Provider } const multiLineErr = "sops couldn't decrypt it:\nFailed to get the data key.\n\nGroup 0: FAILED\n age1abc: FAILED\n - | no identity matched any of the recipients" func (failingValue) Value(context.Context, provider.Object) (provider.SecretValue, error) { return provider.SecretValue{}, errors.New(multiLineErr) } func TestMultiLineErrorsShownWhole(t *testing.T) { t.Parallel() d := newDriver(t, failingValue{fake.Azure(0)}) d.keys("3", "space") scr := d.screen() for _, line := range strings.Split(multiLineErr, "\n") { if line != "" && !strings.Contains(scr, line) { t.Errorf("detail pane lacks %q:\n%s", line, scr) } } d.keys("y") // copying fails the same way: one line in the footer if !strings.Contains(scr, "Group 0") || !strings.Contains(d.screen(), "✗ copy failed: sops couldn't decrypt it: … (the whole message: space on the secret)") { t.Errorf("footer:\n%s", d.screen()) } } // signedOut is a second Azure account whose sign-in has expired. type signedOut struct{ provider.Provider } func (signedOut) ID() string { return "Azure:admin@contoso.com/t1" } func (signedOut) Label() string { return "Azure · admin" } func (signedOut) Sources(context.Context) ([]provider.Source, error) { return nil, &provider.AuthError{Kind: provider.KindAzure, Account: "Azure · admin", Login: []string{"az", "login", "--tenant", "t1"}, Err: errors.New("AADSTS70043")} } func TestSignInMessageOnceThenInDetails(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)}) hint := "Azure · admin: sign-in required" if !strings.Contains(lastLines(d.screen(), 2), hint) { // the footer: one line t.Fatalf("not said at start:\n%s", d.screen()) } d.keys("j") if strings.Contains(lastLines(d.screen(), 2), hint) { t.Errorf("still in the footer after moving:\n%s", d.screen()) } // On the account's group, Details says it, and L signs in to it. d.keys("G") if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" { t.Fatalf("selected %+v", r) } if scr := d.screen(); !strings.Contains(scr, "problem") || !strings.Contains(scr, hint) || !strings.Contains(scr, "press L to run az login") { t.Errorf("not in Details:\n%s", d.screen()) } if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { t.Errorf("L signs in to %+v", a) } // Elsewhere Details is as usual. d.keys("g") if strings.Contains(d.screen(), hint) { t.Errorf("shown for another account:\n%s", d.screen()) } } // lastLines is the bottom n lines of a screen. func lastLines(scr string, n int) string { lines := strings.Split(strings.TrimRight(scr, "\n"), "\n") return strings.Join(lines[max(0, len(lines)-n):], "\n") } // described is an account that can say who it is. type described struct{ provider.Provider } func (described) Account() (provider.Account, bool) { return provider.Account{ Fields: []provider.Attr{{Label: "name", Value: "Alice Example"}, {Label: "object ID", Value: "oid-123"}}, Copy: "oid-123", CopyLabel: "object ID", }, true } func TestAccountInDetails(t *testing.T) { t.Parallel() d := newDriver(t, described{fake.Azure(0)}) d.keys("g") // the group's row if r, _ := d.m.sources.Selected(); r.kind != rowGroup { t.Fatalf("not on the group: %+v", r) } if scr := d.screen(); !strings.Contains(scr, "Alice Example") || !strings.Contains(scr, "oid-123") { t.Errorf("account not in Details:\n%s", scr) } d.keys("y") if got := d.clip().content; got != "oid-123" { t.Errorf("y copied %q", got) } d.keys("j") if strings.Contains(d.screen(), "Alice Example") { t.Errorf("still shown on a subscription:\n%s", d.screen()) } } // grouped is an account in two groups. type grouped struct{ described } func (grouped) Groups(context.Context) ([]string, error) { return []string{"Platform team", "Vault readers"}, nil } func TestAccountPane(t *testing.T) { t.Parallel() d := newDriver(t, grouped{described{fake.Azure(0)}}) d.keys("g", "tab") // the account's row, then straight to its pane if d.m.focus != paneDetail { t.Fatalf("focus %v, want the fourth pane", d.m.focus) } scr := d.screen() for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} { if !strings.Contains(scr, want) { t.Errorf("missing %q:\n%s", want, scr) } } bar := lastLine(scr) if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") { t.Errorf("help bar: %q", bar) } d.keys("j", "y") if got := d.clip().content; got != "oid-123" { t.Errorf("y on the object ID copied %q", got) } d.keys("G", "y") if got := d.clip().content; got != "Vault readers" { t.Errorf("y on a group copied %q", got) } // / filters it, labels too. d.keys("/") d.typeText("vault") d.keys("enter") if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") { t.Errorf("filtered:\n%s", scr) } d.keys("y") if got := d.clip().content; got != "Vault readers" { t.Errorf("y in the filtered pane copied %q", got) } d.keys("esc") if !strings.Contains(d.screen(), "Platform team") { t.Errorf("filter not cleared:\n%s", d.screen()) } d.keys("shift+tab") if d.m.focus != paneSources { t.Errorf("back to %v, want Sources", d.m.focus) } } func TestDetailsFollowFocus(t *testing.T) { t.Parallel() d := newDriver(t) d.keys("3") // a secret if !strings.Contains(d.screen(), "version 3f9c2a") { t.Fatalf("secret not shown:\n%s", d.screen()) } d.keys("2") // its vault if scr := d.screen(); !strings.Contains(scr, "name kv-app-prod") || strings.Contains(scr, "version 3f9c2a") { t.Errorf("vault not described:\n%s", scr) } d.keys("1") // its subscription if scr := d.screen(); !strings.Contains(scr, "name platform-prod") || !strings.Contains(scr, "vaults 3") { t.Errorf("subscription not described:\n%s", scr) } // Into the fourth pane: it keeps describing the subscription, and // shift+tab goes back there. d.keys("4") if !strings.Contains(d.screen(), "name platform-prod") { t.Errorf("lost the subscription:\n%s", d.screen()) } d.keys("shift+tab") if d.m.focus != paneSources { t.Errorf("back to %v", d.m.focus) } // A value revealed from a left pane stays in view until hidden. d.keys("space") if !strings.Contains(d.screen(), "correct-horse-battery-staple") { t.Errorf("value not shown:\n%s", d.screen()) } } func TestStatusPane(t *testing.T) { t.Parallel() var opened []string d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json", OpenURL: func(u string) error { opened = append(opened, u); return nil }}, fake.Azure(0), signedOut{fake.Azure(0)}) if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") { t.Errorf("status line:\n%s", d.screen()) } d.keys("0") if d.m.focus != paneStatus { t.Fatalf("focus %v", d.m.focus) } scr := d.screen() for _, want := range []string{"version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required", "/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} { if !strings.Contains(scr, want) { t.Errorf("missing %q:\n%s", want, scr) } } // Into its rows: L signs in to the failing account, y copies a path, // o opens a link. d.keys("enter") if d.m.focus != paneDetail { t.Fatalf("enter: focus %v", d.m.focus) } d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Provider == "Azure:admin@contoso.com/t1" }) if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { t.Errorf("L signs in to %+v", a) } d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "config" }) d.keys("y") if got := d.clip().content; got != "/cfg/config.yaml" { t.Errorf("y copied %q", got) } d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "docs" }) d.keys("o") if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") { t.Errorf("o opened %q", opened) } d.keys("shift+tab") if d.m.focus != paneStatus { t.Errorf("back to %v", d.m.focus) } } // empty is an account with nothing in it, like Databricks without profiles. type empty struct{ provider.Provider } func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil } func TestGetStarted(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)}) scr := d.screen() for _, want := range []string{"get started", "Azure az login", "L runs it here", "databricks auth login --host", "lazysecrets --demo"} { if !strings.Contains(scr, want) { t.Errorf("missing %q:\n%s", want, scr) } } d.keys("l", "j") if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { t.Errorf("L signs in to %+v", a) } d.keys("y") if got := d.clip().content; got != "az login" { t.Errorf("y copied %q", got) } // Once an account has something, an empty one says what it needs. d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)}) d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() }) d.send(nil) if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") { t.Errorf("empty Databricks:\n%s", scr) } }