Graphical PDS migrator for AT Protocol
at main 9.0 kB view raw
1import { afterAll, beforeAll, describe, expect, test } from "@jest/globals"; 2 3import { Agent, CredentialSession } from "@atproto/api"; 4import { consola } from "consola"; 5import { TestEnvironment } from "../utils/test-env.ts"; 6import { TEST_CONFIG } from "../utils/config.ts"; 7import { 8 MigrationClient, 9 MigrationError, 10 MigrationErrorType, 11} from "../../lib/client.ts"; 12 13describe("e2e migration test", () => { 14 let testEnv: TestEnvironment; 15 let migrationClient: MigrationClient; 16 let agent: Agent; 17 let cookieHelper: ReturnType<typeof createCookieFetch> | null = null; 18 19 async function isServerAvailable(url: string): Promise<boolean> { 20 try { 21 const res = await fetch(url, { method: "HEAD" }); 22 return res.ok || res.status < 500; 23 } catch { 24 return false; 25 } 26 } 27 28 function createCookieFetch(baseUrl: string) { 29 const originalFetch = globalThis.fetch.bind(globalThis); 30 const cookieStore = new Map<string, string>(); 31 const base = new URL(baseUrl); 32 33 function mergeSetCookie(headerVal: string | null) { 34 if (!headerVal) return; 35 const parts = headerVal.split(","); 36 for (const raw of parts) { 37 const pair = raw.trim().split(";")[0]; 38 const [name, value] = pair.split("="); 39 if (name && value) cookieStore.set(name, value); 40 } 41 } 42 43 function cookiesHeader(): string { 44 return Array.from(cookieStore.entries()).map(([k, v]) => `${k}=${v}`) 45 .join("; "); 46 } 47 48 globalThis.fetch = async ( 49 input: Request | URL | string, 50 init?: RequestInit, 51 ) => { 52 let finalInput: Request | URL | string; 53 let finalInit: RequestInit | undefined; 54 55 if (input instanceof Request) { 56 const url = new URL(input.url, base); 57 const headers = new Headers(input.headers); 58 59 if (url.origin === base.origin) { 60 const existing = headers.get("cookie"); 61 const jar = cookiesHeader(); 62 if (jar) { 63 headers.set("cookie", existing ? `${existing}; ${jar}` : jar); 64 } 65 } 66 67 finalInput = new Request(url, { 68 method: input.method, 69 headers: headers, 70 body: input.body, 71 mode: input.mode, 72 credentials: input.credentials, 73 cache: input.cache, 74 redirect: input.redirect, 75 referrer: input.referrer, 76 integrity: input.integrity, 77 signal: input.signal, 78 duplex: input.body ? "half" : undefined, 79 } as RequestInit); 80 finalInit = init; 81 } else { 82 const urlObj = input instanceof URL ? input : new URL(input, base); 83 const headers = new Headers(init?.headers || {}); 84 85 if (urlObj.origin === base.origin) { 86 const existing = headers.get("cookie"); 87 const jar = cookiesHeader(); 88 if (jar) { 89 headers.set("cookie", existing ? `${existing}; ${jar}` : jar); 90 } 91 } 92 93 finalInput = urlObj; 94 finalInit = { ...init, headers }; 95 } 96 97 const res = await originalFetch(finalInput, finalInit); 98 mergeSetCookie(res.headers.get("set-cookie")); 99 return res; 100 }; 101 102 return { 103 addCookiesFrom: (res: Response) => 104 mergeSetCookie(res.headers.get("set-cookie")), 105 getAll: () => cookiesHeader(), 106 }; 107 } 108 109 beforeAll(async () => { 110 const serverAvailable = await isServerAvailable(TEST_CONFIG.airportUrl); 111 if (!serverAvailable) { 112 throw new Error( 113 `Airport server not available at ${TEST_CONFIG.airportUrl}; tests will be skipped.`, 114 ); 115 } 116 117 try { 118 testEnv = await TestEnvironment.create(); 119 console.log(`Using Airport URL: ${TEST_CONFIG.airportUrl}`); 120 migrationClient = new MigrationClient( 121 { 122 baseUrl: TEST_CONFIG.airportUrl, 123 updateStepStatus(stepIndex, status, error) { 124 consola.log(`Step ${stepIndex} updated: ${status}`); 125 if (error) { 126 consola.error(`Step ${stepIndex} error: ${error}`); 127 throw new MigrationError( 128 `Step ${stepIndex} failed: ${error}`, 129 stepIndex, 130 MigrationErrorType.OTHER, 131 ); 132 } 133 }, 134 async nextStepHook(stepNum) { 135 if (stepNum == 2) { 136 const verificationCode = await testEnv.awaitMail(10000); 137 consola.info(`Got verification code: ${verificationCode}`); 138 139 await migrationClient.handleIdentityMigration(verificationCode); 140 // If successful, continue to next step 141 await migrationClient.finalizeMigration(); 142 } 143 }, 144 }, 145 ); 146 cookieHelper = createCookieFetch(TEST_CONFIG.airportUrl); 147 consola.success("Test environment setup completed"); 148 } catch (error) { 149 consola.error("Failed to setup test environment:", error); 150 throw error; 151 } 152 }); 153 154 afterAll(async () => { 155 try { 156 await testEnv?.cleanup(); 157 consola.success("Test environment cleaned up"); 158 } catch (error) { 159 consola.error("Failed to clean up test environment:", error); 160 } 161 }); 162 163 test("should create new account on source pds", async () => { 164 const session = new CredentialSession(new URL(testEnv.sourcePds.url)); 165 166 const result = await session.createAccount({ 167 handle: TEST_CONFIG.handle, 168 password: TEST_CONFIG.password, 169 email: TEST_CONFIG.email, 170 }); 171 172 expect(result.success).toBe(true); 173 174 if (!result.success) { 175 throw new Error( 176 `Failed to create source account: ${JSON.stringify(result)}`, 177 ); 178 } 179 180 agent = new Agent(session); 181 182 consola.success(`Test account created on source PDS (${agent.did})`); 183 }); 184 185 test("should create test data for source account", async () => { 186 await agent.post({ 187 text: "Hello from Airport!", 188 }); 189 190 consola.success("Post data created successfully"); 191 }); 192 193 test("should login via credentials", async () => { 194 const loginRes = await fetch(`${TEST_CONFIG.airportUrl}/api/cred/login`, { 195 method: "POST", 196 headers: { "content-type": "application/json" }, 197 body: JSON.stringify({ 198 handle: agent.assertDid, 199 password: TEST_CONFIG.password, 200 }), 201 }); 202 203 expect(loginRes.ok).toBe(true); 204 205 if (!loginRes.ok) { 206 throw new Error(`Login failed: ${loginRes.status}`); 207 } 208 cookieHelper?.addCookiesFrom(loginRes); 209 210 consola.success("Successfully logged in via credentials"); 211 }); 212 213 test("should make sure i'm logged in", async () => { 214 const meRes = await fetch(`${TEST_CONFIG.airportUrl}/api/me`, { 215 method: "GET", 216 headers: { "content-type": "application/json" }, 217 }); 218 219 if (!meRes.ok) { 220 throw new Error(`Failed to verify login: ${meRes.status}`); 221 } 222 223 const meData = await meRes.json(); 224 consola.log("User data:", meData); 225 expect(meData.did).toBe(agent.assertDid); 226 227 consola.success("Verified login via /api/me"); 228 }); 229 230 test("should start migration flow", async () => { 231 await migrationClient.startMigration( 232 { 233 ...TEST_CONFIG, 234 handle: TEST_CONFIG.targetHandle, 235 service: testEnv.targetPds.url, 236 }, 237 ); 238 239 consola.success("Migration flow completed successfully"); 240 }); 241 242 test("login to new pds", async () => { 243 // login 244 const session = new CredentialSession(new URL(testEnv.targetPds.url)); 245 246 const result = await session.login({ 247 identifier: agent.assertDid, 248 password: TEST_CONFIG.password, 249 }); 250 251 expect(result.success).toBe(true); 252 253 if (!result.success) { 254 throw new Error( 255 `Failed to login to target pds: ${JSON.stringify(result)}`, 256 ); 257 } 258 259 agent = new Agent(session); 260 }); 261 262 test("make sure it's on correct pds", async () => { 263 const doc = await testEnv.plc.getClient().getDocumentData(agent.assertDid); 264 console.log(doc); 265 expect(doc).toBeDefined(); 266 expect(doc.services["atproto_pds"]).toBeDefined(); 267 expect(doc.services["atproto_pds"].endpoint).toBe(testEnv.targetPds.url); 268 }); 269 270 test("make sure data migrated successfully", async () => { 271 const records = await agent.com.atproto.repo.listRecords({ 272 collection: "app.bsky.feed.post", 273 repo: agent.assertDid, 274 }); 275 276 console.log(records.data.records); 277 278 expect(records.success).toBe(true); 279 expect(records).toBeDefined(); 280 expect(Array.isArray(records.data.records)).toBe(true); 281 expect(records.data.records.length).toBe(1); 282 }); 283 284 test("should handle migration errors appropriately", async () => { 285 try { 286 // Try to create account with invalid params 287 await migrationClient.startMigration({ 288 service: "invalid-service", 289 handle: "invalid-handle", 290 email: "invalid-email", 291 password: "invalid-password", 292 }); 293 294 // Should not reach here 295 throw new Error("Expected migration to fail but it succeeded"); 296 } catch (error) { 297 expect(error).toBeInstanceOf(Error); 298 } 299 }); 300});