5169 lines
167 KiB
Go
5169 lines
167 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gioui.org/io/key"
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/unit"
|
|
"gioui.org/widget"
|
|
|
|
"git.julianfamily.org/keepassgo/apiapproval"
|
|
"git.julianfamily.org/keepassgo/apiaudit"
|
|
"git.julianfamily.org/keepassgo/apitokens"
|
|
"git.julianfamily.org/keepassgo/clipboard"
|
|
"git.julianfamily.org/keepassgo/passwords"
|
|
"git.julianfamily.org/keepassgo/session"
|
|
"git.julianfamily.org/keepassgo/vault"
|
|
"git.julianfamily.org/keepassgo/webdav"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
stateDir, err := os.MkdirTemp("", "keepassgo-test-state-")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if err := os.Setenv("KEEPASSGO_STATE_DIR", stateDir); err != nil {
|
|
_ = os.RemoveAll(stateDir)
|
|
panic(err)
|
|
}
|
|
code := m.Run()
|
|
_ = os.RemoveAll(stateDir)
|
|
os.Exit(code)
|
|
}
|
|
|
|
func waitForBackgroundResult(t *testing.T, u *ui) backgroundActionResult {
|
|
t.Helper()
|
|
select {
|
|
case result := <-u.backgroundResults:
|
|
return result
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timed out waiting for background action result")
|
|
return backgroundActionResult{}
|
|
}
|
|
}
|
|
|
|
type summarySession struct {
|
|
model vault.Model
|
|
hasVault bool
|
|
locked bool
|
|
remote bool
|
|
}
|
|
|
|
func (s summarySession) Current() (vault.Model, error) {
|
|
if s.locked {
|
|
return vault.Model{}, session.ErrLocked
|
|
}
|
|
return s.model, nil
|
|
}
|
|
|
|
func (s summarySession) Save(vault.Model) error { return nil }
|
|
func (s summarySession) SaveAs(string, vault.MasterKey) error { return nil }
|
|
func (s summarySession) Create(vault.MasterKey) error { return nil }
|
|
func (s summarySession) Open(string, vault.MasterKey) error { return nil }
|
|
func (s summarySession) OpenRemote(*webdav.Client, string, vault.MasterKey) error { return nil }
|
|
func (s summarySession) ChangeMasterKey(vault.MasterKey) error { return nil }
|
|
func (s summarySession) Lock() error { return nil }
|
|
func (s summarySession) Unlock(vault.MasterKey) error { return nil }
|
|
func (s summarySession) HasVault() bool { return s.hasVault }
|
|
func (s summarySession) IsLocked() bool { return s.locked }
|
|
func (s summarySession) IsRemote() bool { return s.remote }
|
|
|
|
func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "1", Title: "Bellagio", Username: "rustyryan", URL: "https://bellagio.example.invalid", Path: []string{"Crew", "Internet"}},
|
|
{ID: "2", Title: "Vault Console", Username: "dannyocean", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
|
{ID: "3", Title: "Surveillance Console", Username: "codex", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
|
},
|
|
})
|
|
|
|
u.state.NavigateToPath([]string{"Crew", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console"}) {
|
|
t.Fatalf("filteredTitles() = %v, want [Bellagio Vault Console]", got)
|
|
}
|
|
|
|
u.search.SetText("surveillance")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Surveillance Console"}) {
|
|
t.Fatalf("search filteredTitles() = %v, want [Surveillance Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIMasterPasswordUsesPasswordInputHint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("phone", &session.Manager{})
|
|
if got := u.masterPassword.InputHint; got != key.HintPassword {
|
|
t.Fatalf("masterPassword.InputHint = %v, want %v", got, key.HintPassword)
|
|
}
|
|
}
|
|
|
|
func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
modes := []string{"desktop", "phone"}
|
|
for _, mode := range modes {
|
|
mode := mode
|
|
t.Run(mode, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel(mode, vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
|
|
{ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
|
},
|
|
Templates: []vault.Entry{
|
|
{ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}},
|
|
{ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}},
|
|
},
|
|
RecycleBin: []vault.Entry{
|
|
{ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}},
|
|
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
|
},
|
|
})
|
|
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.search.SetText("climate")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) {
|
|
t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got)
|
|
}
|
|
|
|
u.showTemplatesSection()
|
|
u.state.NavigateToPath([]string{"Templates", "Web"})
|
|
u.search.SetText("infra")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
|
|
t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got)
|
|
}
|
|
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) {
|
|
t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got)
|
|
}
|
|
|
|
u.showRecycleBinSection()
|
|
u.search.SetText("climate")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) {
|
|
t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got)
|
|
}
|
|
if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) {
|
|
t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUICurrentVaultSummary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("local", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("phone", summarySession{hasVault: true})
|
|
u.vaultPath.SetText("/vaults/family.kdbx")
|
|
u.recentVaultGroups["/vaults/family.kdbx"] = []string{"Root", "Internet"}
|
|
|
|
got := u.currentVaultSummary()
|
|
want := vaultSummary{
|
|
Title: "family.kdbx",
|
|
Detail: "/vaults/family.kdbx",
|
|
Context: "Resume in: Root / Internet",
|
|
}
|
|
if got != want {
|
|
t.Fatalf("currentVaultSummary() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("remote", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("phone", summarySession{hasVault: true, remote: true})
|
|
u.remoteBaseURL.SetText("https://dav.example.com")
|
|
u.remotePath.SetText("vaults/home.kdbx")
|
|
u.recentRemotes = []recentRemoteRecord{{
|
|
BaseURL: "https://dav.example.com",
|
|
Path: "vaults/home.kdbx",
|
|
LastGroup: []string{"Root", "Shared"},
|
|
}}
|
|
|
|
got := u.currentVaultSummary()
|
|
want := vaultSummary{
|
|
Title: "home.kdbx · dav.example.com",
|
|
Detail: "https://dav.example.com",
|
|
Context: "Resume in: Root / Shared",
|
|
}
|
|
if got != want {
|
|
t.Fatalf("currentVaultSummary() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Templates: []vault.Entry{
|
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
|
{ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}},
|
|
{ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
|
|
},
|
|
})
|
|
|
|
u.showTemplatesSection()
|
|
u.state.NavigateToPath([]string{"Templates", "Web"})
|
|
u.search.SetText("ssh")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
|
|
t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got)
|
|
}
|
|
|
|
u.search.SetText("")
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) {
|
|
t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
runs := 0
|
|
|
|
u.runBackgroundAction("open vault", func() (func() error, error) {
|
|
runs++
|
|
close(started)
|
|
<-release
|
|
return func() error { return nil }, nil
|
|
})
|
|
<-started
|
|
|
|
u.runBackgroundAction("open vault", func() (func() error, error) {
|
|
runs++
|
|
return func() error { return nil }, nil
|
|
})
|
|
|
|
if runs != 1 {
|
|
t.Fatalf("background runs = %d, want 1", runs)
|
|
}
|
|
if got := u.loadingMessage; got != "Open vault..." {
|
|
t.Fatalf("loadingMessage = %q, want %q", got, "Open vault...")
|
|
}
|
|
|
|
close(release)
|
|
result := waitForBackgroundResult(t, u)
|
|
u.applyBackgroundResult(result)
|
|
if got := u.loadingMessage; got != "" {
|
|
t.Fatalf("loadingMessage after apply = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.vaultPath.SetText("/tmp/example.kdbx")
|
|
u.lastLifecycleAction = "open vault"
|
|
|
|
started := make(chan struct{})
|
|
release := make(chan struct{})
|
|
u.runBackgroundAction("open vault", func() (func() error, error) {
|
|
close(started)
|
|
<-release
|
|
return func() error {
|
|
u.state.StatusMessage = "should not apply"
|
|
return nil
|
|
}, nil
|
|
})
|
|
<-started
|
|
|
|
u.cancelLifecycleBusyState()
|
|
if got := u.loadingMessage; got != "" {
|
|
t.Fatalf("loadingMessage after cancel = %q, want empty", got)
|
|
}
|
|
if got := u.activeBackgroundAction; got != 0 {
|
|
t.Fatalf("activeBackgroundAction after cancel = %d, want 0", got)
|
|
}
|
|
if !u.requestMasterPassFocus {
|
|
t.Fatal("requestMasterPassFocus after cancel = false, want true")
|
|
}
|
|
|
|
close(release)
|
|
result := waitForBackgroundResult(t, u)
|
|
u.applyBackgroundResult(result)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("StatusMessage after stale apply = %q, want empty", got)
|
|
}
|
|
if got := u.lastLifecycleAction; got != "open vault" {
|
|
t.Fatalf("lastLifecycleAction after cancel = %q, want open vault", got)
|
|
}
|
|
}
|
|
|
|
func TestUIChildGroupsComeFromVaultModel(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
|
{ID: "2", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
|
{ID: "3", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
|
},
|
|
})
|
|
|
|
u.state.NavigateToPath([]string{"Crew"})
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
|
t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.showAPITokensSection()
|
|
u.apiTokenName.SetText("Browser Extension")
|
|
u.apiTokenClientName.SetText("firefox")
|
|
u.apiTokenExpiresAt.SetText("2026-04-01T15:04:05Z")
|
|
|
|
if err := u.issueAPITokenAction(); err != nil {
|
|
t.Fatalf("issueAPITokenAction() error = %v", err)
|
|
}
|
|
if strings.TrimSpace(u.apiTokenSecret) == "" {
|
|
t.Fatal("apiTokenSecret = empty, want one-time secret")
|
|
}
|
|
|
|
tokens := u.apiTokens()
|
|
if len(tokens) != 1 {
|
|
t.Fatalf("len(apiTokens()) = %d, want 1", len(tokens))
|
|
}
|
|
if tokens[0].Name != "Browser Extension" || tokens[0].ClientName != "firefox" {
|
|
t.Fatalf("issued token = %#v, want Browser Extension/firefox", tokens[0])
|
|
}
|
|
|
|
if err := u.rotateAPITokenAction(); err != nil {
|
|
t.Fatalf("rotateAPITokenAction() error = %v", err)
|
|
}
|
|
if strings.TrimSpace(u.apiTokenSecret) == "" {
|
|
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
|
}
|
|
|
|
if err := u.disableAPITokenAction(); err != nil {
|
|
t.Fatalf("disableAPITokenAction() error = %v", err)
|
|
}
|
|
disabled, ok := u.selectedAPIToken()
|
|
if !ok || !disabled.Disabled {
|
|
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
|
}
|
|
}
|
|
|
|
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.showAPITokensSection()
|
|
u.apiTokenName.SetText("CLI")
|
|
u.apiTokenClientName.SetText("grpc-cli")
|
|
if err := u.issueAPITokenAction(); err != nil {
|
|
t.Fatalf("issueAPITokenAction() error = %v", err)
|
|
}
|
|
|
|
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
|
|
u.apiPolicyPath.SetText("Root / Internet")
|
|
u.apiPolicyAllow.Value = true
|
|
u.apiPolicyGroupScopeW.Value = true
|
|
if err := u.addAPIPolicyRuleAction(); err != nil {
|
|
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
|
|
}
|
|
|
|
token, ok := u.selectedAPIToken()
|
|
if !ok || len(token.Policies) != 1 {
|
|
t.Fatalf("selectedAPIToken().Policies = %#v, want 1 rule", token.Policies)
|
|
}
|
|
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
|
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
|
}
|
|
|
|
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
|
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
|
}
|
|
token, ok = u.selectedAPIToken()
|
|
if !ok || len(token.Policies) != 0 {
|
|
t.Fatalf("selectedAPIToken().Policies after remove = %#v, want empty", token.Policies)
|
|
}
|
|
}
|
|
|
|
func TestAPITokenStatusSummary(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
expiresAt := time.Date(2026, 4, 1, 15, 4, 5, 0, time.UTC)
|
|
revokedAt := time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
token apitokens.Token
|
|
want string
|
|
}{
|
|
{
|
|
name: "active non expiring",
|
|
token: apitokens.Token{},
|
|
want: "Active · No expiration · 0 policy rules",
|
|
},
|
|
{
|
|
name: "disabled expiring single rule",
|
|
token: apitokens.Token{
|
|
Disabled: true,
|
|
ExpiresAt: &expiresAt,
|
|
Policies: []apitokens.PolicyRule{{}},
|
|
},
|
|
want: "Disabled · Expires " + expiresAt.Local().Format(time.RFC3339) + " · 1 policy rule",
|
|
},
|
|
{
|
|
name: "revoked overrides disabled",
|
|
token: apitokens.Token{
|
|
Disabled: true,
|
|
RevokedAt: &revokedAt,
|
|
Policies: []apitokens.PolicyRule{{}, {}},
|
|
},
|
|
want: "Revoked · No expiration · 2 policy rules",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := apiTokenStatusSummary(tt.token); got != tt.want {
|
|
t.Fatalf("apiTokenStatusSummary(%#v) = %q, want %q", tt.token, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAPITokenManagementSummaryText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
token := apitokens.Token{
|
|
Name: "Browser Extension",
|
|
ClientName: "firefox",
|
|
}
|
|
|
|
if got := apiTokenManagementTitle(apitokens.Token{}, false); got != "Issue API Token" {
|
|
t.Fatalf("apiTokenManagementTitle(no selection) = %q, want %q", got, "Issue API Token")
|
|
}
|
|
if got := apiTokenManagementTitle(token, true); got != "Browser Extension" {
|
|
t.Fatalf("apiTokenManagementTitle(%#v) = %q, want %q", token, got, "Browser Extension")
|
|
}
|
|
if got := apiTokenManagementSubtitle(apitokens.Token{}, false); got != "Create a scoped gRPC credential, then select it here to inspect identity, lifecycle, and policy rules." {
|
|
t.Fatalf("apiTokenManagementSubtitle(no selection) = %q, want default management guidance", got)
|
|
}
|
|
if got := apiTokenManagementSubtitle(token, true); got != "firefox · Active · No expiration · 0 policy rules" {
|
|
t.Fatalf("apiTokenManagementSubtitle(%#v) = %q, want %q", token, got, "firefox · Active · No expiration · 0 policy rules")
|
|
}
|
|
}
|
|
|
|
func TestPolicyRulePartsFormatsGroupAndEntryResources(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
groupRule := apitokens.PolicyRule{
|
|
Effect: apitokens.EffectAllow,
|
|
Operation: apitokens.OperationListEntries,
|
|
Resource: apitokens.Resource{
|
|
Kind: apitokens.ResourceGroup,
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
}
|
|
entryRule := apitokens.PolicyRule{
|
|
Effect: apitokens.EffectDeny,
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Resource: apitokens.Resource{
|
|
Kind: apitokens.ResourceEntry,
|
|
EntryID: "vault-console",
|
|
},
|
|
}
|
|
|
|
if effect, operation, resource := policyRuleParts(groupRule); effect != "ALLOW" || operation != string(apitokens.OperationListEntries) || resource != "Root / Internet" {
|
|
t.Fatalf("policyRuleParts(groupRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "ALLOW", apitokens.OperationListEntries, "Root / Internet")
|
|
}
|
|
if effect, operation, resource := policyRuleParts(entryRule); effect != "DENY" || operation != string(apitokens.OperationCopyPassword) || resource != "Entry: vault-console" {
|
|
t.Fatalf("policyRuleParts(entryRule) = (%q, %q, %q), want (%q, %q, %q)", effect, operation, resource, "DENY", apitokens.OperationCopyPassword, "Entry: vault-console")
|
|
}
|
|
}
|
|
|
|
func TestUIAPITokenDetailPanelHandlesMissingRemoveClickables(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.showAPITokensSection()
|
|
u.apiTokenName.SetText("CLI")
|
|
u.apiTokenClientName.SetText("grpc-cli")
|
|
if err := u.issueAPITokenAction(); err != nil {
|
|
t.Fatalf("issueAPITokenAction() error = %v", err)
|
|
}
|
|
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
|
|
u.apiPolicyPath.SetText("Crew / codex")
|
|
u.apiPolicyAllow.Value = true
|
|
u.apiPolicyGroupScopeW.Value = true
|
|
if err := u.addAPIPolicyRuleAction(); err != nil {
|
|
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
|
|
}
|
|
|
|
u.apiPolicyRemoves = nil
|
|
ops := new(op.Ops)
|
|
gtx := layout.Context{
|
|
Ops: ops,
|
|
Constraints: layout.Exact(image.Pt(800, 600)),
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("apiTokenDetailPanel() panicked: %v", r)
|
|
}
|
|
}()
|
|
|
|
_ = u.apiTokenDetailPanel(gtx)
|
|
}
|
|
|
|
func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.showAPITokensSection()
|
|
u.apiTokenName.SetText("CLI One")
|
|
u.apiTokenClientName.SetText("grpc-cli-1")
|
|
if err := u.issueAPITokenAction(); err != nil {
|
|
t.Fatalf("issueAPITokenAction() error = %v", err)
|
|
}
|
|
firstID := u.state.SelectedEntryID
|
|
u.apiPolicyOperation.SetText(string(apitokens.OperationListEntries))
|
|
u.apiPolicyPath.SetText("Crew / codex")
|
|
u.apiPolicyAllow.Value = true
|
|
u.apiPolicyGroupScopeW.Value = true
|
|
if err := u.addAPIPolicyRuleAction(); err != nil {
|
|
t.Fatalf("addAPIPolicyRuleAction() error = %v", err)
|
|
}
|
|
|
|
u.apiTokenName.SetText("CLI Two")
|
|
u.apiTokenClientName.SetText("grpc-cli-2")
|
|
if err := u.issueAPITokenAction(); err != nil {
|
|
t.Fatalf("issueAPITokenAction() error = %v", err)
|
|
}
|
|
secondID := u.state.SelectedEntryID
|
|
|
|
ops := new(op.Ops)
|
|
gtx := layout.Context{
|
|
Ops: ops,
|
|
Constraints: layout.Exact(image.Pt(800, 600)),
|
|
}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("apiTokenDetailPanel() panicked after token switch: %v", r)
|
|
}
|
|
}()
|
|
|
|
u.state.SelectedEntryID = secondID
|
|
u.loadSelectedAPITokenIntoEditor()
|
|
if len(u.apiPolicyRemoves) != 0 {
|
|
t.Fatalf("len(apiPolicyRemoves) after selecting token without policies = %d, want 0", len(u.apiPolicyRemoves))
|
|
}
|
|
_ = u.apiTokenDetailPanel(gtx)
|
|
|
|
u.state.SelectedEntryID = firstID
|
|
u.loadSelectedAPITokenIntoEditor()
|
|
if len(u.apiPolicyRemoves) != 1 {
|
|
t.Fatalf("len(apiPolicyRemoves) after reselecting token with policy = %d, want 1", len(u.apiPolicyRemoves))
|
|
}
|
|
_ = u.apiTokenDetailPanel(gtx)
|
|
}
|
|
|
|
func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.auditLog = apiaudit.New(10)
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalAllowed,
|
|
TokenName: "Browser Extension",
|
|
ClientName: "firefox",
|
|
Message: "approved",
|
|
})
|
|
|
|
u.showAPIAuditSection()
|
|
events := u.apiAuditEvents()
|
|
if len(events) != 1 {
|
|
t.Fatalf("len(apiAuditEvents()) = %d, want 1", len(events))
|
|
}
|
|
if events[0].TokenName != "Browser Extension" {
|
|
t.Fatalf("apiAuditEvents()[0].TokenName = %q, want %q", events[0].TokenName, "Browser Extension")
|
|
}
|
|
}
|
|
|
|
func TestUIAPIAuditEventsMatchFriendlyQuickFilters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.auditLog = apiaudit.New(10)
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalAllowed,
|
|
TokenName: "Browser Extension",
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Message: "approved",
|
|
})
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalDenied,
|
|
TokenName: "CLI",
|
|
Operation: apitokens.OperationListEntries,
|
|
Message: "denied",
|
|
})
|
|
|
|
u.showAPIAuditSection()
|
|
|
|
u.search.SetText("Allowed")
|
|
if got := u.apiAuditEvents(); len(got) != 1 || got[0].Type != apiaudit.EventApprovalAllowed {
|
|
t.Fatalf("apiAuditEvents() with Allowed = %#v, want allowed event", got)
|
|
}
|
|
|
|
u.search.SetText("copy password")
|
|
if got := u.apiAuditEvents(); len(got) != 1 || got[0].Operation != apitokens.OperationCopyPassword {
|
|
t.Fatalf("apiAuditEvents() with copy password = %#v, want copy_password event", got)
|
|
}
|
|
|
|
u.search.SetText("CLI")
|
|
if got := u.apiAuditEvents(); len(got) != 1 || got[0].TokenName != "CLI" {
|
|
t.Fatalf("apiAuditEvents() with CLI = %#v, want CLI token event", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAPIAuditMessagesGuideQuickFilters(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.showAPIAuditSection()
|
|
|
|
if got := u.listEmptyState(); got.Title != "No API audit events yet" || got.Body != "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity." {
|
|
t.Fatalf("listEmptyState() = %#v, want updated API audit guidance", got)
|
|
}
|
|
if got := u.detailPlaceholderMessage(); got != "Select an audit event to inspect it, or use Search vault or the quick filters above." {
|
|
t.Fatalf("detailPlaceholderMessage() = %q, want quick-filter guidance", got)
|
|
}
|
|
|
|
u.search.SetText("allowed")
|
|
if got := u.listEmptyState(); got.Title != "No matching audit events" || got.Body != `No audit events match "allowed". Clear the search or try a different quick filter.` {
|
|
t.Fatalf("listEmptyState() with search = %#v, want quick-filter empty-state guidance", got)
|
|
}
|
|
}
|
|
|
|
func TestUILifecycleSecuritySettingsSummaryMovesAdvancedFieldsOutOfOpenFlow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
if got := u.lifecycleSecuritySettingsSummary(); got != "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices." {
|
|
t.Fatalf("lifecycleSecuritySettingsSummary() = %q, want focused lifecycle guidance", got)
|
|
}
|
|
}
|
|
|
|
func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
|
{ID: "2", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
|
},
|
|
})
|
|
|
|
u.state.NavigateToPath([]string{"Crew", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "2"
|
|
|
|
got, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want true")
|
|
}
|
|
|
|
if got.Title != "Vault Console" {
|
|
t.Fatalf("selectedEntry().Title = %q, want %q", got.Title, "Vault Console")
|
|
}
|
|
}
|
|
|
|
func TestUILockHidesVisibleEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "1", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
|
},
|
|
})
|
|
|
|
if err := u.state.Lock(); err != nil {
|
|
t.Fatalf("state.Lock() error = %v", err)
|
|
}
|
|
u.filter()
|
|
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
|
|
}
|
|
}
|
|
|
|
func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if got := u.masterPassword.Text(); got != "" {
|
|
t.Fatalf("masterPassword after create = %q, want empty", got)
|
|
}
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("filteredTitles() = %v, want empty while locked", got)
|
|
}
|
|
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.unlockAction(); err != nil {
|
|
t.Fatalf("unlockAction() error = %v", err)
|
|
}
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("filteredTitles() after unlock = %v, want [Vault Console]", got)
|
|
}
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.masterPassword.SetText("correct horse battery staple")
|
|
reopened.vaultPath.SetText(path)
|
|
if err := reopened.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
if got := reopened.masterPassword.Text(); got != "" {
|
|
t.Fatalf("masterPassword after open = %q, want empty", got)
|
|
}
|
|
reopened.state.NavigateToPath([]string{"Root", "Internet"})
|
|
reopened.filter()
|
|
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUICreateVaultUsesSelectedSecuritySettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.securityCipher.SetText(vault.CipherAES256)
|
|
u.securityKDF.SetText(vault.KDFAES)
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "secure.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
var reopened session.Manager
|
|
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
|
t.Fatalf("Open() error = %v", err)
|
|
}
|
|
got := reopened.SecuritySettings()
|
|
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
|
|
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
|
|
}
|
|
}
|
|
|
|
func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manager := &session.Manager{}
|
|
u := newUIWithSession("desktop", manager)
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
u.securityCipher.SetText(vault.CipherAES256)
|
|
u.securityKDF.SetText(vault.KDFAES)
|
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "updated-secure.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
var reopened session.Manager
|
|
if err := reopened.Open(path, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
|
t.Fatalf("Open() error = %v", err)
|
|
}
|
|
got := reopened.SecuritySettings()
|
|
if got.Cipher != vault.CipherAES256 || got.KDF != vault.KDFAES {
|
|
t.Fatalf("SecuritySettings() = %#v, want aes256/aes-kdf", got)
|
|
}
|
|
}
|
|
|
|
func TestUISaveSettingsPersistsUIPreferences(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "ui-prefs.json")
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
u.settingsGroupControls.Value = true
|
|
u.settingsLifecycleAdvanced.Value = false
|
|
u.settingsHistory.Value = false
|
|
u.settingsDenseLayout.Value = true
|
|
|
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
|
}
|
|
|
|
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
|
|
if !reloaded.groupControlsHidden {
|
|
t.Fatal("groupControlsHidden after reload = false, want true")
|
|
}
|
|
if reloaded.lifecycleAdvancedHidden {
|
|
t.Fatal("lifecycleAdvancedHidden after reload = true, want false")
|
|
}
|
|
if reloaded.historyHidden {
|
|
t.Fatal("historyHidden after reload = true, want false")
|
|
}
|
|
if !reloaded.denseLayout {
|
|
t.Fatal("denseLayout after reload = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.masterPassword.SetText("should-be-cleared")
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
if got := u.masterPassword.Text(); got != "" {
|
|
t.Fatalf("masterPassword after lock = %q, want empty", got)
|
|
}
|
|
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.unlockAction(); err != nil {
|
|
t.Fatalf("unlockAction() error = %v", err)
|
|
}
|
|
if got := u.masterPassword.Text(); got != "" {
|
|
t.Fatalf("masterPassword after unlock = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
mode vault.MasterKeyMode
|
|
password string
|
|
keyFileData []byte
|
|
}{
|
|
{
|
|
name: "password only",
|
|
mode: vault.MasterKeyModePasswordOnly,
|
|
password: "correct horse battery staple",
|
|
},
|
|
{
|
|
name: "key file only",
|
|
mode: vault.MasterKeyModeKeyFileOnly,
|
|
keyFileData: []byte("key-file-only-material"),
|
|
},
|
|
{
|
|
name: "composite",
|
|
mode: vault.MasterKeyModePasswordAndKeyFile,
|
|
password: "correct horse battery staple",
|
|
keyFileData: []byte("composite-key-material"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keyFile := ""
|
|
if len(tt.keyFileData) > 0 {
|
|
keyFile = filepath.Join(t.TempDir(), "master.key")
|
|
if err := os.WriteFile(keyFile, tt.keyFileData, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(master.key) error = %v", err)
|
|
}
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.setMasterKeyMode(tt.mode)
|
|
u.masterPassword.SetText(tt.password)
|
|
u.keyFilePath.SetText(keyFile)
|
|
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
u.masterPassword.SetText(tt.password)
|
|
u.keyFilePath.SetText(keyFile)
|
|
if err := u.unlockAction(); err != nil {
|
|
t.Fatalf("unlockAction() error = %v", err)
|
|
}
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.setMasterKeyMode(tt.mode)
|
|
reopened.masterPassword.SetText(tt.password)
|
|
reopened.keyFilePath.SetText(keyFile)
|
|
reopened.vaultPath.SetText(path)
|
|
if err := reopened.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
reopened.state.NavigateToPath([]string{"Root", "Internet"})
|
|
reopened.filter()
|
|
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
updated := filepath.Join(t.TempDir(), "updated.key")
|
|
if err := os.WriteFile(updated, []byte("updated-key"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(updated.key) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
|
|
u.masterPassword.SetText("old-password")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
|
|
u.masterPassword.SetText("new-password")
|
|
u.keyFilePath.SetText(updated)
|
|
if err := u.changeMasterKeyAction(); err != nil {
|
|
t.Fatalf("changeMasterKeyAction() error = %v", err)
|
|
}
|
|
if err := u.saveAction(); err != nil {
|
|
t.Fatalf("saveAction() error = %v", err)
|
|
}
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
|
|
u.masterPassword.SetText("old-password")
|
|
u.keyFilePath.SetText("")
|
|
u.setMasterKeyMode(vault.MasterKeyModePasswordOnly)
|
|
u.runAction("unlock vault", u.unlockAction)
|
|
if u.state.ErrorMessage == "" {
|
|
t.Fatal("state.ErrorMessage = empty, want visible invalid master key error")
|
|
}
|
|
|
|
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
|
|
u.masterPassword.SetText("new-password")
|
|
u.keyFilePath.SetText(updated)
|
|
if err := u.unlockAction(); err != nil {
|
|
t.Fatalf("unlockAction() with updated key error = %v", err)
|
|
}
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
|
|
reopened.masterPassword.SetText("new-password")
|
|
reopened.keyFilePath.SetText(updated)
|
|
reopened.vaultPath.SetText(path)
|
|
if err := reopened.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() with updated key error = %v", err)
|
|
}
|
|
reopened.state.NavigateToPath([]string{"Root", "Internet"})
|
|
reopened.filter()
|
|
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIMasterKeyValidationErrorsAreVisible(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
password string
|
|
keyFile string
|
|
wantError string
|
|
}{
|
|
{
|
|
name: "requires either password or key file",
|
|
wantError: "master password or key file is required",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText(tt.password)
|
|
u.keyFilePath.SetText(tt.keyFile)
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
|
|
if got := u.state.ErrorMessage; got != tt.wantError {
|
|
t.Fatalf("state.ErrorMessage = %q, want %q", got, tt.wantError)
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("state.StatusMessage = %q, want empty on validation error", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUIUnreadableAndInvalidMasterKeyErrorsAreVisible(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keyFile := filepath.Join(t.TempDir(), "master.key")
|
|
if err := os.WriteFile(keyFile, []byte("key-material"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(master.key) error = %v", err)
|
|
}
|
|
|
|
create := newUIWithSession("desktop", &session.Manager{})
|
|
create.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
|
|
create.keyFilePath.SetText(keyFile)
|
|
if err := create.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
|
|
create.saveAsPath.SetText(path)
|
|
if err := create.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
unreadable := newUIWithSession("desktop", &session.Manager{})
|
|
unreadable.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
|
|
unreadable.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key"))
|
|
unreadable.runAction("open vault", unreadable.openVaultAction)
|
|
if got := unreadable.state.ErrorMessage; got == "" || got[:14] != "read key file:" {
|
|
t.Fatalf("state.ErrorMessage = %q, want read key file error", got)
|
|
}
|
|
|
|
wrong := newUIWithSession("desktop", &session.Manager{})
|
|
wrong.setMasterKeyMode(vault.MasterKeyModeKeyFileOnly)
|
|
wrong.keyFilePath.SetText(filepath.Join(t.TempDir(), "wrong.key"))
|
|
if err := os.WriteFile(wrong.keyFilePath.Text(), []byte("wrong-key"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(wrong.key) error = %v", err)
|
|
}
|
|
wrong.vaultPath.SetText(path)
|
|
wrong.runAction("open vault", wrong.openVaultAction)
|
|
if got := wrong.state.ErrorMessage; got == "" || !bytes.Contains([]byte(got), []byte(vault.ErrInvalidMasterKey.Error())) {
|
|
t.Fatalf("state.ErrorMessage = %q, want invalid master key error", got)
|
|
}
|
|
}
|
|
|
|
func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
model := vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
}
|
|
|
|
var putCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
|
}
|
|
w.Header().Set("ETag", "\"v1\"")
|
|
_, _ = w.Write(encoded.Bytes())
|
|
case http.MethodPut:
|
|
putCount++
|
|
w.Header().Set("ETag", "\"v2\"")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.remoteBaseURL.SetText(server.URL)
|
|
u.remotePath.SetText("vaults/main.kdbx")
|
|
|
|
if err := u.openRemoteAction(); err != nil {
|
|
t.Fatalf("openRemoteAction() error = %v", err)
|
|
}
|
|
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-2",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
if err := u.saveAction(); err != nil {
|
|
t.Fatalf("saveAction() error = %v", err)
|
|
}
|
|
|
|
if putCount != 1 {
|
|
t.Fatalf("remote PUT count = %d, want 1", putCount)
|
|
}
|
|
}
|
|
|
|
func TestUIStartOpenRemoteActionAppliesResultOnMainThread(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
model := vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
|
}
|
|
w.Header().Set("ETag", "\"v1\"")
|
|
_, _ = w.Write(encoded.Bytes())
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager := &session.Manager{}
|
|
u := newUIWithSession("desktop", manager)
|
|
u.masterPassword.SetText(key.Password)
|
|
u.remoteBaseURL.SetText(server.URL)
|
|
u.remotePath.SetText("vaults/main.kdbx")
|
|
|
|
u.startOpenRemoteAction()
|
|
|
|
if got := u.loadingMessage; got != "Open remote vault..." {
|
|
t.Fatalf("loadingMessage after start = %q, want %q", got, "Open remote vault...")
|
|
}
|
|
if manager.HasVault() {
|
|
t.Fatal("manager.HasVault() = true before remote result applied, want false")
|
|
}
|
|
|
|
result := waitForBackgroundResult(t, u)
|
|
u.applyBackgroundResult(result)
|
|
|
|
if got := u.loadingMessage; got != "" {
|
|
t.Fatalf("loadingMessage after apply = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("ErrorMessage after apply = %q, want empty", got)
|
|
}
|
|
if !manager.HasVault() {
|
|
t.Fatal("manager.HasVault() = false after remote result applied, want true")
|
|
}
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIOpenRemoteReportsTransportFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
url := server.URL
|
|
server.Close()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.remoteBaseURL.SetText(url)
|
|
u.remotePath.SetText("vaults/main.kdbx")
|
|
|
|
u.runAction("open remote vault", u.openRemoteAction)
|
|
|
|
if got := u.state.ErrorMessage; !strings.Contains(got, "open remote vault failed:") {
|
|
t.Fatalf("state.ErrorMessage = %q, want open remote vault failure", got)
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("state.StatusMessage = %q, want empty on remote open failure", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
model := vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
}
|
|
|
|
var putCount int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
|
}
|
|
w.Header().Set("ETag", "\"v1\"")
|
|
_, _ = w.Write(encoded.Bytes())
|
|
case http.MethodPut:
|
|
putCount++
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.remoteBaseURL.SetText(server.URL)
|
|
u.remotePath.SetText("vaults/main.kdbx")
|
|
|
|
if err := u.openRemoteAction(); err != nil {
|
|
t.Fatalf("openRemoteAction() error = %v", err)
|
|
}
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-2",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
u.runAction("save vault", u.saveAction)
|
|
|
|
if got := u.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." {
|
|
t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got)
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("state.StatusMessage = %q, want empty after remote save conflict", got)
|
|
}
|
|
if !u.state.Dirty {
|
|
t.Fatal("Dirty = false, want true after remote save conflict")
|
|
}
|
|
if putCount != 1 {
|
|
t.Fatalf("remote PUT count = %d, want 1", putCount)
|
|
}
|
|
}
|
|
|
|
func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
|
otherPath := filepath.Join(t.TempDir(), "other.kdbx")
|
|
|
|
writeKDBXMainTestFile(t, currentPath, vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "entry-current",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-current",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}, key)
|
|
writeKDBXMainTestFile(t, otherPath, vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "entry-other",
|
|
Title: "Bellagio",
|
|
Username: "rustyryan",
|
|
Password: "token-other",
|
|
URL: "https://bellagio.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}, key)
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText(key.Password)
|
|
u.vaultPath.SetText(currentPath)
|
|
if err := u.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.openAdvancedSyncDialog()
|
|
u.syncDirection = syncDirectionPull
|
|
u.syncSourceMode = syncSourceLocal
|
|
u.syncLocalPath.SetText(otherPath)
|
|
if err := u.advancedSyncAction(); err != nil {
|
|
t.Fatalf("advancedSyncAction() error = %v", err)
|
|
}
|
|
|
|
var reopened session.Manager
|
|
if err := reopened.Open(currentPath, key); err != nil {
|
|
t.Fatalf("reopen Open(current) error = %v", err)
|
|
}
|
|
model, err := reopened.Current()
|
|
if err != nil {
|
|
t.Fatalf("reopened Current() error = %v", err)
|
|
}
|
|
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
path := filepath.Join(t.TempDir(), "vault.kdbx")
|
|
writeKDBXMainTestFile(t, path, vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "entry-1",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-current",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}, key)
|
|
|
|
manager := &session.Manager{}
|
|
u := newUIWithSession("desktop", manager)
|
|
u.masterPassword.SetText(key.Password)
|
|
u.vaultPath.SetText(path)
|
|
|
|
u.startOpenVaultAction()
|
|
|
|
if got := u.loadingMessage; got != "Open vault..." {
|
|
t.Fatalf("loadingMessage after start = %q, want %q", got, "Open vault...")
|
|
}
|
|
if manager.HasVault() {
|
|
t.Fatal("manager.HasVault() = true before background result applied, want false")
|
|
}
|
|
|
|
result := waitForBackgroundResult(t, u)
|
|
u.applyBackgroundResult(result)
|
|
|
|
if got := u.loadingMessage; got != "" {
|
|
t.Fatalf("loadingMessage after apply = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("ErrorMessage after apply = %q, want empty", got)
|
|
}
|
|
if !manager.HasVault() {
|
|
t.Fatal("manager.HasVault() = false after background result applied, want true")
|
|
}
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStartUnlockActionAppliesResultOnMainThread(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
manager := &session.Manager{}
|
|
u := newUIWithSession("desktop", manager)
|
|
u.masterPassword.SetText(key.Password)
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
|
|
u.masterPassword.SetText(key.Password)
|
|
u.startUnlockAction()
|
|
|
|
if got := u.loadingMessage; got != "Unlock vault..." {
|
|
t.Fatalf("loadingMessage after start = %q, want %q", got, "Unlock vault...")
|
|
}
|
|
if !manager.IsLocked() {
|
|
t.Fatal("manager.IsLocked() = false before background result applied, want true")
|
|
}
|
|
|
|
result := waitForBackgroundResult(t, u)
|
|
u.applyBackgroundResult(result)
|
|
|
|
if got := u.loadingMessage; got != "" {
|
|
t.Fatalf("loadingMessage after apply = %q, want empty", got)
|
|
}
|
|
if manager.IsLocked() {
|
|
t.Fatal("manager.IsLocked() = true after background result applied, want false")
|
|
}
|
|
}
|
|
|
|
func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
key := vault.MasterKey{Password: "correct horse battery staple"}
|
|
currentPath := filepath.Join(t.TempDir(), "current.kdbx")
|
|
writeKDBXMainTestFile(t, currentPath, vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "entry-current",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-current",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}, key)
|
|
|
|
var remoteBytes bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&remoteBytes, vault.Model{
|
|
Entries: []vault.Entry{{
|
|
ID: "entry-remote",
|
|
Title: "Bellagio",
|
|
Username: "rustyryan",
|
|
Password: "token-remote",
|
|
URL: "https://bellagio.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}},
|
|
}, key); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey(remote) error = %v", err)
|
|
}
|
|
|
|
etag := "\"v1\""
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("ETag", etag)
|
|
_, _ = w.Write(remoteBytes.Bytes())
|
|
case http.MethodPut:
|
|
payload, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll(PUT body) error = %v", err)
|
|
}
|
|
remoteBytes.Reset()
|
|
if _, err := remoteBytes.Write(payload); err != nil {
|
|
t.Fatalf("Write(remoteBytes) error = %v", err)
|
|
}
|
|
etag = "\"v2\""
|
|
w.Header().Set("ETag", etag)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText(key.Password)
|
|
u.vaultPath.SetText(currentPath)
|
|
if err := u.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.openAdvancedSyncDialog()
|
|
u.syncDirection = syncDirectionPush
|
|
u.syncSourceMode = syncSourceRemote
|
|
u.syncRemoteBaseURL.SetText(server.URL)
|
|
u.syncRemotePath.SetText("vaults/other.kdbx")
|
|
if err := u.advancedSyncAction(); err != nil {
|
|
t.Fatalf("advancedSyncAction() error = %v", err)
|
|
}
|
|
|
|
var reopened session.Manager
|
|
if err := reopened.OpenRemote(webdav.Client{BaseURL: server.URL}, "vaults/other.kdbx", key); err != nil {
|
|
t.Fatalf("OpenRemote(reopened) error = %v", err)
|
|
}
|
|
model, err := reopened.Current()
|
|
if err != nil {
|
|
t.Fatalf("reopened Current() error = %v", err)
|
|
}
|
|
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 {
|
|
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keyFile := filepath.Join(t.TempDir(), "master.key")
|
|
keyData := []byte("key-file-bytes")
|
|
if err := os.WriteFile(keyFile, keyData, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(keyFile) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.setMasterKeyMode(vault.MasterKeyModePasswordAndKeyFile)
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.keyFilePath.SetText(keyFile)
|
|
|
|
key, err := u.currentMasterKey()
|
|
if err != nil {
|
|
t.Fatalf("currentMasterKey() error = %v", err)
|
|
}
|
|
|
|
if key.Password != "correct horse battery staple" {
|
|
t.Fatalf("MasterKey.Password = %q, want correct horse battery staple", key.Password)
|
|
}
|
|
if !bytes.Equal(key.KeyFileData, keyData) {
|
|
t.Fatalf("MasterKey.KeyFileData = %q, want %q", key.KeyFileData, keyData)
|
|
}
|
|
}
|
|
|
|
func TestUISectionNavigationShowsTemplatesAndRecycleBin(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
},
|
|
Templates: []vault.Entry{
|
|
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
|
|
},
|
|
RecycleBin: []vault.Entry{
|
|
{ID: "deleted-1", Title: "Deleted Entry", Path: []string{"Root", "Internet"}},
|
|
},
|
|
})
|
|
|
|
u.showTemplatesSection()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) {
|
|
t.Fatalf("template filteredTitles() = %v, want [Website Login]", got)
|
|
}
|
|
|
|
u.showRecycleBinSection()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted Entry"}) {
|
|
t.Fatalf("recycle filteredTitles() = %v, want [Deleted Entry]", got)
|
|
}
|
|
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("entry filteredTitles() = %v, want [Vault Console]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
{ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root"})
|
|
u.filter()
|
|
|
|
u.groupName.SetText("Finance")
|
|
if err := u.createGroupAction(); err != nil {
|
|
t.Fatalf("createGroupAction() error = %v", err)
|
|
}
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
|
t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got)
|
|
}
|
|
|
|
u.state.EnterGroup("Finance")
|
|
u.filter()
|
|
u.groupName.SetText("Budget")
|
|
if err := u.renameGroupAction(); err != nil {
|
|
t.Fatalf("renameGroupAction() error = %v", err)
|
|
}
|
|
if !slices.Equal(u.state.CurrentPath, []string{"Root", "Budget"}) {
|
|
t.Fatalf("state.CurrentPath after rename = %v, want [Root Budget]", u.state.CurrentPath)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root"})
|
|
u.filter()
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) {
|
|
t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root", "Budget"})
|
|
u.filter()
|
|
u.armDeleteCurrentGroupAction()
|
|
if err := u.deleteCurrentGroupAction(); err != nil {
|
|
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
|
|
}
|
|
if !slices.Equal(u.state.CurrentPath, []string{"Root"}) {
|
|
t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath)
|
|
}
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
|
t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIGroupControlsCanBeCollapsed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showEntriesSection()
|
|
|
|
if u.groupControlsHidden {
|
|
t.Fatal("groupControlsHidden = true, want false by default")
|
|
}
|
|
|
|
u.groupControlsHidden = true
|
|
if !u.groupControlsHidden {
|
|
t.Fatal("groupControlsHidden = false, want true after collapsing")
|
|
}
|
|
|
|
u.groupControlsHidden = false
|
|
if u.groupControlsHidden {
|
|
t.Fatal("groupControlsHidden = true, want false after expanding")
|
|
}
|
|
}
|
|
|
|
func TestUIGroupNavigationLabelsDistinguishRootCurrentAndParent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Groups: [][]string{{"Root"}, {"Root", "Infrastructure"}, {"Root", "Infrastructure", "Prod"}},
|
|
})
|
|
u.showEntriesSection()
|
|
|
|
if got := u.currentGroupDisplayName(); got != "Vault root (/)" {
|
|
t.Fatalf("currentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)")
|
|
}
|
|
if got := u.parentGroupDisplayName(); got != "Vault root (/)" {
|
|
t.Fatalf("parentGroupDisplayName() at root = %q, want %q", got, "Vault root (/)")
|
|
}
|
|
if got := u.createGroupLabel(); got != "Create Top-Level Group" {
|
|
t.Fatalf("createGroupLabel() at root = %q, want %q", got, "Create Top-Level Group")
|
|
}
|
|
|
|
u.setCurrentPath([]string{"Root", "Infrastructure", "Prod"})
|
|
|
|
if got := u.currentGroupDisplayName(); got != "Infrastructure / Prod" {
|
|
t.Fatalf("currentGroupDisplayName() = %q, want %q", got, "Infrastructure / Prod")
|
|
}
|
|
if got := u.parentGroupDisplayName(); got != "Infrastructure" {
|
|
t.Fatalf("parentGroupDisplayName() = %q, want %q", got, "Infrastructure")
|
|
}
|
|
if got := u.createGroupLabel(); got != "Create Subgroup" {
|
|
t.Fatalf("createGroupLabel() = %q, want %q", got, "Create Subgroup")
|
|
}
|
|
}
|
|
|
|
func TestUIParentGroupShowsDescendantEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Crew"})
|
|
u.filter()
|
|
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console", "Surveillance Console"}) {
|
|
t.Fatalf("filteredTitles() = %v, want descendant entries under Crew", got)
|
|
}
|
|
}
|
|
|
|
func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root"})
|
|
u.groupName.SetText("Infrastructure / Prod")
|
|
|
|
if err := u.createGroupAction(); err != nil {
|
|
t.Fatalf("createGroupAction() error = %v", err)
|
|
}
|
|
|
|
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) {
|
|
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got)
|
|
}
|
|
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
|
|
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIMoveCurrentGroupActionMovesHierarchy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
},
|
|
}
|
|
model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure")
|
|
|
|
u := newUIWithModel("desktop", model)
|
|
u.showEntriesSection()
|
|
u.setCurrentPath([]string{"Root", "Internet"})
|
|
u.groupParentPath.SetText("Root / Crew")
|
|
|
|
if err := u.moveCurrentGroupAction(); err != nil {
|
|
t.Fatalf("moveCurrentGroupAction() error = %v", err)
|
|
}
|
|
|
|
if !slices.Equal(u.state.CurrentPath, []string{"Root", "Crew", "Internet"}) {
|
|
t.Fatalf("state.CurrentPath = %v, want [Root Crew Internet]", u.state.CurrentPath)
|
|
}
|
|
got := u.state.Session.(*uiSession).model.EntriesInPath([]string{"Root", "Crew", "Internet"})
|
|
if len(got) != 1 || got[0].ID != "vault-console" {
|
|
t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved vault-console entry", got)
|
|
}
|
|
}
|
|
|
|
func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
{
|
|
ID: "ha",
|
|
Title: "Home Assistant",
|
|
Username: "rustyryan",
|
|
Password: "token-2",
|
|
URL: "https://ha.example.test",
|
|
Path: []string{"Root", "Home Assistant"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryPath.SetText("Root / Home Assistant")
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() error = %v", err)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("filteredTitles() in source group = %v, want empty after move", got)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root", "Home Assistant"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) {
|
|
t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got)
|
|
}
|
|
}
|
|
|
|
func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryPassword.SetText("token-2")
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() error = %v", err)
|
|
}
|
|
u.filter()
|
|
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-2" {
|
|
t.Fatalf("selectedEntry() = %#v, want updated password token-2", entry)
|
|
}
|
|
|
|
if err := u.duplicateSelectedEntryAction(); err != nil {
|
|
t.Fatalf("duplicateSelectedEntryAction() error = %v", err)
|
|
}
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
|
|
t.Fatalf("filteredTitles() after duplicate = %v, want copy present", got)
|
|
}
|
|
|
|
if err := u.deleteSelectedEntryAction(); err != nil {
|
|
t.Fatalf("deleteSelectedEntryAction() error = %v", err)
|
|
}
|
|
u.showRecycleBinSection()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console (Copy)"}) {
|
|
t.Fatalf("recycle filteredTitles() = %v, want deleted copy", got)
|
|
}
|
|
|
|
u.state.SelectedEntryID = "vault-console-copy"
|
|
if err := u.restoreSelectedRecycleEntryAction(); err != nil {
|
|
t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err)
|
|
}
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) {
|
|
t.Fatalf("filteredTitles() after restore = %v, want restored copy", got)
|
|
}
|
|
}
|
|
|
|
func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
|
|
u.entryID.SetText("bellagio")
|
|
u.entryTitle.SetText("Bellagio")
|
|
u.entryUsername.SetText("rustyryan")
|
|
u.entryPassword.SetText("token-1")
|
|
u.entryURL.SetText("https://bellagio.example.invalid")
|
|
u.entryNotes.SetText("Registrar account")
|
|
u.entryTags.SetText("dns, registrar")
|
|
u.entryPath.SetText("Root / Internet")
|
|
u.setCustomFieldRows(map[string]string{
|
|
"Environment": "prod",
|
|
"Account ID": "12345",
|
|
})
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() create error = %v", err)
|
|
}
|
|
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
|
|
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
|
|
}
|
|
|
|
item, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want created entry")
|
|
}
|
|
if item.Title != "Bellagio" || item.Username != "rustyryan" || item.Password != "token-1" || item.URL != "https://bellagio.example.invalid" {
|
|
t.Fatalf("selectedEntry() = %#v, want created Bellagio credentials", item)
|
|
}
|
|
if item.Notes != "Registrar account" {
|
|
t.Fatalf("selectedEntry().Notes = %q, want %q", item.Notes, "Registrar account")
|
|
}
|
|
if !slices.Equal(item.Tags, []string{"dns", "registrar"}) {
|
|
t.Fatalf("selectedEntry().Tags = %v, want [dns registrar]", item.Tags)
|
|
}
|
|
if item.Fields["Environment"] != "prod" || item.Fields["Account ID"] != "12345" {
|
|
t.Fatalf("selectedEntry().Fields = %#v, want parsed custom fields", item.Fields)
|
|
}
|
|
}
|
|
|
|
func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "gitlab",
|
|
Title: "Gitlab",
|
|
Path: []string{"Root", "Internet"},
|
|
Fields: map[string]string{
|
|
"AndroidApp1": "androidapp://com.gitlab.android",
|
|
"OTP": "123456",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "gitlab"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
if len(u.customFieldKeys) != 2 || len(u.customFieldValues) != 2 {
|
|
t.Fatalf("custom field rows = %d/%d, want 2 rows", len(u.customFieldKeys), len(u.customFieldValues))
|
|
}
|
|
|
|
got := map[string]string{}
|
|
for i := range u.customFieldKeys {
|
|
got[u.customFieldKeys[i].Text()] = u.customFieldValues[i].Text()
|
|
}
|
|
if got["AndroidApp1"] != "androidapp://com.gitlab.android" || got["OTP"] != "123456" {
|
|
t.Fatalf("custom field rows = %#v, want AndroidApp1 and OTP values", got)
|
|
}
|
|
}
|
|
|
|
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryPath.SetText("Root / Infrastructure")
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() move error = %v", err)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("filteredTitles() in old path = %v, want empty after move", got)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Root", "Infrastructure"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
|
|
t.Fatalf("filteredTitles() in new path = %v, want [Vault Console]", got)
|
|
}
|
|
u.state.SelectedEntryID = "vault-console"
|
|
|
|
model, err := u.state.Session.Current()
|
|
if err != nil {
|
|
t.Fatalf("state.Session.Current() error = %v", err)
|
|
}
|
|
|
|
var (
|
|
item vault.Entry
|
|
ok bool
|
|
)
|
|
for _, candidate := range model.Entries {
|
|
if candidate.ID == "vault-console" {
|
|
item = candidate
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
t.Fatal("model.Entries contains vault-console = false, want moved entry")
|
|
}
|
|
if !slices.Equal(item.Path, []string{"Root", "Infrastructure"}) {
|
|
t.Fatalf("model.Entries vault-console Path = %v, want [Root Infrastructure]", item.Path)
|
|
}
|
|
}
|
|
|
|
func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Templates: []vault.Entry{
|
|
{
|
|
ID: "tpl-1",
|
|
Title: "Website Login",
|
|
Username: "template-user",
|
|
Password: "template-password",
|
|
Notes: "Reusable template",
|
|
Path: []string{"Templates", "Web"},
|
|
},
|
|
},
|
|
})
|
|
|
|
u.showTemplatesSection()
|
|
u.filter()
|
|
u.state.SelectedEntryID = "tpl-1"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryTitle.SetText("Website Login Updated")
|
|
|
|
if err := u.saveTemplateAction(); err != nil {
|
|
t.Fatalf("saveTemplateAction() error = %v", err)
|
|
}
|
|
|
|
u.entryID.SetText("entry-1")
|
|
u.entryTitle.SetText("Bellagio")
|
|
u.entryUsername.SetText("rustyryan")
|
|
u.entryPassword.SetText("token-1")
|
|
u.entryURL.SetText("https://bellagio.example.invalid")
|
|
u.entryPath.SetText("Root / Internet")
|
|
if err := u.instantiateSelectedTemplateAction(); err != nil {
|
|
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
|
|
}
|
|
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "entry-1"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
attachmentPath := filepath.Join(t.TempDir(), "token.txt")
|
|
attachmentExportPath := filepath.Join(t.TempDir(), "exported.txt")
|
|
content := []byte("attachment-content")
|
|
if err := os.WriteFile(attachmentPath, content, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(attachmentPath) error = %v", err)
|
|
}
|
|
|
|
u.attachmentPath.SetText(attachmentPath)
|
|
u.attachmentName.SetText("token.txt")
|
|
if err := u.addAttachmentAction(); err != nil {
|
|
t.Fatalf("addAttachmentAction() error = %v", err)
|
|
}
|
|
if got := u.selectedAttachmentNames(); !slices.Equal(got, []string{"token.txt"}) {
|
|
t.Fatalf("selectedAttachmentNames() = %v, want [token.txt]", got)
|
|
}
|
|
|
|
replacementPath := filepath.Join(t.TempDir(), "token-replacement.txt")
|
|
replacement := []byte("attachment-replacement")
|
|
if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(replacementPath) error = %v", err)
|
|
}
|
|
u.attachmentPath.SetText(replacementPath)
|
|
if err := u.replaceAttachmentAction(); err != nil {
|
|
t.Fatalf("replaceAttachmentAction() error = %v", err)
|
|
}
|
|
|
|
u.exportAttachmentPath.SetText(attachmentExportPath)
|
|
if err := u.exportAttachmentAction(); err != nil {
|
|
t.Fatalf("exportAttachmentAction() error = %v", err)
|
|
}
|
|
|
|
exported, err := os.ReadFile(attachmentExportPath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err)
|
|
}
|
|
if !bytes.Equal(exported, replacement) {
|
|
t.Fatalf("exported attachment = %q, want %q", exported, replacement)
|
|
}
|
|
|
|
if err := u.removeAttachmentAction(); err != nil {
|
|
t.Fatalf("removeAttachmentAction() error = %v", err)
|
|
}
|
|
|
|
u.showTemplatesSection()
|
|
u.filter()
|
|
u.state.SelectedEntryID = "tpl-1"
|
|
if err := u.deleteSelectedTemplateAction(); err != nil {
|
|
t.Fatalf("deleteSelectedTemplateAction() error = %v", err)
|
|
}
|
|
u.filter()
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("template filteredTitles() after delete = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Templates: []vault.Entry{
|
|
{
|
|
ID: "tpl-existing",
|
|
Title: "SSH Login",
|
|
Username: "root",
|
|
Password: "template-password",
|
|
Path: []string{"Templates", "Infra"},
|
|
},
|
|
},
|
|
})
|
|
|
|
u.showTemplatesSection()
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Infra"}) {
|
|
t.Fatalf("childGroups() = %v, want [Infra] at template root", got)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Templates", "Infra"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) {
|
|
t.Fatalf("filteredTitles() = %v, want [SSH Login] in template path", got)
|
|
}
|
|
|
|
u.state.SelectedEntryID = ""
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryID.SetText("tpl-web")
|
|
u.entryTitle.SetText("Website Login")
|
|
u.entryUsername.SetText("template-user")
|
|
u.entryPassword.SetText("template-password")
|
|
u.entryNotes.SetText("Reusable template for website accounts.")
|
|
u.entryTags.SetText("template, web")
|
|
u.entryPath.SetText("Templates / Web")
|
|
if err := u.saveTemplateAction(); err != nil {
|
|
t.Fatalf("saveTemplateAction(create) error = %v", err)
|
|
}
|
|
|
|
u.state.NavigateToPath([]string{"Templates", "Web"})
|
|
u.filter()
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Website Login"}) {
|
|
t.Fatalf("filteredTitles() after create = %v, want [Website Login]", got)
|
|
}
|
|
|
|
u.state.SelectedEntryID = "tpl-web"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryTitle.SetText("Website Login Updated")
|
|
u.setCustomFieldRows(map[string]string{"Environment": "prod"})
|
|
if err := u.saveTemplateAction(); err != nil {
|
|
t.Fatalf("saveTemplateAction(edit) error = %v", err)
|
|
}
|
|
|
|
u.filter()
|
|
selected, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want updated template")
|
|
}
|
|
if selected.Title != "Website Login Updated" {
|
|
t.Fatalf("selectedEntry().Title = %q, want %q", selected.Title, "Website Login Updated")
|
|
}
|
|
if selected.Fields["Environment"] != "prod" {
|
|
t.Fatalf("selectedEntry().Fields[Environment] = %q, want %q", selected.Fields["Environment"], "prod")
|
|
}
|
|
|
|
u.entryID.SetText("entry-1")
|
|
u.entryTitle.SetText("Bellagio")
|
|
u.entryUsername.SetText("rustyryan")
|
|
u.entryPassword.SetText("token-1")
|
|
u.entryURL.SetText("https://bellagio.example.invalid")
|
|
u.entryPath.SetText("Root / Internet")
|
|
if err := u.instantiateSelectedTemplateAction(); err != nil {
|
|
t.Fatalf("instantiateSelectedTemplateAction() error = %v", err)
|
|
}
|
|
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "entry-1"
|
|
instantiated, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want instantiated entry")
|
|
}
|
|
if instantiated.Title != "Bellagio" {
|
|
t.Fatalf("selectedEntry().Title = %q, want %q", instantiated.Title, "Bellagio")
|
|
}
|
|
if instantiated.Notes != "Reusable template for website accounts." {
|
|
t.Fatalf("selectedEntry().Notes = %q, want template notes", instantiated.Notes)
|
|
}
|
|
if instantiated.Fields["Environment"] != "prod" {
|
|
t.Fatalf("selectedEntry().Fields[Environment] = %q, want %q", instantiated.Fields["Environment"], "prod")
|
|
}
|
|
|
|
u.showTemplatesSection()
|
|
u.state.NavigateToPath([]string{"Templates", "Web"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "tpl-web"
|
|
if err := u.deleteSelectedTemplateAction(); err != nil {
|
|
t.Fatalf("deleteSelectedTemplateAction() error = %v", err)
|
|
}
|
|
|
|
u.filter()
|
|
if got := u.filteredTitles(); len(got) != 0 {
|
|
t.Fatalf("filteredTitles() after delete = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAttachmentActionsRejectDuplicateMissingAndOversizeCases(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Attachments: map[string][]byte{"token.txt": []byte("original")},
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
addPath := filepath.Join(t.TempDir(), "token.txt")
|
|
if err := os.WriteFile(addPath, []byte("duplicate"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(addPath) error = %v", err)
|
|
}
|
|
u.attachmentName.SetText("token.txt")
|
|
u.attachmentPath.SetText(addPath)
|
|
if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "already exists") {
|
|
t.Fatalf("addAttachmentAction() error = %v, want duplicate-name failure", err)
|
|
}
|
|
|
|
u.attachmentName.SetText("missing.txt")
|
|
if err := u.replaceAttachmentAction(); err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("replaceAttachmentAction() error = %v, want missing-attachment failure", err)
|
|
}
|
|
|
|
oversizePath := filepath.Join(t.TempDir(), "oversize.bin")
|
|
oversizeContent := bytes.Repeat([]byte("a"), maxAttachmentBytes+1)
|
|
if err := os.WriteFile(oversizePath, oversizeContent, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(oversizePath) error = %v", err)
|
|
}
|
|
u.attachmentName.SetText("oversize.bin")
|
|
u.attachmentPath.SetText(oversizePath)
|
|
if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "too large") {
|
|
t.Fatalf("addAttachmentAction() oversize error = %v, want size failure", err)
|
|
}
|
|
}
|
|
|
|
func TestUIAttachmentActionSummaryReflectsSelectionState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Attachments: map[string][]byte{"token.txt": []byte("original")},
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
if got := u.attachmentActionSummary(); !strings.Contains(got, "Select an attachment above") {
|
|
t.Fatalf("attachmentActionSummary() = %q, want prompt to select an attachment", got)
|
|
}
|
|
|
|
u.attachmentName.SetText("token.txt")
|
|
if got := u.attachmentActionSummary(); !strings.Contains(got, "Selected attachment") {
|
|
t.Fatalf("attachmentActionSummary() = %q, want selected attachment guidance", got)
|
|
}
|
|
|
|
u.attachmentName.SetText("missing.txt")
|
|
if got := u.attachmentActionSummary(); !strings.Contains(got, "is not on this entry yet") {
|
|
t.Fatalf("attachmentActionSummary() = %q, want missing attachment guidance", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-2",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
History: []vault.Entry{
|
|
{
|
|
ID: "vault-console-h1",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.historyIndex.SetText("0")
|
|
|
|
if err := u.restoreSelectedHistoryAction(); err != nil {
|
|
t.Fatalf("restoreSelectedHistoryAction() error = %v", err)
|
|
}
|
|
u.filter()
|
|
if entry, ok := u.selectedEntry(); !ok || entry.Password != "token-1" {
|
|
t.Fatalf("selectedEntry() = %#v, want restored password token-1", entry)
|
|
}
|
|
}
|
|
|
|
func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-2",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
History: []vault.Entry{
|
|
{
|
|
ID: "vault-console-h1",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
Notes: "previous token",
|
|
},
|
|
{
|
|
ID: "vault-console-h0",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-0",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
Notes: "oldest token",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
history := u.visibleHistory()
|
|
if len(history) != 2 {
|
|
t.Fatalf("len(visibleHistory()) = %d, want 2", len(history))
|
|
}
|
|
if history[1].Notes != "oldest token" {
|
|
t.Fatalf("visibleHistory()[1].Notes = %q, want %q", history[1].Notes, "oldest token")
|
|
}
|
|
|
|
if err := u.selectHistoryVersion(1); err != nil {
|
|
t.Fatalf("selectHistoryVersion(1) error = %v", err)
|
|
}
|
|
|
|
selected, ok := u.selectedHistoryEntry()
|
|
if !ok {
|
|
t.Fatal("selectedHistoryEntry() ok = false, want true")
|
|
}
|
|
if selected.Password != "token-0" {
|
|
t.Fatalf("selectedHistoryEntry().Password = %q, want %q", selected.Password, "token-0")
|
|
}
|
|
}
|
|
func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
if err := u.performShortcut(shortcutNewEntry); err != nil {
|
|
t.Fatalf("performShortcut(new-entry) error = %v", err)
|
|
}
|
|
if u.state.SelectedEntryID != "" {
|
|
t.Fatalf("SelectedEntryID = %q, want empty after new-entry shortcut", u.state.SelectedEntryID)
|
|
}
|
|
|
|
u.state.SelectedEntryID = "vault-console"
|
|
if err := u.performShortcut(shortcutCopyUser); err != nil {
|
|
t.Fatalf("performShortcut(copy-user) error = %v", err)
|
|
}
|
|
if err := u.performShortcut(shortcutCopyPassword); err != nil {
|
|
t.Fatalf("performShortcut(copy-password) error = %v", err)
|
|
}
|
|
if err := u.performShortcut(shortcutCopyURL); err != nil {
|
|
t.Fatalf("performShortcut(copy-url) error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "bellagio",
|
|
Title: "Bellagio",
|
|
Username: "rustyryan",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
|
|
if got := u.keyboardFocus; got != focusSearch {
|
|
t.Fatalf("keyboardFocus = %q, want %q", got, focusSearch)
|
|
}
|
|
|
|
u.handleKeyPress(key.NameTab, 0)
|
|
if got := u.keyboardFocus; got != breadcrumbFocusID(0) {
|
|
t.Fatalf("keyboardFocus after Tab = %q, want %q", got, breadcrumbFocusID(0))
|
|
}
|
|
|
|
u.handleKeyPress(key.NameTab, 0)
|
|
if got := u.keyboardFocus; got != listFocusID(0) {
|
|
t.Fatalf("keyboardFocus after second Tab = %q, want %q", got, listFocusID(0))
|
|
}
|
|
if got := u.state.SelectedEntryID; got != "bellagio" {
|
|
t.Fatalf("SelectedEntryID after list focus = %q, want %q", got, "bellagio")
|
|
}
|
|
|
|
u.handleKeyPress(key.NameDownArrow, 0)
|
|
if got := u.keyboardFocus; got != listFocusID(1) {
|
|
t.Fatalf("keyboardFocus after Down = %q, want %q", got, listFocusID(1))
|
|
}
|
|
if got := u.state.SelectedEntryID; got != "vault-console" {
|
|
t.Fatalf("SelectedEntryID after Down = %q, want %q", got, "vault-console")
|
|
}
|
|
|
|
u.handleKeyPress(key.NameTab, 0)
|
|
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
|
|
t.Fatalf("keyboardFocus after detail Tab = %q, want %q", got, detailFocusID(detailFieldTitle))
|
|
}
|
|
}
|
|
|
|
func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.keyboardFocus = breadcrumbFocusID(0)
|
|
|
|
u.handleKeyPress(key.NameRightArrow, 0)
|
|
if got := u.keyboardFocus; got != breadcrumbFocusID(1) {
|
|
t.Fatalf("keyboardFocus after Right = %q, want %q", got, breadcrumbFocusID(1))
|
|
}
|
|
|
|
u.handleKeyPress(key.NameReturn, 0)
|
|
if got := u.state.CurrentPath; !slices.Equal(got, []string{"Root"}) {
|
|
t.Fatalf("state.CurrentPath after breadcrumb activation = %v, want [Root]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.keyboardFocus = listFocusID(0)
|
|
|
|
u.handleKeyPress("F", key.ModShortcut)
|
|
if got := u.keyboardFocus; got != focusSearch {
|
|
t.Fatalf("keyboardFocus after shortcut search = %q, want %q", got, focusSearch)
|
|
}
|
|
|
|
u.handleKeyPress("N", key.ModShortcut)
|
|
if got := u.state.SelectedEntryID; got != "" {
|
|
t.Fatalf("SelectedEntryID after shortcut new-entry = %q, want empty", got)
|
|
}
|
|
if got := u.keyboardFocus; got != detailFocusID(detailFieldTitle) {
|
|
t.Fatalf("keyboardFocus after shortcut new-entry = %q, want %q", got, detailFocusID(detailFieldTitle))
|
|
}
|
|
}
|
|
|
|
func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
|
|
if got := u.accessibilityLabel(focusSearch); got != "Search vault" {
|
|
t.Fatalf("accessibilityLabel(search) = %q, want %q", got, "Search vault")
|
|
}
|
|
if got := u.accessibilityLabel(breadcrumbFocusID(1)); got != "Navigate to Root" {
|
|
t.Fatalf("accessibilityLabel(breadcrumb) = %q, want %q", got, "Navigate to Root")
|
|
}
|
|
if got := u.accessibilityLabel(listFocusID(0)); got != "Select entry Vault Console" {
|
|
t.Fatalf("accessibilityLabel(list) = %q, want %q", got, "Select entry Vault Console")
|
|
}
|
|
if got := u.accessibilityLabel(detailFocusID(detailFieldPassword)); got != "Edit Password" {
|
|
t.Fatalf("accessibilityLabel(detail password) = %q, want %q", got, "Edit Password")
|
|
}
|
|
}
|
|
|
|
func TestFieldFocusAppearanceScalesForHighDPI(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
lo := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), true)
|
|
hi := fieldFocusAppearance(unit.Metric{PxPerDp: 2.5, PxPerSp: 2.5}, defaultAccessibilityPreferences(), true)
|
|
unfocused := fieldFocusAppearance(unit.Metric{PxPerDp: 1, PxPerSp: 1}, defaultAccessibilityPreferences(), false)
|
|
|
|
if got := lo.MinHeight; got != 44 {
|
|
t.Fatalf("fieldFocusAppearance(low).MinHeight = %d, want 44", got)
|
|
}
|
|
if got := hi.MinHeight; got != 110 {
|
|
t.Fatalf("fieldFocusAppearance(high).MinHeight = %d, want 110", got)
|
|
}
|
|
if got := lo.OutlineWidth; got < 2 {
|
|
t.Fatalf("fieldFocusAppearance(low).OutlineWidth = %d, want >= 2", got)
|
|
}
|
|
if hi.OutlineWidth <= lo.OutlineWidth {
|
|
t.Fatalf("fieldFocusAppearance(high).OutlineWidth = %d, want > %d", hi.OutlineWidth, lo.OutlineWidth)
|
|
}
|
|
if lo.OutlineColor == unfocused.OutlineColor {
|
|
t.Fatalf("fieldFocusAppearance().OutlineColor focused = %#v, want distinct from unfocused %#v", lo.OutlineColor, unfocused.OutlineColor)
|
|
}
|
|
}
|
|
|
|
func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.vaultPath.SetText("/does/not/exist.kdbx")
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
u.runAction("open vault", u.openVaultAction)
|
|
if u.state.ErrorMessage == "" {
|
|
t.Fatal("state.ErrorMessage = empty, want visible action error")
|
|
}
|
|
|
|
u = newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "vault-console", Title: "Vault Console", Username: "dannyocean", Password: "token-1", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
|
|
if u.state.StatusMessage == "" {
|
|
t.Fatal("state.StatusMessage = empty, want visible success status")
|
|
}
|
|
}
|
|
|
|
func TestUIPasswordProfilesAreVisibleInEntryWorkflow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
|
|
got := u.passwordProfileOptionsText()
|
|
for _, want := range passwords.DefaultProfileNames() {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("passwordProfileOptionsText() = %q, want profile %q to be visible", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.entryID.SetText("entry-1")
|
|
u.entryTitle.SetText("Generated Entry")
|
|
u.entryUsername.SetText("rustyryan")
|
|
u.entryURL.SetText("https://vault.crew.example.invalid")
|
|
u.entryPath.SetText("Root / Internet")
|
|
u.passwordProfile.SetText("memorable")
|
|
|
|
if err := u.generatePasswordAction(); err != nil {
|
|
t.Fatalf("generatePasswordAction() error = %v", err)
|
|
}
|
|
|
|
generated := u.entryPassword.Text()
|
|
if len(generated) < passwords.DefaultProfiles()["memorable"].Length {
|
|
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["memorable"].Length)
|
|
}
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() error = %v", err)
|
|
}
|
|
|
|
u.state.SelectedEntryID = "entry-1"
|
|
saved, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want true for saved generated entry")
|
|
}
|
|
if saved.Password != generated {
|
|
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
|
|
}
|
|
}
|
|
|
|
func TestUIGeneratedPasswordDraftStateClearsOnReloadAndSave(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
if u.generatedPasswordDraft {
|
|
t.Fatal("generatedPasswordDraft = true, want false before generating")
|
|
}
|
|
|
|
u.passwordProfile.SetText("strong")
|
|
if err := u.generatePasswordAction(); err != nil {
|
|
t.Fatalf("generatePasswordAction() error = %v", err)
|
|
}
|
|
if !u.generatedPasswordDraft {
|
|
t.Fatal("generatedPasswordDraft = false, want true after generating")
|
|
}
|
|
|
|
u.loadSelectedEntryIntoEditor()
|
|
if u.generatedPasswordDraft {
|
|
t.Fatal("generatedPasswordDraft = true, want false after reloading entry into editor")
|
|
}
|
|
|
|
u.passwordProfile.SetText("strong")
|
|
if err := u.generatePasswordAction(); err != nil {
|
|
t.Fatalf("generatePasswordAction() second call error = %v", err)
|
|
}
|
|
if !u.generatedPasswordDraft {
|
|
t.Fatal("generatedPasswordDraft = false, want true after generating the second time")
|
|
}
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() error = %v", err)
|
|
}
|
|
if u.generatedPasswordDraft {
|
|
t.Fatal("generatedPasswordDraft = true, want false after saving")
|
|
}
|
|
}
|
|
|
|
func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.loadingMessage = "Opening vault..."
|
|
if got := u.bannerSurface(); got.Kind != bannerLoading || got.Message != "Opening vault..." {
|
|
t.Fatalf("bannerSurface() with loading = %#v, want loading banner", got)
|
|
}
|
|
|
|
u.loadingMessage = ""
|
|
u.state.ErrorMessage = "save failed"
|
|
if got := u.bannerSurface(); got.Kind != bannerError || got.Message != "save failed" {
|
|
t.Fatalf("bannerSurface() with error = %#v, want error banner", got)
|
|
}
|
|
|
|
u.state.ErrorMessage = ""
|
|
u.state.StatusMessage = "save complete"
|
|
if got := u.bannerSurface(); got.Kind != bannerNone {
|
|
t.Fatalf("bannerSurface() with status = %#v, want no status banner", got)
|
|
}
|
|
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "save complete" {
|
|
t.Fatalf("statusToastSurface() with status = %#v, want status toast", got)
|
|
}
|
|
}
|
|
|
|
func TestUIBannerActionLabelsExposeCancelAndRetryForLifecycleOpen(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.loadingMessage = "Open vault..."
|
|
u.loadingActionLabel = "open vault"
|
|
|
|
primary, secondary := u.bannerActionLabels(u.bannerSurface())
|
|
if primary != "Cancel" || secondary != "" {
|
|
t.Fatalf("bannerActionLabels(loading) = %q/%q, want Cancel/empty", primary, secondary)
|
|
}
|
|
|
|
u.loadingMessage = ""
|
|
u.loadingActionLabel = ""
|
|
u.lastLifecycleAction = "open vault"
|
|
u.state.ErrorMessage = "open failed"
|
|
|
|
primary, secondary = u.bannerActionLabels(u.bannerSurface())
|
|
if primary != "Retry" || secondary != "Dismiss" {
|
|
t.Fatalf("bannerActionLabels(error) = %q/%q, want Retry/Dismiss", primary, secondary)
|
|
}
|
|
}
|
|
|
|
func TestCompactPathDirectorySummaryCollapsesLongPaths(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := compactPathDirectorySummary("/home/julian/vaults/family/main.kdbx")
|
|
if got != "home/.../family" {
|
|
t.Fatalf("compactPathDirectorySummary() = %q, want %q", got, "home/.../family")
|
|
}
|
|
|
|
short := compactPathDirectorySummary("/tmp/main.kdbx")
|
|
if short != "/tmp" {
|
|
t.Fatalf("compactPathDirectorySummary(short) = %q, want %q", short, "/tmp")
|
|
}
|
|
}
|
|
|
|
func TestUIStatusToastExpiresAfterTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.state.StatusMessage = "synchronize vault complete"
|
|
if statusBannerDuration != 2600*time.Millisecond {
|
|
t.Fatalf("statusBannerDuration = %v, want 2.6s", statusBannerDuration)
|
|
}
|
|
u.statusExpiresAt = now.Add(statusBannerDuration)
|
|
|
|
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
|
|
t.Fatalf("statusToastSurface() before expiry = %#v, want visible status toast", got)
|
|
}
|
|
|
|
now = now.Add(statusBannerDuration + time.Millisecond)
|
|
if got := u.statusToastSurface(); got.Kind != bannerNone {
|
|
t.Fatalf("statusToastSurface() after expiry = %#v, want no toast", got)
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("state.StatusMessage after expiry = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStatusToastExpiresAfterConfiguredTimeout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.statusBannerTTL = statusBannerLong
|
|
u.state.StatusMessage = "save complete"
|
|
u.statusExpiresAt = now.Add(u.statusBannerTTL)
|
|
|
|
now = now.Add(statusBannerDuration + time.Second)
|
|
if got := u.statusToastSurface(); got.Kind != bannerStatus {
|
|
t.Fatalf("statusToastSurface() before configured expiry = %#v, want visible status toast", got)
|
|
}
|
|
|
|
now = now.Add(statusBannerLong)
|
|
if got := u.statusToastSurface(); got.Kind != bannerNone {
|
|
t.Fatalf("statusToastSurface() after configured expiry = %#v, want no toast", got)
|
|
}
|
|
}
|
|
|
|
func TestUIReducedMotionKeepsStatusToastVisible(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.statusBannerTTL = statusBannerLong
|
|
u.applyAccessibilityPreferences(accessibilityPreferences{ReducedMotion: true})
|
|
|
|
u.showStatusMessage("synchronize vault complete")
|
|
if !u.statusExpiresAt.IsZero() {
|
|
t.Fatalf("statusExpiresAt with reduced motion = %v, want zero", u.statusExpiresAt)
|
|
}
|
|
|
|
now = now.Add(statusBannerLong * 2)
|
|
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
|
|
t.Fatalf("statusToastSurface() with reduced motion = %#v, want persistent status toast", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAutofillStatusSurfaceUsesPendingApproval(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.state.Approvals = &mainStubApprovalManager{
|
|
pending: []apiapproval.Request{
|
|
{
|
|
ID: "approval-1",
|
|
TokenName: "Browser Extension",
|
|
ClientName: "Firefox",
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"},
|
|
},
|
|
},
|
|
}
|
|
|
|
got := u.autofillStatusSurface()
|
|
if got.Kind != autofillStatusAwaitingApproval {
|
|
t.Fatalf("autofillStatusSurface().Kind = %q, want %q", got.Kind, autofillStatusAwaitingApproval)
|
|
}
|
|
if got.Title != "Autofill approval needed" {
|
|
t.Fatalf("autofillStatusSurface().Title = %q, want %q", got.Title, "Autofill approval needed")
|
|
}
|
|
if !strings.Contains(got.Message, "Firefox (Browser Extension)") {
|
|
t.Fatalf("autofillStatusSurface().Message = %q, want requester details", got.Message)
|
|
}
|
|
if got.Detail != "Entry entry-1" {
|
|
t.Fatalf("autofillStatusSurface().Detail = %q, want %q", got.Detail, "Entry entry-1")
|
|
}
|
|
}
|
|
|
|
func TestUIAutofillStatusSurfaceRespectsNoticePreference(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.auditLog = &apiaudit.Log{}
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillFound,
|
|
TokenName: "Browser Extension",
|
|
ClientName: "Firefox",
|
|
Operation: apitokens.OperationCopyPassword,
|
|
At: now,
|
|
})
|
|
|
|
u.autofillNoticePreference = autofillNoticeApprovals
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
|
t.Fatalf("autofillStatusSurface() with approvals-only preference = %#v, want no recent notice", got)
|
|
}
|
|
|
|
u.autofillNoticePreference = autofillNoticeSuppressed
|
|
u.state.Approvals = &mainStubApprovalManager{
|
|
pending: []apiapproval.Request{{
|
|
ID: "approval-1",
|
|
TokenName: "Browser Extension",
|
|
ClientName: "Firefox",
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "entry-1"},
|
|
}},
|
|
}
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
|
t.Fatalf("autofillStatusSurface() with suppressed preference = %#v, want no notice", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAutofillStatusSurfaceUsesAuditEventsForFoundAmbiguousAndBlocked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.auditLog = apiaudit.New(10)
|
|
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillFound,
|
|
At: now,
|
|
TokenName: "Browser Extension",
|
|
Message: "Vault Console is ready to fill.",
|
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console"},
|
|
})
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusFound || got.Title != "Autofill match ready" {
|
|
t.Fatalf("autofillStatusSurface(found) = %#v, want found status", got)
|
|
}
|
|
|
|
u.auditLog = apiaudit.New(10)
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillAmbiguous,
|
|
At: now,
|
|
TokenName: "Browser Extension",
|
|
Message: "Multiple entries match example.com.",
|
|
})
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusAmbiguous || got.Title != "Autofill needs a narrower match" {
|
|
t.Fatalf("autofillStatusSurface(ambiguous) = %#v, want ambiguous status", got)
|
|
}
|
|
|
|
u.auditLog = apiaudit.New(10)
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalDenied,
|
|
At: now,
|
|
TokenName: "Browser Extension",
|
|
ClientName: "Firefox",
|
|
Operation: apitokens.OperationCopyPassword,
|
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console"},
|
|
})
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusBlocked || got.Title != "Autofill was not allowed" {
|
|
t.Fatalf("autofillStatusSurface(blocked) = %#v, want blocked status", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAutofillStatusSurfaceIgnoresExpiredAndNonAutofillAuditEvents(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.now = func() time.Time { return now }
|
|
u.auditLog = apiaudit.New(10)
|
|
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventAutofillFound,
|
|
At: now.Add(-autofillStatusTTL - time.Second),
|
|
TokenName: "Browser Extension",
|
|
Message: "stale event",
|
|
})
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
|
t.Fatalf("autofillStatusSurface(stale) = %#v, want none", got)
|
|
}
|
|
|
|
u.auditLog = apiaudit.New(10)
|
|
u.auditLog.Record(apiaudit.Event{
|
|
Type: apiaudit.EventApprovalAllowed,
|
|
At: now,
|
|
TokenName: "CLI",
|
|
Operation: apitokens.OperationListEntries,
|
|
Message: "not autofill",
|
|
})
|
|
if got := u.autofillStatusSurface(); got.Kind != autofillStatusNone {
|
|
t.Fatalf("autofillStatusSurface(non-autofill) = %#v, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.runAction("save vault", func() error {
|
|
return errors.New("save remote vaults/main.kdbx: " + webdav.ErrConflict.Error())
|
|
})
|
|
|
|
if got := u.state.ErrorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." {
|
|
t.Fatalf("state.ErrorMessage = %q, want normalized save conflict guidance", got)
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("state.StatusMessage = %q, want empty on conflict", got)
|
|
}
|
|
}
|
|
|
|
func TestUIUsesKeePassGOProductCopy(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if productName != "KeePassGO" {
|
|
t.Fatalf("productName = %q, want %q", productName, "KeePassGO")
|
|
}
|
|
}
|
|
|
|
func TestUIShowsLifecycleSetupOnlyBeforeVaultIsOpened(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
if !u.shouldShowLifecycleSetup() {
|
|
t.Fatal("shouldShowLifecycleSetup() = false, want true before opening a vault")
|
|
}
|
|
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
|
|
if u.shouldShowLifecycleSetup() {
|
|
t.Fatal("shouldShowLifecycleSetup() = true, want false after opening a vault")
|
|
}
|
|
}
|
|
|
|
func TestUIPendingApprovalUsesFirstPendingRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.state.Approvals = &mainStubApprovalManager{
|
|
pending: []apiapproval.Request{
|
|
{ID: "approval-1", TokenName: "CLI", Operation: apitokens.OperationListEntries},
|
|
{ID: "approval-2", TokenName: "Browser", Operation: apitokens.OperationReadEntry},
|
|
},
|
|
}
|
|
|
|
request, ok := u.pendingApproval()
|
|
if !ok {
|
|
t.Fatal("pendingApproval() ok = false, want true")
|
|
}
|
|
if request.ID != "approval-1" {
|
|
t.Fatalf("pendingApproval().ID = %q, want approval-1", request.ID)
|
|
}
|
|
}
|
|
|
|
func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manager := &mainStubApprovalManager{
|
|
pending: []apiapproval.Request{{ID: "approval-1"}},
|
|
}
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.state.Approvals = manager
|
|
|
|
if err := u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent); err != nil {
|
|
t.Fatalf("resolvePendingApproval() error = %v", err)
|
|
}
|
|
if manager.lastID != "approval-1" || manager.lastOutcome != apiapproval.OutcomeDenyPermanent {
|
|
t.Fatalf("resolvePendingApproval() delegated (%q, %q), want (approval-1, deny-permanent)", manager.lastID, manager.lastOutcome)
|
|
}
|
|
}
|
|
|
|
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{{ID: "1", Title: "Vault Console", Path: []string{"Root"}}},
|
|
})
|
|
|
|
u.filter()
|
|
u.state.SelectedEntryID = "1"
|
|
if u.editingEntry {
|
|
t.Fatal("editingEntry = true, want false by default")
|
|
}
|
|
|
|
u.editingEntry = true
|
|
u.loadSelectedEntryIntoEditor()
|
|
if !u.editingEntry {
|
|
t.Fatal("editingEntry = false, want true after entering edit mode")
|
|
}
|
|
}
|
|
|
|
func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "keepass.kdbx")
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBX(&encoded, vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
|
|
},
|
|
}, "correct horse battery staple"); err != nil {
|
|
t.Fatalf("SaveKDBX() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(keepass.kdbx) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText(path)
|
|
if err := u.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
|
|
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
|
t.Fatalf("currentPath = %v, want [keepass]", got)
|
|
}
|
|
if got := u.displayPath(); len(got) != 0 {
|
|
t.Fatalf("displayPath() = %v, want root slash path", got)
|
|
}
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) {
|
|
t.Fatalf("childGroups() = %v, want [Crew]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
|
|
{ID: "2", Title: "Home Assistant", Path: []string{"keepass", "Crew", "Home"}},
|
|
},
|
|
})
|
|
|
|
u.showEntriesSection()
|
|
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
|
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got)
|
|
}
|
|
|
|
u.showAPITokensSection()
|
|
u.showEntriesSection()
|
|
|
|
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) {
|
|
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got)
|
|
}
|
|
if got := u.displayPath(); len(got) != 0 {
|
|
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
|
|
}
|
|
if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) {
|
|
t.Fatalf("childGroups() after returning to entries = %v, want [Crew]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "amazon", Title: "Amazon", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}},
|
|
{ID: "aws", Title: "Amazon AWS", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}},
|
|
{ID: "git", Title: "Vault Console", Username: "dannyocean", Path: []string{"keepass", "Crew", "Internet"}},
|
|
},
|
|
})
|
|
|
|
u.showEntriesSection()
|
|
u.setCurrentPath([]string{"keepass", "Crew", "Internet"})
|
|
u.search.SetText("amazon")
|
|
u.filter()
|
|
u.state.SelectedEntryID = "amazon"
|
|
u.editingEntry = true
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
u.showAPITokensSection()
|
|
u.showEntriesSection()
|
|
|
|
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
|
|
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got)
|
|
}
|
|
if got := u.search.Text(); got != "amazon" {
|
|
t.Fatalf("search text after returning to entries = %q, want amazon", got)
|
|
}
|
|
if got := u.state.SelectedEntryID; got != "amazon" {
|
|
t.Fatalf("SelectedEntryID after returning to entries = %q, want amazon", got)
|
|
}
|
|
if !u.editingEntry {
|
|
t.Fatal("editingEntry = false, want true after returning to entries")
|
|
}
|
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Amazon", "Amazon AWS"}) {
|
|
t.Fatalf("filteredTitles() after returning to entries = %v, want [Amazon Amazon AWS]", got)
|
|
}
|
|
}
|
|
|
|
func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.recentVaultsPath = filepath.Join(t.TempDir(), "recent-vaults.json")
|
|
u.recentVaults = nil
|
|
u.noteRecentVault("/tmp/one.kdbx")
|
|
u.noteRecentVault("/tmp/two.kdbx")
|
|
u.noteRecentVault("/tmp/one.kdbx")
|
|
|
|
if got := u.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) {
|
|
t.Fatalf("recentVaults = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got)
|
|
}
|
|
}
|
|
|
|
func TestUILoadsRecentVaultsFromPersistedConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.recentVaultsPath = configPath
|
|
first.recentVaults = nil
|
|
first.noteRecentVault("/tmp/one.kdbx")
|
|
first.noteRecentVault("/tmp/two.kdbx")
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{})
|
|
second.recentVaultsPath = configPath
|
|
second.recentVaults = nil
|
|
second.loadRecentVaults()
|
|
|
|
if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/two.kdbx", "/tmp/one.kdbx"}) {
|
|
t.Fatalf("recentVaults after reload = %v, want [/tmp/two.kdbx /tmp/one.kdbx]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStartupPreselectsMostRecentLocalVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.recentVaultsPath = configPath
|
|
first.recentVaults = nil
|
|
first.recentVaultUsedAt = map[string]time.Time{}
|
|
first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) }
|
|
first.noteRecentVault("/tmp/older.kdbx")
|
|
first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) }
|
|
first.noteRecentVault("/tmp/newer.kdbx")
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
|
|
RecentVaultsPath: configPath,
|
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"),
|
|
})
|
|
|
|
if got := second.lifecycleMode; got != "local" {
|
|
t.Fatalf("lifecycleMode = %q, want local", got)
|
|
}
|
|
if got := second.vaultPath.Text(); got != "/tmp/newer.kdbx" {
|
|
t.Fatalf("vaultPath = %q, want /tmp/newer.kdbx", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.recentVaultsPath = configPath
|
|
first.recentVaults = nil
|
|
first.currentPath = []string{"Root", "Internet"}
|
|
first.syncedPath = []string{"Root", "Internet"}
|
|
first.noteRecentVault("/tmp/one.kdbx")
|
|
first.currentPath = []string{"Root", "Home Assistant"}
|
|
first.syncedPath = []string{"Root", "Home Assistant"}
|
|
first.noteRecentVault("/tmp/two.kdbx")
|
|
first.currentPath = []string{"Root", "Finance"}
|
|
first.syncedPath = []string{"Root", "Finance"}
|
|
first.noteRecentVault("/tmp/one.kdbx")
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{})
|
|
second.recentVaultsPath = configPath
|
|
second.recentVaults = nil
|
|
second.loadRecentVaults()
|
|
|
|
if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) {
|
|
t.Fatalf("recentVaults after reload = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got)
|
|
}
|
|
if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) {
|
|
t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got)
|
|
}
|
|
if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) {
|
|
t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "keepass.kdbx")
|
|
statePath := filepath.Join(dir, "recent-vaults.json")
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.recentVaultsPath = statePath
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "entry-1",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.currentPath = []string{"Root", "Internet"}
|
|
u.syncedPath = []string{"Root", "Internet"}
|
|
u.saveAsPath.SetText(path)
|
|
if err := u.saveAsAction(); err != nil {
|
|
t.Fatalf("saveAsAction() error = %v", err)
|
|
}
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.recentVaultsPath = statePath
|
|
reopened.recentVaults = nil
|
|
reopened.loadRecentVaults()
|
|
reopened.masterPassword.SetText("correct horse battery staple")
|
|
reopened.vaultPath.SetText(path)
|
|
if err := reopened.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
|
|
if got := reopened.displayPath(); !slices.Equal(got, []string{"Internet"}) {
|
|
t.Fatalf("displayPath() after reopen = %v, want [Internet]", got)
|
|
}
|
|
if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) {
|
|
t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "recent-remotes.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.recentRemotesPath = configPath
|
|
first.recentRemotes = nil
|
|
first.currentPath = []string{"Root", "Internet"}
|
|
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
|
|
first.currentPath = []string{"Root", "Home"}
|
|
first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false)
|
|
first.currentPath = []string{"Root", "Finance"}
|
|
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true)
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{})
|
|
second.recentRemotesPath = configPath
|
|
second.recentRemotes = nil
|
|
second.loadRecentRemotes()
|
|
|
|
if got := len(second.recentRemotes); got != 2 {
|
|
t.Fatalf("len(recentRemotes) = %d, want 2", got)
|
|
}
|
|
if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" {
|
|
t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got)
|
|
}
|
|
if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) {
|
|
t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got)
|
|
}
|
|
if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" {
|
|
t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got)
|
|
}
|
|
if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Home"}) {
|
|
t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Home]", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
vaultsPath := filepath.Join(dir, "recent-vaults.json")
|
|
remotesPath := filepath.Join(dir, "recent-remotes.json")
|
|
paths := statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: vaultsPath,
|
|
RecentRemotesPath: remotesPath,
|
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
|
}
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, paths)
|
|
first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) }
|
|
first.noteRecentVault("/tmp/local.kdbx")
|
|
first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) }
|
|
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, paths)
|
|
|
|
if got := second.lifecycleMode; got != "remote" {
|
|
t.Fatalf("lifecycleMode = %q, want remote", got)
|
|
}
|
|
if got := second.remoteBaseURL.Text(); got != "https://dav.example.com" {
|
|
t.Fatalf("remoteBaseURL = %q, want https://dav.example.com", got)
|
|
}
|
|
if got := second.remotePath.Text(); got != "vaults/home.kdbx" {
|
|
t.Fatalf("remotePath = %q, want vaults/home.kdbx", got)
|
|
}
|
|
if got := second.remoteUsername.Text(); got != "alice" {
|
|
t.Fatalf("remoteUsername = %q, want alice", got)
|
|
}
|
|
if got := second.remotePassword.Text(); got != "secret-1" {
|
|
t.Fatalf("remotePassword = %q, want secret-1", got)
|
|
}
|
|
}
|
|
|
|
func TestUIStartupDoesNotRequestMasterPasswordFocusWithoutSelectedTarget(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
u := newUIWithSession("phone", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
|
AutofillCachePath: filepath.Join(dir, "autofill-cache.json"),
|
|
})
|
|
|
|
if u.requestMasterPassFocus {
|
|
t.Fatal("requestMasterPassFocus = true without a selected startup target, want false")
|
|
}
|
|
}
|
|
|
|
func TestUIStartupRequestsMasterPasswordFocusForSelectedRecentVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
paths := statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
|
AutofillCachePath: filepath.Join(dir, "autofill-cache.json"),
|
|
}
|
|
|
|
first := newUIWithSession("phone", &session.Manager{}, paths)
|
|
first.noteRecentVault("/tmp/demo.kdbx")
|
|
|
|
reopened := newUIWithSession("phone", &session.Manager{}, paths)
|
|
|
|
if got := reopened.vaultPath.Text(); got != "/tmp/demo.kdbx" {
|
|
t.Fatalf("vaultPath = %q, want /tmp/demo.kdbx", got)
|
|
}
|
|
if !reopened.requestMasterPassFocus {
|
|
t.Fatal("requestMasterPassFocus = false with a selected startup vault, want true")
|
|
}
|
|
}
|
|
|
|
func TestUIGroupToolsDisclosureStatePersists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
first.groupControlsHidden = true
|
|
first.saveUIPreferences()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
second.groupControlsHidden = false
|
|
second.loadUIPreferences()
|
|
|
|
if !second.groupControlsHidden {
|
|
t.Fatal("groupControlsHidden = false after reload, want true")
|
|
}
|
|
}
|
|
|
|
func TestUIDenseLayoutPreferencePersists(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
first.denseLayout = true
|
|
first.saveUIPreferences()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
second.denseLayout = false
|
|
second.loadUIPreferences()
|
|
|
|
if !second.denseLayout {
|
|
t.Fatal("denseLayout = false after reload, want true")
|
|
}
|
|
}
|
|
|
|
func TestUISyncDefaultsPersistInSettings(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "settings.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
SettingsPath: configPath,
|
|
})
|
|
first.syncDefaultSourceMode = syncSourceRemote
|
|
first.syncDefaultDirection = syncDirectionPush
|
|
first.saveSettings()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
SettingsPath: configPath,
|
|
})
|
|
second.syncDefaultSourceMode = syncSourceLocal
|
|
second.syncDefaultDirection = syncDirectionPull
|
|
second.loadSettings()
|
|
|
|
if got := second.syncDefaultSourceMode; got != syncSourceRemote {
|
|
t.Fatalf("syncDefaultSourceMode = %q, want remote", got)
|
|
}
|
|
if got := second.syncDefaultDirection; got != syncDirectionPush {
|
|
t.Fatalf("syncDefaultDirection = %q, want push", got)
|
|
}
|
|
}
|
|
|
|
func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
legacyPath := filepath.Join(dir, "ui-prefs.json")
|
|
content, err := json.MarshalIndent(legacySyncPreferences{
|
|
SyncSourceDefault: string(syncSourceRemote),
|
|
SyncDirectionDefault: string(syncDirectionPush),
|
|
}, "", " ")
|
|
if err != nil {
|
|
t.Fatalf("json.MarshalIndent() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(legacyPath, content, 0o600); err != nil {
|
|
t.Fatalf("os.WriteFile() error = %v", err)
|
|
}
|
|
|
|
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
UIPreferencesPath: legacyPath,
|
|
})
|
|
reloaded.syncDefaultSourceMode = syncSourceLocal
|
|
reloaded.syncDefaultDirection = syncDirectionPull
|
|
reloaded.loadSettings()
|
|
|
|
if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote {
|
|
t.Fatalf("syncDefaultSourceMode = %q after legacy load, want remote", got)
|
|
}
|
|
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
|
|
t.Fatalf("syncDefaultDirection = %q after legacy load, want push", got)
|
|
}
|
|
}
|
|
|
|
func TestUIOpenAdvancedSyncDialogUsesSavedSyncDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.syncDefaultSourceMode = syncSourceRemote
|
|
u.syncDefaultDirection = syncDirectionPush
|
|
u.syncSourceMode = syncSourceLocal
|
|
u.syncDirection = syncDirectionPull
|
|
u.vaultPath.SetText("/vaults/current.kdbx")
|
|
|
|
u.openAdvancedSyncDialog()
|
|
|
|
if got := u.syncSourceMode; got != syncSourceRemote {
|
|
t.Fatalf("syncSourceMode = %q after open, want remote default", got)
|
|
}
|
|
if got := u.syncDirection; got != syncDirectionPush {
|
|
t.Fatalf("syncDirection = %q after open, want push default", got)
|
|
}
|
|
if got := u.syncLocalPath.Text(); got != "/vaults/current.kdbx" {
|
|
t.Fatalf("syncLocalPath = %q after open, want current vault path", got)
|
|
}
|
|
}
|
|
|
|
func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
manager := &session.Manager{}
|
|
dir := t.TempDir()
|
|
u := newUIWithSession("desktop", manager, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
|
})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
u.securityCipher.SetText(vault.CipherAES256)
|
|
u.securityKDF.SetText(vault.KDFAES)
|
|
u.loadSettingsDraft()
|
|
u.settingsDraft.Sync.SourceDefault = syncSourceRemote
|
|
u.settingsDraft.Sync.DirectionDefault = syncDirectionPush
|
|
|
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
|
}
|
|
|
|
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
SettingsPath: u.settingsPath,
|
|
})
|
|
reloaded.loadSettings()
|
|
|
|
if got := reloaded.syncDefaultSourceMode; got != syncSourceRemote {
|
|
t.Fatalf("reloaded syncDefaultSourceMode = %q, want remote", got)
|
|
}
|
|
if got := reloaded.syncDefaultDirection; got != syncDirectionPush {
|
|
t.Fatalf("reloaded syncDefaultDirection = %q, want push", got)
|
|
}
|
|
}
|
|
|
|
func TestUIAccessibilityPreferencesPersist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
first.applyAccessibilityPreferences(accessibilityPreferences{
|
|
DisplayDensity: displayDensityComfortable,
|
|
Contrast: contrastHigh,
|
|
ReducedMotion: true,
|
|
KeyboardFocus: keyboardFocusProminent,
|
|
})
|
|
first.saveUIPreferences()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
second.loadUIPreferences()
|
|
|
|
if second.denseLayout {
|
|
t.Fatal("denseLayout after reload = true, want comfortable layout preference")
|
|
}
|
|
if got := second.accessibilityPrefs; got != (accessibilityPreferences{
|
|
DisplayDensity: displayDensityComfortable,
|
|
Contrast: contrastHigh,
|
|
ReducedMotion: true,
|
|
KeyboardFocus: keyboardFocusProminent,
|
|
}) {
|
|
t.Fatalf("accessibilityPrefs after reload = %#v, want comfortable/high/reduced/prominent", got)
|
|
}
|
|
}
|
|
|
|
func TestFieldFocusAppearanceUsesAccessibilityPreferences(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
metric := unit.Metric{PxPerDp: 1, PxPerSp: 1}
|
|
base := fieldFocusAppearance(metric, defaultAccessibilityPreferences(), true)
|
|
comfortable := fieldFocusAppearance(metric, accessibilityPreferences{
|
|
DisplayDensity: displayDensityComfortable,
|
|
Contrast: contrastHigh,
|
|
KeyboardFocus: keyboardFocusProminent,
|
|
}, true)
|
|
|
|
if comfortable.MinHeight <= base.MinHeight {
|
|
t.Fatalf("fieldFocusAppearance(comfortable).MinHeight = %d, want > %d", comfortable.MinHeight, base.MinHeight)
|
|
}
|
|
if comfortable.OutlineWidth <= base.OutlineWidth {
|
|
t.Fatalf("fieldFocusAppearance(prominent).OutlineWidth = %d, want > %d", comfortable.OutlineWidth, base.OutlineWidth)
|
|
}
|
|
if comfortable.OutlineColor.A <= base.OutlineColor.A {
|
|
t.Fatalf("fieldFocusAppearance(high contrast).OutlineColor.A = %d, want > %d", comfortable.OutlineColor.A, base.OutlineColor.A)
|
|
}
|
|
}
|
|
|
|
func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
|
|
comfortableInset, comfortableTitle, _, _, _, comfortableGap := u.entryRowMetrics()
|
|
u.denseLayout = true
|
|
denseInset, denseTitle, _, _, _, denseGap := u.entryRowMetrics()
|
|
|
|
if denseInset >= comfortableInset {
|
|
t.Fatalf("dense inset = %v, want smaller than comfortable inset %v", denseInset, comfortableInset)
|
|
}
|
|
if denseTitle >= comfortableTitle {
|
|
t.Fatalf("dense title size = %v, want smaller than comfortable title size %v", denseTitle, comfortableTitle)
|
|
}
|
|
if denseGap >= comfortableGap {
|
|
t.Fatalf("dense divider gap = %v, want smaller than comfortable divider gap %v", denseGap, comfortableGap)
|
|
}
|
|
}
|
|
|
|
func TestUINotificationPreferencesPersist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
first.statusBannerTTL = statusBannerLong
|
|
first.autofillNoticePreference = autofillNoticeApprovals
|
|
first.saveUIPreferences()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
UIPreferencesPath: configPath,
|
|
})
|
|
second.statusBannerTTL = statusBannerDuration
|
|
second.autofillNoticePreference = autofillNoticeAll
|
|
second.loadUIPreferences()
|
|
|
|
if got := second.statusBannerTTL; got != statusBannerLong {
|
|
t.Fatalf("statusBannerTTL after reload = %v, want %v", got, statusBannerLong)
|
|
}
|
|
if got := second.autofillNoticePreference; got != autofillNoticeApprovals {
|
|
t.Fatalf("autofillNoticePreference after reload = %q, want %q", got, autofillNoticeApprovals)
|
|
}
|
|
}
|
|
|
|
func TestAutofillPrivacyLinesNormalizesEntries(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := autofillPrivacyLines(" com.android.chrome \n\ncom.example.app\ncom.android.chrome\n org.keepassgo.browser ")
|
|
want := []string{"com.android.chrome", "com.example.app", "org.keepassgo.browser"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("autofillPrivacyLines() = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestJoinAutofillPrivacyLines(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := joinAutofillPrivacyLines([]string{"com.android.chrome", "com.example.app"})
|
|
if got != "com.android.chrome\ncom.example.app" {
|
|
t.Fatalf("joinAutofillPrivacyLines() = %q, want %q", got, "com.android.chrome\ncom.example.app")
|
|
}
|
|
}
|
|
|
|
func TestUIAutofillPrivacyPreferencesPersist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.uiPreferencesPath = configPath
|
|
first.autofillFirstFillApprovalMode = autofillFirstFillApprovalBlock
|
|
first.autofillBrowserAllowlist.SetText("https://accounts.example.com\nhttps://login.example.org\nhttps://accounts.example.com")
|
|
first.autofillAppAllowlist.SetText("org.mozilla.firefox\ncom.android.chrome")
|
|
first.autofillPackageRules.SetText("com.android.chrome=hostname\norg.keepassgo.browser=view-id")
|
|
first.saveUIPreferences()
|
|
|
|
second := newUIWithSession("desktop", &session.Manager{})
|
|
second.uiPreferencesPath = configPath
|
|
second.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk
|
|
second.loadUIPreferences()
|
|
|
|
if got := second.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalBlock {
|
|
t.Fatalf("autofillFirstFillApprovalMode = %q, want %q", got, autofillFirstFillApprovalBlock)
|
|
}
|
|
if got := second.autofillBrowserAllowlist.Text(); got != "https://accounts.example.com\nhttps://login.example.org" {
|
|
t.Fatalf("autofillBrowserAllowlist = %q, want normalized browser allowlist", got)
|
|
}
|
|
if got := second.autofillAppAllowlist.Text(); got != "org.mozilla.firefox\ncom.android.chrome" {
|
|
t.Fatalf("autofillAppAllowlist = %q, want preserved allowlist entries", got)
|
|
}
|
|
if got := second.autofillPackageRules.Text(); got != "com.android.chrome=hostname\norg.keepassgo.browser=view-id" {
|
|
t.Fatalf("autofillPackageRules = %q, want persisted package rules", got)
|
|
}
|
|
}
|
|
|
|
func TestUILoadUIPreferencesKeepsDefaultAutofillApprovalWhenMissing(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
|
|
content, err := json.Marshal(uiPreferences{
|
|
GroupControlsHidden: true,
|
|
LifecycleAdvancedHidden: true,
|
|
HistoryHidden: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Marshal(uiPreferences) error = %v", err)
|
|
}
|
|
if err := os.WriteFile(configPath, content, 0o600); err != nil {
|
|
t.Fatalf("WriteFile(uiPreferences) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.uiPreferencesPath = configPath
|
|
u.autofillFirstFillApprovalMode = autofillFirstFillApprovalAsk
|
|
u.loadUIPreferences()
|
|
|
|
if got := u.autofillFirstFillApprovalMode; got != autofillFirstFillApprovalAsk {
|
|
t.Fatalf("autofillFirstFillApprovalMode = %q, want %q when preference missing", got, autofillFirstFillApprovalAsk)
|
|
}
|
|
}
|
|
|
|
func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.recentRemotes = []recentRemoteRecord{{
|
|
BaseURL: "https://dav.example.com",
|
|
Path: "vaults/home.kdbx",
|
|
Username: "alice",
|
|
Password: "secret-1",
|
|
}}
|
|
u.recentRemoteClicks = make([]widget.Clickable, 1)
|
|
|
|
u.remotePassword.Mask = 0
|
|
u.recentRemoteClicks[0].Click()
|
|
|
|
gtx := layout.Context{}
|
|
for u.recentRemoteClicks[0].Clicked(gtx) {
|
|
record := u.recentRemotes[0]
|
|
u.remoteBaseURL.SetText(record.BaseURL)
|
|
u.remotePath.SetText(record.Path)
|
|
u.remoteUsername.SetText(record.Username)
|
|
u.remotePassword.SetText(record.Password)
|
|
u.remotePassword.Mask = '•'
|
|
u.rememberRemoteAuth.Value = true
|
|
}
|
|
|
|
if got := u.remotePassword.Mask; got != '•' {
|
|
t.Fatalf("remotePassword.Mask = %q, want bullet mask", got)
|
|
}
|
|
}
|
|
|
|
func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "remote"
|
|
u.requestMasterPassFocus = false
|
|
u.recentVaults = []string{"/tmp/example.kdbx"}
|
|
u.recentVaultClicks = make([]widget.Clickable, 1)
|
|
u.recentVaultClicks[0].Click()
|
|
|
|
gtx := layout.Context{}
|
|
for u.recentVaultClicks[0].Clicked(gtx) {
|
|
if 0 < len(u.recentVaults) {
|
|
u.lifecycleMode = "local"
|
|
u.vaultPath.SetText(u.recentVaults[0])
|
|
u.requestMasterPassFocus = true
|
|
}
|
|
}
|
|
|
|
if got := u.lifecycleMode; got != "local" {
|
|
t.Fatalf("lifecycleMode after recent vault click = %q, want local", got)
|
|
}
|
|
if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" {
|
|
t.Fatalf("vaultPath after recent vault click = %q, want /tmp/example.kdbx", got)
|
|
}
|
|
if !u.requestMasterPassFocus {
|
|
t.Fatal("requestMasterPassFocus after recent vault click = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "local"
|
|
u.requestMasterPassFocus = false
|
|
u.recentRemotes = []recentRemoteRecord{{
|
|
BaseURL: "https://dav.example.com",
|
|
Path: "vaults/home.kdbx",
|
|
}}
|
|
u.recentRemoteClicks = make([]widget.Clickable, 1)
|
|
u.recentRemoteClicks[0].Click()
|
|
|
|
gtx := layout.Context{}
|
|
for u.recentRemoteClicks[0].Clicked(gtx) {
|
|
if 0 < len(u.recentRemotes) {
|
|
u.lifecycleMode = "remote"
|
|
u.applyRecentRemoteRecord(u.recentRemotes[0])
|
|
u.requestMasterPassFocus = true
|
|
}
|
|
}
|
|
|
|
if got := u.lifecycleMode; got != "remote" {
|
|
t.Fatalf("lifecycleMode after recent remote click = %q, want remote", got)
|
|
}
|
|
if got := u.remoteBaseURL.Text(); got != "https://dav.example.com" {
|
|
t.Fatalf("remoteBaseURL after recent remote click = %q, want https://dav.example.com", got)
|
|
}
|
|
if !u.requestMasterPassFocus {
|
|
t.Fatal("requestMasterPassFocus after recent remote click = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestUILoadingDetailMessageUsesSelectedVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "local"
|
|
u.vaultPath.SetText("/home/julian/vaults/main.kdbx")
|
|
u.loadingMessage = "Open vault..."
|
|
|
|
got := u.loadingDetailMessage()
|
|
want := "Target: /home/julian/vaults/main.kdbx"
|
|
if got != want {
|
|
t.Fatalf("loadingDetailMessage() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestUILoadingDetailMessageUsesSelectedRemote(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "remote"
|
|
u.remoteBaseURL.SetText("https://dav.example.com")
|
|
u.remotePath.SetText("vaults/home.kdbx")
|
|
u.loadingMessage = "Open remote vault..."
|
|
|
|
got := u.loadingDetailMessage()
|
|
want := "Target: home.kdbx · dav.example.com (vaults/home.kdbx)"
|
|
if got != want {
|
|
t.Fatalf("loadingDetailMessage() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := friendlyRecentRemoteLabel(recentRemoteRecord{
|
|
BaseURL: "https://dav.example.com/remote.php/webdav/",
|
|
Path: "vaults/family/home.kdbx",
|
|
})
|
|
want := "home.kdbx · dav.example.com"
|
|
if got != want {
|
|
t.Fatalf("friendlyRecentRemoteLabel() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
record recentRemoteRecord
|
|
want string
|
|
}{
|
|
{
|
|
name: "location_only",
|
|
record: recentRemoteRecord{},
|
|
want: "location only",
|
|
},
|
|
{
|
|
name: "username_only",
|
|
record: recentRemoteRecord{Username: "alice"},
|
|
want: "saved username",
|
|
},
|
|
{
|
|
name: "password_only",
|
|
record: recentRemoteRecord{Password: "token-1"},
|
|
want: "saved password",
|
|
},
|
|
{
|
|
name: "full_sign_in",
|
|
record: recentRemoteRecord{Username: "alice", Password: "token-1"},
|
|
want: "saved username and password",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want {
|
|
t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUIRemotePreferencesCurrentSummaryExplainsWhatWillBeRemembered(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.remoteBaseURL.SetText("https://dav.example.com")
|
|
u.remotePath.SetText("vaults/home.kdbx")
|
|
|
|
if got := u.remotePreferencesCurrentSummary(); got != "Current choice: KeePassGO will remember only the WebDAV location for this connection." {
|
|
t.Fatalf("remotePreferencesCurrentSummary() = %q, want location-only guidance", got)
|
|
}
|
|
|
|
u.rememberRemoteAuth.Value = true
|
|
if got := u.remotePreferencesCurrentSummary(); got != "Current choice: sign-in retention is enabled, but no username or password is entered yet." {
|
|
t.Fatalf("remotePreferencesCurrentSummary() = %q, want empty-sign-in guidance", got)
|
|
}
|
|
|
|
u.remoteUsername.SetText("alice")
|
|
if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will save the entered sign-in for this connection on this device." {
|
|
t.Fatalf("remotePreferencesCurrentSummary() = %q, want pending-save guidance", got)
|
|
}
|
|
|
|
u.recentRemotes = []recentRemoteRecord{{
|
|
BaseURL: "https://dav.example.com",
|
|
Path: "vaults/home.kdbx",
|
|
Username: "alice",
|
|
Password: "secret-1",
|
|
}}
|
|
if got := u.remotePreferencesCurrentSummary(); got != "Current choice: a successful open will update the saved sign-in for this connection on this device." {
|
|
t.Fatalf("remotePreferencesCurrentSummary() = %q, want saved-sign-in guidance", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRemotePreferencesHelpExplainsSavedFieldsAndRetention(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
|
|
if got := u.remotePreferencesAlwaysSavedSummary(); got != "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection." {
|
|
t.Fatalf("remotePreferencesAlwaysSavedSummary() = %q, want saved-fields guidance", got)
|
|
}
|
|
if got := u.remotePreferencesRetentionSummary(); got != "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password." {
|
|
t.Fatalf("remotePreferencesRetentionSummary() = %q, want retention guidance", got)
|
|
}
|
|
}
|
|
|
|
func TestUIRemotePreferencesHelpDialogToggle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
gtx := layout.Context{}
|
|
|
|
u.openRemotePrefsHelp.Click()
|
|
for u.openRemotePrefsHelp.Clicked(gtx) {
|
|
u.remotePrefsDialogOpen = true
|
|
}
|
|
if !u.remotePrefsDialogOpen {
|
|
t.Fatal("remotePrefsDialogOpen = false after open click, want true")
|
|
}
|
|
|
|
u.closeRemotePrefsHelp.Click()
|
|
for u.closeRemotePrefsHelp.Clicked(gtx) {
|
|
u.remotePrefsDialogOpen = false
|
|
}
|
|
if u.remotePrefsDialogOpen {
|
|
t.Fatal("remotePrefsDialogOpen = true after close click, want false")
|
|
}
|
|
}
|
|
|
|
func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "remote"
|
|
|
|
if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" {
|
|
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault")
|
|
}
|
|
|
|
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
|
|
if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" {
|
|
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault")
|
|
}
|
|
}
|
|
|
|
func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
statePath := filepath.Join(dir, "recent-remotes.json")
|
|
masterKey := vault.MasterKey{Password: "correct horse battery staple"}
|
|
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
},
|
|
}, masterKey); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
_, _ = w.Write(encoded.Bytes())
|
|
default:
|
|
t.Fatalf("unexpected method = %s", r.Method)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
first := newUIWithSession("desktop", &session.Manager{})
|
|
first.recentRemotesPath = statePath
|
|
first.recentRemotes = nil
|
|
first.lifecycleMode = "remote"
|
|
first.masterPassword.SetText("correct horse battery staple")
|
|
first.remoteBaseURL.SetText(server.URL)
|
|
first.remotePath.SetText("vault.kdbx")
|
|
if err := first.openRemoteAction(); err != nil {
|
|
t.Fatalf("openRemoteAction() error = %v", err)
|
|
}
|
|
first.state.NavigateToPath([]string{"Root", "Internet"})
|
|
first.currentPath = []string{"Root", "Internet"}
|
|
first.syncedPath = []string{"Root", "Internet"}
|
|
first.noteCurrentRemotePath()
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.recentRemotesPath = statePath
|
|
reopened.recentRemotes = nil
|
|
reopened.loadRecentRemotes()
|
|
reopened.lifecycleMode = "remote"
|
|
reopened.masterPassword.SetText("correct horse battery staple")
|
|
reopened.remoteBaseURL.SetText(server.URL)
|
|
reopened.remotePath.SetText("vault.kdbx")
|
|
if err := reopened.openRemoteAction(); err != nil {
|
|
t.Fatalf("openRemoteAction() error = %v", err)
|
|
}
|
|
|
|
if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) {
|
|
t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got)
|
|
}
|
|
}
|
|
|
|
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
base := filepath.Join(t.TempDir(), "keepassgo-state")
|
|
paths := defaultStatePaths(base)
|
|
|
|
if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") {
|
|
t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx"))
|
|
}
|
|
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
|
|
t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json"))
|
|
}
|
|
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
|
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
|
}
|
|
if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") {
|
|
t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json"))
|
|
}
|
|
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
|
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
|
}
|
|
if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
|
|
t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
|
|
}
|
|
}
|
|
|
|
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
|
|
base := filepath.Join(t.TempDir(), "keepassgo-state-env")
|
|
t.Setenv("KEEPASSGO_STATE_DIR", base)
|
|
|
|
paths := defaultStatePaths("")
|
|
|
|
if got := paths.DefaultSaveAsPath; got != filepath.Join(base, "vault.kdbx") {
|
|
t.Fatalf("DefaultSaveAsPath = %q, want %q", got, filepath.Join(base, "vault.kdbx"))
|
|
}
|
|
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
|
|
t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json"))
|
|
}
|
|
if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") {
|
|
t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json"))
|
|
}
|
|
if got := paths.SettingsPath; got != filepath.Join(base, "settings.json") {
|
|
t.Fatalf("SettingsPath = %q, want %q", got, filepath.Join(base, "settings.json"))
|
|
}
|
|
if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") {
|
|
t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json"))
|
|
}
|
|
if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") {
|
|
t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json"))
|
|
}
|
|
}
|
|
|
|
func TestRunActionSynchronizesAutofillCache(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
cachePath := filepath.Join(dir, "autofill-cache.json")
|
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
|
AutofillCachePath: cachePath,
|
|
})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
u.entryTitle.SetText("Chrome Test")
|
|
u.entryUsername.SetText("joe")
|
|
u.entryPassword.SetText("secret")
|
|
u.entryURL.SetText("https://10.0.2.2:8443/login")
|
|
u.runAction("save entry", u.saveEntryAction)
|
|
|
|
data, err := os.ReadFile(cachePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile(cache) error = %v", err)
|
|
}
|
|
if !strings.Contains(string(data), "\"host\": \"10.0.2.2\"") {
|
|
t.Fatalf("cache contents = %s, want host entry", string(data))
|
|
}
|
|
|
|
u.runAction("lock vault", u.lockAction)
|
|
if _, err := os.Stat(cachePath); !os.IsNotExist(err) {
|
|
t.Fatalf("cache path still exists after lock, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) {
|
|
t.Setenv("KEEPASSGO_TEST_VALUE", "from-env")
|
|
|
|
if got := resolveFlagOrEnv("from-flag", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-flag" {
|
|
t.Fatalf("resolveFlagOrEnv(flag) = %q, want %q", got, "from-flag")
|
|
}
|
|
if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_VALUE", "fallback"); got != "from-env" {
|
|
t.Fatalf("resolveFlagOrEnv(env) = %q, want %q", got, "from-env")
|
|
}
|
|
if got := resolveFlagOrEnv("", "KEEPASSGO_TEST_MISSING", "fallback"); got != "fallback" {
|
|
t.Fatalf("resolveFlagOrEnv(fallback) = %q, want %q", got, "fallback")
|
|
}
|
|
}
|
|
|
|
func TestDefaultModeForRuntimeUsesPhoneOnAndroid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := defaultModeForRuntime("android"); got != "phone" {
|
|
t.Fatalf("defaultModeForRuntime(android) = %q, want %q", got, "phone")
|
|
}
|
|
if got := defaultModeForRuntime("linux"); got != "desktop" {
|
|
t.Fatalf("defaultModeForRuntime(linux) = %q, want %q", got, "desktop")
|
|
}
|
|
}
|
|
|
|
func TestShouldUsePreviewWindowSizeSkipsAndroid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := shouldUsePreviewWindowSize("desktop", "android"); got {
|
|
t.Fatal("shouldUsePreviewWindowSize(desktop, android) = true, want false")
|
|
}
|
|
if got := shouldUsePreviewWindowSize("phone", "android"); got {
|
|
t.Fatal("shouldUsePreviewWindowSize(phone, android) = true, want false")
|
|
}
|
|
if got := shouldUsePreviewWindowSize("desktop", "linux"); !got {
|
|
t.Fatal("shouldUsePreviewWindowSize(desktop, linux) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestSupportsDesktopFilePicker(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := supportsDesktopFilePicker("android"); got {
|
|
t.Fatal("supportsDesktopFilePicker(android) = true, want false")
|
|
}
|
|
if got := supportsDesktopFilePicker("linux"); !got {
|
|
t.Fatal("supportsDesktopFilePicker(linux) = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "vault.kdbx")
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBX(&encoded, vault.Model{}, "correct horse battery staple"); err != nil {
|
|
t.Fatalf("SaveKDBX() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(vault.kdbx) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText(path)
|
|
|
|
handled := u.handleKeyPress(key.NameReturn, 0)
|
|
if !handled {
|
|
t.Fatal("handleKeyPress(Return) = false, want true")
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("StatusMessage = %q, want empty after open", got)
|
|
}
|
|
}
|
|
|
|
func TestEnterOnRemoteLifecycleScreenDefaultsToOpenRemoteVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
masterKey := vault.MasterKey{Password: "correct horse battery staple"}
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{}, masterKey); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey() error = %v", err)
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
t.Fatalf("unexpected method = %s, want GET", r.Method)
|
|
}
|
|
_, _ = w.Write(encoded.Bytes())
|
|
}))
|
|
defer server.Close()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.lifecycleMode = "remote"
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.remoteBaseURL.SetText(server.URL)
|
|
u.remotePath.SetText("vault.kdbx")
|
|
|
|
handled := u.handleKeyPress(key.NameReturn, 0)
|
|
if !handled {
|
|
t.Fatal("handleKeyPress(Return) = false, want true")
|
|
}
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("StatusMessage = %q, want empty after remote open", got)
|
|
}
|
|
}
|
|
|
|
func TestMasterPasswordPeekResetsAfterOpeningVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "vault.kdbx")
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBX(&encoded, vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
|
|
},
|
|
}, "correct horse battery staple"); err != nil {
|
|
t.Fatalf("SaveKDBX() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(vault.kdbx) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText(path)
|
|
u.showPassword = true
|
|
|
|
if err := u.openVaultAction(); err != nil {
|
|
t.Fatalf("openVaultAction() error = %v", err)
|
|
}
|
|
if u.showPassword {
|
|
t.Fatal("showPassword = true after openVaultAction(), want false")
|
|
}
|
|
}
|
|
|
|
func TestPasswordPeekResetsWhenChangingSelectedEntry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "vault-console", Title: "Vault Console", Password: "token-1", Path: []string{"Root", "Internet"}},
|
|
{ID: "bellagio", Title: "Bellagio", Password: "token-2", Path: []string{"Root", "Internet"}},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.showPassword = true
|
|
|
|
u.state.SelectedEntryID = "bellagio"
|
|
u.loadSelectedEntryIntoEditor()
|
|
|
|
if u.showPassword {
|
|
t.Fatal("showPassword = true after selecting a different entry, want false")
|
|
}
|
|
}
|
|
|
|
func TestEnterOnLockedScreenDefaultsToUnlockVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
handled := u.handleKeyPress(key.NameReturn, 0)
|
|
if !handled {
|
|
t.Fatal("handleKeyPress(Return) = false, want true while locked")
|
|
}
|
|
if got := u.masterPassword.Text(); got != "" {
|
|
t.Fatalf("masterPassword after unlock = %q, want empty", got)
|
|
}
|
|
if !u.isVaultLocked() {
|
|
t.Fatal("isVaultLocked() = false before background apply, want still locked")
|
|
}
|
|
result := waitForBackgroundResult(t, u)
|
|
if err := result.err; err != nil {
|
|
t.Fatalf("background unlock prepare error = %v", err)
|
|
}
|
|
u.applyBackgroundResult(result)
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("state.ErrorMessage after unlock apply = %q, want empty", got)
|
|
}
|
|
if u.isVaultLocked() {
|
|
t.Fatal("isVaultLocked() = true, want false after unlock apply")
|
|
}
|
|
}
|
|
|
|
func TestUILockedVaultUsesSingleUnlockPaneAndOmitsSearchFocus(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
|
|
if !u.shouldUseLockedSinglePane() {
|
|
t.Fatal("shouldUseLockedSinglePane() = false, want true while locked")
|
|
}
|
|
|
|
if got := u.focusOrder(); !slices.Equal(got, []focusID{detailFocusID(detailFieldPassword)}) {
|
|
t.Fatalf("focusOrder() while locked = %v, want only unlock password focus", got)
|
|
}
|
|
}
|
|
|
|
func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model := vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
target clipboard.Target
|
|
label string
|
|
want string
|
|
}{
|
|
{name: "username", target: clipboard.TargetUsername, label: "copy username", want: "dannyocean"},
|
|
{name: "password", target: clipboard.TargetPassword, label: "copy password", want: "token-1"},
|
|
{name: "url", target: clipboard.TargetURL, label: "copy URL", want: "https://vault.crew.example.invalid"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
u := newUIWithModel("desktop", model)
|
|
writer := &memoryClipboardWriter{}
|
|
u.clipboardWriter = writer
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
|
|
u.runAction(tt.label, func() error { return u.copySelectedFieldAction(tt.target) })
|
|
|
|
if writer.content != tt.want {
|
|
t.Fatalf("clipboard content = %q, want %q", writer.content, tt.want)
|
|
}
|
|
if u.state.StatusMessage != tt.label+" complete" {
|
|
t.Fatalf("state.StatusMessage = %q, want %q", u.state.StatusMessage, tt.label+" complete")
|
|
}
|
|
if u.state.ErrorMessage != "" {
|
|
t.Fatalf("state.ErrorMessage = %q, want empty", u.state.ErrorMessage)
|
|
}
|
|
if strings.Contains(u.state.StatusMessage, tt.want) {
|
|
t.Fatalf("state.StatusMessage = %q, must not contain copied secret or field value %q", u.state.StatusMessage, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.clipboardWriter = failingClipboardWriter{err: os.ErrPermission}
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
|
|
u.runAction("copy password", func() error { return u.copySelectedFieldAction(clipboard.TargetPassword) })
|
|
|
|
if u.state.ErrorMessage != clipboard.ErrWriteFailed.Error() {
|
|
t.Fatalf("state.ErrorMessage = %q, want %q", u.state.ErrorMessage, clipboard.ErrWriteFailed.Error())
|
|
}
|
|
if strings.Contains(u.state.ErrorMessage, "token-1") {
|
|
t.Fatalf("state.ErrorMessage = %q, must not contain copied password", u.state.ErrorMessage)
|
|
}
|
|
if u.state.StatusMessage != "" {
|
|
t.Fatalf("state.StatusMessage = %q, want empty on copy failure", u.state.StatusMessage)
|
|
}
|
|
}
|
|
|
|
func TestUIGeneratedPasswordFlowsIntoEditEntryForm(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
u.loadSelectedEntryIntoEditor()
|
|
u.passwordProfile.SetText("strong")
|
|
|
|
if err := u.generatePasswordAction(); err != nil {
|
|
t.Fatalf("generatePasswordAction() error = %v", err)
|
|
}
|
|
|
|
generated := u.entryPassword.Text()
|
|
if generated == "token-1" {
|
|
t.Fatal("entryPassword.Text() = token-1, want a newly generated password")
|
|
}
|
|
if len(generated) < passwords.DefaultProfiles()["strong"].Length {
|
|
t.Fatalf("len(entryPassword.Text()) = %d, want at least %d after generate", len(generated), passwords.DefaultProfiles()["strong"].Length)
|
|
}
|
|
|
|
if err := u.saveEntryAction(); err != nil {
|
|
t.Fatalf("saveEntryAction() error = %v", err)
|
|
}
|
|
|
|
saved, ok := u.selectedEntry()
|
|
if !ok {
|
|
t.Fatal("selectedEntry() ok = false, want true for edited entry")
|
|
}
|
|
if saved.Password != generated {
|
|
t.Fatalf("saved.Password = %q, want generated password %q", saved.Password, generated)
|
|
}
|
|
}
|
|
|
|
func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
Path: []string{"Root", "Internet"},
|
|
},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
u.state.SelectedEntryID = "vault-console"
|
|
|
|
if got := u.detailPasswordValue(); got != "••••••••" {
|
|
t.Fatalf("detailPasswordValue() hidden = %q, want %q", got, "••••••••")
|
|
}
|
|
|
|
u.showPassword = true
|
|
if got := u.detailPasswordValue(); got != "token-1" {
|
|
t.Fatalf("detailPasswordValue() revealed = %q, want %q", got, "token-1")
|
|
}
|
|
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
if u.showPassword {
|
|
t.Fatal("showPassword = true after lockAction(), want false")
|
|
}
|
|
}
|
|
|
|
type memoryClipboardWriter struct {
|
|
content string
|
|
}
|
|
|
|
func (w *memoryClipboardWriter) WriteText(text string) error {
|
|
w.content = text
|
|
return nil
|
|
}
|
|
|
|
type failingClipboardWriter struct {
|
|
err error
|
|
}
|
|
|
|
func (w failingClipboardWriter) WriteText(string) error {
|
|
return w.err
|
|
}
|
|
|
|
func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
if got := u.state.StatusMessage; got != "create vault complete" {
|
|
t.Fatalf("status after create = %q, want %q", got, "create vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after create = %q, want empty", got)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "keepassgo.kdbx")
|
|
u.saveAsPath.SetText(path)
|
|
u.runAction("save-as vault", u.saveAsAction)
|
|
if got := u.state.StatusMessage; got != "save-as vault complete" {
|
|
t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after save-as = %q, want empty", got)
|
|
}
|
|
|
|
if err := u.state.UpsertEntry(vault.Entry{
|
|
ID: "vault-console",
|
|
Title: "Vault Console",
|
|
Username: "dannyocean",
|
|
Password: "token-1",
|
|
URL: "https://vault.crew.example.invalid",
|
|
Path: []string{"Root", "Internet"},
|
|
}); err != nil {
|
|
t.Fatalf("UpsertEntry() error = %v", err)
|
|
}
|
|
|
|
u.runAction("save vault", u.saveAction)
|
|
if got := u.state.StatusMessage; got != "save vault complete" {
|
|
t.Fatalf("status after save = %q, want %q", got, "save vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after save = %q, want empty", got)
|
|
}
|
|
|
|
u.runAction("lock vault", u.lockAction)
|
|
if got := u.state.StatusMessage; got != "lock vault complete" {
|
|
t.Fatalf("status after lock = %q, want %q", got, "lock vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after lock = %q, want empty", got)
|
|
}
|
|
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.runAction("unlock vault", u.unlockAction)
|
|
if got := u.state.StatusMessage; got != "unlock vault complete" {
|
|
t.Fatalf("status after unlock = %q, want %q", got, "unlock vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after unlock = %q, want empty", got)
|
|
}
|
|
|
|
reopened := newUIWithSession("desktop", &session.Manager{})
|
|
reopened.masterPassword.SetText("correct horse battery staple")
|
|
reopened.vaultPath.SetText(path)
|
|
reopened.runAction("open vault", reopened.openVaultAction)
|
|
if got := reopened.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after open = %q, want empty", got)
|
|
}
|
|
if got := reopened.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after open = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestUIGroupDeletionOnlyAllowsEmptyGroupsAndRequiresConfirmation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("non-empty group cannot be deleted", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
|
},
|
|
Groups: [][]string{{"Root"}, {"Root", "Internet"}},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Internet"})
|
|
u.filter()
|
|
|
|
if deletable, reason := u.currentGroupDeletionState(); deletable {
|
|
t.Fatal("currentGroupDeletionState() deletable = true, want false for non-empty group")
|
|
} else if !strings.Contains(reason, "contains entries") {
|
|
t.Fatalf("currentGroupDeletionState() reason = %q, want contains entries guidance", reason)
|
|
}
|
|
})
|
|
|
|
t.Run("empty group requires confirmation before deletion", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Groups: [][]string{{"Root"}, {"Root", "Archive"}},
|
|
})
|
|
u.showEntriesSection()
|
|
u.state.NavigateToPath([]string{"Root", "Archive"})
|
|
u.filter()
|
|
|
|
if deletable, reason := u.currentGroupDeletionState(); !deletable {
|
|
t.Fatalf("currentGroupDeletionState() = false, want true for empty group: %q", reason)
|
|
}
|
|
|
|
u.armDeleteCurrentGroupAction()
|
|
if !u.deleteGroupPendingConfirmation() {
|
|
t.Fatal("deleteGroupPendingConfirmation() = false, want true after arming delete")
|
|
}
|
|
if got := u.state.StatusMessage; !strings.Contains(got, "Confirm deleting empty group") {
|
|
t.Fatalf("StatusMessage after arming delete = %q, want confirmation guidance", got)
|
|
}
|
|
|
|
if err := u.deleteCurrentGroupAction(); err != nil {
|
|
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
|
|
}
|
|
if u.deleteGroupPendingConfirmation() {
|
|
t.Fatal("deleteGroupPendingConfirmation() = true, want false after deletion")
|
|
}
|
|
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
|
|
t.Fatalf("currentPath after delete = %v, want [Root]", got)
|
|
}
|
|
if got := u.childGroups(); len(got) != 0 {
|
|
t.Fatalf("childGroups() after delete = %v, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUITemplateSectionEmptyStateStaysProductSpecific(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showTemplatesSection()
|
|
|
|
got := u.listEmptyMessage()
|
|
if got != "Templates are not available in this build." {
|
|
t.Fatalf("listEmptyMessage() = %q, want templates unavailable copy", got)
|
|
}
|
|
}
|
|
|
|
func TestUIListEmptyStateProvidesSectionSpecificGuidance(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("empty group", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{
|
|
Entries: []vault.Entry{
|
|
{ID: "entry-1", Title: "Root Entry", Path: []string{"Root"}},
|
|
},
|
|
})
|
|
u.showEntriesSection()
|
|
u.setCurrentPath([]string{"Root", "Empty Group"})
|
|
|
|
got := u.listEmptyState()
|
|
want := emptyState{
|
|
Title: "This group is empty",
|
|
Body: "Add an entry here, search below this point, or open a subgroup.",
|
|
}
|
|
if got != want {
|
|
t.Fatalf("listEmptyState() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("recycle search", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showRecycleBinSection()
|
|
u.search.SetText("orphaned")
|
|
|
|
got := u.listEmptyState()
|
|
want := emptyState{
|
|
Title: "No matching deleted entries",
|
|
Body: `No recycle-bin entries match "orphaned". Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.`,
|
|
}
|
|
if got != want {
|
|
t.Fatalf("listEmptyState() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
|
|
t.Run("api tokens", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("desktop", vault.Model{})
|
|
u.showAPITokensSection()
|
|
|
|
got := u.listEmptyState()
|
|
want := emptyState{
|
|
Title: "No API tokens yet",
|
|
Body: "Issue a token to grant scoped gRPC access to an external tool.",
|
|
}
|
|
if got != want {
|
|
t.Fatalf("listEmptyState() = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithModel("phone", vault.Model{})
|
|
|
|
gotCrumbs, gotIndices := u.visibleBreadcrumbs([]string{"Root", "Infrastructure"})
|
|
if !slices.Equal(gotCrumbs, []string{"/", "Infrastructure"}) {
|
|
t.Fatalf("visibleBreadcrumbs() crumbs = %v, want [\"/\" Infrastructure]", gotCrumbs)
|
|
}
|
|
if !slices.Equal(gotIndices, []int{0, 2}) {
|
|
t.Fatalf("visibleBreadcrumbs() indices = %v, want [0 2]", gotIndices)
|
|
}
|
|
|
|
gotCrumbs, gotIndices = u.visibleBreadcrumbs([]string{"Root", "Infrastructure", "SSH"})
|
|
if !slices.Equal(gotCrumbs, []string{"/", "…", "SSH"}) {
|
|
t.Fatalf("visibleBreadcrumbs() deep crumbs = %v, want [\"/\" \"…\" SSH]", gotCrumbs)
|
|
}
|
|
if !slices.Equal(gotIndices, []int{0, 2, 3}) {
|
|
t.Fatalf("visibleBreadcrumbs() deep indices = %v, want [0 2 3]", gotIndices)
|
|
}
|
|
}
|
|
|
|
func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("save without configured path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
|
|
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
|
|
})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
u.runAction("save vault", u.saveAction)
|
|
|
|
if got := u.state.StatusMessage; got != "save vault complete" {
|
|
t.Fatalf("status after save = %q, want %q", got, "save vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after save = %q, want empty", got)
|
|
}
|
|
})
|
|
|
|
t.Run("save-as uses default target path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
|
DefaultSaveAsPath: filepath.Join(t.TempDir(), "default-save-path.kdbx"),
|
|
RecentVaultsPath: filepath.Join(t.TempDir(), "recent-vaults.json"),
|
|
RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"),
|
|
UIPreferencesPath: filepath.Join(t.TempDir(), "ui-preferences.json"),
|
|
})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.defaultSaveAsPath = filepath.Join(t.TempDir(), "default-save-as.kdbx")
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
u.runAction("save-as vault", u.saveAsAction)
|
|
|
|
if got := u.state.StatusMessage; got != "save-as vault complete" {
|
|
t.Fatalf("status after save-as = %q, want %q", got, "save-as vault complete")
|
|
}
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after save-as = %q, want empty", got)
|
|
}
|
|
if _, err := os.Stat(u.defaultSaveAsPath); err != nil {
|
|
t.Fatalf("Stat(defaultSaveAsPath) error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("open without target path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText("")
|
|
|
|
u.runAction("open vault", u.openVaultAction)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after failed open = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; got != "vault path is required" {
|
|
t.Fatalf("error after failed open = %q, want %q", got, "vault path is required")
|
|
}
|
|
})
|
|
|
|
t.Run("open unreadable path", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText(filepath.Join(t.TempDir(), "missing.kdbx"))
|
|
|
|
u.runAction("open vault", u.openVaultAction)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after unreadable open = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; got == "" || !strings.Contains(got, "read ") {
|
|
t.Fatalf("error after unreadable open = %q, want read failure", got)
|
|
}
|
|
})
|
|
|
|
t.Run("open decode failure", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "corrupt.kdbx")
|
|
if err := os.WriteFile(path, []byte("not-a-kdbx"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(corrupt) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
u.vaultPath.SetText(path)
|
|
|
|
u.runAction("open vault", u.openVaultAction)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after decode failure = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; got == "" || !strings.Contains(got, "decode kdbx") {
|
|
t.Fatalf("error after decode failure = %q, want decode kdbx failure", got)
|
|
}
|
|
})
|
|
|
|
t.Run("open invalid master key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
path := filepath.Join(t.TempDir(), "vault.kdbx")
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBX(&encoded, vault.Model{}, "correct horse battery staple"); err != nil {
|
|
t.Fatalf("SaveKDBX() error = %v", err)
|
|
}
|
|
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(vault) error = %v", err)
|
|
}
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("wrong password")
|
|
u.vaultPath.SetText(path)
|
|
|
|
u.runAction("open vault", u.openVaultAction)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after invalid master open = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) {
|
|
t.Fatalf("error after invalid master open = %q, want %q", got, vault.ErrInvalidMasterKey.Error())
|
|
}
|
|
})
|
|
|
|
t.Run("unlock invalid master key", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
if err := u.createVaultAction(); err != nil {
|
|
t.Fatalf("createVaultAction() error = %v", err)
|
|
}
|
|
if err := u.lockAction(); err != nil {
|
|
t.Fatalf("lockAction() error = %v", err)
|
|
}
|
|
|
|
u.masterPassword.SetText("wrong password")
|
|
u.runAction("unlock vault", u.unlockAction)
|
|
|
|
if got := u.state.StatusMessage; got != "" {
|
|
t.Fatalf("status after invalid unlock = %q, want empty", got)
|
|
}
|
|
if got := u.state.ErrorMessage; !strings.Contains(got, vault.ErrInvalidMasterKey.Error()) {
|
|
t.Fatalf("error after invalid unlock = %q, want %q", got, vault.ErrInvalidMasterKey.Error())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUILocalLifecycleActionsClearStaleMessagesOnSuccess(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.masterPassword.SetText("correct horse battery staple")
|
|
|
|
u.runAction("save vault", u.saveAction)
|
|
if u.state.ErrorMessage == "" {
|
|
t.Fatal("error after failed save = empty, want visible failure")
|
|
}
|
|
|
|
u.runAction("create vault", u.createVaultAction)
|
|
if got := u.state.ErrorMessage; got != "" {
|
|
t.Fatalf("error after create = %q, want cleared", got)
|
|
}
|
|
if got := u.state.StatusMessage; got != "create vault complete" {
|
|
t.Fatalf("status after create = %q, want %q", got, "create vault complete")
|
|
}
|
|
}
|
|
|
|
func TestUICurrentMasterKeyReportsUnreadableKeyFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
u := newUIWithSession("desktop", &session.Manager{})
|
|
u.keyFilePath.SetText(filepath.Join(t.TempDir(), "missing.key"))
|
|
|
|
_, err := u.currentMasterKey()
|
|
if err == nil {
|
|
t.Fatal("currentMasterKey() error = nil, want read failure")
|
|
}
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("currentMasterKey() error = %v, want os.ErrNotExist", err)
|
|
}
|
|
}
|
|
|
|
func writeKDBXMainTestFile(t *testing.T, path string, model vault.Model, key vault.MasterKey) {
|
|
t.Helper()
|
|
|
|
var encoded bytes.Buffer
|
|
if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil {
|
|
t.Fatalf("SaveKDBXWithKey(%s) error = %v", path, err)
|
|
}
|
|
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
|
|
t.Fatalf("WriteFile(%s) error = %v", path, err)
|
|
}
|
|
}
|
|
|
|
type mainStubApprovalManager struct {
|
|
pending []apiapproval.Request
|
|
lastID string
|
|
lastOutcome apiapproval.Outcome
|
|
}
|
|
|
|
func (m mainStubApprovalManager) Pending() []apiapproval.Request {
|
|
return append([]apiapproval.Request(nil), m.pending...)
|
|
}
|
|
|
|
func (m *mainStubApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
|
m.lastID = id
|
|
m.lastOutcome = outcome
|
|
return apiapproval.Request{ID: id}, nil, nil
|
|
}
|