1import {
2 BearerConnectionCredential,
3 BearerConnectionProfile,
4 BearerConnectionRegistration,
5 BearerConnectionTarget,
6 CredentialStore,
7 HubConnectionCredential,
8 ConnectionTransientError,
9 PrimaryConnectionTarget,
10} from "@t3tools/client-runtime/connection";
11import { EnvironmentId, ThreadId } from "@t3tools/contracts";
12import { ConnectionCatalogDocument, Persistence } from "@t3tools/client-runtime/platform";
13import { describe, expect, it } from "@effect/vitest";
14import * as Effect from "effect/Effect";
15import * as Deferred from "effect/Deferred";
16import * as Option from "effect/Option";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import { afterEach, vi } from "vite-plus/test";
20
21import * as ConnectionStorage from "./storage";
22
23const emptyCatalog = {
24 schemaVersion: 1,
25 targets: [],
26 profiles: [],
27 credentials: [],
28 remoteDpopTokens: [],
29 disabledEnvironmentIds: [],
30} as const;
31const decodeCatalog = Schema.decodeUnknownSync(Schema.fromJsonString(ConnectionCatalogDocument));
32const encodeCatalog = Schema.encodeSync(Schema.fromJsonString(ConnectionCatalogDocument));
33
34afterEach(() => {
35 vi.unstubAllGlobals();
36 vi.restoreAllMocks();
37});
38
39describe("ConnectionStorage.makeCatalogStore", () => {
40 it.effect("restores hub route pointers and the single bearer after reopening storage", () =>
41 Effect.gen(function* () {
42 let saved: string | null = null;
43 vi.stubGlobal("window", {
44 desktopBridge: {
45 getConnectionCatalog: async () => saved,
46 setConnectionCatalog: async (raw: string) => {
47 saved = raw;
48 return true;
49 },
50 },
51 });
52 const connectionId = "hub:hub:source";
53 const hubCredentialId = "hub-session:hub";
54 const credential = new HubConnectionCredential({ connectionId: hubCredentialId });
55 const bearer = new BearerConnectionCredential({ token: "single-hub-secret" });
56 const registration = new BearerConnectionRegistration({
57 target: new BearerConnectionTarget({
58 connectionId,
59 environmentId: EnvironmentId.make("source"),
60 label: "Source",
61 }),
62 profile: new BearerConnectionProfile({
63 connectionId,
64 environmentId: EnvironmentId.make("source"),
65 label: "Source",
66 httpBaseUrl: "https://hub.example/environments/source/",
67 wsBaseUrl: "wss://hub.example/environments/source/",
68 }),
69 credential,
70 });
71 yield* Effect.gen(function* () {
72 const credentials = yield* CredentialStore.ConnectionCredentialStore;
73 const registrations = yield* Persistence.ConnectionRegistrationStore;
74 yield* credentials.put(hubCredentialId, bearer);
75 yield* registrations.register(registration, [registration.target]);
76 expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential));
77 }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped);
78
79 yield* Effect.gen(function* () {
80 const credentials = yield* CredentialStore.ConnectionCredentialStore;
81 expect(yield* credentials.get(connectionId)).toEqual(Option.some(credential));
82 expect(yield* credentials.get(hubCredentialId)).toEqual(Option.some(bearer));
83 }).pipe(Effect.provide(ConnectionStorage.layer), Effect.scoped);
84 expect(JSON.parse(saved!).credentials).toEqual([
85 {
86 connectionId: hubCredentialId,
87 credential: { _tag: "BearerConnectionCredential", token: bearer.token },
88 },
89 {
90 connectionId,
91 credential: { _tag: "HubConnectionCredential", connectionId: hubCredentialId },
92 },
93 ]);
94 }),
95 );
96
97 it.effect("quarantines malformed catalogs and starts from an empty document", () =>
98 Effect.gen(function* () {
99 const writes: string[] = [];
100 const quarantined: string[] = [];
101 const store = yield* ConnectionStorage.makeCatalogStore({
102 read: Effect.succeed("{not-json"),
103 write: (raw) => Effect.sync(() => writes.push(raw)),
104 quarantine: (raw) => Effect.sync(() => quarantined.push(raw)),
105 });
106
107 expect(yield* store.read).toEqual(emptyCatalog);
108 expect(quarantined).toEqual(["{not-json"]);
109 expect(writes).toHaveLength(1);
110 expect(decodeCatalog(writes[0]!)).toEqual(emptyCatalog);
111 }),
112 );
113
114 it.effect("does not hide catalog read failures", () =>
115 Effect.gen(function* () {
116 const failure = new ConnectionTransientError({
117 reason: "remote-unavailable",
118 detail: "permission denied",
119 });
120 const store = yield* ConnectionStorage.makeCatalogStore({
121 read: Effect.fail(failure),
122 write: () => Effect.void,
123 });
124
125 expect(yield* Effect.flip(store.read)).toBe(failure);
126 }),
127 );
128});
129
130const fixedHandle = (database: IDBDatabase) => ({
131 get: Effect.succeed(database),
132 invalidate: () => Effect.void,
133});
134
135describe("ConnectionStorage.makeCatalogBackend", () => {
136 it.effect("reports a closed IndexedDB connection as a typed read and write failure", () =>
137 Effect.gen(function* () {
138 vi.stubGlobal("window", {});
139 const database = {
140 transaction: () => {
141 throw new DOMException("The database connection is closing.", "InvalidStateError");
142 },
143 } as unknown as IDBDatabase;
144 const backend = ConnectionStorage.makeCatalogBackend(fixedHandle(database));
145
146 const readError = yield* Effect.flip(backend.read);
147 const writeError = yield* Effect.flip(backend.write("{}"));
148
149 expect(readError).toBeInstanceOf(ConnectionTransientError);
150 expect(readError.message).toContain("The database connection is closing.");
151 expect(writeError).toBeInstanceOf(ConnectionTransientError);
152 }),
153 );
154
155 it.effect("fails writes when desktop secure storage declines the catalog", () =>
156 Effect.gen(function* () {
157 const setConnectionCatalog = vi.fn().mockResolvedValue(false);
158 vi.stubGlobal("window", {
159 desktopBridge: {
160 getConnectionCatalog: vi.fn().mockResolvedValue(null),
161 setConnectionCatalog,
162 },
163 });
164 const backend = ConnectionStorage.makeCatalogBackend(fixedHandle({} as IDBDatabase));
165
166 const error = yield* backend.write("{}").pipe(Effect.flip);
167
168 expect(error).toBeInstanceOf(ConnectionTransientError);
169 expect(error.message).toContain("Desktop secure storage is unavailable");
170 expect(setConnectionCatalog).toHaveBeenCalledWith("{}");
171 }),
172 );
173
174 it.effect("fails IndexedDB writes whose commit aborts", () =>
175 Effect.gen(function* () {
176 vi.stubGlobal("window", {});
177 const transaction = Object.assign(new EventTarget(), {
178 error: null as DOMException | null,
179 objectStore: () => ({
180 put: () => {
181 // A failed commit aborts the transaction without an "error" event.
182 queueMicrotask(() => {
183 transaction.error = new DOMException("Quota exceeded", "QuotaExceededError");
184 transaction.dispatchEvent(new Event("abort"));
185 });
186 },
187 }),
188 });
189 const backend = ConnectionStorage.makeCatalogBackend(
190 fixedHandle({ transaction: () => transaction } as unknown as IDBDatabase),
191 );
192
193 const error = yield* backend.write("{}").pipe(Effect.flip);
194
195 expect(error.message).toContain("QuotaExceededError");
196 }),
197 );
198});
199
200describe("environment cache removal", () => {
201 it.effect("fails both removal operations when IndexedDB aborts their commits", () =>
202 Effect.gen(function* () {
203 vi.stubGlobal("window", {});
204 vi.stubGlobal("IDBKeyRange", { bound: () => ({}) });
205 const database = Object.assign(new EventTarget(), {
206 transaction: () => {
207 const transaction = Object.assign(new EventTarget(), {
208 error: new DOMException("Commit aborted", "AbortError"),
209 objectStore: () => ({
210 delete: () => queueMicrotask(() => transaction.dispatchEvent(new Event("abort"))),
211 openCursor: () => {
212 queueMicrotask(() => transaction.dispatchEvent(new Event("abort")));
213 return new EventTarget();
214 },
215 }),
216 });
217 return transaction;
218 },
219 close: vi.fn(),
220 }) as unknown as IDBDatabase;
221 const openRequest = Object.assign(new EventTarget(), { result: database, error: null });
222 vi.stubGlobal("indexedDB", {
223 open: () => {
224 queueMicrotask(() => openRequest.dispatchEvent(new Event("success")));
225 return openRequest;
226 },
227 });
228
229 const [threadError, refsError] = yield* Effect.gen(function* () {
230 const cache = yield* Persistence.EnvironmentCacheStore;
231 return [
232 yield* Effect.flip(
233 cache.removeThread(EnvironmentId.make("env"), ThreadId.make("thread")),
234 ),
235 yield* Effect.flip(cache.clearVcsRefs(EnvironmentId.make("env"))),
236 ] as const;
237 }).pipe(Effect.provide(ConnectionStorage.layer));
238
239 expect(threadError.message).toContain("Commit aborted");
240 expect(refsError.message).toContain("Commit aborted");
241 expect(database.close).toHaveBeenCalledOnce();
242 }),
243 );
244});
245
246describe("IndexedDB connection recovery", () => {
247 it.effect("reports an initial open failure from the cache operation", () =>
248 Effect.gen(function* () {
249 vi.stubGlobal("window", {});
250 const open = vi.fn(() => {
251 throw new DOMException("Storage is unavailable", "InvalidStateError");
252 });
253 vi.stubGlobal("indexedDB", { open });
254
255 yield* Effect.gen(function* () {
256 const cache = yield* Persistence.EnvironmentCacheStore;
257 expect(open).not.toHaveBeenCalled();
258 const error = yield* Effect.flip(
259 cache.loadThread(EnvironmentId.make("env"), ThreadId.make("thread")),
260 );
261 expect(error.message).toContain("Storage is unavailable");
262 }).pipe(Effect.provide(ConnectionStorage.layer));
263
264 expect(open).toHaveBeenCalledOnce();
265 }),
266 );
267
268 it.effect("reopens after a forced close and finalizes the current connection", () =>
269 Effect.gen(function* () {
270 vi.stubGlobal("window", {});
271 const makeDatabase = () =>
272 Object.assign(new EventTarget(), {
273 close: vi.fn(),
274 transaction: () => ({
275 objectStore: () => ({
276 get: () => {
277 const request = Object.assign(new EventTarget(), {
278 result: undefined,
279 error: null,
280 });
281 queueMicrotask(() => request.dispatchEvent(new Event("success")));
282 return request;
283 },
284 }),
285 }),
286 }) as unknown as IDBDatabase;
287 const first = makeDatabase();
288 const second = makeDatabase();
289 const databases = [first, second];
290 let openCount = 0;
291 const open = vi.fn(() => {
292 const request = Object.assign(new EventTarget(), {
293 result: databases[openCount++],
294 error: null,
295 });
296 queueMicrotask(() => request.dispatchEvent(new Event("success")));
297 return request;
298 });
299 vi.stubGlobal("indexedDB", { open });
300
301 yield* Effect.gen(function* () {
302 const cache = yield* Persistence.EnvironmentCacheStore;
303 const environmentId = EnvironmentId.make("env");
304 const threadId = ThreadId.make("thread");
305 expect(Option.isNone(yield* cache.loadThread(environmentId, threadId))).toBe(true);
306 expect(open).toHaveBeenCalledTimes(1);
307
308 first.dispatchEvent(new Event("close"));
309 const recovered = yield* Effect.all(
310 [cache.loadThread(environmentId, threadId), cache.loadThread(environmentId, threadId)],
311 { concurrency: 2 },
312 );
313 expect(recovered.every(Option.isNone)).toBe(true);
314 expect(open).toHaveBeenCalledTimes(2);
315 }).pipe(Effect.provide(ConnectionStorage.layer));
316
317 expect(first.close).not.toHaveBeenCalled();
318 expect(second.close).toHaveBeenCalledOnce();
319 }),
320 );
321});
322
323describe("IndexedDB connection closed without a close event", () => {
324 it.effect("reopens and retries the failing operation once", () =>
325 Effect.gen(function* () {
326 vi.stubGlobal("window", {});
327 const closing = Object.assign(new EventTarget(), {
328 close: vi.fn(),
329 transaction: () => {
330 // Chromium force-closed this connection; this tab never saw "close".
331 throw new DOMException("The database connection is closing.", "InvalidStateError");
332 },
333 }) as unknown as IDBDatabase;
334 const fresh = Object.assign(new EventTarget(), {
335 close: vi.fn(),
336 transaction: () => ({
337 objectStore: () => ({
338 get: () => {
339 const request = Object.assign(new EventTarget(), { result: undefined, error: null });
340 queueMicrotask(() => request.dispatchEvent(new Event("success")));
341 return request;
342 },
343 }),
344 }),
345 }) as unknown as IDBDatabase;
346 const databases = [closing, fresh];
347 let openCount = 0;
348 const open = vi.fn(() => {
349 const request = Object.assign(new EventTarget(), {
350 result: databases[openCount++],
351 error: null,
352 });
353 queueMicrotask(() => request.dispatchEvent(new Event("success")));
354 return request;
355 });
356 vi.stubGlobal("indexedDB", { open });
357
358 yield* Effect.gen(function* () {
359 const cache = yield* Persistence.EnvironmentCacheStore;
360 const loaded = yield* cache.loadThread(EnvironmentId.make("env"), ThreadId.make("thread"));
361 expect(Option.isNone(loaded)).toBe(true);
362 expect(open).toHaveBeenCalledTimes(2);
363 }).pipe(Effect.provide(ConnectionStorage.layer));
364 }),
365 );
366});
367
368describe("browser GitHub routing permissions", () => {
369 it.effect("revokes across runtimes before storage events and resists stale catalog writes", () =>
370 Effect.gen(function* () {
371 const values = new Map<string, string>();
372 const localStorage: Storage = {
373 get length() {
374 return values.size;
375 },
376 key: (index) => [...values.keys()][index] ?? null,
377 getItem: (key) => values.get(key) ?? null,
378 setItem: (key, value) => {
379 values.set(key, value);
380 },
381 removeItem: (key) => {
382 values.delete(key);
383 },
384 clear: () => {
385 values.clear();
386 },
387 };
388 const firstBrowser = Object.assign(new EventTarget(), { localStorage });
389 const secondBrowser = Object.assign(new EventTarget(), { localStorage });
390 const first = ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser);
391 const second = ConnectionStorage.makeBrowserGitHubRoutingPermissions(secondBrowser);
392 const entry = {
393 target: new PrimaryConnectionTarget({
394 environmentId: EnvironmentId.make("first"),
395 label: "First",
396 httpBaseUrl: "http://localhost:3000",
397 wsBaseUrl: "ws://localhost:3000",
398 }),
399 profile: Option.none(),
400 enabled: true,
401 };
402 const other = {
403 ...entry,
404 target: new PrimaryConnectionTarget({
405 ...entry.target,
406 environmentId: EnvironmentId.make("second"),
407 }),
408 };
409 expect(yield* first.get(entry)).toBe("off");
410 yield* first.set(entry, "read-write");
411 expect(yield* second.get(entry)).toBe("read-write");
412 const oldPermissions = Option.getOrThrow(yield* Stream.runHead(first.changes));
413 const staleCatalog = yield* ConnectionStorage.makeCatalogStore({
414 read: Effect.succeed(
415 encodeCatalog({ ...emptyCatalog, githubRoutingPermissions: oldPermissions }),
416 ),
417 write: () => Effect.void,
418 });
419 yield* staleCatalog.read;
420 const listening = yield* Deferred.make<void>();
421 const revoked = yield* Deferred.make<void>();
422 yield* second.changes.pipe(
423 Stream.runForEach((permissions) =>
424 Deferred.succeed(permissions.length > 0 ? listening : revoked, undefined),
425 ),
426 Effect.forkChild,
427 );
428 yield* Deferred.await(listening);
429
430 yield* first.set(entry, "off");
431 expect(yield* second.get(entry)).toBe("off");
432 secondBrowser.dispatchEvent(Object.assign(new Event("storage"), { key: null }));
433 yield* Deferred.await(revoked);
434 yield* second.set(other, "read");
435 yield* staleCatalog.update((document) => ({ ...document, accountId: "updated" }));
436 expect(yield* second.get(entry)).toBe("off");
437 expect(yield* first.get(other)).toBe("read");
438 expect(
439 yield* ConnectionStorage.makeBrowserGitHubRoutingPermissions(firstBrowser).get(entry),
440 ).toBe("off");
441
442 yield* first.set(entry, "read-write");
443 yield* second.forget(entry.target.environmentId);
444 expect(yield* first.get(entry)).toBe("off");
445 expect(yield* first.get(other)).toBe("read");
446 vi.spyOn(localStorage, "setItem").mockImplementation(() => {
447 throw new Error("Storage unavailable");
448 });
449 expect(yield* first.set(entry, "read-write").pipe(Effect.flip)).toBeInstanceOf(
450 ConnectionTransientError,
451 );
452 expect(yield* second.get(entry)).toBe("off");
453 }).pipe(Effect.scoped),
454 );
455});