this repo has no description
at main 10 kB view raw
1import { beforeEach, describe, expect, it, vi } from "vitest"; 2import { PlcOps, plcOps } from "../../lib/migration/plc-ops"; 3 4describe("migration/plc-ops", () => { 5 beforeEach(() => { 6 vi.restoreAllMocks(); 7 }); 8 9 describe("PlcOps class", () => { 10 it("uses default PLC directory URL", () => { 11 const ops = new PlcOps(); 12 expect(ops).toBeDefined(); 13 }); 14 15 it("accepts custom PLC directory URL", () => { 16 const ops = new PlcOps("https://custom-plc.example.com"); 17 expect(ops).toBeDefined(); 18 }); 19 }); 20 21 describe("plcOps singleton", () => { 22 it("exports a singleton instance", () => { 23 expect(plcOps).toBeInstanceOf(PlcOps); 24 }); 25 }); 26 27 describe("getPlcAuditLogs", () => { 28 it("throws on HTTP error", async () => { 29 globalThis.fetch = vi.fn().mockResolvedValue({ 30 ok: false, 31 status: 404, 32 }); 33 34 await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow( 35 "Failed to fetch PLC audit logs: 404", 36 ); 37 }); 38 }); 39 40 describe("getLastPlcOpFromPlc", () => { 41 it("throws when empty array returned", async () => { 42 globalThis.fetch = vi.fn().mockResolvedValue({ 43 ok: true, 44 json: () => Promise.resolve([]), 45 }); 46 47 await expect( 48 plcOps.getLastPlcOpFromPlc("did:plc:empty"), 49 ).rejects.toThrow(); 50 }); 51 }); 52 53 describe("createNewSecp256k1Keypair", () => { 54 it("generates a keypair with private and public keys", async () => { 55 const result = await plcOps.createNewSecp256k1Keypair(); 56 57 expect(result.privateKey).toBeDefined(); 58 expect(result.publicKey).toBeDefined(); 59 expect(result.publicKey.startsWith("did:key:")).toBe(true); 60 }); 61 62 it("generates different keypairs each time", async () => { 63 const result1 = await plcOps.createNewSecp256k1Keypair(); 64 const result2 = await plcOps.createNewSecp256k1Keypair(); 65 66 expect(result1.privateKey).not.toBe(result2.privateKey); 67 expect(result1.publicKey).not.toBe(result2.publicKey); 68 }); 69 }); 70 71 describe("getKeyPair", () => { 72 it("parses 64-character hex private key", async () => { 73 const hexKey = "a".repeat(64); 74 75 const result = await plcOps.getKeyPair(hexKey); 76 77 expect(result.type).toBe("private_key"); 78 expect(result.didPublicKey.startsWith("did:key:")).toBe(true); 79 expect(result.keypair).toBeDefined(); 80 }); 81 82 it("handles whitespace in key input", async () => { 83 const hexKey = " " + "b".repeat(64) + " "; 84 85 const result = await plcOps.getKeyPair(hexKey); 86 87 expect(result.type).toBe("private_key"); 88 }); 89 90 it("throws for invalid key format", async () => { 91 await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow( 92 "Invalid key format", 93 ); 94 }); 95 96 it("throws for hex key with wrong length", async () => { 97 await expect(plcOps.getKeyPair("abc123")).rejects.toThrow( 98 "Invalid key format", 99 ); 100 }); 101 }); 102 103 describe("pushPlcOperation", () => { 104 it("posts operation to PLC directory", async () => { 105 globalThis.fetch = vi.fn().mockResolvedValue({ 106 ok: true, 107 }); 108 109 const operation = { 110 type: "plc_operation" as const, 111 prev: "bafyreiabc", 112 alsoKnownAs: ["at://alice.example.com"], 113 rotationKeys: ["did:key:z123"], 114 services: { 115 atproto_pds: { 116 type: "AtprotoPersonalDataServer", 117 endpoint: "https://pds.example.com", 118 }, 119 }, 120 verificationMethods: { 121 atproto: "did:key:z456", 122 }, 123 sig: "test-signature", 124 }; 125 126 await plcOps.pushPlcOperation("did:plc:abc123", operation); 127 128 expect(fetch).toHaveBeenCalledWith( 129 "https://plc.directory/did:plc:abc123", 130 expect.objectContaining({ 131 method: "POST", 132 headers: { "Content-Type": "application/json" }, 133 body: JSON.stringify(operation), 134 }), 135 ); 136 }); 137 138 it("throws with error message from PLC directory", async () => { 139 globalThis.fetch = vi.fn().mockResolvedValue({ 140 ok: false, 141 status: 400, 142 headers: new Map([["content-type", "application/json"]]), 143 json: () => Promise.resolve({ message: "Invalid signature" }), 144 }); 145 146 const operation = { 147 type: "plc_operation" as const, 148 prev: "bafyreiabc", 149 alsoKnownAs: [], 150 rotationKeys: ["did:key:z123"], 151 services: {}, 152 verificationMethods: {}, 153 sig: "bad-sig", 154 }; 155 156 await expect( 157 plcOps.pushPlcOperation("did:plc:abc123", operation), 158 ).rejects.toThrow("Invalid signature"); 159 }); 160 161 it("throws generic error when no message in response", async () => { 162 globalThis.fetch = vi.fn().mockResolvedValue({ 163 ok: false, 164 status: 500, 165 headers: new Map([["content-type", "text/plain"]]), 166 }); 167 168 const operation = { 169 type: "plc_operation" as const, 170 prev: null, 171 alsoKnownAs: [], 172 rotationKeys: [], 173 services: {}, 174 verificationMethods: {}, 175 }; 176 177 await expect( 178 plcOps.pushPlcOperation("did:plc:abc123", operation), 179 ).rejects.toThrow("PLC directory returned HTTP 500"); 180 }); 181 }); 182 183 describe("createServiceAuthToken", () => { 184 it("creates a valid JWT", async () => { 185 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 186 const keypair = await plcOps.getKeyPair(privateKey); 187 188 const token = await plcOps.createServiceAuthToken( 189 "did:plc:issuer", 190 "did:web:audience.example.com", 191 keypair.keypair, 192 "com.atproto.server.createAccount", 193 ); 194 195 expect(token).toBeDefined(); 196 const parts = token.split("."); 197 expect(parts).toHaveLength(3); 198 }); 199 200 it("includes correct header", async () => { 201 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 202 const keypair = await plcOps.getKeyPair(privateKey); 203 204 const token = await plcOps.createServiceAuthToken( 205 "did:plc:issuer", 206 "did:web:audience", 207 keypair.keypair, 208 "com.atproto.server.createAccount", 209 ); 210 211 const headerB64 = token.split(".")[0]; 212 const header = JSON.parse( 213 atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")), 214 ); 215 expect(header.typ).toBe("JWT"); 216 expect(header.alg).toBe("ES256K"); 217 }); 218 219 it("includes correct payload claims", async () => { 220 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 221 const keypair = await plcOps.getKeyPair(privateKey); 222 223 const before = Math.floor(Date.now() / 1000); 224 const token = await plcOps.createServiceAuthToken( 225 "did:plc:myissuer", 226 "did:web:myaudience.com", 227 keypair.keypair, 228 "com.atproto.sync.getRepo", 229 ); 230 const after = Math.floor(Date.now() / 1000); 231 232 const payloadB64 = token.split(".")[1]; 233 const payload = JSON.parse( 234 atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")), 235 ); 236 237 expect(payload.iss).toBe("did:plc:myissuer"); 238 expect(payload.aud).toBe("did:web:myaudience.com"); 239 expect(payload.lxm).toBe("com.atproto.sync.getRepo"); 240 expect(payload.iat).toBeGreaterThanOrEqual(before); 241 expect(payload.iat).toBeLessThanOrEqual(after); 242 expect(payload.exp).toBe(payload.iat + 60); 243 expect(payload.jti).toBeDefined(); 244 }); 245 }); 246 247 describe("signAndPublishNewOp", () => { 248 it("throws when no rotation keys provided", async () => { 249 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 250 const keypair = await plcOps.getKeyPair(privateKey); 251 252 await expect( 253 plcOps.signAndPublishNewOp( 254 "did:plc:test", 255 keypair.keypair, 256 ["at://alice.example.com"], 257 [], 258 "https://pds.example.com", 259 "did:key:zVerify", 260 "bafyreiprev", 261 ), 262 ).rejects.toThrow("No rotation keys provided"); 263 }); 264 265 it("throws when more than 5 unique rotation keys provided", async () => { 266 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 267 const keypair = await plcOps.getKeyPair(privateKey); 268 269 const tooManyKeys = [ 270 "did:key:z1", 271 "did:key:z2", 272 "did:key:z3", 273 "did:key:z4", 274 "did:key:z5", 275 "did:key:z6", 276 ]; 277 278 await expect( 279 plcOps.signAndPublishNewOp( 280 "did:plc:test", 281 keypair.keypair, 282 [], 283 tooManyKeys, 284 "https://pds.example.com", 285 "did:key:zVerify", 286 "bafyreiprev", 287 ), 288 ).rejects.toThrow("Maximum 5 rotation keys allowed"); 289 }); 290 }); 291 292 describe("signPlcOperationWithCredentials", () => { 293 it("throws when no rotation keys provided", async () => { 294 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 295 const keypair = await plcOps.getKeyPair(privateKey); 296 297 await expect( 298 plcOps.signPlcOperationWithCredentials( 299 "did:plc:test", 300 keypair.keypair, 301 { 302 rotationKeys: [], 303 alsoKnownAs: [], 304 verificationMethods: {}, 305 services: {}, 306 }, 307 [], 308 "bafyreiprev", 309 ), 310 ).rejects.toThrow("No rotation keys provided"); 311 }); 312 313 it("throws when more than 5 rotation keys provided", async () => { 314 const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 315 const keypair = await plcOps.getKeyPair(privateKey); 316 317 await expect( 318 plcOps.signPlcOperationWithCredentials( 319 "did:plc:test", 320 keypair.keypair, 321 { 322 rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"], 323 alsoKnownAs: [], 324 verificationMethods: {}, 325 services: {}, 326 }, 327 ["did:key:z4", "did:key:z5", "did:key:z6"], 328 "bafyreiprev", 329 ), 330 ).rejects.toThrow("Maximum 5 rotation keys allowed"); 331 }); 332 }); 333});