| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Ref from "effect/Ref"; |
| 9 | |
| 10 | import * as BrowserSession from "../BrowserSession.ts"; |
| 11 | import * as BrowserImport from "./BrowserImport.ts"; |
| 12 | import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; |
| 13 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 14 | |
| 15 | const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; |
| 16 | |
| 17 | const cookie = { |
| 18 | url: "https://rejected.example/path", |
| 19 | name: "session", |
| 20 | value: "value", |
| 21 | domain: undefined, |
| 22 | path: "/", |
| 23 | secure: true, |
| 24 | httpOnly: true, |
| 25 | expirationDate: undefined, |
| 26 | sameSite: "lax" as const, |
| 27 | }; |
| 28 | |
| 29 | /** |
| 30 | * Dies if the import reaches session work: every case here covers a request |
| 31 | * that must be rejected before a cookie is read or written. |
| 32 | */ |
| 33 | const layerRejectedBeforeSession = Layer.succeed( |
| 34 | BrowserSession.BrowserSession, |
| 35 | BrowserSession.BrowserSession.of({ |
| 36 | getPartition: () => Effect.die("getPartition must not be reached"), |
| 37 | isPartition: () => false, |
| 38 | getSession: () => Effect.die("getSession must not be reached"), |
| 39 | clearCookies: () => Effect.die("clearCookies must not be reached"), |
| 40 | clearCache: () => Effect.die("clearCache must not be reached"), |
| 41 | }), |
| 42 | ); |
| 43 | |
| 44 | /** |
| 45 | * Builds the service against a scratch home containing an installed, closed |
| 46 | * copy of the source browser. |
| 47 | */ |
| 48 | const withImporter = Effect.fnUntraced(function* () { |
| 49 | const fileSystem = yield* FileSystem.FileSystem; |
| 50 | const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); |
| 51 | const layerEnvironment = Layer.succeed(HostProcess.Environment, { HOME: home }); |
| 52 | const context = yield* sourcePathContext.pipe( |
| 53 | Effect.provideService(HostProcess.Environment, { HOME: home }), |
| 54 | Effect.provideService(HostProcess.Platform, "darwin"), |
| 55 | ); |
| 56 | const root = helium.userDataDirectory(context); |
| 57 | if (root === undefined) throw new Error("Helium has no macOS user-data directory"); |
| 58 | yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); |
| 59 | // The cookie database is what marks a source as installed, so a fixture |
| 60 | // without one is reported as absent before any other check runs. |
| 61 | yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); |
| 62 | |
| 63 | const importer = yield* BrowserImport.BrowserImport.pipe( |
| 64 | Effect.provide( |
| 65 | BrowserImport.layer.pipe( |
| 66 | Layer.provide(layerRejectedBeforeSession), |
| 67 | Layer.provide(layerEnvironment), |
| 68 | Layer.provide(Layer.succeed(HostProcess.Platform, "darwin")), |
| 69 | Layer.provide(Layer.succeed(HostProcess.ExecutablePath, "/Applications/T3 Code.app")), |
| 70 | Layer.provide(NodeServices.layer), |
| 71 | ), |
| 72 | ), |
| 73 | ); |
| 74 | return { importer, home, root }; |
| 75 | }); |
| 76 | |
| 77 | describe("BrowserImport.importCookies", () => { |
| 78 | it.effect("rejects a source profile the browser never reported", () => |
| 79 | Effect.gen(function* () { |
| 80 | const fileSystem = yield* FileSystem.FileSystem; |
| 81 | const { importer, home } = yield* withImporter(); |
| 82 | |
| 83 | // A cookie database reachable on disk but outside the browser's |
| 84 | // user-data directory — the payoff a traversal would be after. |
| 85 | yield* fileSystem.makeDirectory(`${home}/secrets`, { recursive: true }); |
| 86 | yield* fileSystem.writeFileString(`${home}/secrets/Cookies`, "not-a-db"); |
| 87 | |
| 88 | const error = yield* importer |
| 89 | .importCookies({ |
| 90 | input: { |
| 91 | sourceId: "helium", |
| 92 | sourceProfileDirectory: "../../../../secrets", |
| 93 | targetProfileId: "default", |
| 94 | }, |
| 95 | scope: "persist:t3code-preview-test", |
| 96 | persistent: true, |
| 97 | }) |
| 98 | .pipe(Effect.flip); |
| 99 | |
| 100 | assert.instanceOf(error, BrowserImport.BrowserImportFailedError); |
| 101 | assert.equal(error.reason, "unknownSourceProfile"); |
| 102 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 103 | ); |
| 104 | |
| 105 | it.effect.skipIf(!symlinksSupported)( |
| 106 | "refuses to import while the source browser holds its profile", |
| 107 | () => |
| 108 | Effect.gen(function* () { |
| 109 | const fileSystem = yield* FileSystem.FileSystem; |
| 110 | const { importer, root } = yield* withImporter(); |
| 111 | // The lock Chromium leaves while it is running, dangling target and |
| 112 | // all. This must stop the import before it ever asks the keychain. |
| 113 | yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); |
| 114 | |
| 115 | const error = yield* importer |
| 116 | .importCookies({ |
| 117 | input: { |
| 118 | sourceId: "helium", |
| 119 | sourceProfileDirectory: "Default", |
| 120 | targetProfileId: "default", |
| 121 | }, |
| 122 | scope: "persist:t3code-preview-test", |
| 123 | persistent: true, |
| 124 | }) |
| 125 | .pipe(Effect.flip); |
| 126 | |
| 127 | assert.equal(error.reason, "browserRunning"); |
| 128 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 129 | ); |
| 130 | }); |
| 131 | |
| 132 | describe("BrowserImport.writeCookies", () => { |
| 133 | it.effect("counts a rejected cookie and its domain as skipped", () => |
| 134 | Effect.gen(function* () { |
| 135 | let flushes = 0; |
| 136 | const result = yield* BrowserImport.writeCookies( |
| 137 | { |
| 138 | cookies: { |
| 139 | set: () => Promise.reject(new Error("fixture rejection")), |
| 140 | flushStore: () => { |
| 141 | flushes += 1; |
| 142 | return Promise.resolve(); |
| 143 | }, |
| 144 | }, |
| 145 | }, |
| 146 | { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, |
| 147 | ); |
| 148 | |
| 149 | assert.deepEqual(result, { |
| 150 | imported: 0, |
| 151 | skipped: 1, |
| 152 | skippedDomains: ["rejected.example"], |
| 153 | }); |
| 154 | // Nothing landed, so there is nothing to persist. |
| 155 | assert.equal(flushes, 0); |
| 156 | }), |
| 157 | ); |
| 158 | |
| 159 | it.effect("flushes the store after writing, and reports success if the flush fails", () => |
| 160 | Effect.gen(function* () { |
| 161 | const events: Array<string> = []; |
| 162 | const result = yield* BrowserImport.writeCookies( |
| 163 | { |
| 164 | cookies: { |
| 165 | set: () => { |
| 166 | events.push("set"); |
| 167 | return Promise.resolve(); |
| 168 | }, |
| 169 | flushStore: () => { |
| 170 | events.push("flush"); |
| 171 | return Promise.reject(new Error("fixture flush failure")); |
| 172 | }, |
| 173 | }, |
| 174 | }, |
| 175 | { cookies: [cookie, cookie], undecryptable: 0, undecryptableHosts: [] }, |
| 176 | ); |
| 177 | |
| 178 | // One flush after every write, not one per cookie; the cookies are in |
| 179 | // the session either way, so a failed flush is not a failed import. |
| 180 | assert.deepEqual(events, ["set", "set", "flush"]); |
| 181 | assert.deepEqual(result, { imported: 2, skipped: 0, skippedDomains: [] }); |
| 182 | }), |
| 183 | ); |
| 184 | |
| 185 | it.effect("propagates interruption while writing a cookie", () => |
| 186 | Effect.gen(function* () { |
| 187 | const write = BrowserImport.writeCookies( |
| 188 | { |
| 189 | cookies: { |
| 190 | set: () => new Promise<void>(() => {}), |
| 191 | flushStore: () => Promise.resolve(), |
| 192 | }, |
| 193 | }, |
| 194 | { cookies: [cookie], undecryptable: 0, undecryptableHosts: [] }, |
| 195 | ); |
| 196 | |
| 197 | const interrupted = yield* Ref.make(false); |
| 198 | const fiber = yield* write.pipe( |
| 199 | Effect.onInterrupt(() => Ref.set(interrupted, true)), |
| 200 | Effect.forkChild, |
| 201 | ); |
| 202 | yield* Effect.yieldNow; |
| 203 | yield* Fiber.interrupt(fiber); |
| 204 | |
| 205 | assert.isTrue(yield* Ref.get(interrupted)); |
| 206 | }), |
| 207 | ); |
| 208 | }); |