Files
lazysecrets/internal/ui/app/app_test.go
T
keb 52b6257dd5 feat: show who an Azure account signs in as, in Details on its row (#5)
On an Azure account's group row, the Details pane now describes the account:

| field | from |
|---|---|
| name | token `name` |
| user (and "service principal") | az's account list |
| tenant: name · ID | az's account list (the name is borrowed from another entry in the same tenant when az left it out) |
| object ID | token `oid`; `y` on the row copies it |
| sign-in: MFA or not, and how | token `amr` |
| directory roles | token `wids`: built-in roles by name, others as `role 1a2b3c4d`, the default member role left out |
| subs: count · default | az's account list |
| cloud | az's account list, when not AzureCloud |

Nothing is fetched for it: the claims are the ones from the token lazysecrets already gets for the account's own tenant, read without verification. The token itself is never stored or shown. Before the first token, only the az-list fields show. A failing account shows its sign-in problem there instead (from #4).

New optional `provider.Accounts` interface; `azure.Provider` implements it. Tests: the fields from claims and az's list, sign-in methods, the UI showing it on the group row only, and `y` copying the object ID. Checked against a real signed-in account (name, MFA · password, default subscription) and an expired one (az-list fields only, since there's no token).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #5
2026-10-11 10:09:17 +02:00

1102 lines
33 KiB
Go

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"
)
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: 30})
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)
}
if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") {
t.Errorf("detail source line missing icon:\n%s", 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 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)})
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)
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(), "Select a secret") {
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}, {"l", paneSources}, {"h", 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) {
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 !strings.Contains(d.screen(), hint) || !strings.Contains(d.screen(), "press L to run az login --tenant t1") {
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())
}
}