| author | |
| committer | |
| log | 20e2e899ecded6b30e12fa23c8f18847c2bf12e1 |
| tree | 14a407564ab4be3363c8f7a3acbdbc7088eba914 |
| parent | 7fbc545ae8c7866ac2b39648120cb2a17250d8b4 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 95 insertions(+), 1 deletions(-)
apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 4 | import { vi } from "vite-plus/test"; | |
| 5 | ||
| 6 | vi.mock("@napi-rs/keyring", () => { | |
| 7 | throw new Error("Cannot find native binding"); | |
| 8 | }); | |
| 9 | ||
| 10 | it("loads browser import code without a keyring native binding", async () => { | |
| 11 | await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined(); | |
| 12 | }); | |
| 13 | ||
| 14 | it.effect("reports an unavailable keychain when the macOS binding cannot load", () => | |
| 15 | Effect.gen(function* () { | |
| 16 | const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise( | |
| 17 | () => import("./ChromiumKeys.ts"), | |
| 18 | ); | |
| 19 | const error = yield* resolveChromiumKeys({ | |
| 20 | platform: "darwin", | |
| 21 | keychainService: "Chrome Safe Storage", | |
| 22 | keychainAccount: "Chrome", | |
| 23 | linuxSecretApplication: undefined, | |
| 24 | }).pipe(Effect.flip); | |
| 25 | ||
| 26 | expect(error).toBeInstanceOf(ChromiumKeyError); | |
| 27 | expect(error.reason).toBe("keychainUnavailable"); | |
| 28 | expect(error.cause).toBeInstanceOf(Error); | |
| 29 | }).pipe( | |
| 30 | Effect.provideService( | |
| 31 | ChildProcessSpawner.ChildProcessSpawner, | |
| 32 | ChildProcessSpawner.make(() => Effect.die("must not spawn")), | |
| 33 | ), | |
| 34 | ), | |
| 35 | ); |
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts+54| ... | ... | @@ -7,6 +7,7 @@ import * as Sink from "effect/Sink"; |
| 7 | 7 | import * as Stream from "effect/Stream"; |
| 8 | 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | 9 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | import { beforeEach, vi } from "vite-plus/test"; | |
| 10 | 11 | |
| 11 | 12 | import { |
| 12 | 13 | ChromiumKeyError, |
| ... | ... | @@ -17,6 +18,18 @@ import { |
| 17 | 18 | } from "./ChromiumKeys.ts"; |
| 18 | 19 | import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; |
| 19 | 20 | |
| 21 | const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() })); | |
| 22 | ||
| 23 | vi.mock("@napi-rs/keyring", () => ({ | |
| 24 | Entry: class { | |
| 25 | getPassword = getPassword; | |
| 26 | }, | |
| 27 | })); | |
| 28 | ||
| 29 | beforeEach(() => { | |
| 30 | getPassword.mockReset(); | |
| 31 | }); | |
| 32 | ||
| 20 | 33 | type CapturedCommand = { |
| 21 | 34 | readonly command: string; |
| 22 | 35 | readonly args: ReadonlyArray<string>; |
| ... | ... | @@ -63,6 +76,47 @@ const helperLayer = (input: { |
| 63 | 76 | ), |
| 64 | 77 | ); |
| 65 | 78 | |
| 79 | describe("macOS Chromium secrets", () => { | |
| 80 | const request = { | |
| 81 | platform: "darwin", | |
| 82 | keychainService: "Chrome Safe Storage", | |
| 83 | keychainAccount: "Chrome", | |
| 84 | linuxSecretApplication: undefined, | |
| 85 | } as const; | |
| 86 | const noProcesses = Layer.succeed( | |
| 87 | ChildProcessSpawner.ChildProcessSpawner, | |
| 88 | ChildProcessSpawner.make(() => Effect.die("must not spawn")), | |
| 89 | ); | |
| 90 | ||
| 91 | it.effect("derives the cookie key from the keychain secret", () => | |
| 92 | Effect.gen(function* () { | |
| 93 | getPassword.mockReturnValue("macos-secret"); | |
| 94 | const keys = yield* resolveChromiumKeys(request); | |
| 95 | expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74"); | |
| 96 | }).pipe(Effect.provide(noProcesses)), | |
| 97 | ); | |
| 98 | ||
| 99 | it.effect("reports a missing keychain entry", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | getPassword.mockReturnValue(null); | |
| 102 | const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); | |
| 103 | expect(error.reason).toBe("keychainItemMissing"); | |
| 104 | }).pipe(Effect.provide(noProcesses)), | |
| 105 | ); | |
| 106 | ||
| 107 | it.effect("preserves a denied keychain approval", () => | |
| 108 | Effect.gen(function* () { | |
| 109 | const denied = new Error("User denied access"); | |
| 110 | getPassword.mockImplementation(() => { | |
| 111 | throw denied; | |
| 112 | }); | |
| 113 | const error = yield* resolveChromiumKeys(request).pipe(Effect.flip); | |
| 114 | expect(error.reason).toBe("needsKeychainApproval"); | |
| 115 | expect(error.cause).toBe(denied); | |
| 116 | }).pipe(Effect.provide(noProcesses)), | |
| 117 | ); | |
| 118 | }); | |
| 119 | ||
| 66 | 120 | describe("Linux Chromium secrets", () => { |
| 67 | 121 | it.effect("retains a missing helper failure alongside the keyring-free fallback", () => |
| 68 | 122 | Effect.gen(function* () { |
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts+6-1| ... | ... | @@ -17,7 +17,6 @@ |
| 17 | 17 | * |
| 18 | 18 | * @module ChromiumKeys |
| 19 | 19 | */ |
| 20 | import * as Keyring from "@napi-rs/keyring"; | |
| 21 | 20 | import * as NodeCrypto from "node:crypto"; |
| 22 | 21 | |
| 23 | 22 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| ... | ... | @@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function |
| 102 | 101 | service: string, |
| 103 | 102 | account: string, |
| 104 | 103 | ) { |
| 104 | // Only macOS cookie imports need this binding; loading it at startup can | |
| 105 | // prevent the desktop from opening on platforms that never use it. | |
| 106 | const Keyring = yield* Effect.tryPromise({ | |
| 107 | try: () => import("@napi-rs/keyring"), | |
| 108 | catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), | |
| 109 | }); | |
| 105 | 110 | const secret = yield* Effect.try({ |
| 106 | 111 | try: () => new Keyring.Entry(service, account).getPassword(), |
| 107 | 112 | catch: (cause) => { |