this repo has no description
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});