Files
lazysecrets/internal/ui/app/repo_test.go
T
kebandClaude Opus 5.5 331e53fb49
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s
feat: Go to scopes are grouped like the Sources pane, an account at a time
The scope list follows the Sources pane: All, then each account (or
Databricks, or Repos) with its sources under it, in place of All Azure,
All Databricks and All repos. An account scope searches and loads only
that account. ctrl+s on a group header narrows to its group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:51:29 +02:00

345 lines
12 KiB
Go

package app
import (
tea "charm.land/bubbletea/v2"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
const sopsFile = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
sops:
age:
- recipient: age1xxxx
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
`
// gitRepoWith makes a git repository holding a SOPS file, with a remote.
func gitRepoWith(t *testing.T, remote string) string {
t.Helper()
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", remote}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
return root
}
func TestCurrentRepoFirst(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git")
mem, _ := store.Load("")
mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) {
n.Source, n.StarredBy = other, []string{"Repos"}
})
d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}}))
// Grouped: the repo it started in on a row of its own on top,
// selected, then the other repos under Repos, without it.
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.ID)
}
}
if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("no Repo · name row:\n%s", d.screen())
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
// Flat: it sorts first, labelled.
d.keys("`")
if !strings.Contains(d.screen(), filepath.Base(here)+" · here") {
t.Errorf("no here label in the flat list:\n%s", d.screen())
}
}
func TestRepoSource(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", "https://git.example.com/team/infra.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...)
scr := d.screen()
for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} {
if !strings.Contains(scr, want) {
t.Fatalf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "f")
key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password"
if !d.m.store.Node(key).StarredFor("Repos") {
t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes())
}
d.keys("s")
if got := d.clip().content; got != key {
t.Errorf("shared link %q", got)
}
// Opening the link finds the repo by its remote and goes there.
d.keys("1")
r, _ := ref.Parse(key)
d.send(OpenLink(r))
d.keys("o")
if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" {
t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen())
}
}
func TestReposLoadThemselves(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
// Started elsewhere, so the repo isn't the selected source.
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}}))
d.openSearch()
d.searchAll()
d.typeText("db.password")
if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") {
t.Errorf("repo keys not searchable without loading them:\n%s", d.screen())
}
}
func TestGroupsStartCollapsed(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
d := newDriverOpts(t, Options{CollapseGroups: true},
fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here}))
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.Name)
}
}
// Folded groups, and the repo it started in on top, selected, so its
// files show.
if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
d.keys("j", "enter") // unfold Azure
if d.sourceCount() < 2 {
t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen())
}
}
func TestCurrentRepoBeforeTheRest(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
p := repo.New(repo.Settings{Dir: here})
d := newDriverOpts(t, Options{}, fake.Azure(0), p)
// Sources asked again, nothing answered yet but the started-in repo
// on its own (provider.Current): it shows on top already.
d.m.loadSources()
src, ok := p.Current(context.Background())
if !ok {
t.Fatal("no current repo")
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
d.send(currentMsg{gen: d.m.sourcesGen, source: src})
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("the started-in repo waits for the others:\n%s", d.screen())
}
}
func TestSearchAllRepos(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
a := gitRepoWith(t, "https://git.example.com/team/alpha.git")
b := gitRepoWith(t, "https://git.example.com/team/beta.git")
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}}))
d.openSearch()
d.send(ctrl('h'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != accountScope+"Repos" {
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
azure := func() (n int) {
for row := range d.m.cache.containers {
if p, _ := splitSourceRow(row); p != "Repos" {
n++
}
}
return n
}
azureBefore := azure()
d.typeText("password")
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
}
if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
}
if !strings.Contains(d.screen(), "Results in Repos") {
t.Errorf("title:\n%s", d.screen())
}
}
func TestEditFile(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
envPath := filepath.Join(root, "secrets", ".env")
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
var opened []string
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
d.m.getenv = func(k string) string { return env[k] }
var nvim []string
d.m.nvimOpen = func(server, path string, line int) error {
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
return nil
}
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
d.keys("2")
if _, c, _ := d.selected(); c != "secrets/.env" {
t.Fatalf("selected %q\n%s", c, d.screen())
}
d.keys("e")
d.keys("3", "e")
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
if !slices.Equal(nvim, want) {
t.Errorf("nvim got %q\nwant %q", nvim, want)
}
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
t.Errorf("after opening in nvim: %#v, want quit", msg)
}
// SOPS files go to sops edit, in the terminal, even inside nvim.
nvim = nil
d.keys("2", "j")
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
t.Fatalf("selected %q", c)
}
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
}
// Neither nvim nor an editor: the system's default app.
delete(env, "NVIM")
d.keys("k", "e")
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
t.Errorf("opened %q\n%s", opened, d.screen())
}
// Done editing: the file's secrets reload.
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
if !strings.Contains(d.screen(), "closed .env") {
t.Errorf("after editing:\n%s", d.screen())
}
// Other providers have no files.
az := newDriver(t, fake.Azure(0))
az.keys("2", "e")
if !strings.Contains(az.screen(), "only SOPS and .env files") {
t.Errorf("azure:\n%s", az.screen())
}
}
func TestEditorArgs(t *testing.T) {
t.Parallel()
f := provider.File{Path: "/r/.env", Line: 7}
for editor, want := range map[string][]string{
"nvim": {"+7", "/r/.env"},
"/usr/bin/vim -p": {"+7", "/r/.env"},
"hx": {"/r/.env:7"},
"code --wait": {"-g", "/r/.env:7"},
"open -a TextEdit": {"/r/.env"},
} {
if got := editorArgs(editor, f); !slices.Equal(got, want) {
t.Errorf("%s: %q, want %q", editor, got, want)
}
}
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
t.Errorf("no line: %q", got)
}
}
func TestEditorAtLine(t *testing.T) {
t.Parallel()
for _, c := range []struct {
editor string
line int
want string
ok bool
}{
{"nvim", 4, "nvim +4", true},
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
{"nvim", 0, "", false},
} {
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
}
}
}