Encrypted, ephemeral, private memos on atproto
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 479 lines 14 kB view raw
1import { expect } from "@std/expect"; 2import { Consumer } from "./mod.ts"; 3import { encryptText, generateKeys } from "@cistern/crypto"; 4import type { ConsumerParams } from "./types.ts"; 5import type { Client, CredentialManager } from "@atcute/client"; 6import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7import { now } from "@atcute/tid"; 8import type { AppCisternMemo } from "@cistern/lexicon"; 9import type { XRPCProcedures, XRPCQueries } from "@cistern/shared"; 10 11// Helper to create a mock Consumer instance 12function createMockConsumer( 13 overrides?: Partial<ConsumerParams>, 14): Consumer { 15 const mockParams: ConsumerParams = { 16 miniDoc: { 17 did: "did:plc:test123" as Did, 18 handle: "test.bsky.social" as Handle, 19 pds: "https://test.pds.example", 20 signing_key: "test-key", 21 }, 22 manager: {} as CredentialManager, 23 rpc: createMockRpcClient(), 24 options: { 25 handle: "test.bsky.social" as Handle, 26 appPassword: "test-password", 27 }, 28 ...overrides, 29 }; 30 31 return new Consumer(mockParams); 32} 33 34// Helper to create a mock RPC client 35function createMockRpcClient(): Client<XRPCQueries, XRPCProcedures> { 36 return { 37 get: () => { 38 throw new Error("Mock RPC get not implemented"); 39 }, 40 post: () => { 41 throw new Error("Mock RPC post not implemented"); 42 }, 43 } as unknown as Client<XRPCQueries, XRPCProcedures>; 44} 45 46Deno.test({ 47 name: "Consumer constructor initializes with provided params", 48 fn() { 49 const consumer = createMockConsumer(); 50 51 expect(consumer.did).toEqual("did:plc:test123"); 52 expect(consumer.keypair).toBeUndefined(); 53 expect(consumer.rpc).toBeDefined(); 54 }, 55}); 56 57Deno.test({ 58 name: "Consumer constructor initializes with existing keypair", 59 fn() { 60 const mockKeypair = { 61 privateKey: new Uint8Array(32).toBase64(), 62 publicKey: "at://did:plc:test/app.cistern.pubkey/abc123" as ResourceUri, 63 }; 64 65 const consumer = createMockConsumer({ 66 options: { 67 handle: "test.bsky.social" as Handle, 68 appPassword: "test-password", 69 keypair: mockKeypair, 70 }, 71 }); 72 73 expect(consumer.keypair).toBeDefined(); 74 expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey); 75 expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array); 76 }, 77}); 78 79Deno.test({ 80 name: "generateKeyPair creates and uploads a new keypair", 81 async fn() { 82 let capturedRecord: unknown; 83 let capturedCollection: string | undefined; 84 85 const mockRpc = { 86 post: (endpoint: string, params: { input: unknown }) => { 87 if (endpoint === "com.atproto.repo.createRecord") { 88 const input = params.input as { 89 collection: string; 90 record: unknown; 91 }; 92 capturedCollection = input.collection; 93 capturedRecord = input.record; 94 95 return Promise.resolve({ 96 ok: true, 97 data: { 98 uri: "at://did:plc:test/app.cistern.pubkey/generated123", 99 }, 100 }); 101 } 102 return Promise.resolve({ ok: false, status: 500, data: {} }); 103 }, 104 } as unknown as Client<XRPCQueries, XRPCProcedures>; 105 106 const consumer = createMockConsumer({ rpc: mockRpc }); 107 const keypair = await consumer.generateKeyPair(); 108 109 expect(keypair).toBeDefined(); 110 expect(keypair.privateKey).toBeInstanceOf(Uint8Array); 111 expect(keypair.publicKey).toEqual( 112 "at://did:plc:test/app.cistern.pubkey/generated123", 113 ); 114 expect(consumer.keypair).toEqual(keypair); 115 116 expect(capturedCollection).toEqual("app.cistern.pubkey"); 117 expect(capturedRecord).toMatchObject({ 118 $type: "app.cistern.pubkey", 119 algorithm: "x_wing", 120 }); 121 }, 122}); 123 124Deno.test({ 125 name: "generateKeyPair throws when consumer already has a keypair", 126 async fn() { 127 const consumer = createMockConsumer({ 128 options: { 129 handle: "test.bsky.social" as Handle, 130 appPassword: "test-password", 131 keypair: { 132 privateKey: new Uint8Array(32).toBase64(), 133 publicKey: 134 "at://did:plc:test/app.cistern.pubkey/existing" as ResourceUri, 135 }, 136 }, 137 }); 138 139 await expect(consumer.generateKeyPair()).rejects.toThrow( 140 "client already has a key pair", 141 ); 142 }, 143}); 144 145Deno.test({ 146 name: "generateKeyPair throws when upload fails", 147 async fn() { 148 const mockRpc = { 149 post: () => 150 Promise.resolve({ 151 ok: false, 152 status: 500, 153 data: { error: "Internal Server Error" }, 154 }), 155 } as unknown as Client<XRPCQueries, XRPCProcedures>; 156 157 const consumer = createMockConsumer({ rpc: mockRpc }); 158 159 await expect(consumer.generateKeyPair()).rejects.toThrow( 160 "failed to save public key", 161 ); 162 }, 163}); 164 165Deno.test({ 166 name: "listMemos throws when no keypair is set", 167 async fn() { 168 const consumer = createMockConsumer(); 169 170 const iterator = consumer.listMemos(); 171 await expect(iterator.next()).rejects.toThrow( 172 "no key pair set; generate a key before listing memos", 173 ); 174 }, 175}); 176 177Deno.test({ 178 name: "listMemos decrypts and yields memos", 179 async fn() { 180 const keys = generateKeys(); 181 const testText = "Test memo content"; 182 const encrypted = encryptText(keys.publicKey, testText); 183 const testTid = now(); 184 185 const mockRpc = { 186 get: (endpoint: string) => { 187 if (endpoint === "com.atproto.repo.listRecords") { 188 return Promise.resolve({ 189 ok: true, 190 data: { 191 records: [ 192 { 193 uri: "at://did:plc:test/app.cistern.memo/memo1", 194 value: { 195 $type: "app.cistern.memo", 196 tid: testTid, 197 ciphertext: { $bytes: encrypted.cipherText }, 198 nonce: { $bytes: encrypted.nonce }, 199 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 200 pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 201 payload: { $bytes: encrypted.content }, 202 contentLength: encrypted.length, 203 contentHash: { $bytes: encrypted.hash }, 204 } as AppCisternMemo.Main, 205 }, 206 ], 207 cursor: undefined, 208 }, 209 }); 210 } 211 return Promise.resolve({ ok: false, status: 500, data: {} }); 212 }, 213 } as unknown as Client<XRPCQueries, XRPCProcedures>; 214 215 const consumer = createMockConsumer({ 216 rpc: mockRpc, 217 options: { 218 handle: "test.bsky.social" as Handle, 219 appPassword: "test-password", 220 keypair: { 221 privateKey: keys.secretKey.toBase64(), 222 publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 223 }, 224 }, 225 }); 226 227 const memos = []; 228 for await (const memo of consumer.listMemos()) { 229 memos.push(memo); 230 } 231 232 expect(memos).toHaveLength(1); 233 expect(memos[0].text).toEqual(testText); 234 expect(memos[0].tid).toEqual(testTid); 235 }, 236}); 237 238Deno.test({ 239 name: "listmemos skips memos with mismatched public key", 240 async fn() { 241 const keys = generateKeys(); 242 const testText = "Test memo content"; 243 const encrypted = encryptText(keys.publicKey, testText); 244 const testTid = now(); 245 246 const mockRpc = { 247 get: (endpoint: string) => { 248 if (endpoint === "com.atproto.repo.listRecords") { 249 return Promise.resolve({ 250 ok: true, 251 data: { 252 records: [ 253 { 254 uri: "at://did:plc:test/app.cistern.memo/memo1", 255 value: { 256 $type: "app.cistern.memo", 257 tid: testTid, 258 ciphertext: { $bytes: encrypted.cipherText }, 259 nonce: { $bytes: encrypted.nonce }, 260 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 261 pubkey: 262 "at://did:plc:test/app.cistern.pubkey/different-key", 263 payload: { $bytes: encrypted.content }, 264 contentLength: encrypted.length, 265 contentHash: { $bytes: encrypted.hash }, 266 } as AppCisternMemo.Main, 267 }, 268 ], 269 cursor: undefined, 270 }, 271 }); 272 } 273 return Promise.resolve({ ok: false, status: 500, data: {} }); 274 }, 275 } as unknown as Client<XRPCQueries, XRPCProcedures>; 276 277 const consumer = createMockConsumer({ 278 rpc: mockRpc, 279 options: { 280 handle: "test.bsky.social" as Handle, 281 appPassword: "test-password", 282 keypair: { 283 privateKey: keys.secretKey.toBase64(), 284 publicKey: 285 "at://did:plc:test/app.cistern.pubkey/my-key" as ResourceUri, 286 }, 287 }, 288 }); 289 290 const memos = []; 291 for await (const memo of consumer.listMemos()) { 292 memos.push(memo); 293 } 294 295 expect(memos).toHaveLength(0); 296 }, 297}); 298 299Deno.test({ 300 name: "listMemos handles pagination", 301 async fn() { 302 const keys = generateKeys(); 303 const text1 = "First memo"; 304 const text2 = "Second memo"; 305 const encrypted1 = encryptText(keys.publicKey, text1); 306 const encrypted2 = encryptText(keys.publicKey, text2); 307 const tid1 = now(); 308 const tid2 = now(); 309 310 let callCount = 0; 311 const mockRpc = { 312 get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 313 if (endpoint === "com.atproto.repo.listRecords") { 314 callCount++; 315 316 if (callCount === 1) { 317 return Promise.resolve({ 318 ok: true, 319 data: { 320 records: [ 321 { 322 uri: "at://did:plc:test/app.cistern.memo/memo1", 323 value: { 324 $type: "app.cistern.memo", 325 tid: tid1, 326 ciphertext: { $bytes: encrypted1.cipherText }, 327 nonce: { $bytes: encrypted1.nonce }, 328 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 329 pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 330 payload: { $bytes: encrypted1.content }, 331 contentLength: encrypted1.length, 332 contentHash: { $bytes: encrypted1.hash }, 333 } as AppCisternMemo.Main, 334 }, 335 ], 336 cursor: "next-page", 337 }, 338 }); 339 } else { 340 return Promise.resolve({ 341 ok: true, 342 data: { 343 records: [ 344 { 345 uri: "at://did:plc:test/app.cistern.memo/memo2", 346 value: { 347 $type: "app.cistern.memo", 348 tid: tid2, 349 ciphertext: { $bytes: encrypted2.cipherText }, 350 nonce: { $bytes: encrypted2.nonce }, 351 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 352 pubkey: "at://did:plc:test/app.cistern.pubkey/key1", 353 payload: { $bytes: encrypted2.content }, 354 contentLength: encrypted2.length, 355 contentHash: { $bytes: encrypted2.hash }, 356 } as AppCisternMemo.Main, 357 }, 358 ], 359 cursor: undefined, 360 }, 361 }); 362 } 363 } 364 return Promise.resolve({ ok: false, status: 500, data: {} }); 365 }, 366 } as unknown as Client<XRPCQueries, XRPCProcedures>; 367 368 const consumer = createMockConsumer({ 369 rpc: mockRpc, 370 options: { 371 handle: "test.bsky.social" as Handle, 372 appPassword: "test-password", 373 keypair: { 374 privateKey: keys.secretKey.toBase64(), 375 publicKey: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 376 }, 377 }, 378 }); 379 380 const memos = []; 381 for await (const memo of consumer.listMemos()) { 382 memos.push(memo); 383 } 384 385 expect(memos).toHaveLength(2); 386 expect(memos[0].text).toEqual(text1); 387 expect(memos[1].text).toEqual(text2); 388 expect(callCount).toEqual(2); 389 }, 390}); 391 392Deno.test({ 393 name: "listMemos throws when list request fails", 394 async fn() { 395 const mockRpc = { 396 get: () => 397 Promise.resolve({ 398 ok: false, 399 status: 401, 400 data: { error: "Unauthorized" }, 401 }), 402 } as unknown as Client<XRPCQueries, XRPCProcedures>; 403 404 const consumer = createMockConsumer({ 405 rpc: mockRpc, 406 options: { 407 handle: "test.bsky.social" as Handle, 408 appPassword: "test-password", 409 keypair: { 410 privateKey: new Uint8Array(32).toBase64(), 411 publicKey: "at://did:plc:test/app.cistern.pubkey/key1", 412 }, 413 }, 414 }); 415 416 const iterator = consumer.listMemos(); 417 await expect(iterator.next()).rejects.toThrow("failed to list memos"); 418 }, 419}); 420 421Deno.test({ 422 name: "subscribeToMemos throws when no keypair is set", 423 async fn() { 424 const consumer = createMockConsumer(); 425 426 const iterator = consumer.subscribeToMemos(); 427 await expect(iterator.next()).rejects.toThrow( 428 "no key pair set; generate a key before subscribing", 429 ); 430 }, 431}); 432 433Deno.test({ 434 name: "deleteMemo successfully deletes a memo", 435 async fn() { 436 let deletedRkey: string | undefined; 437 438 const mockRpc = { 439 post: (endpoint: string, params: { input: unknown }) => { 440 if (endpoint === "com.atproto.repo.deleteRecord") { 441 const input = params.input as { rkey: string }; 442 deletedRkey = input.rkey; 443 444 return Promise.resolve({ 445 ok: true, 446 data: {}, 447 }); 448 } 449 return Promise.resolve({ ok: false, status: 500, data: {} }); 450 }, 451 } as unknown as Client<XRPCQueries, XRPCProcedures>; 452 453 const consumer = createMockConsumer({ rpc: mockRpc }); 454 455 await consumer.deleteMemo("memo123"); 456 457 expect(deletedRkey).toEqual("memo123"); 458 }, 459}); 460 461Deno.test({ 462 name: "deleteMemo throws when delete request fails", 463 async fn() { 464 const mockRpc = { 465 post: () => 466 Promise.resolve({ 467 ok: false, 468 status: 404, 469 data: { error: "Not Found" }, 470 }), 471 } as unknown as Client<XRPCQueries, XRPCProcedures>; 472 473 const consumer = createMockConsumer({ rpc: mockRpc }); 474 475 await expect(consumer.deleteMemo("memo123")).rejects.toThrow( 476 "failed to delete memo memo123", 477 ); 478 }, 479});