Skip to content
Source
Sign inSign up
Open History to see the last commit

BrowserSession.test.ts

656 lines27.7 KB

1import { assert, describe, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as NodeEvents from "node:events";
9import { beforeEach, vi } from "vite-plus/test";
10​
11const { fromPartition, sessions } = vi.hoisted(() => ({
12 fromPartition: vi.fn(),
13 sessions: new Map<
14 string,
15 {
16 readonly clearCache: ReturnType<typeof vi.fn>;
17 readonly clearStorageData: ReturnType<typeof vi.fn>;
18 readonly closeAllConnections: ReturnType<typeof vi.fn>;
19 readonly storagePath: string | null;
20 readonly getUserAgent: ReturnType<typeof vi.fn<() => string>>;
21 readonly setPermissionRequestHandler: ReturnType<typeof vi.fn>;
22 readonly setPermissionCheckHandler: ReturnType<typeof vi.fn>;
23 readonly setUserAgent: ReturnType<typeof vi.fn>;
24 readonly webRequest: { readonly onBeforeSendHeaders: ReturnType<typeof vi.fn> };
25 }
26 >(),
27}));
28​
29const storage = vi.hoisted(() => ({ root: "" }));
30​
31const { fromWebContents } = vi.hoisted(() => ({
32 fromWebContents: vi.fn<(webContents: unknown) => unknown>(() => null),
33}));
34​
35const { appListeners } = vi.hoisted(() => ({
36 appListeners: new Map<string, (...args: unknown[]) => void>(),
37}));
38​
39vi.mock("electron", () => ({
40 app: {
41 on: (eventName: string, listener: (...args: unknown[]) => void) => {
42 appListeners.set(eventName, listener);
43 },
44 removeListener: (eventName: string) => {
45 appListeners.delete(eventName);
46 },
47 },
48 BrowserWindow: { fromWebContents },
49 session: {
50 fromPartition,
51 },
52}));
53​
54import * as ElectronApp from "../electron/ElectronApp.ts";
55import * as ElectronDialog from "../electron/ElectronDialog.ts";
56import * as BrowserSession from "./BrowserSession.ts";
57​
58const { showMessageBox, showErrorBox } = vi.hoisted(() => ({
59 showMessageBox: vi.fn<
60 (
61 options: Electron.MessageBoxOptions,
62 owner?: Electron.BrowserWindow,
63 ) => number | Promise<number>
64 >(() => 1),
65 showErrorBox: vi.fn<(title: string, content: string) => void>(),
66}));
67const layerDialog = Layer.succeed(ElectronDialog.ElectronDialog, {
68 pickFolder: () => Effect.die("unused"),
69 pickFiles: () => Effect.die("unused"),
70 showMessageBox: (options, owner) =>
71 Effect.promise(async () => ({
72 response: await showMessageBox(options, owner),
73 checkboxChecked: false,
74 })),
75 showErrorBox: (title, content) => Effect.sync(() => showErrorBox(title, content)),
76});
77const layer = BrowserSession.layer.pipe(
78 Layer.provide(
79 Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, layerDialog, ElectronApp.layer),
80 ),
81);
82​
83const selfSignedCertificate = {
84 fingerprint: "sha256/self-signed",
85 subjectName: "localhost",
86 issuerName: "localhost",
87 validStart: 1_735_776_000,
88 validExpiry: 1_807_056_000,
89} as Electron.Certificate;
90​
91/** Raises Electron's `certificate-error` for a preview guest in `browserSession`. */
92const certificateError = (
93 browserSession: unknown,
94 input: {
95 readonly url: string;
96 readonly certificate?: Electron.Certificate;
97 readonly isMainFrame?: boolean;
98 },
99) => {
100 const listener = appListeners.get("certificate-error");
101 assert.isDefined(listener, "preview sessions should handle certificate errors");
102 const guest = Object.assign(new NodeEvents.EventEmitter(), {
103 session: browserSession,
104 hostWebContents: null,
105 isDestroyed: () => false,
106 });
107 const event = { preventDefault: vi.fn() };
108 const decision = Deferred.makeUnsafe<boolean>();
109 const callback = vi.fn((trusted: boolean) => {
110 Deferred.doneUnsafe(decision, Effect.succeed(trusted));
111 });
112 listener(
113 event,
114 guest,
115 input.url,
116 "net::ERR_CERT_AUTHORITY_INVALID",
117 input.certificate ?? selfSignedCertificate,
118 callback,
119 input.isMainFrame ?? true,
120 );
121 return { guest, event, callback, trusted: Deferred.await(decision) };
122};
123​
124/** Answers the certificate prompt with its "Trust…" button. */
125const trustCertificateButton = (options: Electron.MessageBoxOptions): number => {
126 const index = (options.buttons ?? []).findIndex((label) => label.startsWith("Trust"));
127 assert.notStrictEqual(index, -1, "the certificate prompt should offer to trust");
128 return index;
129};
130​
131describe("BrowserSession", () => {
132 beforeEach(() => {
133 sessions.clear();
134 appListeners.clear();
135 storage.root = "";
136 showErrorBox.mockReset();
137 fromPartition.mockReset();
138 fromPartition.mockImplementation((partition: string) => {
139 const browserSession = {
140 clearCache: vi.fn(() => Promise.resolve()),
141 clearStorageData: vi.fn(() => Promise.resolve()),
142 closeAllConnections: vi.fn(() => Promise.resolve()),
143 // Like Electron, only a `persist:` partition has storage on disk.
144 storagePath:
145 storage.root !== "" && partition.startsWith("persist:")
146 ? `${storage.root}/Partitions/${partition}`
147 : null,
148 getUserAgent: vi.fn(() => "Mozilla/5.0 Electron/41.5.0 t3code/0.0.27"),
149 setPermissionRequestHandler: vi.fn(),
150 setPermissionCheckHandler: vi.fn(),
151 setUserAgent: vi.fn(),
152 webRequest: { onBeforeSendHeaders: vi.fn() },
153 };
154 sessions.set(partition, browserSession);
155 return browserSession;
156 });
157 });
158​
159 it.effect("derives deterministic partitions and memoizes sessions", () =>
160 Effect.gen(function* () {
161 const browserSessions = yield* BrowserSession.BrowserSession;
162​
163 const partition = yield* browserSessions.getPartition("scope-a");
164 const first = yield* browserSessions.getSession("scope-a");
165 const second = yield* browserSessions.getSession("scope-a");
166​
167 assert.strictEqual(partition, "persist:t3code-preview-f051bb2c68cb7b2fe969");
168 assert.strictEqual(first, second);
169 assert.strictEqual(fromPartition.mock.calls.length, 1);
170 }).pipe(Effect.provide(layer)),
171 );
172​
173 it.effect("keeps scopes that differ only by a lone surrogate in separate partitions", () =>
174 Effect.gen(function* () {
175 const browserSessions = yield* BrowserSession.BrowserSession;
176​
177 const loneSurrogate = yield* browserSessions.getPartition("p\ud800");
178 const replacementChar = yield* browserSessions.getPartition("p\ufffd");
179 assert.notStrictEqual(loneSurrogate, replacementChar);
180​
181 const literal = yield* browserSessions.getPartition("p\\ud800");
182 assert.notStrictEqual(literal, loneSurrogate);
183​
184 assert.strictEqual(
185 yield* browserSessions.getPartition("scope-a"),
186 "persist:t3code-preview-f051bb2c68cb7b2fe969",
187 );
188 }).pipe(Effect.provide(layer)),
189 );
190​
191 it.effect("keeps legacy defaults disjoint from nondefault profile partitions", () =>
192 Effect.gen(function* () {
193 const browserSessions = yield* BrowserSession.BrowserSession;
194​
195 const legacyDefault = yield* browserSessions.getPartition("a::b");
196 const nondefaultProfile = yield* browserSessions.getPartition("a::b", true, "profile");
197​
198 assert.strictEqual(legacyDefault, "persist:t3code-preview-78f0be89237d77f7a70e");
199 assert.strictEqual(nondefaultProfile, "persist:t3code-preview-profile-78f0be89237d77f7a70e");
200 assert.notStrictEqual(nondefaultProfile, legacyDefault);
201 assert.isTrue(browserSessions.isPartition(legacyDefault));
202 assert.isTrue(browserSessions.isPartition(nondefaultProfile));
203 }).pipe(Effect.provide(layer)),
204 );
205​
206 it.effect("keeps the native User-Agent except for the Electron token sent to Teams", () =>
207 Effect.gen(function* () {
208 const nativeUserAgent =
209 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Electron/41.5.0 Safari/537.36";
210 const teamsUserAgent =
211 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) T3Code(Alpha)/0.0.33 Chrome/146.0.7680.216 Safari/537.36";
212 fromPartition.mockReset();
213 fromPartition.mockImplementation((partition: string) => {
214 let userAgent = nativeUserAgent;
215 const browserSession = {
216 clearCache: vi.fn(() => Promise.resolve()),
217 clearStorageData: vi.fn(() => Promise.resolve()),
218 closeAllConnections: vi.fn(() => Promise.resolve()),
219 storagePath: null,
220 getUserAgent: vi.fn(() => userAgent),
221 setPermissionRequestHandler: vi.fn(),
222 setPermissionCheckHandler: vi.fn(),
223 setUserAgent: vi.fn((next: string) => {
224 userAgent = next;
225 }),
226 webRequest: { onBeforeSendHeaders: vi.fn() },
227 };
228 sessions.set(partition, browserSession);
229 return browserSession;
230 });
231​
232 const browserSessions = yield* BrowserSession.BrowserSession;
233 const partition = yield* browserSessions.getPartition("scope-a");
234 yield* browserSessions.getSession("scope-a");
235​
236 const browserSession = sessions.get(partition);
237 assert.isDefined(browserSession);
238 assert.strictEqual(browserSession.getUserAgent(), nativeUserAgent);
239​
240 // Teams redirects Electron to an error page before sign-in; every other host sees the native one.
241 const calls = browserSession.webRequest.onBeforeSendHeaders.mock.calls;
242 assert.strictEqual(calls.length, 1);
243 const [filter, listener] = calls[0] ?? [];
244 assert.deepStrictEqual(filter, {
245 urls: ["https://teams.cloud.microsoft/*", "https://teams.microsoft.com/*"],
246 });
247 const send = (requestHeaders: Record<string, string>) => {
248 let response: { requestHeaders?: Record<string, string> } | undefined;
249 listener({ requestHeaders }, (next: typeof response) => {
250 response = next;
251 });
252 return response;
253 };
254 for (const key of ["User-Agent", "user-agent"]) {
255 const requestHeaders = { [key]: nativeUserAgent, Accept: "*/*" };
256 assert.deepStrictEqual(send(requestHeaders), {
257 requestHeaders: { [key]: teamsUserAgent, Accept: "*/*" },
258 });
259 assert.strictEqual(requestHeaders[key], nativeUserAgent);
260 }
261 assert.deepStrictEqual(send({ Accept: "*/*" }), {});
262 }).pipe(Effect.provide(layer)),
263 );
264​
265 it.effect("grants preview permissions through both the request and check handlers", () =>
266 Effect.gen(function* () {
267 const browserSessions = yield* BrowserSession.BrowserSession;
268 const partition = yield* browserSessions.getPartition("scope-a");
269 yield* browserSessions.getSession("scope-a");
270​
271 const browserSession = sessions.get(partition);
272 assert.isDefined(browserSession);
273​
274 const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0];
275 const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0];
276 assert.isFunction(requestHandler);
277 assert.isFunction(checkHandler);
278​
279 const requestAllows = (permission: string): boolean => {
280 let granted: boolean | undefined;
281 requestHandler(null, permission, (value: boolean) => {
282 granted = value;
283 });
284 assert.isDefined(granted);
285 return granted;
286 };
287​
288 for (const permission of [
289 "clipboard-sanitized-write",
290 "geolocation",
291 "fullscreen",
292 "pointerLock",
293 "loopback-network",
294 ]) {
295 assert.isTrue(requestAllows(permission), `request handler should allow ${permission}`);
296 assert.isTrue(
297 checkHandler(null, permission) as boolean,
298 `check handler should allow ${permission}`,
299 );
300 }
301​
302 // `clipboard-write` is not a real Electron permission — the async write API
303 // uses `clipboard-sanitized-write` — so the stale name must not be granted,
304 // and unrelated permissions stay denied.
305 for (const permission of [
306 "clipboard-write",
307 "notifications",
308 "midi",
309 "local-network",
310 "local-network-access",
311 ]) {
312 assert.isFalse(requestAllows(permission), `request handler should deny ${permission}`);
313 assert.isFalse(
314 checkHandler(null, permission) as boolean,
315 `check handler should deny ${permission}`,
316 );
317 }
318 }).pipe(Effect.provide(layer)),
319 );
320​
321 it.effect("opens custom-scheme links externally only after the user confirms", () =>
322 Effect.gen(function* () {
323 const browserSessions = yield* BrowserSession.BrowserSession;
324 const partition = yield* browserSessions.getPartition("scope-a");
325 yield* browserSessions.getSession("scope-a");
326 const requestHandler =
327 sessions.get(partition)?.setPermissionRequestHandler.mock.calls[0]?.[0];
328 assert.isFunction(requestHandler);
329​
330 const request = (externalURL: string) =>
331 Effect.callback<boolean>((resume) => {
332 requestHandler(
333 null,
334 "openExternal",
335 (granted: boolean) => resume(Effect.succeed(granted)),
336 { externalURL },
337 );
338 });
339​
340 showMessageBox.mockReset();
341 showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
342 assert.isTrue(yield* request("slack://open?team=T1"));
343 assert.isFalse(yield* request("zoommtg://zoom.us/join"));
344 assert.strictEqual(showMessageBox.mock.calls.length, 2);
345​
346 for (const url of ["file:///etc/passwd", "javascript:alert(1)", "data:text/html,x"]) {
347 assert.isFalse(yield* request(url), `${url} must never open externally`);
348 }
349 assert.strictEqual(showMessageBox.mock.calls.length, 2);
350 }).pipe(Effect.provide(layer)),
351 );
352​
353 it.effect("asks once per origin before a page reads the clipboard", () =>
354 Effect.gen(function* () {
355 const browserSessions = yield* BrowserSession.BrowserSession;
356 const partition = yield* browserSessions.getPartition("scope-a");
357 yield* browserSessions.getSession("scope-a");
358 const browserSession = sessions.get(partition);
359 assert.isDefined(browserSession);
360 const requestHandler = browserSession.setPermissionRequestHandler.mock.calls[0]?.[0];
361 const checkHandler = browserSession.setPermissionCheckHandler.mock.calls[0]?.[0];
362 assert.isFunction(requestHandler);
363 assert.isFunction(checkHandler);
364​
365 const readClipboard = (requestingUrl: string) =>
366 Effect.callback<boolean>((resume) => {
367 requestHandler(
368 null,
369 "clipboard-read",
370 (granted: boolean) => resume(Effect.succeed(granted)),
371 { requestingUrl, isMainFrame: true },
372 );
373 });
374 const checkClipboard = (origin: string): boolean =>
375 checkHandler(null, "clipboard-read", origin, {}) as boolean;
376​
377 showMessageBox.mockReset();
378 showMessageBox.mockReturnValueOnce(0).mockReturnValueOnce(1);
379​
380 assert.isFalse(checkClipboard("https://allowed.test"));
381 assert.isTrue(yield* readClipboard("https://allowed.test/page"));
382 assert.strictEqual(showMessageBox.mock.calls.length, 1);
383 assert.strictEqual(
384 showMessageBox.mock.calls[0]?.[0].message,
385 "https://allowed.test wants to see text and images copied to the clipboard",
386 );
387 assert.deepStrictEqual(showMessageBox.mock.calls[0]?.[0].buttons, ["Allow", "Block"]);
388 // Enter must not accept a prompt the page raised while the user was typing.
389 assert.strictEqual(showMessageBox.mock.calls[0]?.[0].defaultId, 1);
390​
391 assert.isTrue(yield* readClipboard("https://allowed.test/other"));
392 assert.isTrue(checkClipboard("https://allowed.test"));
393​
394 assert.isFalse(yield* readClipboard("https://blocked.test/"));
395 assert.isFalse(yield* readClipboard("https://blocked.test/again"));
396 assert.isFalse(checkClipboard("https://blocked.test"));
397 assert.isTrue(checkClipboard("https://allowed.test"));
398 assert.strictEqual(showMessageBox.mock.calls.length, 2);
399 }).pipe(Effect.provide(layer)),
400 );
401​
402 it.effect("clears a partition whose session has not been opened yet", () =>
403 Effect.gen(function* () {
404 const browserSessions = yield* BrowserSession.BrowserSession;
405 const partition = yield* browserSessions.getPartition("scope-untouched");
406​
407 assert.isUndefined(sessions.get(partition));
408 yield* browserSessions.clearCookies([partition]);
409 assert.isUndefined(sessions.get(partition));
410​
411 yield* browserSessions.getSession("scope-untouched");
412 const other = yield* browserSessions.getSession("scope-other");
413 yield* browserSessions.clearCookies([partition]);
414​
415 const created = sessions.get(partition);
416 assert.isDefined(created);
417 assert.strictEqual(created.clearStorageData.mock.calls.length, 1);
418 assert.deepStrictEqual(created.clearStorageData.mock.calls[0]?.[0], {
419 storages: ["cookies", "localstorage", "indexdb", "serviceworkers", "cachestorage"],
420 });
421 assert.strictEqual(vi.mocked(other.clearStorageData).mock.calls.length, 0);
422 }).pipe(Effect.provide(layer)),
423 );
424​
425 it.effect("asks before trusting an untrusted certificate and remembers the approval", () =>
426 Effect.gen(function* () {
427 const browserSessions = yield* BrowserSession.BrowserSession;
428 const partition = yield* browserSessions.getPartition("scope-a");
429 yield* browserSessions.getSession("scope-a");
430 const browserSession = sessions.get(partition);
431​
432 showMessageBox.mockReset();
433 showMessageBox.mockImplementation(trustCertificateButton);
434 const first = certificateError(browserSession, { url: "https://localhost:18443/" });
435 assert.strictEqual(first.event.preventDefault.mock.calls.length, 1);
436 assert.isTrue(yield* first.trusted);
437 assert.strictEqual(showMessageBox.mock.calls.length, 1);
438​
439 // The approval covers this origin, port, and certificate in this profile.
440 const again = certificateError(browserSession, { url: "https://localhost:18443/other" });
441 assert.isTrue(yield* again.trusted);
442 assert.strictEqual(showMessageBox.mock.calls.length, 1);
443 // Its sockets reach the same TLS endpoint, and cannot ask on their own.
444 const socket = certificateError(browserSession, {
445 url: "wss://localhost:18443/hmr",
446 isMainFrame: false,
447 });
448 assert.isTrue(yield* socket.trusted);
449 const otherSocket = certificateError(browserSession, {
450 url: "wss://localhost:18444/hmr",
451 isMainFrame: false,
452 });
453 assert.isFalse(yield* otherSocket.trusted);
454 assert.strictEqual(showMessageBox.mock.calls.length, 1);
455​
456 showMessageBox.mockReset();
457 showMessageBox.mockReturnValue(1);
458 const otherPort = certificateError(browserSession, { url: "https://localhost:18444/" });
459 assert.isFalse(yield* otherPort.trusted);
460 const renewed = certificateError(browserSession, {
461 url: "https://localhost:18443/",
462 certificate: { ...selfSignedCertificate, fingerprint: "sha256/renewed" },
463 });
464 assert.isFalse(yield* renewed.trusted);
465 assert.strictEqual(showMessageBox.mock.calls.length, 2);
466​
467 // A Session that is not a preview profile keeps Electron's denial.
468 const appSession = certificateError({}, { url: "https://localhost:18443/" });
469 assert.strictEqual(appSession.event.preventDefault.mock.calls.length, 0);
470 assert.strictEqual(appSession.callback.mock.calls.length, 0);
471 }).pipe(Effect.provide(layer)),
472 );
473​
474 it.effect("revokes certificate approvals when cookies are cleared, not the cache", () =>
475 Effect.gen(function* () {
476 const browserSessions = yield* BrowserSession.BrowserSession;
477 const partition = yield* browserSessions.getPartition("scope-a");
478 yield* browserSessions.getSession("scope-a");
479 const browserSession = sessions.get(partition);
480 const url = "https://localhost:18443/";
481​
482 showMessageBox.mockReset();
483 showMessageBox.mockImplementation(trustCertificateButton);
484 assert.isTrue(yield* certificateError(browserSession, { url }).trusted);
485​
486 yield* browserSessions.clearCache([partition]);
487 assert.isTrue(yield* certificateError(browserSession, { url }).trusted);
488 assert.strictEqual(showMessageBox.mock.calls.length, 1);
489​
490 // A failed storage clear still forgets the approvals the user asked to clear.
491 browserSession!.clearStorageData.mockRejectedValueOnce(new Error("disk busy"));
492 const failure = yield* browserSessions.clearCookies([partition]).pipe(Effect.flip);
493 assert.strictEqual(failure._tag, "BrowserSessionStorageClearError");
494 showMessageBox.mockReset();
495 showMessageBox.mockReturnValue(1);
496 assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
497 assert.strictEqual(showMessageBox.mock.calls.length, 1);
498 }).pipe(Effect.provide(layer)),
499 );
500​
501 it.effect("remembers approvals across restarts, but not incognito ones", () =>
502 Effect.gen(function* () {
503 storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
504 const url = "https://localhost:18443/";
505 const approve = Effect.gen(function* () {
506 const browserSessions = yield* BrowserSession.BrowserSession;
507 const persistent = yield* browserSessions.getPartition("scope-a");
508 const incognito = yield* browserSessions.getPartition("scope-a", false);
509 yield* browserSessions.getSession("scope-a");
510 yield* browserSessions.getSession("scope-a", false);
511 showMessageBox.mockReset();
512 showMessageBox.mockImplementation(trustCertificateButton);
513 assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted);
514 assert.isTrue(yield* certificateError(sessions.get(incognito), { url }).trusted);
515 return { persistent, incognito };
516 });
517 const { persistent, incognito } = yield* approve.pipe(Effect.provide(layer));
518​
519 // A restarted app gets fresh Session objects and reads approvals from disk.
520 sessions.clear();
521 appListeners.clear();
522 yield* Effect.gen(function* () {
523 const browserSessions = yield* BrowserSession.BrowserSession;
524 yield* browserSessions.getSession("scope-a");
525 yield* browserSessions.getSession("scope-a", false);
526 showMessageBox.mockReset();
527 showMessageBox.mockReturnValue(1);
528 assert.isTrue(yield* certificateError(sessions.get(persistent), { url }).trusted);
529 assert.strictEqual(showMessageBox.mock.calls.length, 0);
530 assert.isFalse(yield* certificateError(sessions.get(incognito), { url }).trusted);
531 assert.strictEqual(showMessageBox.mock.calls.length, 1);
532 }).pipe(Effect.provide(layer));
533 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
534 );
535​
536 it.effect("an answer to a prompt opened before cookies were cleared does not trust", () =>
537 Effect.gen(function* () {
538 storage.root = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped();
539 const browserSessions = yield* BrowserSession.BrowserSession;
540 const partition = yield* browserSessions.getPartition("scope-a");
541 yield* browserSessions.getSession("scope-a");
542 const browserSession = sessions.get(partition);
543 const url = "https://localhost:18443/";
544​
545 const asked = yield* Deferred.make<void>();
546 let answer = () => {};
547 const answered = new Promise<void>((resolve) => {
548 answer = resolve;
549 });
550 showMessageBox.mockReset();
551 showMessageBox.mockImplementation((options) => {
552 Deferred.doneUnsafe(asked, Effect.void);
553 return answered.then(() => trustCertificateButton(options));
554 });
555 const pending = certificateError(browserSession, { url });
556 yield* Deferred.await(asked);
557 yield* browserSessions.clearCookies([partition]);
558 answer();
559 assert.isFalse(yield* pending.trusted);
560​
561 showMessageBox.mockReset();
562 showMessageBox.mockReturnValue(1);
563 assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
564 assert.strictEqual(showMessageBox.mock.calls.length, 1);
565 assert.isFalse(
566 yield* (yield* FileSystem.FileSystem).exists(
567 `${browserSession!.storagePath}/t3code-certificate-approvals.json`,
568 ),
569 );
570 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
571 );
572​
573 it.effect("a tab that closes or navigates away dismisses its certificate prompt", () =>
574 Effect.gen(function* () {
575 const browserSessions = yield* BrowserSession.BrowserSession;
576 const partition = yield* browserSessions.getPartition("scope-a");
577 yield* browserSessions.getSession("scope-a");
578 const browserSession = sessions.get(partition);
579 const url = "https://localhost:18443/";
580​
581 for (const leave of [
582 (guest: NodeEvents.EventEmitter) =>
583 guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }),
584 (guest: NodeEvents.EventEmitter) => guest.emit("destroyed"),
585 ]) {
586 showMessageBox.mockReset();
587 // Answering "Trust" as the tab leaves must not approve the certificate.
588 showMessageBox.mockImplementation(
589 (options) =>
590 new Promise((resolve) =>
591 options.signal?.addEventListener("abort", () =>
592 resolve(trustCertificateButton(options)),
593 ),
594 ),
595 );
596 const pending = certificateError(browserSession, { url });
597 pending.guest.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
598 leave(pending.guest);
599 assert.isFalse(yield* pending.trusted);
600 assert.strictEqual(showMessageBox.mock.calls.length, 1);
601 }
602 }).pipe(Effect.provide(layer)),
603 );
604​
605 it.effect("does not trust a certificate whose approval cannot be saved", () =>
606 Effect.gen(function* () {
607 const fileSystem = yield* FileSystem.FileSystem;
608 // A file where the profile directory should be makes every write fail.
609 const root = yield* fileSystem.makeTempDirectoryScoped();
610 yield* fileSystem.writeFileString(`${root}/Partitions`, "");
611 storage.root = root;
612 const browserSessions = yield* BrowserSession.BrowserSession;
613 const partition = yield* browserSessions.getPartition("scope-a");
614 yield* browserSessions.getSession("scope-a");
615 const browserSession = sessions.get(partition);
616 const url = "https://localhost:18443/";
617​
618 showMessageBox.mockReset();
619 showMessageBox.mockImplementation(trustCertificateButton);
620 assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
621 assert.strictEqual(showErrorBox.mock.calls.length, 1);
622 // Nothing was trusted for this run either, so the next visit asks again.
623 showMessageBox.mockReset();
624 showMessageBox.mockReturnValue(1);
625 assert.isFalse(yield* certificateError(browserSession, { url }).trusted);
626 assert.strictEqual(showMessageBox.mock.calls.length, 1);
627 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
628 );
629​
630 it.effect("clearing cookies deletes a saved approval even when none are loaded", () =>
631 Effect.gen(function* () {
632 const fileSystem = yield* FileSystem.FileSystem;
633 storage.root = yield* fileSystem.makeTempDirectoryScoped();
634 const browserSessions = yield* BrowserSession.BrowserSession;
635 const partition = yield* browserSessions.getPartition("scope-a");
636 yield* browserSessions.getSession("scope-a");
637 const browserSession = sessions.get(partition);
638 // What a clear whose removal failed leaves behind: memory forgot, the file did not.
639 const file = `${browserSession!.storagePath}/t3code-certificate-approvals.json`;
640 yield* fileSystem.makeDirectory(browserSession!.storagePath!, { recursive: true });
641 yield* fileSystem.writeFileString(
642 file,
643 JSON.stringify([
644 {
645 origin: "https://localhost:18443",
646 fingerprint: selfSignedCertificate.fingerprint,
647 error: "net::ERR_CERT_AUTHORITY_INVALID",
648 },
649 ]),
650 );
651​
652 yield* browserSessions.clearCookies([partition]);
653 assert.isFalse(yield* fileSystem.exists(file));
654 }).pipe(Effect.scoped, Effect.provide(Layer.merge(layer, NodeServices.layer))),
655 );
656});