Files
keepassgo/internal/vault/model_test.go
2026-04-09 06:42:21 -07:00

344 lines
9.9 KiB
Go

package vault
import (
"errors"
"slices"
"testing"
)
func testModel() Model {
return Model{
Entries: []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"}},
{ID: "4", Title: "Alma (WA Prep)", Username: "christina.julian", URL: "https://waprep.getalma.com", Path: []string{"Tricia", "School"}},
},
}
}
func TestChildGroupsReturnsImmediateGroupsOnly(t *testing.T) {
model := testModel()
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
}
}
func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
model := testModel()
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Vault Console" {
t.Fatalf("EntriesInPath() titles = %q, %q", got[0].Title, got[1].Title)
}
}
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
t.Parallel()
model := testModel()
got := model.EntriesUnderPath([]string{"Crew"})
if len(got) != 3 {
t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got))
}
if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" {
t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
}
}
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
model := testModel()
got := model.Search("vault")
if len(got) != 1 {
t.Fatalf("len(Search()) = %d, want 1", len(got))
}
if got[0].Entry.Title != "Vault Console" {
t.Fatalf("Search() title = %q, want %q", got[0].Entry.Title, "Vault Console")
}
if got[0].Path != "Crew / Internet" {
t.Fatalf("Search() path = %q, want %q", got[0].Path, "Crew / Internet")
}
}
func TestTemplateEntriesAreStoredSeparatelyFromNormalEntries(t *testing.T) {
model := testModel()
model.UpsertTemplate(Entry{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Tags: []string{"template", "web"},
Path: []string{"Templates"},
})
if len(model.Entries) != 4 {
t.Fatalf("len(Entries) = %d, want 4", len(model.Entries))
}
if len(model.Templates) != 1 {
t.Fatalf("len(Templates) = %d, want 1", len(model.Templates))
}
if got := model.Templates[0].Title; got != "Website Login" {
t.Fatalf("Templates[0].Title = %q, want %q", got, "Website Login")
}
}
func TestInstantiateTemplateCreatesNormalEntryWithOverrides(t *testing.T) {
model := Model{
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
URL: "https://example.com",
Notes: "Reusable template for website accounts.",
Tags: []string{"template", "web"},
Fields: map[string]string{
"Environment": "prod",
},
Path: []string{"Templates"},
},
},
}
entry, err := model.InstantiateTemplate("tpl-1", Entry{
ID: "entry-1",
Title: "Bellagio",
Username: "rustyryan",
Password: "hunter2",
URL: "https://bellagio.example.invalid",
Path: []string{"Crew", "Internet"},
Tags: []string{"dns"},
})
if err != nil {
t.Fatalf("InstantiateTemplate() error = %v", err)
}
if entry.ID != "entry-1" {
t.Fatalf("entry.ID = %q, want %q", entry.ID, "entry-1")
}
if entry.Title != "Bellagio" {
t.Fatalf("entry.Title = %q, want %q", entry.Title, "Bellagio")
}
if entry.Username != "rustyryan" || entry.Password != "hunter2" || entry.URL != "https://bellagio.example.invalid" {
t.Fatalf("entry credentials = %#v, want override values", entry)
}
if entry.Notes != "Reusable template for website accounts." {
t.Fatalf("entry.Notes = %q, want %q", entry.Notes, "Reusable template for website accounts.")
}
if !slices.Equal(entry.Tags, []string{"dns"}) {
t.Fatalf("entry.Tags = %v, want [dns]", entry.Tags)
}
if entry.Fields["Environment"] != "prod" {
t.Fatalf("entry.Fields[Environment] = %q, want %q", entry.Fields["Environment"], "prod")
}
got := model.EntriesInPath([]string{"Crew", "Internet"})
if len(got) != 1 || got[0].Title != "Bellagio" {
t.Fatalf("EntriesInPath() = %#v, want instantiated Bellagio entry", got)
}
}
func TestInstantiateTemplateFailsForUnknownTemplate(t *testing.T) {
model := Model{}
_, err := model.InstantiateTemplate("missing-template", Entry{ID: "entry-1"})
if err == nil {
t.Fatal("InstantiateTemplate() error = nil, want ErrEntryNotFound")
}
if !errors.Is(err, ErrEntryNotFound) {
t.Fatalf("InstantiateTemplate() error = %v, want ErrEntryNotFound", err)
}
}
func TestDeleteTemplateRemovesTemplateWithoutTouchingEntries(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates"}},
},
}
if err := model.DeleteTemplate("tpl-1"); err != nil {
t.Fatalf("DeleteTemplate() error = %v", err)
}
if len(model.Templates) != 0 {
t.Fatalf("len(Templates) = %d, want 0", len(model.Templates))
}
if len(model.Entries) != 1 || model.Entries[0].ID != "entry-1" {
t.Fatalf("Entries = %#v, want unchanged normal entry", model.Entries)
}
}
func TestMoveTemplateChangesItsPath(t *testing.T) {
t.Parallel()
model := Model{
Templates: []Entry{
{ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}},
},
}
if err := model.MoveTemplate("tpl-1", []string{"Templates", "Infra"}); err != nil {
t.Fatalf("MoveTemplate() error = %v", err)
}
if got := model.Templates[0].Path; !slices.Equal(got, []string{"Templates", "Infra"}) {
t.Fatalf("Templates[0].Path = %v, want [Templates Infra]", got)
}
}
func TestDuplicateEntryCopiesEntryWithNewIDAndTitle(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
Path: []string{"Root", "Internet"},
},
},
}
duplicate, err := model.DuplicateEntry("entry-1", "entry-2")
if err != nil {
t.Fatalf("DuplicateEntry() error = %v", err)
}
if duplicate.ID != "entry-2" {
t.Fatalf("duplicate.ID = %q, want %q", duplicate.ID, "entry-2")
}
if duplicate.Title != "Vault Console (Copy)" {
t.Fatalf("duplicate.Title = %q, want %q", duplicate.Title, "Vault Console (Copy)")
}
got := model.EntriesInPath([]string{"Root", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath()) = %d, want 2", len(got))
}
}
func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance")
got := model.ChildGroups([]string{"Crew"})
want := []string{"Finance", "Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
}
}
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod")
got := model.ChildGroups([]string{"Crew"})
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got)
}
got = model.ChildGroups([]string{"Crew", "Infrastructure"})
if !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got)
}
}
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
model := testModel()
if err := model.RenameGroup([]string{"Crew", "Internet"}, "Infra"); err != nil {
t.Fatalf("RenameGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Crew", "Infra"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Crew/Infra)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after rename")
}
}
func TestMoveEntryChangesItsPath(t *testing.T) {
model := testModel()
if err := model.MoveEntry("1", []string{"Tricia", "School"}); err != nil {
t.Fatalf("MoveEntry() error = %v", err)
}
got := model.EntriesInPath([]string{"Tricia", "School"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/School)) = %d, want 2", len(got))
}
}
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
t.Parallel()
model := testModel()
model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure")
if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil {
t.Fatalf("MoveGroup() error = %v", err)
}
got := model.EntriesInPath([]string{"Tricia", "Internet"})
if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got))
}
if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 {
t.Fatal("EntriesInPath(Crew/Internet) should be empty after move")
}
gotGroups := model.ChildGroups([]string{"Tricia", "Internet"})
if !slices.Equal(gotGroups, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups)
}
}
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
model := testModel()
model.CreateGroup([]string{"Crew"}, "Finance")
if err := model.DeleteGroup([]string{"Crew", "Finance"}); err != nil {
t.Fatalf("DeleteGroup() error = %v", err)
}
got := model.ChildGroups([]string{"Crew"})
want := []string{"Home Assistant", "Internet"}
if !slices.Equal(got, want) {
t.Fatalf("ChildGroups() = %v, want %v", got, want)
}
}