Files
lazysecrets/internal/provider/repo/repo_test.go
T
keb 17d2705e12 feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor (#1)
`e` on a file in a repo opens it; on a secret, it opens the file at that key's line.

- **SOPS files**: `sops edit` in the terminal, then back to lazysecrets with the keys reloaded. Exit 200 (nothing changed) shows "unchanged".
- **Other files inside nvim** (`$NVIM` set): lazysecrets asks that nvim, over `--remote-expr`, to open the file once lazysecrets' terminal closes, then quits. The file lands in the window you came from, not the closing float. Started from a shell in a terminal, it opens in the previous window straight away. No change to the nvim plugin is needed.
- **Otherwise**: `$VISUAL`/`$EDITOR` in the terminal (with `+N`, `file:N` or `-g file:N` for editors that take a line), else the system default app.

The repo provider now records each key's line (YAML node lines, env/ini line numbers) behind a new optional `provider.Files` interface. Other providers show "only SOPS and .env files in repos can be opened".

The nvim handoff was tested against a headless nvim 0.12: a floating terminal closed on exit the way snacks does, and a shell in a split. A file name with a space and a quote opened at the right line in both. Unit tests cover lines per format, the nvim, sops and default-app paths, and the editor line arguments.

Note: `e` is a new default key, so a config that already binds `e` to something else now gets the "bound to both" error, until one of them is rebound.

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

480 lines
16 KiB
Go

package repo
import (
"context"
"errors"
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A sops-shaped YAML file: listing reads its keys and metadata only.
const sopsYAML = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
list:
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
public_unencrypted: hello
sops:
age:
- recipient: age1xxxx
enc: x
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
version: 3.13.3
`
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
// gitRepo makes a git repository with files, all committed, and returns
// its root.
func gitRepo(t *testing.T, files map[string]string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
for name, body := range files {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"}, {"add", "."},
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
return root
}
func names[T any](items []T, name func(T) string) []string {
var out []string
for _, it := range items {
out = append(out, name(it))
}
return out
}
func TestRepoFilesAndKeys(t *testing.T) {
root := gitRepo(t, map[string]string{
"secrets/app.yaml": sopsYAML,
"svc/billing/prod.env": sopsEnv,
"svc/billing/plain.yaml": "not: sops\n",
"README.md": "# hi\n",
})
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
ctx := context.Background()
srcs, err := p.Sources(ctx)
if err != nil || len(srcs) != 1 {
t.Fatalf("sources %v, %v", srcs, err)
}
src := srcs[0]
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
t.Errorf("source %+v", src)
}
files, err := p.Containers(ctx, src)
if err != nil {
t.Fatal(err)
}
// Only sops files; those under where it started first.
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
t.Errorf("files %v", got)
}
if files[1].Detail != "sops · age" {
t.Errorf("detail %q", files[1].Detail)
}
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
t.Errorf("keys %v", got)
}
if keys[0].Updated.IsZero() {
t.Error("lastmodified not read")
}
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
t.Errorf("extract %q", extract)
}
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
t.Errorf("env keys %v", got)
}
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
t.Errorf("locate: %q %v", id, ok)
}
}
func TestWhichFiles(t *testing.T) {
files := map[string]string{
"secrets/a.yaml": sopsYAML,
"other/b.yaml": sopsYAML,
"other/testdata/c.yaml": sopsYAML,
}
list := func(root string, s Settings) []string {
s.Dir = root
p := New(s)
srcs, _ := p.Sources(context.Background())
cs, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(cs, func(c provider.Container) string { return c.Name })
slices.Sort(got)
return got
}
// No rules: every tracked sops file.
root := gitRepo(t, files)
if got := list(root, Settings{}); len(got) != 3 {
t.Errorf("whole repo: %v", got)
}
// The config's globs.
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
t.Errorf("config globs: %v", got)
}
// .sops.yaml says where sops files go.
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
for k, v := range files {
withRules[k] = v
}
root = gitRepo(t, withRules)
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
t.Errorf(".sops.yaml rules: %v", got)
}
// The repo's own .lazysecrets.yaml wins over both.
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
root = gitRepo(t, withRules)
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
t.Errorf(".lazysecrets.yaml: %v", got)
}
}
func TestNormalizeRemote(t *testing.T) {
for in, want := range map[string]string{
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
"git@github.com:Team/Repo.git": "github.com/team/repo",
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
} {
if got, _ := normalizeRemote(in); got != want {
t.Errorf("%s: %q, want %q", in, got, want)
}
}
}
// With sops and age installed: real encryption and decryption, with a
// throwaway key.
func TestDecrypt(t *testing.T) {
for _, bin := range []string{"sops", "age-keygen"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skip("needs " + bin)
}
}
dir := t.TempDir()
keyFile := filepath.Join(dir, "key.txt")
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
t.Fatalf("age-keygen: %v %s", err, out)
}
b, _ := os.ReadFile(keyFile)
var recipient string
for _, f := range strings.Fields(string(b)) {
if strings.HasPrefix(f, "age1") {
recipient = f
}
}
plain := filepath.Join(dir, "plain.yaml")
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
if err != nil {
t.Fatal(err)
}
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
for _, k := range keys {
v, err := p.Value(ctx, k)
if err != nil {
t.Fatalf("%s: %v", k.Name, err)
}
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
if v.Reveal() != want {
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
}
}
// From a link, without the listing: the extract path from the name.
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
if err != nil || v.Reveal() != "first" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
t.Errorf("no key: %v", err)
}
}
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
if err != nil || len(srcs) != 0 {
t.Errorf("sources %v, %v", srcs, err)
}
}
func TestPlainEnvFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n.env.local\n",
".env.example": "TOKEN=changeme\n",
"secrets/app.yaml": sopsYAML,
})
// Ignored, as they usually are: written after the commit.
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
files, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, c := range files {
got[c.Name] = c.Detail
}
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
}
var env provider.Container
for _, c := range files {
if c.Name == ".env" {
env = c
}
}
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
if len(keys) != len(want) {
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
}
for _, k := range keys {
v, err := p.Value(ctx, k) // no sops involved
if err != nil || v.Reveal() != want[k.Name] {
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
}
}
// .sops.yaml rules limit SOPS files, not plain env files.
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
t.Errorf("with rules elsewhere: %v", names)
}
}
func TestRoots(t *testing.T) {
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
// A folder of repos: two at the top, one under an org folder.
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(withSecrets, filepath.Join(root, "infra"))
os.Rename(without, filepath.Join(root, "docs"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(nested, filepath.Join(root, "acme", "billing"))
outside := t.TempDir() // not in a repo
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.Name })
slices.Sort(got)
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
t.Errorf("repos under the root: %v (want the two with secret files)", got)
}
// Inside a repo: the same repos, that one first and marked Here.
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
got = names(srcs, func(s provider.Source) string { return s.Name })
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
}
}
func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
_ = other
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.ID })
if !slices.Equal(got, []string{here, starred}) {
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
}
}
// sops' output when no key opens the file, as sops 3.13 prints it.
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
Group 0: FAILED
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
- | failed to create reader for decrypting sops data key with
| age: no identity matched any of the recipients. Did not find
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
- | failed to decrypt sops data key with Azure Key Vault key
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
Recovery failed because no master key was able to decrypt the file. In
order for SOPS to recover the file, at least one key has to be successful,
but none were.
`
func TestSopsError(t *testing.T) {
got := sopsError(noKeyStderr, errors.New("exit status 128"))
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
t.Errorf("not sops' whole message:\n%s", got)
}
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
t.Errorf("without output: %q", got)
}
}
func TestOriginURL(t *testing.T) {
dir := t.TempDir()
write := func(s string) string {
p := filepath.Join(dir, "config")
os.WriteFile(p, []byte(s), 0o644)
return p
}
for _, c := range []struct {
config, url string
ok bool
}{
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
{"[core]\n\tbare = false\n", "", true}, // no origin
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
} {
url, ok := originURL(write(c.config))
if url != c.url || ok != c.ok {
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
}
}
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
t.Error("a missing config should leave it to git")
}
}
func TestEnvFilesSkipPackages(t *testing.T) {
root := gitRepo(t, map[string]string{
"app/main.go": "package main\n",
"vendor/lib/x.go": "package lib\n",
"web/node_modules/pkg/index.js": "x\n",
"secrets/sops.yaml": sopsYAML,
"web/node_modules/pkg/sops.yaml": sopsYAML,
})
// Untracked, as .env files usually are: next to tracked code, in a
// package folder, and in a folder with nothing tracked.
for f, body := range map[string]string{
"app/.env": "A=1\n",
"web/node_modules/pkg/.env": "B=2\n",
"vendor/lib/.env": "C=3\n",
"scratch/.env": "D=4\n",
} {
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(context.Background())
files, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(files, func(c provider.Container) string { return c.Name })
slices.Sort(got)
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
}
}
func TestFile(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n",
"secrets/app.yaml": sopsYAML,
})
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
p := New(Settings{Dir: root, Sops: "/opt/sops"})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
files, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
lines := map[string]int{}
for _, c := range files {
f, err := p.File(c, nil)
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
t.Errorf("%s: %+v, %v", c.Name, f, err)
}
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
t.Errorf("%s: sops %q", c.Name, f.Sops)
}
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
for _, k := range keys {
f, err := p.File(c, &k)
if err != nil {
t.Fatal(err)
}
lines[c.Name+" "+k.Name] = f.Line
}
}
want := map[string]int{
".env TOKEN": 2, ".env URL": 4,
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
}
if !maps.Equal(lines, want) {
t.Errorf("lines %v\nwant %v", lines, want)
}
}