| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as TestClock from "effect/testing/TestClock"; |
| 9 | import { beforeEach, vi } from "vite-plus/test"; |
| 10 | |
| 11 | const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({ |
| 12 | handleMock: vi.fn(), |
| 13 | netFetchMock: vi.fn(), |
| 14 | unhandleMock: vi.fn(), |
| 15 | })); |
| 16 | |
| 17 | vi.mock("electron", () => ({ |
| 18 | net: { fetch: netFetchMock }, |
| 19 | protocol: { handle: handleMock, unhandle: unhandleMock }, |
| 20 | })); |
| 21 | |
| 22 | import * as ElectronProtocol from "./ElectronProtocol.ts"; |
| 23 | |
| 24 | const layerProtocol = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer)); |
| 25 | |
| 26 | describe("ElectronProtocol", () => { |
| 27 | beforeEach(() => { |
| 28 | handleMock.mockReset(); |
| 29 | netFetchMock.mockReset(); |
| 30 | unhandleMock.mockReset(); |
| 31 | }); |
| 32 | |
| 33 | it.effect("serves the bundled client from disk without a backend", () => |
| 34 | Effect.gen(function* () { |
| 35 | const fileSystem = yield* FileSystem.FileSystem; |
| 36 | const directory = yield* fileSystem.makeTempDirectoryScoped(); |
| 37 | yield* fileSystem.writeFileString(`${directory}/index.html`, "<html>app</html>"); |
| 38 | yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;"); |
| 39 | let handler: ((request: Request) => Promise<Response>) | undefined; |
| 40 | handleMock.mockImplementation((_scheme, nextHandler) => { |
| 41 | handler = nextHandler; |
| 42 | }); |
| 43 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 44 | yield* protocol.registerDesktopProtocol({ |
| 45 | scheme: "t3code", |
| 46 | assetDirectories: Effect.succeed([directory]), |
| 47 | clerkFrontendApiHostname: undefined, |
| 48 | }); |
| 49 | const request = (pathname: string, init?: RequestInit) => |
| 50 | Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init))); |
| 51 | |
| 52 | // SPA routes fall back to index.html, including ones containing dots. |
| 53 | const page = yield* request("/settings/connections"); |
| 54 | assert.equal(yield* Effect.promise(() => page.text()), "<html>app</html>"); |
| 55 | assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'"); |
| 56 | const dottedRoute = yield* request("/environment/thread.with.dots", { |
| 57 | headers: { accept: "text/html" }, |
| 58 | }); |
| 59 | assert.equal(yield* Effect.promise(() => dottedRoute.text()), "<html>app</html>"); |
| 60 | |
| 61 | const script = yield* request("/app.js?v=1"); |
| 62 | assert.equal(yield* Effect.promise(() => script.text()), "export default 1;"); |
| 63 | assert.include(script.headers.get("content-type") ?? "", "javascript"); |
| 64 | |
| 65 | assert.equal((yield* request("/missing.js")).status, 404); |
| 66 | assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404); |
| 67 | assert.equal((yield* request("/%invalid")).status, 400); |
| 68 | assert.equal((yield* request("/", { method: "POST" })).status, 405); |
| 69 | assert.equal(netFetchMock.mock.calls.length, 0); |
| 70 | }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer)), Effect.scoped), |
| 71 | ); |
| 72 | |
| 73 | it.effect("proxies the stable renderer origin to the current app server", () => |
| 74 | Effect.gen(function* () { |
| 75 | let handler: ((request: Request) => Promise<Response>) | undefined; |
| 76 | handleMock.mockImplementation((_scheme, nextHandler) => { |
| 77 | handler = nextHandler; |
| 78 | }); |
| 79 | netFetchMock.mockResolvedValue(new Response("ok")); |
| 80 | |
| 81 | yield* Effect.scoped( |
| 82 | Effect.gen(function* () { |
| 83 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 84 | yield* protocol.registerDesktopProtocol({ |
| 85 | scheme: "t3code-dev", |
| 86 | targetOrigin: new URL("http://127.0.0.1:3773/"), |
| 87 | clerkFrontendApiHostname: "clerk.t3.codes", |
| 88 | }); |
| 89 | assert.isDefined(handler); |
| 90 | |
| 91 | const response = yield* Effect.promise(() => |
| 92 | handler!( |
| 93 | new Request("t3code-dev://app/api/health?verbose=1", { |
| 94 | headers: { |
| 95 | accept: "application/json", |
| 96 | origin: "t3code-dev://app", |
| 97 | referer: "t3code-dev://app/", |
| 98 | "sec-fetch-site": "same-origin", |
| 99 | }, |
| 100 | }), |
| 101 | ), |
| 102 | ); |
| 103 | assert.equal(yield* Effect.promise(() => response.text()), "ok"); |
| 104 | assert.include( |
| 105 | response.headers.get("content-security-policy") ?? "", |
| 106 | "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", |
| 107 | ); |
| 108 | assert.include( |
| 109 | response.headers.get("content-security-policy") ?? "", |
| 110 | "connect-src 'self' blob: http: https: ws: wss:", |
| 111 | ); |
| 112 | assert.include( |
| 113 | response.headers.get("content-security-policy") ?? "", |
| 114 | "img-src 'self' t3code-dev: blob: data: http: https:", |
| 115 | ); |
| 116 | assert.include( |
| 117 | response.headers.get("content-security-policy") ?? "", |
| 118 | "font-src 'self' t3code-dev: data:", |
| 119 | ); |
| 120 | }), |
| 121 | ); |
| 122 | |
| 123 | assert.deepEqual( |
| 124 | handleMock.mock.calls.map((call) => call[0]), |
| 125 | ["t3code-dev"], |
| 126 | ); |
| 127 | assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1"); |
| 128 | const forwardedHeaders = new Headers(netFetchMock.mock.calls[0]?.[1]?.headers); |
| 129 | assert.equal(forwardedHeaders.get("accept"), "application/json"); |
| 130 | assert.isNull(forwardedHeaders.get("origin")); |
| 131 | assert.isNull(forwardedHeaders.get("referer")); |
| 132 | assert.isNull(forwardedHeaders.get("sec-fetch-site")); |
| 133 | assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]); |
| 134 | }).pipe(Effect.provide(layerProtocol)), |
| 135 | ); |
| 136 | |
| 137 | it.effect("rejects custom protocol requests for another host", () => |
| 138 | Effect.gen(function* () { |
| 139 | let handler: ((request: Request) => Promise<Response>) | undefined; |
| 140 | handleMock.mockImplementation((_scheme, nextHandler) => { |
| 141 | handler = nextHandler; |
| 142 | }); |
| 143 | |
| 144 | const response = yield* Effect.scoped( |
| 145 | Effect.gen(function* () { |
| 146 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 147 | yield* protocol.registerDesktopProtocol({ |
| 148 | scheme: "t3code", |
| 149 | targetOrigin: new URL("http://127.0.0.1:3773/"), |
| 150 | clerkFrontendApiHostname: undefined, |
| 151 | }); |
| 152 | return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); |
| 153 | }), |
| 154 | ); |
| 155 | |
| 156 | assert.equal(response.status, 404); |
| 157 | assert.equal(netFetchMock.mock.calls.length, 0); |
| 158 | }).pipe(Effect.provide(layerProtocol)), |
| 159 | ); |
| 160 | |
| 161 | it.effect("retries transient renderer target failures", () => |
| 162 | Effect.gen(function* () { |
| 163 | let handler: ((request: Request) => Promise<Response>) | undefined; |
| 164 | handleMock.mockImplementation((_scheme, nextHandler) => { |
| 165 | handler = nextHandler; |
| 166 | }); |
| 167 | netFetchMock |
| 168 | .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733")) |
| 169 | .mockResolvedValueOnce(new Response("ready")); |
| 170 | |
| 171 | const response = yield* Effect.scoped( |
| 172 | Effect.gen(function* () { |
| 173 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 174 | yield* protocol.registerDesktopProtocol({ |
| 175 | scheme: "t3code-dev", |
| 176 | targetOrigin: new URL("http://127.0.0.1:5733/"), |
| 177 | clerkFrontendApiHostname: undefined, |
| 178 | }); |
| 179 | const fiber = yield* Effect.forkChild( |
| 180 | Effect.promise(() => handler!(new Request("t3code-dev://app/"))), |
| 181 | ); |
| 182 | yield* TestClock.adjust("50 millis"); |
| 183 | return yield* Fiber.join(fiber); |
| 184 | }), |
| 185 | ); |
| 186 | |
| 187 | assert.equal(yield* Effect.promise(() => response.text()), "ready"); |
| 188 | assert.equal(netFetchMock.mock.calls.length, 2); |
| 189 | }).pipe(Effect.provide(layerProtocol)), |
| 190 | ); |
| 191 | |
| 192 | it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () => |
| 193 | Effect.gen(function* () { |
| 194 | let handler: ((request: Request) => Promise<Response>) | undefined; |
| 195 | handleMock.mockImplementation((_scheme, nextHandler) => { |
| 196 | handler = nextHandler; |
| 197 | }); |
| 198 | const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)"); |
| 199 | netFetchMock |
| 200 | .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)")) |
| 201 | .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)")) |
| 202 | .mockRejectedValueOnce(lastFailure); |
| 203 | |
| 204 | const rejection = yield* Effect.scoped( |
| 205 | Effect.gen(function* () { |
| 206 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 207 | yield* protocol.registerDesktopProtocol({ |
| 208 | scheme: "t3code-dev", |
| 209 | targetOrigin: new URL("http://127.0.0.1:5733/"), |
| 210 | clerkFrontendApiHostname: undefined, |
| 211 | }); |
| 212 | const fiber = yield* Effect.forkChild( |
| 213 | Effect.promise(() => |
| 214 | handler!(new Request("t3code-dev://app/")).then( |
| 215 | () => null, |
| 216 | (error: unknown) => error, |
| 217 | ), |
| 218 | ), |
| 219 | ); |
| 220 | yield* TestClock.adjust("49 millis"); |
| 221 | assert.equal(netFetchMock.mock.calls.length, 1); |
| 222 | yield* TestClock.adjust("1 millis"); |
| 223 | assert.equal(netFetchMock.mock.calls.length, 2); |
| 224 | yield* TestClock.adjust("149 millis"); |
| 225 | assert.equal(netFetchMock.mock.calls.length, 2); |
| 226 | yield* TestClock.adjust("1 millis"); |
| 227 | return yield* Fiber.join(fiber); |
| 228 | }), |
| 229 | ); |
| 230 | |
| 231 | assert.strictEqual(rejection, lastFailure); |
| 232 | assert.equal(netFetchMock.mock.calls.length, 3); |
| 233 | }).pipe(Effect.provide(layerProtocol)), |
| 234 | ); |
| 235 | |
| 236 | it.effect("preserves protocol registration failures", () => |
| 237 | Effect.gen(function* () { |
| 238 | const cause = new Error("protocol registration failed"); |
| 239 | handleMock.mockImplementationOnce(() => { |
| 240 | throw cause; |
| 241 | }); |
| 242 | |
| 243 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 244 | const error = yield* Effect.scoped( |
| 245 | protocol.registerDesktopProtocol({ |
| 246 | scheme: "t3code-dev", |
| 247 | targetOrigin: new URL("http://127.0.0.1:3773/"), |
| 248 | clerkFrontendApiHostname: undefined, |
| 249 | }), |
| 250 | ).pipe(Effect.flip); |
| 251 | |
| 252 | assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError); |
| 253 | assert.equal(error.scheme, "t3code-dev"); |
| 254 | assert.strictEqual(error.cause, cause); |
| 255 | assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".'); |
| 256 | }).pipe(Effect.provide(layerProtocol)), |
| 257 | ); |
| 258 | |
| 259 | it.effect("preserves protocol unregistration failures", () => |
| 260 | Effect.gen(function* () { |
| 261 | const cause = new Error("protocol unregistration failed"); |
| 262 | unhandleMock.mockImplementationOnce(() => { |
| 263 | throw cause; |
| 264 | }); |
| 265 | |
| 266 | const protocol = yield* ElectronProtocol.ElectronProtocol; |
| 267 | const exit = yield* Effect.exit( |
| 268 | Effect.scoped( |
| 269 | protocol.registerDesktopProtocol({ |
| 270 | scheme: "t3code", |
| 271 | targetOrigin: new URL("http://127.0.0.1:3773/"), |
| 272 | clerkFrontendApiHostname: undefined, |
| 273 | }), |
| 274 | ), |
| 275 | ); |
| 276 | |
| 277 | assert.equal(exit._tag, "Failure"); |
| 278 | if (exit._tag === "Failure") { |
| 279 | const error = Cause.squash(exit.cause); |
| 280 | assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError); |
| 281 | assert.equal(error.scheme, "t3code"); |
| 282 | assert.strictEqual(error.cause, cause); |
| 283 | assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".'); |
| 284 | } |
| 285 | }).pipe(Effect.provide(layerProtocol)), |
| 286 | ); |
| 287 | |
| 288 | it("keeps executable sources host-restricted while allowing runtime network resources", () => { |
| 289 | const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ |
| 290 | scheme: "t3code", |
| 291 | targetOrigin: new URL("http://127.0.0.1:3773/"), |
| 292 | clerkFrontendApiHostname: "clerk.t3.codes", |
| 293 | }); |
| 294 | const directives = Object.fromEntries( |
| 295 | policy.split("; ").map((directive) => { |
| 296 | const [name, ...sources] = directive.split(" "); |
| 297 | return [name, sources]; |
| 298 | }), |
| 299 | ); |
| 300 | |
| 301 | assert.deepEqual(directives["script-src"], [ |
| 302 | "'self'", |
| 303 | "'unsafe-inline'", |
| 304 | "'wasm-unsafe-eval'", |
| 305 | "https://clerk.t3.codes", |
| 306 | "https://challenges.cloudflare.com", |
| 307 | ]); |
| 308 | assert.deepEqual(directives["connect-src"], [ |
| 309 | "'self'", |
| 310 | "blob:", |
| 311 | "http:", |
| 312 | "https:", |
| 313 | "ws:", |
| 314 | "wss:", |
| 315 | ]); |
| 316 | assert.deepEqual(directives["img-src"], [ |
| 317 | "'self'", |
| 318 | "t3code:", |
| 319 | "blob:", |
| 320 | "data:", |
| 321 | "http:", |
| 322 | "https:", |
| 323 | ]); |
| 324 | assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]); |
| 325 | assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]); |
| 326 | assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); |
| 327 | }); |
| 328 | }); |