1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Ref from "effect/Ref";
9
10import * as BrowserSession from "../BrowserSession.ts";
11import * as BrowserImport from "./BrowserImport.ts";
12import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
13import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
14
15const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
16
17const 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 */
33const 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 */
48const 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
77describe("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
132describe("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});