Graphical PDS migrator for AT Protocol
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});