Graphical PDS migrator for AT Protocol
at main 5.5 kB view raw
1/** 2 * Test environment utilities for setting up virtual PDS instances 3 */ 4 5import { Agent } from "@atproto/api"; 6import { TestBsky, TestPds, TestPlc } from "@atproto/dev-env"; 7import { ComAtprotoServerCreateAccount } from "@atproto/api"; 8import { SMTPServer, SMTPServerAddress } from "smtp-server"; 9import * as cheerio from "cheerio"; 10import consola from "consola"; 11 12export interface TestPDSConfig { 13 sourcePds: TestPds; 14 targetPds: TestPds; 15 plc: TestPlc; 16} 17 18const PLC_PORT = 2580; 19const PDS_A_PORT = 2583; 20const PDS_B_PORT = 2584; 21const SMTP_PORT = 2525; 22 23export type TestAccount = ComAtprotoServerCreateAccount.OutputSchema & { 24 agent: Agent; 25}; 26 27export interface Email { 28 to: SMTPServerAddress[]; 29 from: false | SMTPServerAddress; 30 subject?: string; 31 body: string; 32 verificationCode?: string; 33 timestamp: Date; 34} 35 36/** 37 * Create a test environment with virtual PDS instances 38 */ 39export class TestEnvironment { 40 sourcePds: TestPds; 41 targetPds: TestPds; 42 plc: TestPlc; 43 smtp!: SMTPServer; 44 private mailbox: Email[] = []; 45 private codeWaiters: Map<string, (code: string) => void> = new Map(); 46 47 private constructor( 48 sourcePds: TestPds, 49 targetPds: TestPds, 50 plc: TestPlc, 51 ) { 52 this.sourcePds = sourcePds; 53 this.targetPds = targetPds; 54 this.plc = plc; 55 } 56 57 static async create(): Promise<TestEnvironment> { 58 const plc = await TestPlc.create({ 59 port: PLC_PORT, 60 }); 61 62 const pds = await TestEnvironment.setupMockPDS(plc.url); 63 const env = new TestEnvironment( 64 pds.sourcePds, 65 pds.targetPds, 66 plc, 67 ); 68 69 env.smtp = await env.createSMTPServer(); 70 71 return env; 72 } 73 74 /** 75 * Get all emails in the mailbox 76 */ 77 getMail(): Email[] { 78 return [...this.mailbox]; 79 } 80 81 /** 82 * Clear all emails from the mailbox 83 */ 84 clearMail(): void { 85 this.mailbox = []; 86 } 87 88 /** 89 * Wait for a verification code to arrive in an email 90 * @param timeoutMs - Timeout in milliseconds 91 * @returns Promise that resolves with the verification code 92 */ 93 awaitMail(timeoutMs: number = 10000): Promise<string> | string { 94 // First, check if a verification code is already present in the mailbox 95 const existing = this.mailbox.find((email) => email.verificationCode); 96 if (existing && existing.verificationCode) { 97 return existing.verificationCode; 98 } 99 100 // Otherwise, wait for a new code to arrive 101 return new Promise((resolve, reject) => { 102 const timeout = setTimeout(() => { 103 this.codeWaiters.delete(waiterId); 104 reject( 105 new Error( 106 `Timeout waiting for verification code after ${timeoutMs}ms`, 107 ), 108 ); 109 }, timeoutMs); 110 111 const waiterId = Date.now().toString(); 112 this.codeWaiters.set(waiterId, (code: string) => { 113 clearTimeout(timeout); 114 this.codeWaiters.delete(waiterId); 115 resolve(code); 116 }); 117 }); 118 } 119 120 private createSMTPServer(): Promise<SMTPServer> { 121 return new Promise((resolve) => { 122 const server = new SMTPServer({ 123 // disable STARTTLS to allow authentication in clear text mode 124 disabledCommands: ["STARTTLS", "AUTH"], 125 allowInsecureAuth: true, 126 hideSTARTTLS: true, 127 onData: (stream, session, callback) => { 128 let emailData = ""; 129 stream.on("data", (chunk) => emailData += chunk.toString("utf8")); 130 stream.on("end", () => { 131 const $ = cheerio.load(emailData); 132 const codeEl = $("code").first(); 133 const code = codeEl.length ? codeEl.text() : undefined; 134 135 const email: Email = { 136 to: session.envelope.rcptTo, 137 from: session.envelope.mailFrom, 138 body: emailData, 139 verificationCode: code, 140 timestamp: new Date(), 141 }; 142 143 this.mailbox.push(email); 144 145 if (code) { 146 consola.info(`Verification code arrived: ${code}`); 147 // Notify all waiting promises 148 this.codeWaiters.forEach((resolve) => resolve(code)); 149 this.codeWaiters.clear(); 150 } else { 151 consola.info("No <code> found in email."); 152 } 153 }); 154 155 callback(); 156 }, 157 }); 158 159 server.listen(SMTP_PORT, () => { 160 consola.info(`SMTP server listening on port ${SMTP_PORT}`); 161 resolve(server); 162 }); 163 }); 164 } 165 166 private static async setupMockPDS(plcUrl: string) { 167 const sourcePds = await TestPds.create({ 168 didPlcUrl: plcUrl, 169 port: PDS_A_PORT, 170 inviteRequired: false, 171 devMode: true, 172 emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`, 173 emailFromAddress: `noreply@localhost:${SMTP_PORT}`, 174 bskyAppViewDid: "did:web:api.bsky.app", 175 }); 176 177 const targetPds = await TestPds.create({ 178 didPlcUrl: plcUrl, 179 port: PDS_B_PORT, 180 inviteRequired: false, 181 acceptingImports: true, 182 devMode: true, 183 emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`, 184 emailFromAddress: `noreply@localhost:${SMTP_PORT}`, 185 bskyAppViewDid: "did:web:api.bsky.app", 186 }); 187 188 return { 189 sourcePds, 190 targetPds, 191 }; 192 } 193 194 async cleanup() { 195 try { 196 await this.sourcePds.close(); 197 await this.targetPds.close(); 198 await this.plc.close(); 199 await new Promise<void>((resolve) => { 200 this.smtp.close(resolve); 201 }); 202 } catch (error) { 203 console.error("Error during cleanup:", error); 204 } 205 } 206}