Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
15 kB · 491 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492import { beforeEach, describe, expect, it, vi } from "vitest";import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte.ts";
const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state";
describe("migration/offline-flow", () => { beforeEach(() => { localStorage.removeItem(OFFLINE_STORAGE_KEY); vi.restoreAllMocks(); });
describe("createOfflineInboundMigrationFlow", () => { it("creates flow with initial state", () => { const flow = createOfflineInboundMigrationFlow();
expect(flow.state.direction).toBe("offline-inbound"); expect(flow.state.step).toBe("welcome"); expect(flow.state.userDid).toBe(""); expect(flow.state.carFile).toBeNull(); expect(flow.state.carFileName).toBe(""); expect(flow.state.carSizeBytes).toBe(0); expect(flow.state.rotationKey).toBe(""); expect(flow.state.rotationKeyDidKey).toBe(""); expect(flow.state.targetHandle).toBe(""); expect(flow.state.targetEmail).toBe(""); expect(flow.state.targetPassword).toBe(""); expect(flow.state.inviteCode).toBe(""); expect(flow.state.localAccessToken).toBeNull(); expect(flow.state.localRefreshToken).toBeNull(); expect(flow.state.error).toBeNull(); });
it("initializes progress correctly", () => { const flow = createOfflineInboundMigrationFlow();
expect(flow.state.progress.repoExported).toBe(false); expect(flow.state.progress.repoImported).toBe(false); expect(flow.state.progress.blobsTotal).toBe(0); expect(flow.state.progress.blobsMigrated).toBe(0); expect(flow.state.progress.blobsFailed).toEqual([]); expect(flow.state.progress.prefsMigrated).toBe(false); expect(flow.state.progress.plcSigned).toBe(false); expect(flow.state.progress.activated).toBe(false); expect(flow.state.progress.deactivated).toBe(false); expect(flow.state.progress.currentOperation).toBe(""); }); });
describe("setUserDid", () => { it("sets the user DID", () => { const flow = createOfflineInboundMigrationFlow();
flow.setUserDid("did:plc:abc123");
expect(flow.state.userDid).toBe("did:plc:abc123"); });
it("saves state to localStorage", () => { const flow = createOfflineInboundMigrationFlow();
flow.setUserDid("did:plc:xyz789");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.userDid).toBe("did:plc:xyz789"); }); });
describe("setCarFile", () => { it("sets CAR file data", () => { const flow = createOfflineInboundMigrationFlow(); const carData = new Uint8Array([1, 2, 3, 4, 5]);
flow.setCarFile(carData, "repo.car");
expect(flow.state.carFile).toEqual(carData); expect(flow.state.carFileName).toBe("repo.car"); expect(flow.state.carSizeBytes).toBe(5); });
it("saves file metadata to localStorage (not file content)", () => { const flow = createOfflineInboundMigrationFlow(); const carData = new Uint8Array([1, 2, 3, 4, 5]);
flow.setCarFile(carData, "backup.car");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.carFileName).toBe("backup.car"); expect(stored.carSizeBytes).toBe(5); }); });
describe("setRotationKey", () => { it("sets the rotation key", () => { const flow = createOfflineInboundMigrationFlow();
flow.setRotationKey("abc123privatekey");
expect(flow.state.rotationKey).toBe("abc123privatekey"); });
it("does not save rotation key to localStorage (security)", () => { const flow = createOfflineInboundMigrationFlow();
flow.setRotationKey("supersecretkey");
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); expect(parsed.rotationKey).toBeUndefined(); } }); });
describe("setTargetHandle", () => { it("sets the target handle", () => { const flow = createOfflineInboundMigrationFlow();
flow.setTargetHandle("alice.example.com");
expect(flow.state.targetHandle).toBe("alice.example.com"); });
it("saves to localStorage", () => { const flow = createOfflineInboundMigrationFlow();
flow.setTargetHandle("bob.example.com");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.targetHandle).toBe("bob.example.com"); }); });
describe("setTargetEmail", () => { it("sets the target email", () => { const flow = createOfflineInboundMigrationFlow();
flow.setTargetEmail("alice@example.com");
expect(flow.state.targetEmail).toBe("alice@example.com"); });
it("saves to localStorage", () => { const flow = createOfflineInboundMigrationFlow();
flow.setTargetEmail("bob@example.com");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.targetEmail).toBe("bob@example.com"); }); });
describe("setTargetPassword", () => { it("sets the target password", () => { const flow = createOfflineInboundMigrationFlow();
flow.setTargetPassword("securepassword123");
expect(flow.state.targetPassword).toBe("securepassword123"); });
it("does not save password to localStorage (security)", () => { const flow = createOfflineInboundMigrationFlow(); flow.setUserDid("did:plc:test");
flow.setTargetPassword("mypassword");
const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); expect(parsed.targetPassword).toBeUndefined(); } }); });
describe("setInviteCode", () => { it("sets the invite code", () => { const flow = createOfflineInboundMigrationFlow();
flow.setInviteCode("invite-abc123");
expect(flow.state.inviteCode).toBe("invite-abc123"); }); });
describe("setStep", () => { it("changes the current step", () => { const flow = createOfflineInboundMigrationFlow();
flow.setStep("provide-did");
expect(flow.state.step).toBe("provide-did"); });
it("clears error when changing step", () => { const flow = createOfflineInboundMigrationFlow(); flow.setError("Previous error");
flow.setStep("upload-car");
expect(flow.state.error).toBeNull(); });
it("saves step to localStorage", () => { const flow = createOfflineInboundMigrationFlow();
flow.setStep("provide-rotation-key");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.step).toBe("provide-rotation-key"); }); });
describe("setError", () => { it("sets the error message", () => { const flow = createOfflineInboundMigrationFlow();
flow.setError("Something went wrong");
expect(flow.state.error).toBe("Something went wrong"); });
it("saves error to localStorage", () => { const flow = createOfflineInboundMigrationFlow();
flow.setError("Connection failed");
const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); expect(stored.lastError).toBe("Connection failed"); }); });
describe("setProgress", () => { it("updates progress fields", () => { const flow = createOfflineInboundMigrationFlow();
flow.setProgress({ repoImported: true, currentOperation: "Importing...", });
expect(flow.state.progress.repoImported).toBe(true); expect(flow.state.progress.currentOperation).toBe("Importing..."); });
it("preserves other progress fields", () => { const flow = createOfflineInboundMigrationFlow(); flow.setProgress({ repoExported: true });
flow.setProgress({ repoImported: true });
expect(flow.state.progress.repoExported).toBe(true); expect(flow.state.progress.repoImported).toBe(true); }); });
describe("reset", () => { it("resets state to initial values", () => { const flow = createOfflineInboundMigrationFlow(); flow.setUserDid("did:plc:abc123"); flow.setTargetHandle("alice.example.com"); flow.setStep("review");
flow.reset();
expect(flow.state.step).toBe("welcome"); expect(flow.state.userDid).toBe(""); expect(flow.state.targetHandle).toBe(""); });
it("clears localStorage", () => { const flow = createOfflineInboundMigrationFlow(); flow.setUserDid("did:plc:abc123"); expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
flow.reset();
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); }); });
describe("clearOfflineState", () => { it("removes state from localStorage", () => { const flow = createOfflineInboundMigrationFlow(); flow.setUserDid("did:plc:abc123"); expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull();
flow.clearOfflineState();
expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); }); });
describe("tryResume", () => { it("returns false when no stored state", () => { const flow = createOfflineInboundMigrationFlow();
const result = flow.tryResume();
expect(result).toBe(false); });
it("restores state from localStorage", () => { const storedState = { version: 1, step: "choose-handle", startedAt: new Date().toISOString(), userDid: "did:plc:restored123", carFileName: "backup.car", carSizeBytes: 12345, rotationKeyDidKey: "did:key:z123abc", targetHandle: "restored.example.com", targetEmail: "restored@example.com", progress: { accountCreated: true, repoImported: false, plcSigned: false, activated: false, }, }; localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
const flow = createOfflineInboundMigrationFlow(); const result = flow.tryResume();
expect(result).toBe(true); expect(flow.state.step).toBe("choose-handle"); expect(flow.state.userDid).toBe("did:plc:restored123"); expect(flow.state.carFileName).toBe("backup.car"); expect(flow.state.carSizeBytes).toBe(12345); expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc"); expect(flow.state.targetHandle).toBe("restored.example.com"); expect(flow.state.targetEmail).toBe("restored@example.com"); expect(flow.state.progress.repoExported).toBe(true); });
it("restores error from stored state", () => { const storedState = { version: 1, step: "error", startedAt: new Date().toISOString(), userDid: "did:plc:abc", carFileName: "", carSizeBytes: 0, rotationKeyDidKey: "", targetHandle: "", targetEmail: "", progress: { accountCreated: false, repoImported: false, plcSigned: false, activated: false, }, lastError: "Previous migration failed", }; localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
const flow = createOfflineInboundMigrationFlow(); flow.tryResume();
expect(flow.state.error).toBe("Previous migration failed"); });
it("returns false and clears for incompatible version", () => { const storedState = { version: 999, step: "review", userDid: "did:plc:abc", }; localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState));
const flow = createOfflineInboundMigrationFlow(); const result = flow.tryResume();
expect(result).toBe(false); expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); });
it("returns false and clears for expired state (> 24 hours)", () => { const expiredState = { version: 1, step: "review", startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), userDid: "did:plc:expired", carFileName: "old.car", carSizeBytes: 100, rotationKeyDidKey: "", targetHandle: "old.example.com", targetEmail: "old@example.com", progress: { accountCreated: false, repoImported: false, plcSigned: false, activated: false, }, }; localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState));
const flow = createOfflineInboundMigrationFlow(); const result = flow.tryResume();
expect(result).toBe(false); expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); });
it("returns false and clears for invalid JSON", () => { localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json");
const flow = createOfflineInboundMigrationFlow(); const result = flow.tryResume();
expect(result).toBe(false); expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); });
it("accepts state within 24 hours", () => { const recentState = { version: 1, step: "review", startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), userDid: "did:plc:recent", carFileName: "recent.car", carSizeBytes: 500, rotationKeyDidKey: "did:key:zRecent", targetHandle: "recent.example.com", targetEmail: "recent@example.com", progress: { accountCreated: true, repoImported: true, plcSigned: false, activated: false, }, }; localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState));
const flow = createOfflineInboundMigrationFlow(); const result = flow.tryResume();
expect(result).toBe(true); expect(flow.state.userDid).toBe("did:plc:recent"); }); });
describe("loadLocalServerInfo", () => { function createMockResponse(data: unknown) { const jsonStr = JSON.stringify(data); return new Response(jsonStr, { status: 200, headers: { "Content-Type": "application/json" }, }); }
it("fetches server description", async () => { const mockServerInfo = { did: "did:web:example.com", availableUserDomains: ["example.com"], inviteCodeRequired: false, };
globalThis.fetch = vi.fn().mockResolvedValue( createMockResponse(mockServerInfo), );
const flow = createOfflineInboundMigrationFlow(); const result = await flow.loadLocalServerInfo();
expect(result).toEqual(mockServerInfo); expect(fetch).toHaveBeenCalledWith( expect.stringContaining("com.atproto.server.describeServer"), expect.any(Object), ); });
it("caches server info", async () => { const mockServerInfo = { did: "did:web:example.com", availableUserDomains: ["example.com"], inviteCodeRequired: false, };
globalThis.fetch = vi.fn().mockResolvedValue( createMockResponse(mockServerInfo), );
const flow = createOfflineInboundMigrationFlow(); await flow.loadLocalServerInfo(); await flow.loadLocalServerInfo();
expect(fetch).toHaveBeenCalledTimes(1); }); });});