1 import { itemBuilder } from '@proton/pass/lib/items/item.builder';
2 import type { State } from '@proton/pass/store/types';
3 import type { FormSubmission } from '@proton/pass/types';
4 import { ItemState } from '@proton/pass/types';
5 import { getEpoch } from '@proton/pass/utils/time/epoch';
6 import { parseUrl } from '@proton/pass/utils/url/parser';
8 import { selectAutofillLoginCandidates, selectItemsByDomain, selectOTPCandidate } from './items';
10 const withOptimistics = (item: {}) => ({ ...item, failed: expect.any(Boolean), optimistic: expect.any(Boolean) });
17 /* item with secure protocol */
18 itemId: 'share1-item1',
19 state: ItemState.Active,
21 data: itemBuilder('login').set('content', (content) =>
23 .set('urls', ['https://proton.me', 'https://subdomain.proton.me'])
24 .set('itemEmail', 'test@proton.me')
25 .set('totpUri', '424242424242424242424242')
29 /* item with unsecure protocol */
30 itemId: 'share1-item2',
31 state: ItemState.Active,
33 data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://proton.me']))
37 /* item with private domain */
38 itemId: 'share1-item3',
39 state: ItemState.Active,
41 data: itemBuilder('login').set('content', (content) => content.set('urls', ['https://github.io']))
45 /* item with private sub-domain */
46 itemId: 'share1-item4',
47 state: ItemState.Active,
49 data: itemBuilder('login')
50 .set('content', (content) =>
52 .set('urls', ['https://private.subdomain.github.io'])
53 .set('itemUsername', 'test@github.io')
58 data: { totpUri: '424242424242424242424242' },
64 /* item with another private sub-domain */
65 itemId: 'share1-item5',
66 state: ItemState.Active,
68 data: itemBuilder('login').set('content', (content) =>
69 content.set('urls', ['https://othersubdomain.github.io'])
75 /* trashed item with secure protocol */
76 itemId: 'share2-item1',
77 state: ItemState.Trashed,
79 data: itemBuilder('login').set('content', (content) => content.set('urls', ['https://proton.me']))
83 /* active item with unsecure protocol */
84 itemId: 'share2-item2',
85 state: ItemState.Active,
87 data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://proton.me']))
93 /* non http protocols & invalid urls */
94 itemId: 'share3-item1',
95 state: ItemState.Active,
97 data: itemBuilder('login').set('content', (content) =>
98 content.set('urls', ['ftp://proton.me', 'htp::://invalid'])
103 itemId: 'share3-item2',
104 state: ItemState.Active,
106 data: itemBuilder('note').data,
110 itemId: 'share3-item3',
111 state: ItemState.Active,
113 data: itemBuilder('alias').data,
116 /* active item with nested subdomain */
117 itemId: 'share3-item4',
118 state: ItemState.Active,
120 data: itemBuilder('login').set('content', (content) =>
121 content.set('urls', ['https://sub.domain.google.com'])
125 /* active item with nested subdomain */
126 itemId: 'share3-item5',
127 state: ItemState.Active,
129 data: itemBuilder('login').set('content', (content) =>
130 content.set('urls', ['https://my.sub.domain.google.com'])
134 /* active item with unsecure nested subdomain */
135 state: ItemState.Active,
136 itemId: 'share3-item6',
138 data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://google.com']))
147 /* subdomain `A` OTP */
148 itemId: 'share5-item2',
149 state: ItemState.Active,
151 lastUseTime: getEpoch() + 1_200,
152 data: itemBuilder('login').set('content', (content) =>
154 .set('itemUsername', 'username@subdomain.com')
155 .set('urls', ['https://a.subdomain.com'])
156 .set('totpUri', '1212121212121212121212121212')
160 /* subdomain `B` OTP */
161 itemId: 'share5-item3',
162 state: ItemState.Active,
164 lastUseTime: getEpoch() + 1_000,
165 data: itemBuilder('login').set('content', (content) =>
167 .set('itemUsername', 'username@subdomain.com')
168 .set('urls', ['https://b.subdomain.com'])
169 .set('totpUri', '1212121212121212121212121212')
173 /* subdomain `B` OTP - more recently used */
174 itemId: 'share5-item3',
175 state: ItemState.Active,
177 lastUseTime: getEpoch() + 2_000,
178 data: itemBuilder('login').set('content', (content) =>
180 .set('itemUsername', 'username@subdomain.com')
181 .set('urls', ['https://b.subdomain.com'])
182 .set('totpUri', '1212121212121212121212121212')
186 /* top-level domain OTP */
187 itemId: 'share5-item4',
188 state: ItemState.Active,
190 lastUseTime: getEpoch(),
191 data: itemBuilder('login').set('content', (content) =>
193 .set('itemUsername', 'username@subdomain.com')
194 .set('urls', ['https://subdomain.com'])
195 .set('totpUri', '1212121212121212121212121212')
199 /* top-level domain OTP more recently used */
200 itemId: 'share5-item5',
201 state: ItemState.Active,
203 lastUseTime: getEpoch() + 2_000,
204 data: itemBuilder('login').set('content', (content) =>
206 .set('itemUsername', 'username@subdomain.com')
207 .set('urls', ['https://subdomain.com'])
208 .set('totpUri', '1212121212121212121212121212')
212 /* top-level domain OTP */
213 itemId: 'share5-item6',
214 state: ItemState.Active,
216 lastUseTime: getEpoch(),
217 data: itemBuilder('login').set('content', (content) =>
219 .set('itemUsername', 'username@subdomain.com')
220 .set('urls', ['https://a.domain.com'])
221 .set('totpUri', '1212121212121212121212121212')
225 optimistic: { history: [], checkpoint: undefined },
228 } as unknown as State;
230 const filter = { protocol: null, port: null, isPrivate: false };
232 describe('item selectors', () => {
233 describe('selectItemsByURL', () => {
234 test('should return nothing if url is not valid or no match', () => {
235 expect(selectItemsByDomain(null, filter)(stateMock)).toEqual([]);
236 expect(selectItemsByDomain('', filter)(stateMock)).toEqual([]);
237 expect(selectItemsByDomain('http::://invalid.com', filter)(stateMock)).toEqual([]);
240 test('should return nothing if no items match url', () => {
241 expect(selectItemsByDomain('proton.ch', filter)(stateMock)).toEqual([]);
242 expect(selectItemsByDomain('unknown.proton.me', filter)(stateMock)).toEqual([]);
243 expect(selectItemsByDomain('proton.me/secret/path', filter)(stateMock)).toEqual([]);
246 test('should return only active items on direct match', () => {
247 const items = selectItemsByDomain('proton.me', filter)(stateMock);
249 expect(items.length).toEqual(4);
250 expect(items[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
251 expect(items[1]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item2));
252 expect(items[2]).toEqual(withOptimistics(stateMock.items.byShareId.share2.item2));
253 expect(items[3]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
256 test('should return only active items on direct match', () => {
257 const items = selectItemsByDomain('proton.me', filter)(stateMock);
259 expect(items.length).toEqual(4);
260 expect(items[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
261 expect(items[1]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item2));
262 expect(items[2]).toEqual(withOptimistics(stateMock.items.byShareId.share2.item2));
263 expect(items[3]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
266 test('should return only share matches if shareId filter', () => {
267 const itemsShare1 = selectItemsByDomain('proton.me', { ...filter, shareIds: ['share1'] })(stateMock);
268 expect(itemsShare1.length).toEqual(2);
269 expect(itemsShare1[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
270 expect(itemsShare1[1]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item2));
272 const itemsShare2 = selectItemsByDomain('proton.me', { ...filter, shareIds: ['share2'] })(stateMock);
273 expect(itemsShare2.length).toEqual(1);
274 expect(itemsShare2[0]).toEqual(withOptimistics(stateMock.items.byShareId.share2.item2));
276 const itemsShare3 = selectItemsByDomain('proton.me', { ...filter, shareIds: ['share3'] })(stateMock);
277 expect(itemsShare3.length).toEqual(1);
278 expect(itemsShare3[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
280 const itemsShare4 = selectItemsByDomain('proton.me', { ...filter, shareIds: ['share4'] })(stateMock);
281 expect(itemsShare4.length).toEqual(0);
284 test('should use protocol filter if any', () => {
285 const itemsHTTPS = selectItemsByDomain('proton.me', { ...filter, protocol: 'https:' })(stateMock);
286 expect(itemsHTTPS.length).toEqual(1);
287 expect(itemsHTTPS[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
289 const itemsHTTP = selectItemsByDomain('proton.me', { ...filter, protocol: 'http:' })(stateMock);
290 expect(itemsHTTP.length).toEqual(2);
291 expect(itemsHTTP[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item2));
292 expect(itemsHTTP[1]).toEqual(withOptimistics(stateMock.items.byShareId.share2.item2));
294 const itemsAny = selectItemsByDomain('proton.me', filter)(stateMock);
295 expect(itemsAny.length).toEqual(4);
296 expect(itemsAny[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
297 expect(itemsAny[1]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item2));
298 expect(itemsAny[2]).toEqual(withOptimistics(stateMock.items.byShareId.share2.item2));
299 expect(itemsAny[3]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
301 const itemsFTP = selectItemsByDomain('proton.me', { ...filter, protocol: 'ftp:' })(stateMock);
302 expect(itemsFTP.length).toEqual(1);
303 expect(itemsFTP[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
307 describe('selectAutofillCandidates', () => {
308 test('should return nothing if invalid url', () => {
309 expect(selectAutofillLoginCandidates(parseUrl(''))(stateMock)).toEqual([]);
310 expect(selectAutofillLoginCandidates({ ...parseUrl('https://a.b.c'), protocol: null })(stateMock)).toEqual(
315 test('should not pass a protocol filter if url is secure', () => {
316 const candidates = selectAutofillLoginCandidates(parseUrl('https://google.com'))(stateMock);
317 expect(candidates.length).toEqual(3);
318 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item6));
319 expect(candidates[1]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item4));
320 expect(candidates[2]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item5));
323 test('should pass a protocol filter if url is not secure `https:`', () => {
324 const candidates = selectAutofillLoginCandidates(parseUrl('http://google.com'))(stateMock);
325 expect(candidates.length).toEqual(1);
326 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item6));
329 test('should pass a protocol filter if url is not secure `https:`', () => {
330 const candidates = selectAutofillLoginCandidates(parseUrl('http://google.com'))(stateMock);
331 expect(candidates.length).toEqual(1);
332 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item6));
335 test('should return only matching protocols', () => {
336 const candidates = selectAutofillLoginCandidates(parseUrl('ftp://proton.me'))(stateMock);
337 expect(candidates.length).toEqual(1);
338 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item1));
341 test('if no direct public subdomain match, should sort top-level domains and other subdomain matches', () => {
342 const candidates = selectAutofillLoginCandidates(parseUrl('https://account.google.com'))(stateMock);
343 expect(candidates.length).toEqual(3);
344 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item6));
345 expect(candidates[1]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item4));
346 expect(candidates[2]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item5));
349 test('if public subdomain match, should push subdomain matches on top, then top-level domain, then other subdomains', () => {
350 const candidates = selectAutofillLoginCandidates(parseUrl('https://my.sub.domain.google.com'))(stateMock);
351 expect(candidates.length).toEqual(3);
352 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item5));
353 expect(candidates[1]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item6));
354 expect(candidates[2]).toEqual(withOptimistics(stateMock.items.byShareId.share3.item4));
357 test('if private top level domain, should match only top level domain', () => {
358 const candidates = selectAutofillLoginCandidates(parseUrl('https://github.io'))(stateMock);
359 expect(candidates.length).toEqual(1);
360 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item3));
363 test('if private sub domain, should match only specific subdomain', () => {
364 const candidates = selectAutofillLoginCandidates(parseUrl('https://subdomain.github.io'))(stateMock);
365 expect(candidates.length).toEqual(1);
366 expect(candidates[0]).toEqual(withOptimistics(stateMock.items.byShareId.share1.item4));
370 describe('selectOTPCandidate', () => {
371 test('should match item for domain and username', () => {
372 const submission = { data: { userIdentifier: 'test@proton.me' } } as FormSubmission;
373 const candidate = selectOTPCandidate({ ...parseUrl('https://proton.me'), submission })(stateMock);
374 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
377 test('should match item for subdomain and username', () => {
378 const submission = { data: { userIdentifier: 'test@proton.me' } } as FormSubmission;
379 const candidate = selectOTPCandidate({ ...parseUrl('https://subdomain.proton.me'), submission })(stateMock);
380 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share1.item1));
383 test('should match item for domain and username when matching extra totp field', () => {
384 const submission = { data: { userIdentifier: 'test@github.io' } } as FormSubmission;
385 const candidate = selectOTPCandidate({
386 ...parseUrl('https://private.subdomain.github.io'),
390 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share1.item4));
393 test('should match last used item for top-level domain if username not provided', () => {
394 const candidate = selectOTPCandidate(parseUrl('https://subdomain.com'))(stateMock);
395 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share5.item5));
398 test('should match last used item for subdomain if username not provided', () => {
399 const candidate = selectOTPCandidate(parseUrl('https://b.subdomain.com'))(stateMock);
400 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share5.item3));
403 test('should match item for username & top-level domain', () => {
404 const submission = { data: { userIdentifier: 'username@subdomain.com' } } as FormSubmission;
405 const candidate = selectOTPCandidate({ ...parseUrl('https://subdomain.com'), submission })(stateMock);
406 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share5.item5));
409 test('should allow item for username & top-level domain on subdomain', () => {
410 const submission = { data: { userIdentifier: 'username@subdomain.com' } } as FormSubmission;
411 const candidate = selectOTPCandidate({ ...parseUrl('https://unknown.subdomain.com'), submission })(
414 expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share5.item5));
417 test('should prioritise subdomain/username match', () => {
418 const submission = { data: { userIdentifier: 'username@subdomain.com' } } as FormSubmission;
419 const candidateA = selectOTPCandidate({ ...parseUrl('https://a.subdomain.com'), submission })(stateMock);
420 const candidateB = selectOTPCandidate({ ...parseUrl('https://b.subdomain.com'), submission })(stateMock);
421 expect(candidateA).toEqual(withOptimistics(stateMock.items.byShareId.share5.item1));
422 expect(candidateB).toEqual(withOptimistics(stateMock.items.byShareId.share5.item3));
425 test('should not match subdomain item for top-level url', () => {
426 const submission = { data: { userIdentifier: 'username@subdomain.com' } } as FormSubmission;
428 const candidateA = selectOTPCandidate({ ...parseUrl('https://domain.com'), submission })(stateMock);
429 expect(candidateA).toEqual(undefined);
431 const candidateB = selectOTPCandidate(parseUrl('https://domain.com'))(stateMock);
432 expect(candidateB).toEqual(undefined);
435 test('should not match subdomain item for sub-subdomain url', () => {
436 const candidate = selectOTPCandidate(parseUrl('https://a.b.domain.com'))(stateMock);
437 expect(candidate).toEqual(undefined);