package autofillcache import ( "encoding/json" "os" "path/filepath" "testing" "time" "git.julianfamily.org/keepassgo/vault" ) func TestBuildFiltersAndNormalizesEntries(t *testing.T) { t.Parallel() now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) got := Build(vault.Model{ Entries: []vault.Entry{ { ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login", Path: []string{"Crew", "Internet"}, }, { ID: "two", Title: "No Password", Username: "joe", URL: "https://example.com", }, { ID: "three", Title: "Bare Host", Username: "user", Password: "pass", URL: "surveillance.crew.example.invalid", Fields: map[string]string{ "AndroidApp1": "androidapp://com.lights.mobile", "KP2A_URL_1": "https://surveillance.crew.example.invalid/account", }, }, }, }, now) if len(got.Entries) != 2 { t.Fatalf("entry count = %d, want 2", len(got.Entries)) } if got.Entries[0].Host != "10.0.2.2" { t.Fatalf("first host = %q, want 10.0.2.2", got.Entries[0].Host) } if got.Entries[1].Host != "surveillance.crew.example.invalid" { t.Fatalf("second host = %q, want surveillance.crew.example.invalid", got.Entries[1].Host) } if len(got.Entries[1].Targets) != 3 { t.Fatalf("len(second targets) = %d, want 3", len(got.Entries[1].Targets)) } if got.UpdatedAt != "2026-03-31T12:00:00Z" { t.Fatalf("updatedAt = %q", got.UpdatedAt) } } func TestWriteAndClear(t *testing.T) { t.Parallel() dir := t.TempDir() path := filepath.Join(dir, "autofill-cache.json") model := vault.Model{ Entries: []vault.Entry{ {ID: "one", Title: "Chrome Test", Username: "joe", Password: "secret", URL: "https://10.0.2.2:8443/login"}, }, } if err := Write(path, model, time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC)); err != nil { t.Fatalf("Write() error = %v", err) } data, err := os.ReadFile(path) if err != nil { t.Fatalf("ReadFile() error = %v", err) } var got File if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("Unmarshal() error = %v", err) } if len(got.Entries) != 1 || got.Entries[0].Host != "10.0.2.2" { t.Fatalf("cache entries = %#v", got.Entries) } if err := Clear(path); err != nil { t.Fatalf("Clear() error = %v", err) } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("cache path still exists, stat err = %v", err) } } func TestMatchChoosesExactURLWhenHostsRepeat(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Primary Login", Username: "first", Password: "secret1", URL: "https://10.0.2.2:8443/login/", Host: "10.0.2.2", }, { ID: "two", Title: "Alt Login", Username: "second", Password: "secret2", URL: "https://10.0.2.2:8443/alt/", Host: "10.0.2.2", }, }, } got, ok := Match(cache, "https://10.0.2.2:8443/alt/") if !ok { t.Fatalf("Match() found no entry") } if got.ID != "two" { t.Fatalf("Match() entry = %q, want two", got.ID) } } func TestMatchRejectsAmbiguousSharedHost(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Host A", Username: "first", Password: "secret1", URL: "https://surveillance.crew.example.invalid/", Host: "surveillance.crew.example.invalid", }, { ID: "two", Title: "Host B", Username: "second", Password: "secret2", URL: "https://surveillance.crew.example.invalid/", Host: "surveillance.crew.example.invalid", }, }, } if _, ok := Match(cache, "https://surveillance.crew.example.invalid/"); ok { t.Fatalf("Match() unexpectedly resolved ambiguous shared host") } } func TestResolveReportsFoundAmbiguousAndMissingStatuses(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Admin Login", Username: "admin", Password: "secret1", URL: "https://example.com/admin", Host: "example.com", }, { ID: "two", Title: "Shared Login A", Username: "shared-a", Password: "secret2", URL: "https://shared.example.com", Host: "shared.example.com", }, { ID: "three", Title: "Shared Login B", Username: "shared-b", Password: "secret3", URL: "https://shared.example.com", Host: "shared.example.com", }, }, } if got := Resolve(cache, "https://example.com/admin/login"); got.Status != MatchStatusFound || got.Entry.ID != "one" { t.Fatalf("Resolve(found) = %#v, want found entry one", got) } if got := Resolve(cache, "https://shared.example.com"); got.Status != MatchStatusAmbiguous { t.Fatalf("Resolve(ambiguous) = %#v, want ambiguous", got) } if got := Resolve(cache, "https://nowhere.invalid"); got.Status != MatchStatusMissing { t.Fatalf("Resolve(missing) = %#v, want missing", got) } } func TestMatchChoosesLongestPathPrefix(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Generic Login", Username: "generic", Password: "secret1", URL: "https://example.com/", Host: "example.com", }, { ID: "two", Title: "Admin Login", Username: "admin", Password: "secret2", URL: "https://example.com/admin", Host: "example.com", }, }, } got, ok := Match(cache, "https://example.com/admin/login") if !ok { t.Fatalf("Match() found no entry") } if got.ID != "two" { t.Fatalf("Match() entry = %q, want two", got.ID) } } func TestMatchSupportsAndroidAppPackageTargets(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Thunderbird", Username: "mail-user", Password: "secret1", URL: "androidapp://org.mozilla.thunderbird/login", Host: "org.mozilla.thunderbird", }, }, } got, ok := Match(cache, "androidapp://org.mozilla.thunderbird") if !ok { t.Fatalf("Match() found no entry") } if got.ID != "one" { t.Fatalf("Match() entry = %q, want one", got.ID) } } func TestMatchRejectsAmbiguousAndroidAppPackageTargets(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Thunderbird Primary", Username: "mail-user", Password: "secret1", URL: "androidapp://org.mozilla.thunderbird", Host: "org.mozilla.thunderbird", }, { ID: "two", Title: "Thunderbird Secondary", Username: "other-user", Password: "secret2", URL: "androidapp://org.mozilla.thunderbird", Host: "org.mozilla.thunderbird", }, }, } if _, ok := Match(cache, "androidapp://org.mozilla.thunderbird"); ok { t.Fatalf("Match() unexpectedly resolved ambiguous android app package target") } } func TestMatchUsesAndroidAppCustomFieldTarget(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Blink", Username: "blink-user", Password: "secret1", URL: "https://account.blinknetwork.com", Host: "account.blinknetwork.com", Targets: []string{"https://account.blinknetwork.com", "androidapp://com.blinknetwork.mobile2"}, }, }, } got, ok := Match(cache, "androidapp://com.blinknetwork.mobile2") if !ok { t.Fatalf("Match() found no entry") } if got.ID != "one" { t.Fatalf("Match() entry = %q, want one", got.ID) } } func TestMatchUsesKP2AURLCustomFieldTarget(t *testing.T) { t.Parallel() cache := File{ Entries: []Entry{ { ID: "one", Title: "Blink", Username: "blink-user", Password: "secret1", URL: "https://blinknetwork.com", Host: "blinknetwork.com", Targets: []string{"https://blinknetwork.com", "https://account.blinknetwork.com"}, }, }, } got, ok := Match(cache, "https://account.blinknetwork.com") if !ok { t.Fatalf("Match() found no entry") } if got.ID != "one" { t.Fatalf("Match() entry = %q, want one", got.ID) } }