diff options
-rw-r--r-- | .eslintrc.cjs | 2 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts | 168 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts | 114 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/hardenSession.ts | 20 | ||||
-rw-r--r-- | packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts | 2 | ||||
-rw-r--r-- | packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts | 20 | ||||
-rw-r--r-- | packages/test-utils/package.json | 3 | ||||
-rw-r--r-- | packages/test-utils/src/fake.ts | 34 | ||||
-rw-r--r-- | packages/test-utils/src/index.ts | 3 | ||||
-rw-r--r-- | yarn.lock | 8 |
10 files changed, 348 insertions, 26 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 55a055d..8f1451b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs | |||
@@ -56,6 +56,8 @@ module.exports = { | |||
56 | 'newlines-between': 'always', | 56 | 'newlines-between': 'always', |
57 | }, | 57 | }, |
58 | ], | 58 | ], |
59 | // jest-each confuses this rule. | ||
60 | 'jest/no-standalone-expect': 'off', | ||
59 | // Allows files with names same as the name of their default export. | 61 | // Allows files with names same as the name of their default export. |
60 | 'unicorn/filename-case': [ | 62 | 'unicorn/filename-case': [ |
61 | 'error', | 63 | 'error', |
diff --git a/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts b/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts new file mode 100644 index 0000000..7457729 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/__tests__/hardenSession.spec.ts | |||
@@ -0,0 +1,168 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { URL } from 'node:url'; | ||
22 | |||
23 | import { jest } from '@jest/globals'; | ||
24 | import { each, fake } from '@sophie/test-utils'; | ||
25 | import type { | ||
26 | OnBeforeRequestListenerDetails, | ||
27 | PermissionRequestHandlerHandlerDetails, | ||
28 | Response, | ||
29 | Session, | ||
30 | WebContents, | ||
31 | } from 'electron'; | ||
32 | |||
33 | import { silenceLogger } from '../../../../utils/log'; | ||
34 | import type Resources from '../../../resources/Resources'; | ||
35 | import hardenSession from '../hardenSession'; | ||
36 | |||
37 | const permissions = [ | ||
38 | 'clipboard-read', | ||
39 | 'media', | ||
40 | 'display-capture', | ||
41 | 'mediaKeySystem', | ||
42 | 'geolocation', | ||
43 | 'notifications', | ||
44 | 'midi', | ||
45 | 'midiSysex', | ||
46 | 'pointerLock', | ||
47 | 'fullscreen', | ||
48 | 'openExternal', | ||
49 | 'unknown', | ||
50 | ] as const; | ||
51 | |||
52 | type Permission = typeof permissions[number]; | ||
53 | |||
54 | let permissionRequestHandler: | ||
55 | | (( | ||
56 | webContents: WebContents, | ||
57 | permission: Permission, | ||
58 | callback: (permissionGranted: boolean) => void, | ||
59 | details: PermissionRequestHandlerHandlerDetails, | ||
60 | ) => void) | ||
61 | | undefined; | ||
62 | |||
63 | let onBeforeRequest: | ||
64 | | (( | ||
65 | details: OnBeforeRequestListenerDetails, | ||
66 | callback: (response: Response) => void, | ||
67 | ) => void) | ||
68 | | undefined; | ||
69 | |||
70 | function getFakeResources(devMode: boolean) { | ||
71 | const resourcesUrl = devMode | ||
72 | ? 'http://localhost:3000/' | ||
73 | : 'file:///opt/sophie/resources/app.asar/renderer/dist/'; | ||
74 | return fake<Resources>({ | ||
75 | getRendererURL(path: string) { | ||
76 | return new URL(path, resourcesUrl).toString(); | ||
77 | }, | ||
78 | }); | ||
79 | } | ||
80 | |||
81 | const fakeSession = fake<Session>({ | ||
82 | setPermissionRequestHandler(handler) { | ||
83 | permissionRequestHandler = | ||
84 | handler instanceof Function ? handler : undefined; | ||
85 | }, | ||
86 | webRequest: { | ||
87 | onBeforeRequest(handler) { | ||
88 | onBeforeRequest = handler instanceof Function ? handler : undefined; | ||
89 | }, | ||
90 | }, | ||
91 | }); | ||
92 | |||
93 | beforeAll(() => { | ||
94 | silenceLogger(); | ||
95 | }); | ||
96 | |||
97 | beforeEach(() => { | ||
98 | permissionRequestHandler = undefined; | ||
99 | onBeforeRequest = undefined; | ||
100 | }); | ||
101 | |||
102 | it('should set permission request and before request handlers', () => { | ||
103 | hardenSession(getFakeResources(false), false, fakeSession); | ||
104 | expect(permissionRequestHandler).toBeDefined(); | ||
105 | expect(onBeforeRequest).toBeDefined(); | ||
106 | }); | ||
107 | |||
108 | each(permissions.map((permission) => [permission])).it( | ||
109 | 'should reject %s permission requests', | ||
110 | (permission: Permission) => { | ||
111 | hardenSession(getFakeResources(false), false, fakeSession); | ||
112 | const callback = jest.fn(); | ||
113 | permissionRequestHandler!(fake<WebContents>({}), permission, callback, { | ||
114 | requestingUrl: | ||
115 | 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', | ||
116 | isMainFrame: true, | ||
117 | }); | ||
118 | expect(callback).toHaveBeenCalledWith(false); | ||
119 | }, | ||
120 | ); | ||
121 | |||
122 | each([ | ||
123 | [ | ||
124 | false, | ||
125 | 'GET', | ||
126 | 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', | ||
127 | false, | ||
128 | ], | ||
129 | [ | ||
130 | false, | ||
131 | 'POST', | ||
132 | 'file:///opt/sophie/resources/app.asar/pacakges/renderer/dist/index.html', | ||
133 | true, | ||
134 | ], | ||
135 | [false, 'GET', 'chrome-extension:aaaa', true], | ||
136 | [false, 'GET', 'devtools:aaaa', true], | ||
137 | [false, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', true], | ||
138 | [false, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', true], | ||
139 | [false, 'GET', 'https://example.com', true], | ||
140 | [false, 'GET', 'invalid-url', true], | ||
141 | [true, 'GET', 'http://localhost:3000/index.html', false], | ||
142 | [true, 'POST', 'http://localhost:3000/index.html', true], | ||
143 | [true, 'GET', 'ws://localhost:3000/index.html', false], | ||
144 | [true, 'GET', 'chrome-extension:aaaa', false], | ||
145 | [true, 'GET', 'devtools:aaaa', false], | ||
146 | [true, 'GET', 'https://clients2.google.com/service/update2/crx/aaaa', false], | ||
147 | [true, 'GET', 'https://clients2.googleusercontent.com/crx/aaaa', false], | ||
148 | [true, 'GET', 'https://example.com', true], | ||
149 | ]).it( | ||
150 | 'in dev mode: %s the request %s %s should be cancelled: %s', | ||
151 | (devMode: boolean, method: string, url: string, cancel: boolean) => { | ||
152 | hardenSession(getFakeResources(devMode), devMode, fakeSession); | ||
153 | const callback = jest.fn(); | ||
154 | onBeforeRequest!( | ||
155 | { | ||
156 | id: 0, | ||
157 | url, | ||
158 | method, | ||
159 | resourceType: 'mainFrame', | ||
160 | referrer: '', | ||
161 | timestamp: 0, | ||
162 | uploadData: [], | ||
163 | }, | ||
164 | callback, | ||
165 | ); | ||
166 | expect(callback).toHaveBeenCalledWith(expect.objectContaining({ cancel })); | ||
167 | }, | ||
168 | ); | ||
diff --git a/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts b/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts new file mode 100644 index 0000000..29c0516 --- /dev/null +++ b/packages/main/src/infrastructure/electron/impl/__tests__/lockWebContentsToFile.spec.ts | |||
@@ -0,0 +1,114 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import { URL } from 'node:url'; | ||
22 | |||
23 | import { jest } from '@jest/globals'; | ||
24 | import { fake } from '@sophie/test-utils'; | ||
25 | import type { Event, HandlerDetails, WebContents } from 'electron'; | ||
26 | import { mocked } from 'jest-mock'; | ||
27 | |||
28 | import { silenceLogger } from '../../../../utils/log'; | ||
29 | import type Resources from '../../../resources/Resources'; | ||
30 | import lockWebContentsToFile from '../lockWebContentsToFile'; | ||
31 | |||
32 | type WillNavigateHandler = (event: Event, url: string) => void; | ||
33 | |||
34 | let willNavigate: WillNavigateHandler | undefined; | ||
35 | |||
36 | let windowOpenHandler: | ||
37 | | ((details: HandlerDetails) => { action: 'allow' | 'deny' }) | ||
38 | | undefined; | ||
39 | |||
40 | const urlToLoad = | ||
41 | 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html'; | ||
42 | |||
43 | const fakeResources = fake<Resources>({ | ||
44 | getRendererURL(path: string) { | ||
45 | return new URL( | ||
46 | path, | ||
47 | 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/', | ||
48 | ).toString(); | ||
49 | }, | ||
50 | }); | ||
51 | |||
52 | const fakeWebContents = fake<WebContents>({ | ||
53 | setWindowOpenHandler(handler) { | ||
54 | windowOpenHandler = handler; | ||
55 | }, | ||
56 | on(event, listener) { | ||
57 | if (event === 'will-navigate') { | ||
58 | willNavigate = listener as WillNavigateHandler; | ||
59 | } | ||
60 | return this as WebContents; | ||
61 | }, | ||
62 | loadURL: jest.fn(), | ||
63 | }); | ||
64 | |||
65 | const event: Event = { | ||
66 | preventDefault: jest.fn(), | ||
67 | }; | ||
68 | |||
69 | beforeAll(() => { | ||
70 | silenceLogger(); | ||
71 | }); | ||
72 | |||
73 | beforeEach(async () => { | ||
74 | windowOpenHandler = undefined; | ||
75 | willNavigate = undefined; | ||
76 | mocked(fakeWebContents.loadURL).mockResolvedValueOnce(); | ||
77 | await lockWebContentsToFile(fakeResources, 'index.html', fakeWebContents); | ||
78 | }); | ||
79 | |||
80 | it('should load the specified file', () => { | ||
81 | expect(fakeWebContents.loadURL).toHaveBeenCalledWith(urlToLoad); | ||
82 | }); | ||
83 | |||
84 | it('should set up will navigate and window open listeners', () => { | ||
85 | expect(willNavigate).toBeDefined(); | ||
86 | expect(windowOpenHandler).toBeDefined(); | ||
87 | }); | ||
88 | |||
89 | it('should prevent opening a window', () => { | ||
90 | const { action } = windowOpenHandler!({ | ||
91 | url: 'https://example.com', | ||
92 | frameName: 'newWindow', | ||
93 | features: '', | ||
94 | disposition: 'default', | ||
95 | referrer: { | ||
96 | url: urlToLoad, | ||
97 | policy: 'default', | ||
98 | }, | ||
99 | }); | ||
100 | expect(action).toBe('deny'); | ||
101 | }); | ||
102 | |||
103 | it('should allow navigation to the loaded URL', () => { | ||
104 | willNavigate!(event, urlToLoad); | ||
105 | expect(event.preventDefault).not.toHaveBeenCalled(); | ||
106 | }); | ||
107 | |||
108 | it('should not allow navigation to another URL', () => { | ||
109 | willNavigate!( | ||
110 | event, | ||
111 | 'file:///opt/sophie/resources/app.asar/packages/renderer/not-allowed.html', | ||
112 | ); | ||
113 | expect(event.preventDefault).toHaveBeenCalled(); | ||
114 | }); | ||
diff --git a/packages/main/src/infrastructure/electron/impl/hardenSession.ts b/packages/main/src/infrastructure/electron/impl/hardenSession.ts index 71d8148..10b694a 100644 --- a/packages/main/src/infrastructure/electron/impl/hardenSession.ts +++ b/packages/main/src/infrastructure/electron/impl/hardenSession.ts | |||
@@ -51,8 +51,12 @@ export default function hardenSession( | |||
51 | const rendererBaseURL = resources.getRendererURL('/'); | 51 | const rendererBaseURL = resources.getRendererURL('/'); |
52 | log.debug('Renderer base URL:', rendererBaseURL); | 52 | log.debug('Renderer base URL:', rendererBaseURL); |
53 | 53 | ||
54 | const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); | 54 | const allowedPrefixes = [rendererBaseURL]; |
55 | log.debug('WebSocket base URL:', webSocketBaseURL); | 55 | if (devMode) { |
56 | const webSocketBaseURL = rendererBaseURL.replace(/^http(s)?:/, 'ws$1:'); | ||
57 | log.debug('WebSocket base URL:', webSocketBaseURL); | ||
58 | allowedPrefixes.push(webSocketBaseURL, ...DEVMODE_ALLOWED_URL_PREFIXES); | ||
59 | } | ||
56 | 60 | ||
57 | function shouldCancelRequest(url: string, method: string): boolean { | 61 | function shouldCancelRequest(url: string, method: string): boolean { |
58 | if (method !== 'GET') { | 62 | if (method !== 'GET') { |
@@ -64,17 +68,7 @@ export default function hardenSession( | |||
64 | } catch { | 68 | } catch { |
65 | return true; | 69 | return true; |
66 | } | 70 | } |
67 | if ( | 71 | return !allowedPrefixes.some((prefix) => normalizedURL.startsWith(prefix)); |
68 | devMode && | ||
69 | DEVMODE_ALLOWED_URL_PREFIXES.some((prefix) => | ||
70 | normalizedURL.startsWith(prefix), | ||
71 | ) | ||
72 | ) { | ||
73 | return false; | ||
74 | } | ||
75 | const isHttp = normalizedURL.startsWith(rendererBaseURL); | ||
76 | const isWs = normalizedURL.startsWith(webSocketBaseURL); | ||
77 | return !isHttp && !isWs; | ||
78 | } | 72 | } |
79 | 73 | ||
80 | session.webRequest.onBeforeRequest(({ url, method }, callback) => { | 74 | session.webRequest.onBeforeRequest(({ url, method }, callback) => { |
diff --git a/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts index 6b458e0..48b1bf0 100644 --- a/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts +++ b/packages/main/src/infrastructure/electron/impl/lockWebContentsToFile.ts | |||
@@ -18,7 +18,7 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | import { WebContents } from 'electron'; | 21 | import type { WebContents } from 'electron'; |
22 | 22 | ||
23 | import { getLogger } from '../../../utils/log'; | 23 | import { getLogger } from '../../../utils/log'; |
24 | import type Resources from '../../resources/Resources'; | 24 | import type Resources from '../../resources/Resources'; |
diff --git a/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts b/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts index e7e9d71..635a6b4 100644 --- a/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts +++ b/packages/main/src/infrastructure/resources/impl/__tests__/getDistResources.spec.ts | |||
@@ -36,18 +36,18 @@ const [ | |||
36 | ] = | 36 | ] = |
37 | os.platform() === 'win32' | 37 | os.platform() === 'win32' |
38 | ? [ | 38 | ? [ |
39 | 'C:\\Program Files\\sophie\\resources\\app.asar\\main\\dist', | 39 | 'C:\\Program Files\\sophie\\resources\\app.asar\\packages\\main\\dist', |
40 | 'C:\\Program Files\\sophie\\resources\\app.asar\\preload\\dist\\index.cjs', | 40 | 'C:\\Program Files\\sophie\\resources\\app.asar\\packages\\preload\\dist\\index.cjs', |
41 | 'file:///C:/Program Files/sophie/resources/app.asar/preload/dist/index.cjs', | 41 | 'file:///C:/Program Files/sophie/resources/app.asar/packages/preload/dist/index.cjs', |
42 | 'file:///C:/Program Files/sophie/resources/app.asar/renderer/dist/index.html', | 42 | 'file:///C:/Program Files/sophie/resources/app.asar/packages/renderer/dist/index.html', |
43 | 'file:///C:/Program Files/sophie/resources/app.asar/renderer/dist/', | 43 | 'file:///C:/Program Files/sophie/resources/app.asar/packages/renderer/dist/', |
44 | ] | 44 | ] |
45 | : [ | 45 | : [ |
46 | '/opt/sophie/resources/app.asar/main/dist', | 46 | '/opt/sophie/resources/app.asar/packages/main/dist', |
47 | '/opt/sophie/resources/app.asar/preload/dist/index.cjs', | 47 | '/opt/sophie/resources/app.asar/packages/preload/dist/index.cjs', |
48 | 'file:///opt/sophie/resources/app.asar/preload/dist/index.cjs', | 48 | 'file:///opt/sophie/resources/app.asar/packages/preload/dist/index.cjs', |
49 | 'file:///opt/sophie/resources/app.asar/renderer/dist/index.html', | 49 | 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/index.html', |
50 | 'file:///opt/sophie/resources/app.asar/renderer/dist/', | 50 | 'file:///opt/sophie/resources/app.asar/packages/renderer/dist/', |
51 | ]; | 51 | ]; |
52 | 52 | ||
53 | const fileURLs: [string, string] = [rendererIndexFileURL, rendererRootFileURL]; | 53 | const fileURLs: [string, string] = [rendererIndexFileURL, rendererRootFileURL]; |
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 9270af2..2f0610a 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json | |||
@@ -12,6 +12,7 @@ | |||
12 | "dependencies": { | 12 | "dependencies": { |
13 | "@types/jest": "^27.4.0", | 13 | "@types/jest": "^27.4.0", |
14 | "jest": "^27.4.7", | 14 | "jest": "^27.4.7", |
15 | "jest-each": "^27.4.6" | 15 | "jest-each": "^27.4.6", |
16 | "type-fest": "^2.11.0" | ||
16 | } | 17 | } |
17 | } | 18 | } |
diff --git a/packages/test-utils/src/fake.ts b/packages/test-utils/src/fake.ts new file mode 100644 index 0000000..57cfa88 --- /dev/null +++ b/packages/test-utils/src/fake.ts | |||
@@ -0,0 +1,34 @@ | |||
1 | /* | ||
2 | * Copyright (C) 2022 Kristóf Marussy <kristof@marussy.com> | ||
3 | * | ||
4 | * This file is part of Sophie. | ||
5 | * | ||
6 | * Sophie is free software: you can redistribute it and/or modify | ||
7 | * it under the terms of the GNU Affero General Public License as | ||
8 | * published by the Free Software Foundation, version 3. | ||
9 | * | ||
10 | * This program is distributed in the hope that it will be useful, | ||
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
13 | * GNU Affero General Public License for more details. | ||
14 | * | ||
15 | * You should have received a copy of the GNU Affero General Public License | ||
16 | * along with this program. If not, see <https://www.gnu.org/licenses/>. | ||
17 | * | ||
18 | * SPDX-License-Identifier: AGPL-3.0-only | ||
19 | */ | ||
20 | |||
21 | import type { PartialDeep } from 'type-fest'; | ||
22 | |||
23 | /** | ||
24 | * Creates a fake object with some properties possibly missing. | ||
25 | * | ||
26 | * Interacting with missing properties will cause a test failure due to | ||
27 | * trying to access members of `undefined.` | ||
28 | * | ||
29 | * @param partialImplementation The partial fake implementation. | ||
30 | * @returns The same value as `partialImplementation` but with a casted type. | ||
31 | */ | ||
32 | export default function fake<T>(partialImplementation: PartialDeep<T>): T { | ||
33 | return partialImplementation as T; | ||
34 | } | ||
diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index 5de84f1..1543c6e 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts | |||
@@ -18,5 +18,6 @@ | |||
18 | * SPDX-License-Identifier: AGPL-3.0-only | 18 | * SPDX-License-Identifier: AGPL-3.0-only |
19 | */ | 19 | */ |
20 | 20 | ||
21 | // eslint-disable-next-line import/prefer-default-export -- More exports will be added here. | ||
22 | export { default as each } from './each'; | 21 | export { default as each } from './each'; |
22 | |||
23 | export { default as fake } from './fake'; | ||
@@ -1360,6 +1360,7 @@ __metadata: | |||
1360 | "@types/jest": ^27.4.0 | 1360 | "@types/jest": ^27.4.0 |
1361 | jest: ^27.4.7 | 1361 | jest: ^27.4.7 |
1362 | jest-each: ^27.4.6 | 1362 | jest-each: ^27.4.6 |
1363 | type-fest: ^2.11.0 | ||
1363 | languageName: unknown | 1364 | languageName: unknown |
1364 | linkType: soft | 1365 | linkType: soft |
1365 | 1366 | ||
@@ -8984,6 +8985,13 @@ __metadata: | |||
8984 | languageName: node | 8985 | languageName: node |
8985 | linkType: hard | 8986 | linkType: hard |
8986 | 8987 | ||
8988 | "type-fest@npm:^2.11.0": | ||
8989 | version: 2.11.0 | ||
8990 | resolution: "type-fest@npm:2.11.0" | ||
8991 | checksum: 267d8d6d36a87d72a050354b79daf644daed24c9880979bb688d7636dca5afc23dfba616dd0fbccfbbb77cc6511dc312e758cea08ab54160d09fda5ca0ef2eed | ||
8992 | languageName: node | ||
8993 | linkType: hard | ||
8994 | |||
8987 | "typedarray-to-buffer@npm:^3.1.5": | 8995 | "typedarray-to-buffer@npm:^3.1.5": |
8988 | version: 3.1.5 | 8996 | version: 3.1.5 |
8989 | resolution: "typedarray-to-buffer@npm:3.1.5" | 8997 | resolution: "typedarray-to-buffer@npm:3.1.5" |