Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / pass / store / selectors / items.spec.ts
blobb5d101ce63e5e849ae6d9618cbc3397e115a82fa
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) });
12 const stateMock = {
13     items: {
14         byShareId: {
15             share1: {
16                 item1: {
17                     /* item with secure protocol */
18                     itemId: 'share1-item1',
19                     state: ItemState.Active,
20                     shareId: 'share1',
21                     data: itemBuilder('login').set('content', (content) =>
22                         content
23                             .set('urls', ['https://proton.me', 'https://subdomain.proton.me'])
24                             .set('itemEmail', 'test@proton.me')
25                             .set('totpUri', '424242424242424242424242')
26                     ).data,
27                 },
28                 item2: {
29                     /* item with unsecure protocol */
30                     itemId: 'share1-item2',
31                     state: ItemState.Active,
32                     shareId: 'share1',
33                     data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://proton.me']))
34                         .data,
35                 },
36                 item3: {
37                     /* item with private domain */
38                     itemId: 'share1-item3',
39                     state: ItemState.Active,
40                     shareId: 'share1',
41                     data: itemBuilder('login').set('content', (content) => content.set('urls', ['https://github.io']))
42                         .data,
43                 },
44                 item4: {
45                     /* item with private sub-domain */
46                     itemId: 'share1-item4',
47                     state: ItemState.Active,
48                     shareId: 'share1',
49                     data: itemBuilder('login')
50                         .set('content', (content) =>
51                             content
52                                 .set('urls', ['https://private.subdomain.github.io'])
53                                 .set('itemUsername', 'test@github.io')
54                         )
55                         .set('extraFields', [
56                             {
57                                 type: 'totp',
58                                 data: { totpUri: '424242424242424242424242' },
59                                 fieldName: 'totp',
60                             },
61                         ]).data,
62                 },
63                 item5: {
64                     /* item with another private sub-domain */
65                     itemId: 'share1-item5',
66                     state: ItemState.Active,
67                     shareId: 'share1',
68                     data: itemBuilder('login').set('content', (content) =>
69                         content.set('urls', ['https://othersubdomain.github.io'])
70                     ).data,
71                 },
72             },
73             share2: {
74                 item1: {
75                     /* trashed item with secure protocol */
76                     itemId: 'share2-item1',
77                     state: ItemState.Trashed,
78                     shareId: 'share2',
79                     data: itemBuilder('login').set('content', (content) => content.set('urls', ['https://proton.me']))
80                         .data,
81                 },
82                 item2: {
83                     /* active item with unsecure protocol */
84                     itemId: 'share2-item2',
85                     state: ItemState.Active,
86                     shareId: 'share2',
87                     data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://proton.me']))
88                         .data,
89                 },
90             },
91             share3: {
92                 item1: {
93                     /* non http protocols & invalid urls */
94                     itemId: 'share3-item1',
95                     state: ItemState.Active,
96                     shareId: 'share3',
97                     data: itemBuilder('login').set('content', (content) =>
98                         content.set('urls', ['ftp://proton.me', 'htp::://invalid'])
99                     ).data,
100                 },
101                 item2: {
102                     /* type note */
103                     itemId: 'share3-item2',
104                     state: ItemState.Active,
105                     shareId: 'share3',
106                     data: itemBuilder('note').data,
107                 },
108                 item3: {
109                     /* type alias */
110                     itemId: 'share3-item3',
111                     state: ItemState.Active,
112                     shareId: 'share3',
113                     data: itemBuilder('alias').data,
114                 },
115                 item4: {
116                     /* active item with nested subdomain */
117                     itemId: 'share3-item4',
118                     state: ItemState.Active,
119                     shareId: 'share3',
120                     data: itemBuilder('login').set('content', (content) =>
121                         content.set('urls', ['https://sub.domain.google.com'])
122                     ).data,
123                 },
124                 item5: {
125                     /* active item with nested subdomain */
126                     itemId: 'share3-item5',
127                     state: ItemState.Active,
128                     shareId: 'share3',
129                     data: itemBuilder('login').set('content', (content) =>
130                         content.set('urls', ['https://my.sub.domain.google.com'])
131                     ).data,
132                 },
133                 item6: {
134                     /* active item with unsecure nested subdomain */
135                     state: ItemState.Active,
136                     itemId: 'share3-item6',
137                     shareId: 'share3',
138                     data: itemBuilder('login').set('content', (content) => content.set('urls', ['http://google.com']))
139                         .data,
140                 },
141             },
142             share4: {
143                 /* empty share */
144             },
145             share5: {
146                 item1: {
147                     /* subdomain `A` OTP */
148                     itemId: 'share5-item2',
149                     state: ItemState.Active,
150                     shareId: 'share5',
151                     lastUseTime: getEpoch() + 1_200,
152                     data: itemBuilder('login').set('content', (content) =>
153                         content
154                             .set('itemUsername', 'username@subdomain.com')
155                             .set('urls', ['https://a.subdomain.com'])
156                             .set('totpUri', '1212121212121212121212121212')
157                     ).data,
158                 },
159                 item2: {
160                     /* subdomain `B` OTP */
161                     itemId: 'share5-item3',
162                     state: ItemState.Active,
163                     shareId: 'share5',
164                     lastUseTime: getEpoch() + 1_000,
165                     data: itemBuilder('login').set('content', (content) =>
166                         content
167                             .set('itemUsername', 'username@subdomain.com')
168                             .set('urls', ['https://b.subdomain.com'])
169                             .set('totpUri', '1212121212121212121212121212')
170                     ).data,
171                 },
172                 item3: {
173                     /* subdomain `B` OTP - more recently used */
174                     itemId: 'share5-item3',
175                     state: ItemState.Active,
176                     shareId: 'share5',
177                     lastUseTime: getEpoch() + 2_000,
178                     data: itemBuilder('login').set('content', (content) =>
179                         content
180                             .set('itemUsername', 'username@subdomain.com')
181                             .set('urls', ['https://b.subdomain.com'])
182                             .set('totpUri', '1212121212121212121212121212')
183                     ).data,
184                 },
185                 item4: {
186                     /* top-level domain OTP */
187                     itemId: 'share5-item4',
188                     state: ItemState.Active,
189                     shareId: 'share5',
190                     lastUseTime: getEpoch(),
191                     data: itemBuilder('login').set('content', (content) =>
192                         content
193                             .set('itemUsername', 'username@subdomain.com')
194                             .set('urls', ['https://subdomain.com'])
195                             .set('totpUri', '1212121212121212121212121212')
196                     ).data,
197                 },
198                 item5: {
199                     /* top-level domain OTP more recently used */
200                     itemId: 'share5-item5',
201                     state: ItemState.Active,
202                     shareId: 'share5',
203                     lastUseTime: getEpoch() + 2_000,
204                     data: itemBuilder('login').set('content', (content) =>
205                         content
206                             .set('itemUsername', 'username@subdomain.com')
207                             .set('urls', ['https://subdomain.com'])
208                             .set('totpUri', '1212121212121212121212121212')
209                     ).data,
210                 },
211                 item6: {
212                     /* top-level domain OTP */
213                     itemId: 'share5-item6',
214                     state: ItemState.Active,
215                     shareId: 'share5',
216                     lastUseTime: getEpoch(),
217                     data: itemBuilder('login').set('content', (content) =>
218                         content
219                             .set('itemUsername', 'username@subdomain.com')
220                             .set('urls', ['https://a.domain.com'])
221                             .set('totpUri', '1212121212121212121212121212')
222                     ).data,
223                 },
224             },
225             optimistic: { history: [], checkpoint: undefined },
226         },
227     },
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([]);
238         });
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([]);
244         });
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));
254         });
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));
264         });
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);
282         });
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));
304         });
305     });
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(
311                 []
312             );
313         });
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));
321         });
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));
327         });
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));
333         });
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));
339         });
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));
347         });
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));
355         });
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));
361         });
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));
367         });
368     });
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));
375         });
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));
381         });
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'),
387                 submission,
388             })(stateMock);
390             expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share1.item4));
391         });
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));
396         });
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));
401         });
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));
407         });
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 })(
412                 stateMock
413             );
414             expect(candidate).toEqual(withOptimistics(stateMock.items.byShareId.share5.item5));
415         });
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));
423         });
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);
433         });
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);
438         });
439     });