feat: share env files as encrypted links
S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
26 files changed
+1541
-98
No files matched your search
@@ -81,6 +81,9 @@ type Options struct {
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
// ShareKey is the config's share.key: the secret that encrypts shared
|
||||
// env files, for repos whose .lazysecrets.yaml names none.
|
||||
ShareKey string
|
||||
// Dir is the folder lazysecrets runs in: it goes back to where it was
|
||||
// left there, unless it was started on a link. "" doesn't.
|
||||
Dir string
|
||||
@@ -270,6 +273,10 @@ type Model struct {
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
|
||||
// shareKey is the config's share.key; lastShareKey the key last
|
||||
// typed when sharing, offered next time.
|
||||
shareKey, lastShareKey string
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
clipChecked time.Time
|
||||
@@ -455,6 +462,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.spinning = true
|
||||
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.shareKey = opts.ShareKey
|
||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
@@ -570,6 +578,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
|
||||
case shareFilesMsg:
|
||||
cmds = append(cmds, m.handleShareFiles(msg))
|
||||
|
||||
case sealedMsg:
|
||||
cmds = append(cmds, m.handleSealed(msg))
|
||||
|
||||
case shareOpenedMsg:
|
||||
cmds = append(cmds, m.handleShareOpened(msg))
|
||||
|
||||
case tea.PasteMsg:
|
||||
cmds = append(cmds, m.handlePaste(msg))
|
||||
|
||||
|
||||
@@ -78,6 +78,9 @@ const (
|
||||
// just open it.
|
||||
// With ask false, vault and scope links open straight away.
|
||||
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
|
||||
if r.IsShare() {
|
||||
return m.offerShare(r)
|
||||
}
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
@@ -607,6 +610,9 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
if m.canShareFiles(s) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
@@ -629,6 +635,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
@@ -653,6 +664,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
)
|
||||
|
||||
// Sharing env files as an encrypted lazysecrets://share link (package
|
||||
// share), and opening one: the files are encrypted with a secret's value,
|
||||
// so whoever can read that secret can open the link.
|
||||
|
||||
// sharing is a share being made: the files read, and the key to use.
|
||||
type sharing struct {
|
||||
repo provider.Source
|
||||
files []share.File
|
||||
key ref.Ref // zero until chosen
|
||||
}
|
||||
|
||||
type shareFilesMsg struct {
|
||||
sharing
|
||||
err error
|
||||
}
|
||||
|
||||
type sealedMsg struct {
|
||||
link ref.Ref
|
||||
n int
|
||||
err error
|
||||
}
|
||||
|
||||
// canShareFiles reports whether src's env files can be shared: it's a
|
||||
// repo whose files are on disk.
|
||||
func (m Model) canShareFiles(src provider.Source) bool {
|
||||
_, ok := m.providerFor(src).(provider.Files)
|
||||
return src.Kind == provider.KindRepo && ok
|
||||
}
|
||||
|
||||
// shareFiles shares env files of a repo: cs, or with cs nil all its
|
||||
// plain ones.
|
||||
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
||||
p := m.providerFor(src)
|
||||
files, ok := p.(provider.Files)
|
||||
if !ok {
|
||||
m.flash = warning("these files can't be shared")
|
||||
return nil
|
||||
}
|
||||
general, timeout := m.shareKey, m.timeout
|
||||
m.flash = flash{text: "reading the files…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
msg := shareFilesMsg{sharing: sharing{repo: src}}
|
||||
if cs == nil {
|
||||
all, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
for _, c := range all {
|
||||
if c.Detail == provider.DetailPlainEnv {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
}
|
||||
if len(cs) == 0 {
|
||||
msg.err = errors.New(src.Name + " has no plain env files")
|
||||
return msg
|
||||
}
|
||||
}
|
||||
for _, c := range cs {
|
||||
f, err := files.File(c, nil)
|
||||
if err == nil {
|
||||
var b []byte
|
||||
b, err = os.ReadFile(f.Path)
|
||||
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
||||
}
|
||||
if err != nil {
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
}
|
||||
key, ok, err := share.DefaultKey(src.ID, general)
|
||||
if ok {
|
||||
msg.key = key
|
||||
}
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||
case msg.key == (ref.Ref{}):
|
||||
return m.askShareKey(msg.sharing)
|
||||
default:
|
||||
m.confirmShare(msg.sharing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// confirmShare says what the link will hold before making it.
|
||||
func (m *Model) confirmShare(s sharing) {
|
||||
lines := []string{m.st.Muted.Render(plural(len(s.files), "env file") + " from " + s.repo.Name + ":")}
|
||||
for _, f := range s.files {
|
||||
lines = append(lines, " "+f.Path)
|
||||
}
|
||||
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
||||
m.showMenu("Share as an encrypted link", lines, []option{
|
||||
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }},
|
||||
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }},
|
||||
})
|
||||
}
|
||||
|
||||
// askShareKey asks for the link to the secret to encrypt with.
|
||||
func (m *Model) askShareKey(s sharing) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "key: "
|
||||
in.Placeholder = "lazysecrets:// link to a secret"
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
in.SetValue(m.lastShareKey)
|
||||
if s.key != (ref.Ref{}) {
|
||||
in.SetValue(s.key.String())
|
||||
}
|
||||
in.CursorEnd()
|
||||
m.prompt = &modal{
|
||||
title: "Encrypt with which secret?", input: &in,
|
||||
lines: []string{
|
||||
m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."),
|
||||
m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."),
|
||||
},
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
key, err := share.ParseKey(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
m.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.lastShareKey = key.String()
|
||||
s.key = key
|
||||
m.confirmShare(s)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
// seal reads the key and encrypts the files with it.
|
||||
func (m *Model) seal(s sharing) tea.Cmd {
|
||||
read := m.keyReader()
|
||||
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
v, version, err := read(s.key)
|
||||
if err != nil {
|
||||
return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)}
|
||||
}
|
||||
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files)
|
||||
return sealedMsg{link: link, n: len(s.files), err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.dismissedLink = msg.link.String() // not offered back on focus
|
||||
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
|
||||
}
|
||||
|
||||
// keyReader reads a key's value with the providers, the account that
|
||||
// last read it first, off the UI goroutine.
|
||||
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
||||
providers, st, timeout := m.providers, m.store, m.timeout
|
||||
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
k := key
|
||||
k.Version = ""
|
||||
node := st.Node(k.String())
|
||||
return ref.Read(ctx, providers, key, node.Account, node.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// --- opening ---
|
||||
|
||||
type shareOpenedMsg struct {
|
||||
sealed share.Sealed
|
||||
files []share.File
|
||||
dir string // the repo's clone; "" when it isn't here
|
||||
err error
|
||||
}
|
||||
|
||||
// offerShare says what a share link holds before decrypting it: links
|
||||
// can come from anywhere, so nothing is read or written unasked.
|
||||
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
||||
s, err := share.Read(r)
|
||||
if err != nil {
|
||||
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||
return nil
|
||||
}
|
||||
from := ""
|
||||
if s.Repo != "" {
|
||||
from = " from " + s.Repo
|
||||
}
|
||||
m.showMenu("Shared env files", []string{
|
||||
m.st.Muted.Render(plural(s.Files, "env file") + from),
|
||||
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
|
||||
}, []option{
|
||||
{key: "o", label: "Decrypt them…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// openShare reads the key, decrypts the files and finds the repo's clone.
|
||||
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
||||
read := m.keyReader()
|
||||
providers, timeout := m.providers, m.timeout
|
||||
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
msg := shareOpenedMsg{sealed: s}
|
||||
v, _, err := read(s.Key)
|
||||
if err != nil {
|
||||
msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err)
|
||||
return msg
|
||||
}
|
||||
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
|
||||
return msg
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
for _, p := range providers {
|
||||
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
|
||||
if root, found, _ := loc.Locate(ctx, s.Repo); found {
|
||||
msg.dir = root
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
|
||||
case msg.dir == "":
|
||||
return m.askShareDir(msg)
|
||||
default:
|
||||
m.offerWrite(msg.files, msg.dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// askShareDir asks where to save files from a repo that isn't here.
|
||||
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "folder: "
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
dir := m.placeDir
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
}
|
||||
in.SetValue(dir)
|
||||
in.CursorEnd()
|
||||
lines := []string{m.st.Muted.Render("Their paths are from the repo's root.")}
|
||||
if msg.sealed.Repo != "" {
|
||||
lines = append([]string{m.st.Muted.Render(msg.sealed.Repo + " isn't among your repos: save them where?")}, lines...)
|
||||
}
|
||||
m.prompt = &modal{
|
||||
title: "Save the files", input: &in, lines: lines,
|
||||
submit: func(m *Model, text string) tea.Cmd {
|
||||
m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return m.prompt.input.Focus()
|
||||
}
|
||||
|
||||
// offerWrite shows where each file goes, then writes them as chosen:
|
||||
// files there already are replaced only when asked.
|
||||
func (m *Model) offerWrite(files []share.File, dir string) {
|
||||
targets, err := share.Plan(dir, files)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||
return
|
||||
}
|
||||
var lines []string
|
||||
exist := 0
|
||||
for _, t := range targets {
|
||||
line := " " + t.Path + m.st.Muted.Render(" · new")
|
||||
if t.Exists {
|
||||
exist++
|
||||
line = " " + t.Path + m.st.Muted.Render(" · replaces the one there")
|
||||
}
|
||||
if t.Exposed {
|
||||
line += m.st.Error.Render(" · not ignored by git")
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
||||
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd {
|
||||
n := 0
|
||||
for _, t := range targets {
|
||||
if onlyNew && t.Exists {
|
||||
continue
|
||||
}
|
||||
if err := t.Write(); err != nil {
|
||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
n++
|
||||
}
|
||||
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var opts []option
|
||||
switch {
|
||||
case exist == 0:
|
||||
opts = append(opts, option{key: "w", label: "Save them", run: write(false)})
|
||||
case exist == len(targets):
|
||||
opts = append(opts, option{key: "w", label: "Replace " + map[bool]string{true: "it", false: "them"}[exist == 1], run: write(false)})
|
||||
default:
|
||||
opts = append(opts,
|
||||
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
|
||||
option{key: "n", label: "Save only the new " + plural(len(targets)-exist, "file"), run: write(true)},
|
||||
)
|
||||
}
|
||||
m.showMenu("Save the shared files", lines, opts)
|
||||
}
|
||||
|
||||
// expandHome expands a leading ~/ to the home folder.
|
||||
func expandHome(dir string) string {
|
||||
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, rest)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
)
|
||||
|
||||
// scryptWait is how long the driver waits for encrypting and decrypting,
|
||||
// which take about a second on purpose.
|
||||
const scryptWait = 20 * time.Second
|
||||
|
||||
// shareKeyLink is a demo secret, long enough to be a key.
|
||||
const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password"
|
||||
|
||||
// cloneWith makes a clone of git.example.com/team/app holding files.
|
||||
func cloneWith(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := gitRepoWith(t, "https://git.example.com/team/app.git")
|
||||
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
|
||||
for name, data := range files {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
|
||||
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// shareFrom shares a repo's env files from lazysecrets started in it,
|
||||
// returning the link copied.
|
||||
func shareFrom(t *testing.T, root string) string {
|
||||
t.Helper()
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
d.wait = scryptWait
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
|
||||
t.Fatalf("selected %v, want the repo", src.ID)
|
||||
}
|
||||
d.keys("S", "f")
|
||||
// No share.key set: it asks which secret.
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil {
|
||||
t.Fatalf("no key prompt: %s", d.m.flash.text)
|
||||
}
|
||||
d.send(tea.PasteMsg{Content: shareKeyLink})
|
||||
d.keys("enter")
|
||||
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
|
||||
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
|
||||
}
|
||||
d.keys("c")
|
||||
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||
}
|
||||
return clip.content
|
||||
}
|
||||
|
||||
func TestShareFilesRoundTrip(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{
|
||||
".env": "DB_PASSWORD=hunter2\n",
|
||||
"prod.env": "TOKEN=abc\n",
|
||||
}))
|
||||
|
||||
// A colleague's clone: .env is there already, prod.env isn't.
|
||||
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
|
||||
d.wait = scryptWait
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
|
||||
t.Fatalf("not offered:\n%s", d.screen())
|
||||
}
|
||||
d.keys("o")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
|
||||
t.Fatalf("no save menu (%s)", d.m.flash.text)
|
||||
}
|
||||
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
|
||||
t.Errorf("save menu doesn't say what happens:\n%s", s)
|
||||
}
|
||||
d.keys("n") // only the new one
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
|
||||
t.Errorf(".env replaced: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
|
||||
t.Errorf("prod.env: %q", b)
|
||||
}
|
||||
|
||||
// Again, replacing.
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
d.keys("o", "w")
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
|
||||
t.Errorf(".env not replaced: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}))
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
||||
d.wait = scryptWait
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
d.keys("o")
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
|
||||
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter", "w")
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
|
||||
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkGarbled(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
|
||||
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
|
||||
t.Errorf("not reported:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user