this repo has no description
at main 15 kB view raw
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});