this repo has no description
1import { beforeEach, describe, expect, it, vi } from "vitest";
2import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte";
3
4const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
5
6describe("migration/offline-flow", () => {
7 beforeEach(() => {
8 localStorage.removeItem(OFFLINE_STORAGE_KEY);
9 vi.restoreAllMocks();
10 });
11
12 describe("createOfflineInboundMigrationFlow", () => {
13 it("creates flow with initial state", () => {
14 const flow = createOfflineInboundMigrationFlow();
15
16 expect(flow.state.direction).toBe("offline-inbound");
17 expect(flow.state.step).toBe("welcome");
18 expect(flow.state.userDid).toBe("");
19 expect(flow.state.carFile).toBeNull();
20 expect(flow.state.carFileName).toBe("");
21 expect(flow.state.carSizeBytes).toBe(0);
22 expect(flow.state.rotationKey).toBe("");
23 expect(flow.state.rotationKeyDidKey).toBe("");
24 expect(flow.state.targetHandle).toBe("");
25 expect(flow.state.targetEmail).toBe("");
26 expect(flow.state.targetPassword).toBe("");
27 expect(flow.state.inviteCode).toBe("");
28 expect(flow.state.localAccessToken).toBeNull();
29 expect(flow.state.localRefreshToken).toBeNull();
30 expect(flow.state.error).toBeNull();
31 });
32
33 it("initializes progress correctly", () => {
34 const flow = createOfflineInboundMigrationFlow();
35
36 expect(flow.state.progress.repoExported).toBe(false);
37 expect(flow.state.progress.repoImported).toBe(false);
38 expect(flow.state.progress.blobsTotal).toBe(0);
39 expect(flow.state.progress.blobsMigrated).toBe(0);
40 expect(flow.state.progress.blobsFailed).toEqual([]);
41 expect(flow.state.progress.prefsMigrated).toBe(false);
42 expect(flow.state.progress.plcSigned).toBe(false);
43 expect(flow.state.progress.activated).toBe(false);
44 expect(flow.state.progress.deactivated).toBe(false);
45 expect(flow.state.progress.currentOperation).toBe("");
46 });
47 });
48
49 describe("setUserDid", () => {
50 it("sets the user DID", () => {
51 const flow = createOfflineInboundMigrationFlow();
52
53 flow.setUserDid("did:plc:abc123");
54
55 expect(flow.state.userDid).toBe("did:plc:abc123");
56 });
57
58 it("saves state to localStorage", () => {
59 const flow = createOfflineInboundMigrationFlow();
60
61 flow.setUserDid("did:plc:xyz789");
62
63 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
64 expect(stored.userDid).toBe("did:plc:xyz789");
65 });
66 });
67
68 describe("setCarFile", () => {
69 it("sets CAR file data", () => {
70 const flow = createOfflineInboundMigrationFlow();
71 const carData = new Uint8Array([1, 2, 3, 4, 5]);
72
73 flow.setCarFile(carData, "repo.car");
74
75 expect(flow.state.carFile).toEqual(carData);
76 expect(flow.state.carFileName).toBe("repo.car");
77 expect(flow.state.carSizeBytes).toBe(5);
78 });
79
80 it("saves file metadata to localStorage (not file content)", () => {
81 const flow = createOfflineInboundMigrationFlow();
82 const carData = new Uint8Array([1, 2, 3, 4, 5]);
83
84 flow.setCarFile(carData, "backup.car");
85
86 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
87 expect(stored.carFileName).toBe("backup.car");
88 expect(stored.carSizeBytes).toBe(5);
89 });
90 });
91
92 describe("setRotationKey", () => {
93 it("sets the rotation key", () => {
94 const flow = createOfflineInboundMigrationFlow();
95
96 flow.setRotationKey("abc123privatekey");
97
98 expect(flow.state.rotationKey).toBe("abc123privatekey");
99 });
100
101 it("does not save rotation key to localStorage (security)", () => {
102 const flow = createOfflineInboundMigrationFlow();
103
104 flow.setRotationKey("supersecretkey");
105
106 const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
107 if (stored) {
108 const parsed = JSON.parse(stored);
109 expect(parsed.rotationKey).toBeUndefined();
110 }
111 });
112 });
113
114 describe("setTargetHandle", () => {
115 it("sets the target handle", () => {
116 const flow = createOfflineInboundMigrationFlow();
117
118 flow.setTargetHandle("alice.example.com");
119
120 expect(flow.state.targetHandle).toBe("alice.example.com");
121 });
122
123 it("saves to localStorage", () => {
124 const flow = createOfflineInboundMigrationFlow();
125
126 flow.setTargetHandle("bob.example.com");
127
128 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
129 expect(stored.targetHandle).toBe("bob.example.com");
130 });
131 });
132
133 describe("setTargetEmail", () => {
134 it("sets the target email", () => {
135 const flow = createOfflineInboundMigrationFlow();
136
137 flow.setTargetEmail("alice@example.com");
138
139 expect(flow.state.targetEmail).toBe("alice@example.com");
140 });
141
142 it("saves to localStorage", () => {
143 const flow = createOfflineInboundMigrationFlow();
144
145 flow.setTargetEmail("bob@example.com");
146
147 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
148 expect(stored.targetEmail).toBe("bob@example.com");
149 });
150 });
151
152 describe("setTargetPassword", () => {
153 it("sets the target password", () => {
154 const flow = createOfflineInboundMigrationFlow();
155
156 flow.setTargetPassword("securepassword123");
157
158 expect(flow.state.targetPassword).toBe("securepassword123");
159 });
160
161 it("does not save password to localStorage (security)", () => {
162 const flow = createOfflineInboundMigrationFlow();
163 flow.setUserDid("did:plc:test");
164
165 flow.setTargetPassword("mypassword");
166
167 const stored = localStorage.getItem(OFFLINE_STORAGE_KEY);
168 if (stored) {
169 const parsed = JSON.parse(stored);
170 expect(parsed.targetPassword).toBeUndefined();
171 }
172 });
173 });
174
175 describe("setInviteCode", () => {
176 it("sets the invite code", () => {
177 const flow = createOfflineInboundMigrationFlow();
178
179 flow.setInviteCode("invite-abc123");
180
181 expect(flow.state.inviteCode).toBe("invite-abc123");
182 });
183 });
184
185 describe("setStep", () => {
186 it("changes the current step", () => {
187 const flow = createOfflineInboundMigrationFlow();
188
189 flow.setStep("provide-did");
190
191 expect(flow.state.step).toBe("provide-did");
192 });
193
194 it("clears error when changing step", () => {
195 const flow = createOfflineInboundMigrationFlow();
196 flow.setError("Previous error");
197
198 flow.setStep("upload-car");
199
200 expect(flow.state.error).toBeNull();
201 });
202
203 it("saves step to localStorage", () => {
204 const flow = createOfflineInboundMigrationFlow();
205
206 flow.setStep("provide-rotation-key");
207
208 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
209 expect(stored.step).toBe("provide-rotation-key");
210 });
211 });
212
213 describe("setError", () => {
214 it("sets the error message", () => {
215 const flow = createOfflineInboundMigrationFlow();
216
217 flow.setError("Something went wrong");
218
219 expect(flow.state.error).toBe("Something went wrong");
220 });
221
222 it("saves error to localStorage", () => {
223 const flow = createOfflineInboundMigrationFlow();
224
225 flow.setError("Connection failed");
226
227 const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!);
228 expect(stored.lastError).toBe("Connection failed");
229 });
230 });
231
232 describe("setProgress", () => {
233 it("updates progress fields", () => {
234 const flow = createOfflineInboundMigrationFlow();
235
236 flow.setProgress({
237 repoImported: true,
238 currentOperation: "Importing...",
239 });
240
241 expect(flow.state.progress.repoImported).toBe(true);
242 expect(flow.state.progress.currentOperation).toBe("Importing...");
243 });
244
245 it("preserves other progress fields", () => {
246 const flow = createOfflineInboundMigrationFlow();
247 flow.setProgress({ repoExported: true });
248
249 flow.setProgress({ repoImported: true });
250
251 expect(flow.state.progress.repoExported).toBe(true);
252 expect(flow.state.progress.repoImported).toBe(true);
253 });
254 });
255
256 describe("reset", () => {
257 it("resets state to initial values", () => {
258 const flow = createOfflineInboundMigrationFlow();
259 flow.setUserDid("did:plc:abc123");
260 flow.setTargetHandle("alice.example.com");
261 flow.setStep("review");
262
263 flow.reset();
264
265 expect(flow.state.step).toBe("welcome");
266 expect(flow.state.userDid).toBe("");
267 expect(flow.state.targetHandle).toBe("");
268 });
269
270 it("clears localStorage", () => {
271 const flow = createOfflineInboundMigrationFlow();
272 flow.setUserDid("did:plc:abc123");
273 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
274
275 flow.reset();
276
277 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
278 });
279 });
280
281 describe("clearOfflineState", () => {
282 it("removes state from localStorage", () => {
283 const flow = createOfflineInboundMigrationFlow();
284 flow.setUserDid("did:plc:abc123");
285 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
286
287 flow.clearOfflineState();
288
289 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
290 });
291 });
292
293 describe("tryResume", () => {
294 it("returns false when no stored state", () => {
295 const flow = createOfflineInboundMigrationFlow();
296
297 const result = flow.tryResume();
298
299 expect(result).toBe(false);
300 });
301
302 it("restores state from localStorage", () => {
303 const storedState = {
304 version: 1,
305 step: "choose-handle",
306 startedAt: new Date().toISOString(),
307 userDid: "did:plc:restored123",
308 carFileName: "backup.car",
309 carSizeBytes: 12345,
310 rotationKeyDidKey: "did:key:z123abc",
311 targetHandle: "restored.example.com",
312 targetEmail: "restored@example.com",
313 progress: {
314 accountCreated: true,
315 repoImported: false,
316 plcSigned: false,
317 activated: false,
318 },
319 };
320 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
321
322 const flow = createOfflineInboundMigrationFlow();
323 const result = flow.tryResume();
324
325 expect(result).toBe(true);
326 expect(flow.state.step).toBe("choose-handle");
327 expect(flow.state.userDid).toBe("did:plc:restored123");
328 expect(flow.state.carFileName).toBe("backup.car");
329 expect(flow.state.carSizeBytes).toBe(12345);
330 expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc");
331 expect(flow.state.targetHandle).toBe("restored.example.com");
332 expect(flow.state.targetEmail).toBe("restored@example.com");
333 expect(flow.state.progress.repoExported).toBe(true);
334 });
335
336 it("restores error from stored state", () => {
337 const storedState = {
338 version: 1,
339 step: "error",
340 startedAt: new Date().toISOString(),
341 userDid: "did:plc:abc",
342 carFileName: "",
343 carSizeBytes: 0,
344 rotationKeyDidKey: "",
345 targetHandle: "",
346 targetEmail: "",
347 progress: {
348 accountCreated: false,
349 repoImported: false,
350 plcSigned: false,
351 activated: false,
352 },
353 lastError: "Previous migration failed",
354 };
355 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
356
357 const flow = createOfflineInboundMigrationFlow();
358 flow.tryResume();
359
360 expect(flow.state.error).toBe("Previous migration failed");
361 });
362
363 it("returns false and clears for incompatible version", () => {
364 const storedState = {
365 version: 999,
366 step: "review",
367 userDid: "did:plc:abc",
368 };
369 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
370
371 const flow = createOfflineInboundMigrationFlow();
372 const result = flow.tryResume();
373
374 expect(result).toBe(false);
375 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
376 });
377
378 it("returns false and clears for expired state (> 24 hours)", () => {
379 const expiredState = {
380 version: 1,
381 step: "review",
382 startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
383 userDid: "did:plc:expired",
384 carFileName: "old.car",
385 carSizeBytes: 100,
386 rotationKeyDidKey: "",
387 targetHandle: "old.example.com",
388 targetEmail: "old@example.com",
389 progress: {
390 accountCreated: false,
391 repoImported: false,
392 plcSigned: false,
393 activated: false,
394 },
395 };
396 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState));
397
398 const flow = createOfflineInboundMigrationFlow();
399 const result = flow.tryResume();
400
401 expect(result).toBe(false);
402 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
403 });
404
405 it("returns false and clears for invalid JSON", () => {
406 localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json");
407
408 const flow = createOfflineInboundMigrationFlow();
409 const result = flow.tryResume();
410
411 expect(result).toBe(false);
412 expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull();
413 });
414
415 it("accepts state within 24 hours", () => {
416 const recentState = {
417 version: 1,
418 step: "review",
419 startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(),
420 userDid: "did:plc:recent",
421 carFileName: "recent.car",
422 carSizeBytes: 500,
423 rotationKeyDidKey: "did:key:zRecent",
424 targetHandle: "recent.example.com",
425 targetEmail: "recent@example.com",
426 progress: {
427 accountCreated: true,
428 repoImported: true,
429 plcSigned: false,
430 activated: false,
431 },
432 };
433 localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState));
434
435 const flow = createOfflineInboundMigrationFlow();
436 const result = flow.tryResume();
437
438 expect(result).toBe(true);
439 expect(flow.state.userDid).toBe("did:plc:recent");
440 });
441 });
442
443 describe("loadLocalServerInfo", () => {
444 function createMockResponse(data: unknown) {
445 const jsonStr = JSON.stringify(data);
446 return new Response(jsonStr, {
447 status: 200,
448 headers: { "Content-Type": "application/json" },
449 });
450 }
451
452 it("fetches server description", async () => {
453 const mockServerInfo = {
454 did: "did:web:example.com",
455 availableUserDomains: ["example.com"],
456 inviteCodeRequired: false,
457 };
458
459 globalThis.fetch = vi.fn().mockResolvedValue(
460 createMockResponse(mockServerInfo),
461 );
462
463 const flow = createOfflineInboundMigrationFlow();
464 const result = await flow.loadLocalServerInfo();
465
466 expect(result).toEqual(mockServerInfo);
467 expect(fetch).toHaveBeenCalledWith(
468 expect.stringContaining("com.atproto.server.describeServer"),
469 expect.any(Object),
470 );
471 });
472
473 it("caches server info", async () => {
474 const mockServerInfo = {
475 did: "did:web:example.com",
476 availableUserDomains: ["example.com"],
477 inviteCodeRequired: false,
478 };
479
480 globalThis.fetch = vi.fn().mockResolvedValue(
481 createMockResponse(mockServerInfo),
482 );
483
484 const flow = createOfflineInboundMigrationFlow();
485 await flow.loadLocalServerInfo();
486 await flow.loadLocalServerInfo();
487
488 expect(fetch).toHaveBeenCalledTimes(1);
489 });
490 });
491});