Add common two level public TLDs to autofill matching implementations (#1264)

This commit is contained in:
Leendert de Borst
2025-09-23 10:55:24 +02:00
parent ceaea5f214
commit 216875ef05
6 changed files with 342 additions and 10 deletions

View File

@@ -36,6 +36,90 @@ function extractDomain(url: string): string {
return domain;
}
/**
* Extract root domain from a domain string.
* E.g., "sub.example.com" -> "example.com"
* E.g., "sub.example.com.au" -> "example.com.au"
* E.g., "sub.example.co.uk" -> "example.co.uk"
*/
function extractRootDomain(domain: string): string {
const parts = domain.split('.');
if (parts.length < 2) return domain;
// Common two-level public TLDs
const twoLevelTlds = new Set([
// Australia
'com.au', 'net.au', 'org.au', 'edu.au', 'gov.au', 'asn.au', 'id.au',
// United Kingdom
'co.uk', 'org.uk', 'net.uk', 'ac.uk', 'gov.uk', 'plc.uk', 'ltd.uk', 'me.uk',
// Canada
'co.ca', 'net.ca', 'org.ca', 'gc.ca', 'ab.ca', 'bc.ca', 'mb.ca', 'nb.ca', 'nf.ca', 'nl.ca', 'ns.ca', 'nt.ca', 'nu.ca',
'on.ca', 'pe.ca', 'qc.ca', 'sk.ca', 'yk.ca',
// India
'co.in', 'net.in', 'org.in', 'edu.in', 'gov.in', 'ac.in', 'res.in', 'gen.in', 'firm.in', 'ind.in',
// Japan
'co.jp', 'ne.jp', 'or.jp', 'ac.jp', 'ad.jp', 'ed.jp', 'go.jp', 'gr.jp', 'lg.jp',
// South Africa
'co.za', 'net.za', 'org.za', 'edu.za', 'gov.za', 'ac.za', 'web.za',
// New Zealand
'co.nz', 'net.nz', 'org.nz', 'edu.nz', 'govt.nz', 'ac.nz', 'geek.nz', 'gen.nz', 'kiwi.nz', 'maori.nz', 'mil.nz', 'school.nz',
// Brazil
'com.br', 'net.br', 'org.br', 'edu.br', 'gov.br', 'mil.br', 'art.br', 'etc.br', 'adv.br', 'arq.br', 'bio.br', 'cim.br',
'cng.br', 'cnt.br', 'ecn.br', 'eng.br', 'esp.br', 'eti.br', 'far.br', 'fnd.br', 'fot.br', 'fst.br', 'g12.br', 'geo.br',
'ggf.br', 'jor.br', 'lel.br', 'mat.br', 'med.br', 'mus.br', 'not.br', 'ntr.br', 'odo.br', 'ppg.br', 'pro.br', 'psc.br',
'psi.br', 'qsl.br', 'rec.br', 'slg.br', 'srv.br', 'tmp.br', 'trd.br', 'tur.br', 'tv.br', 'vet.br', 'zlg.br',
// Russia
'com.ru', 'net.ru', 'org.ru', 'edu.ru', 'gov.ru', 'int.ru', 'mil.ru', 'spb.ru', 'msk.ru',
// China
'com.cn', 'net.cn', 'org.cn', 'edu.cn', 'gov.cn', 'mil.cn', 'ac.cn', 'ah.cn', 'bj.cn', 'cq.cn', 'fj.cn', 'gd.cn', 'gs.cn',
'gz.cn', 'gx.cn', 'ha.cn', 'hb.cn', 'he.cn', 'hi.cn', 'hk.cn', 'hl.cn', 'hn.cn', 'jl.cn', 'js.cn', 'jx.cn', 'ln.cn', 'mo.cn',
'nm.cn', 'nx.cn', 'qh.cn', 'sc.cn', 'sd.cn', 'sh.cn', 'sn.cn', 'sx.cn', 'tj.cn', 'tw.cn', 'xj.cn', 'xz.cn', 'yn.cn', 'zj.cn',
// Mexico
'com.mx', 'net.mx', 'org.mx', 'edu.mx', 'gob.mx',
// Argentina
'com.ar', 'net.ar', 'org.ar', 'edu.ar', 'gov.ar', 'mil.ar', 'int.ar',
// Chile
'com.cl', 'net.cl', 'org.cl', 'edu.cl', 'gov.cl', 'mil.cl',
// Colombia
'com.co', 'net.co', 'org.co', 'edu.co', 'gov.co', 'mil.co', 'nom.co',
// Venezuela
'com.ve', 'net.ve', 'org.ve', 'edu.ve', 'gov.ve', 'mil.ve', 'web.ve',
// Peru
'com.pe', 'net.pe', 'org.pe', 'edu.pe', 'gob.pe', 'mil.pe', 'nom.pe',
// Ecuador
'com.ec', 'net.ec', 'org.ec', 'edu.ec', 'gov.ec', 'mil.ec', 'med.ec', 'fin.ec', 'pro.ec', 'info.ec',
// Europe
'co.at', 'or.at', 'ac.at', 'gv.at', 'priv.at',
'co.be', 'ac.be',
'co.dk', 'ac.dk',
'co.il', 'net.il', 'org.il', 'ac.il', 'gov.il', 'idf.il', 'k12.il', 'muni.il',
'co.no', 'ac.no', 'priv.no',
'co.pl', 'net.pl', 'org.pl', 'edu.pl', 'gov.pl', 'mil.pl', 'nom.pl', 'com.pl',
'co.th', 'net.th', 'org.th', 'edu.th', 'gov.th', 'mil.th', 'ac.th', 'in.th',
'co.kr', 'net.kr', 'org.kr', 'edu.kr', 'gov.kr', 'mil.kr', 'ac.kr', 'go.kr', 'ne.kr', 'or.kr', 'pe.kr', 're.kr', 'seoul.kr',
'kyonggi.kr',
// Others
'co.id', 'net.id', 'org.id', 'edu.id', 'gov.id', 'mil.id', 'web.id', 'ac.id', 'sch.id',
'co.ma', 'net.ma', 'org.ma', 'edu.ma', 'gov.ma', 'ac.ma', 'press.ma',
'co.ke', 'net.ke', 'org.ke', 'edu.ke', 'gov.ke', 'ac.ke', 'go.ke', 'info.ke', 'me.ke', 'mobi.ke', 'sc.ke',
'co.ug', 'net.ug', 'org.ug', 'edu.ug', 'gov.ug', 'ac.ug', 'sc.ug', 'go.ug', 'ne.ug', 'or.ug',
'co.tz', 'net.tz', 'org.tz', 'edu.tz', 'gov.tz', 'ac.tz', 'go.tz', 'hotel.tz', 'info.tz', 'me.tz', 'mil.tz', 'mobi.tz',
'ne.tz', 'or.tz', 'sc.tz', 'tv.tz',
]);
// Check if the last two parts form a known two-level TLD
if (parts.length >= 3) {
const lastTwoParts = parts.slice(-2).join('.');
if (twoLevelTlds.has(lastTwoParts)) {
// Take the last three parts for two-level TLDs
return parts.slice(-3).join('.');
}
}
// Default to last two parts for regular TLDs
return parts.length >= 2 ? parts.slice(-2).join('.') : domain;
}
/**
* Check if two domains match, supporting partial matches
* @param domain1 - First domain
@@ -60,13 +144,9 @@ function domainsMatch(domain1: string, domain2: string): boolean {
return true;
}
// Extract root domains for comparison
const d1Parts = d1.split('.');
const d2Parts = d2.split('.');
// Get the last 2 parts (domain.tld) for comparison
const d1Root = d1Parts.slice(-2).join('.');
const d2Root = d2Parts.slice(-2).join('.');
// Check root domain match
const d1Root = extractRootDomain(d1);
const d2Root = extractRootDomain(d2);
return d1Root === d2Root;
}

View File

@@ -292,6 +292,44 @@ describe('Filter - Credential URL Matching', () => {
expect(matches[0].ServiceName).toBe('Reddit');
});
// [#20] - Test multi-part TLDs like .com.au don't match incorrectly
it('should handle multi-part TLDs correctly without false matches', () => {
// Create test data with different .com.au domains
const australianCredentials = [
createTestCredential('Example Site AU', 'https://example.com.au', 'user@example.com.au'),
createTestCredential('BlaBla AU', 'https://blabla.blabla.com.au', 'user@blabla.com.au'),
createTestCredential('Another AU', 'https://another.com.au', 'user@another.com.au'),
createTestCredential('UK Site', 'https://example.co.uk', 'user@example.co.uk'),
];
// Test that blabla.blabla.com.au doesn't match other .com.au sites
const blablaMatches = filterCredentials(
australianCredentials,
'https://blabla.blabla.com.au',
''
);
expect(blablaMatches).toHaveLength(1);
expect(blablaMatches[0].ServiceName).toBe('BlaBla AU');
// Test that example.com.au doesn't match blabla.blabla.com.au
const exampleMatches = filterCredentials(
australianCredentials,
'https://example.com.au',
''
);
expect(exampleMatches).toHaveLength(1);
expect(exampleMatches[0].ServiceName).toBe('Example Site AU');
// Test that .co.uk domains work correctly too
const ukMatches = filterCredentials(
australianCredentials,
'https://example.co.uk',
''
);
expect(ukMatches).toHaveLength(1);
expect(ukMatches[0].ServiceName).toBe('UK Site');
});
/**
* Creates the shared test credential dataset used across all platforms.
* Note: when making changes to this list, make sure to update the corresponding list for iOS and Android tests as well.

View File

@@ -51,10 +51,89 @@ object CredentialMatcher {
/**
* Extract root domain from a domain string.
* E.g., "sub.example.com" -> "example.com"
* E.g., "sub.example.com.au" -> "example.com.au"
* E.g., "sub.example.co.uk" -> "example.co.uk"
*/
private fun extractRootDomain(domain: String): String {
val parts = domain.split(".")
return if (parts.size >= 2) parts.takeLast(2).joinToString(".") else domain
if (parts.size < 2) return domain
// Common two-level public TLDs
val twoLevelTlds = setOf(
// Australia
"com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
// United Kingdom
"co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk",
// Canada
"co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca",
"on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
// India
"co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in",
// Japan
"co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp",
// South Africa
"co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za",
// New Zealand
"co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz",
// Brazil
"com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br",
"cng.br", "cnt.br", "ecn.br", "eng.br", "esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br",
"ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br", "odo.br", "ppg.br", "pro.br", "psc.br",
"psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br",
// Russia
"com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru",
// China
"com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn",
"gz.cn", "gx.cn", "ha.cn", "hb.cn", "he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn",
"nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn", "sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
// Mexico
"com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx",
// Argentina
"com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar",
// Chile
"com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl",
// Colombia
"com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co",
// Venezuela
"com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve",
// Peru
"com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe",
// Ecuador
"com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec",
// Europe
"co.at", "or.at", "ac.at", "gv.at", "priv.at",
"co.be", "ac.be",
"co.dk", "ac.dk",
"co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il",
"co.no", "ac.no", "priv.no",
"co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl",
"co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th",
"co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr",
"kyonggi.kr",
// Others
"co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id",
"co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma",
"co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke",
"co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug",
"co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz",
"ne.tz", "or.tz", "sc.tz", "tv.tz",
)
// Check if the last two parts form a known two-level TLD
if (parts.size >= 3) {
val lastTwoParts = parts.takeLast(2).joinToString(".")
if (twoLevelTlds.contains(lastTwoParts)) {
// Take the last three parts for two-level TLDs
return parts.takeLast(3).joinToString(".")
}
}
// Default to last two parts for regular TLDs
return if (parts.size >= 2) {
parts.takeLast(2).joinToString(".")
} else {
domain
}
}
/**

View File

@@ -295,6 +295,42 @@ class AutofillTest {
assertEquals("Reddit", matches[0].service.name)
}
// [#20] - Test multi-part TLDs like .com.au don't match incorrectly
@Test
fun testMultiPartTldNoFalseMatches() {
// Create test data with different .com.au domains
val australianCredentials = listOf(
createTestCredential("Example Site AU", "https://example.com.au", "user@example.com.au"),
createTestCredential("BlaBla AU", "https://blabla.blabla.com.au", "user@blabla.com.au"),
createTestCredential("Another AU", "https://another.com.au", "user@another.com.au"),
createTestCredential("UK Site", "https://example.co.uk", "user@example.co.uk"),
)
// Test that blabla.blabla.com.au doesn't match other .com.au sites
val blablaMatches = CredentialMatcher.filterCredentialsByAppInfo(
australianCredentials,
"https://blabla.blabla.com.au",
)
assertEquals(1, blablaMatches.size, "Should only match the exact domain, not all .com.au sites")
assertEquals("BlaBla AU", blablaMatches[0].service.name)
// Test that example.com.au doesn't match blabla.blabla.com.au
val exampleMatches = CredentialMatcher.filterCredentialsByAppInfo(
australianCredentials,
"https://example.com.au",
)
assertEquals(1, exampleMatches.size, "Should only match example.com.au")
assertEquals("Example Site AU", exampleMatches[0].service.name)
// Test that .co.uk domains work correctly too
val ukMatches = CredentialMatcher.filterCredentialsByAppInfo(
australianCredentials,
"https://example.co.uk",
)
assertEquals(1, ukMatches.size, "Should only match the .co.uk domain")
assertEquals("UK Site", ukMatches[0].service.name)
}
/**
* Creates the shared test credential dataset used across all platforms.
* This ensures consistent testing across Browser Extension, iOS, and Android.

View File

@@ -58,10 +58,83 @@ public class CredentialFilter {
/// Extract root domain from a domain string.
/// - Parameter domain: Domain string
/// - Returns: Root domain (e.g., "sub.example.com" -> "example.com")
/// - Returns: Root domain (e.g., "sub.example.com" -> "example.com", "sub.example.com.au" -> "example.com.au", "sub.example.co.uk" -> "example.co.uk")
private static func extractRootDomain(from domain: String) -> String {
let parts = domain.components(separatedBy: ".")
return parts.count >= 2 ? parts.suffix(2).joined(separator: ".") : domain
guard parts.count >= 2 else { return domain }
// Common two-level public TLDs
let twoLevelTlds: Set<String> = [
// Australia
"com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
// United Kingdom
"co.uk", "org.uk", "net.uk", "ac.uk", "gov.uk", "plc.uk", "ltd.uk", "me.uk",
// Canada
"co.ca", "net.ca", "org.ca", "gc.ca", "ab.ca", "bc.ca", "mb.ca", "nb.ca", "nf.ca", "nl.ca", "ns.ca", "nt.ca", "nu.ca", "on.ca", "pe.ca", "qc.ca", "sk.ca", "yk.ca",
// India
"co.in", "net.in", "org.in", "edu.in", "gov.in", "ac.in", "res.in", "gen.in", "firm.in", "ind.in",
// Japan
"co.jp", "ne.jp", "or.jp", "ac.jp", "ad.jp", "ed.jp", "go.jp", "gr.jp", "lg.jp",
// South Africa
"co.za", "net.za", "org.za", "edu.za", "gov.za", "ac.za", "web.za",
// New Zealand
"co.nz", "net.nz", "org.nz", "edu.nz", "govt.nz", "ac.nz", "geek.nz", "gen.nz", "kiwi.nz", "maori.nz", "mil.nz", "school.nz",
// Brazil
"com.br", "net.br", "org.br", "edu.br", "gov.br", "mil.br", "art.br", "etc.br", "adv.br", "arq.br", "bio.br", "cim.br", "cng.br", "cnt.br", "ecn.br", "eng.br",
"esp.br", "eti.br", "far.br", "fnd.br", "fot.br", "fst.br", "g12.br", "geo.br", "ggf.br", "jor.br", "lel.br", "mat.br", "med.br", "mus.br", "not.br", "ntr.br",
"odo.br", "ppg.br", "pro.br", "psc.br", "psi.br", "qsl.br", "rec.br", "slg.br", "srv.br", "tmp.br", "trd.br", "tur.br", "tv.br", "vet.br", "zlg.br",
// Russia
"com.ru", "net.ru", "org.ru", "edu.ru", "gov.ru", "int.ru", "mil.ru", "spb.ru", "msk.ru",
// China
"com.cn", "net.cn", "org.cn", "edu.cn", "gov.cn", "mil.cn", "ac.cn", "ah.cn", "bj.cn", "cq.cn", "fj.cn", "gd.cn", "gs.cn", "gz.cn", "gx.cn", "ha.cn", "hb.cn",
"he.cn", "hi.cn", "hk.cn", "hl.cn", "hn.cn", "jl.cn", "js.cn", "jx.cn", "ln.cn", "mo.cn", "nm.cn", "nx.cn", "qh.cn", "sc.cn", "sd.cn", "sh.cn", "sn.cn",
"sx.cn", "tj.cn", "tw.cn", "xj.cn", "xz.cn", "yn.cn", "zj.cn",
// Mexico
"com.mx", "net.mx", "org.mx", "edu.mx", "gob.mx",
// Argentina
"com.ar", "net.ar", "org.ar", "edu.ar", "gov.ar", "mil.ar", "int.ar",
// Chile
"com.cl", "net.cl", "org.cl", "edu.cl", "gov.cl", "mil.cl",
// Colombia
"com.co", "net.co", "org.co", "edu.co", "gov.co", "mil.co", "nom.co",
// Venezuela
"com.ve", "net.ve", "org.ve", "edu.ve", "gov.ve", "mil.ve", "web.ve",
// Peru
"com.pe", "net.pe", "org.pe", "edu.pe", "gob.pe", "mil.pe", "nom.pe",
// Ecuador
"com.ec", "net.ec", "org.ec", "edu.ec", "gov.ec", "mil.ec", "med.ec", "fin.ec", "pro.ec", "info.ec",
// Europe
"co.at", "or.at", "ac.at", "gv.at", "priv.at",
"co.be", "ac.be",
"co.dk", "ac.dk",
"co.il", "net.il", "org.il", "ac.il", "gov.il", "idf.il", "k12.il", "muni.il",
"co.no", "ac.no", "priv.no",
"co.pl", "net.pl", "org.pl", "edu.pl", "gov.pl", "mil.pl", "nom.pl", "com.pl",
"co.th", "net.th", "org.th", "edu.th", "gov.th", "mil.th", "ac.th", "in.th",
"co.kr", "net.kr", "org.kr", "edu.kr", "gov.kr", "mil.kr", "ac.kr", "go.kr", "ne.kr", "or.kr", "pe.kr", "re.kr", "seoul.kr", "kyonggi.kr",
// Others
"co.id", "net.id", "org.id", "edu.id", "gov.id", "mil.id", "web.id", "ac.id", "sch.id",
"co.ma", "net.ma", "org.ma", "edu.ma", "gov.ma", "ac.ma", "press.ma",
"co.ke", "net.ke", "org.ke", "edu.ke", "gov.ke", "ac.ke", "go.ke", "info.ke", "me.ke", "mobi.ke", "sc.ke",
"co.ug", "net.ug", "org.ug", "edu.ug", "gov.ug", "ac.ug", "sc.ug", "go.ug", "ne.ug", "or.ug",
"co.tz", "net.tz", "org.tz", "edu.tz", "gov.tz", "ac.tz", "go.tz", "hotel.tz", "info.tz", "me.tz", "mil.tz", "mobi.tz", "ne.tz", "or.tz", "sc.tz", "tv.tz"
]
// Check if the last two parts form a known two-level TLD
if parts.count >= 3 {
let lastTwoParts = parts.suffix(2).joined(separator: ".")
if twoLevelTlds.contains(lastTwoParts) {
// Take the last three parts for two-level TLDs
return parts.suffix(3).joined(separator: ".")
}
}
// Default to last two parts for regular TLDs
if parts.count >= 2 {
return parts.suffix(2).joined(separator: ".")
}
return domain
}
/// Check if two domains match, supporting partial matches.

View File

@@ -189,6 +189,32 @@ final class CredentialFilterTests: XCTestCase {
XCTAssertEqual(matches.first?.service.name, "Reddit")
}
// [#20] - Test multi-part TLDs like .com.au don't match incorrectly
func testMultiPartTldNoFalseMatches() {
// Create test data with different .com.au domains
let australianCredentials = [
createTestCredential(serviceName: "Example Site AU", serviceUrl: "https://example.com.au", username: "user@example.com.au"),
createTestCredential(serviceName: "BlaBla AU", serviceUrl: "https://blabla.blabla.com.au", username: "user@blabla.com.au"),
createTestCredential(serviceName: "Another AU", serviceUrl: "https://another.com.au", username: "user@another.com.au"),
createTestCredential(serviceName: "UK Site", serviceUrl: "https://example.co.uk", username: "user@example.co.uk"),
]
// Test that blabla.blabla.com.au doesn't match other .com.au sites
let blablaMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://blabla.blabla.com.au")
XCTAssertEqual(blablaMatches.count, 1, "Should only match the exact domain, not all .com.au sites")
XCTAssertEqual(blablaMatches.first?.service.name, "BlaBla AU")
// Test that example.com.au doesn't match blabla.blabla.com.au
let exampleMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://example.com.au")
XCTAssertEqual(exampleMatches.count, 1, "Should only match example.com.au")
XCTAssertEqual(exampleMatches.first?.service.name, "Example Site AU")
// Test that .co.uk domains work correctly too
let ukMatches = CredentialFilter.filterCredentials(australianCredentials, searchText: "https://example.co.uk")
XCTAssertEqual(ukMatches.count, 1, "Should only match the .co.uk domain")
XCTAssertEqual(ukMatches.first?.service.name, "UK Site")
}
// MARK: - Shared Test Data
/**