Encrypted, ephemeral, private memos on atproto

test(consumer): add suite

graham.systems 35570813 42a8bfac

verified
Changed files
+489 -2
packages
+3 -1
deno.lock
··· 215 215 "packages/consumer": { 216 216 "dependencies": [ 217 217 "jsr:@puregarlic/randimal@^1.0.1", 218 + "jsr:@std/expect@^1.0.17", 218 219 "npm:@atcute/atproto@^3.1.9", 219 220 "npm:@atcute/client@^4.0.5", 220 221 "npm:@atcute/jetstream@^1.1.2", 221 - "npm:@atcute/lexicons@^1.2.2" 222 + "npm:@atcute/lexicons@^1.2.2", 223 + "npm:@atcute/tid@^1.0.3" 222 224 ] 223 225 }, 224 226 "packages/crypto": {
+3 -1
packages/consumer/deno.jsonc
··· 8 8 "@atcute/client": "npm:@atcute/client@^4.0.5", 9 9 "@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2", 10 10 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2", 11 - "@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1" 11 + "@atcute/tid": "npm:@atcute/tid@^1.0.3", 12 + "@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1", 13 + "@std/expect": "jsr:@std/expect@^1.0.17" 12 14 } 13 15 }
+483
packages/consumer/mod.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { Consumer } from "./mod.ts"; 3 + import { encryptText, generateKeys } from "@cistern/crypto"; 4 + import type { ConsumerParams } from "./types.ts"; 5 + import type { Client, CredentialManager } from "@atcute/client"; 6 + import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 + import { now } from "@atcute/tid"; 8 + 9 + // Helper to create a mock Consumer instance 10 + function createMockConsumer( 11 + overrides?: Partial<ConsumerParams>, 12 + ): Consumer { 13 + const mockParams: ConsumerParams = { 14 + miniDoc: { 15 + did: "did:plc:test123" as Did, 16 + handle: "test.bsky.social" as Handle, 17 + pds: "https://test.pds.example", 18 + signing_key: "test-key", 19 + }, 20 + manager: {} as CredentialManager, 21 + rpc: createMockRpcClient(), 22 + options: { 23 + handle: "test.bsky.social" as Handle, 24 + appPassword: "test-password", 25 + }, 26 + ...overrides, 27 + }; 28 + 29 + return new Consumer(mockParams); 30 + } 31 + 32 + // Helper to create a mock RPC client 33 + function createMockRpcClient(): Client { 34 + return { 35 + get: () => { 36 + throw new Error("Mock RPC get not implemented"); 37 + }, 38 + post: () => { 39 + throw new Error("Mock RPC post not implemented"); 40 + }, 41 + } as unknown as Client; 42 + } 43 + 44 + Deno.test({ 45 + name: "Consumer constructor initializes with provided params", 46 + fn() { 47 + const consumer = createMockConsumer(); 48 + 49 + expect(consumer.did).toEqual("did:plc:test123"); 50 + expect(consumer.keypair).toBeUndefined(); 51 + expect(consumer.rpc).toBeDefined(); 52 + expect(consumer.manager).toBeDefined(); 53 + }, 54 + }); 55 + 56 + Deno.test({ 57 + name: "Consumer constructor initializes with existing keypair", 58 + fn() { 59 + const mockKeypair = { 60 + privateKey: new Uint8Array(32).toBase64(), 61 + publicKey: 62 + "at://did:plc:test/app.cistern.lexicon.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 + 79 + Deno.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.lexicon.pubkey/generated123", 99 + }, 100 + }); 101 + } 102 + return Promise.resolve({ ok: false, status: 500, data: {} }); 103 + }, 104 + } as unknown as Client; 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.lexicon.pubkey/generated123", 113 + ); 114 + expect(consumer.keypair).toEqual(keypair); 115 + 116 + expect(capturedCollection).toEqual("app.cistern.lexicon.pubkey"); 117 + expect(capturedRecord).toMatchObject({ 118 + $type: "app.cistern.lexicon.pubkey", 119 + algorithm: "x_wing", 120 + }); 121 + }, 122 + }); 123 + 124 + Deno.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.lexicon.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 + 145 + Deno.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; 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 + 165 + Deno.test({ 166 + name: "listItems throws when no keypair is set", 167 + async fn() { 168 + const consumer = createMockConsumer(); 169 + 170 + const iterator = consumer.listItems(); 171 + await expect(iterator.next()).rejects.toThrow( 172 + "no key pair set; generate a key before listing items", 173 + ); 174 + }, 175 + }); 176 + 177 + Deno.test({ 178 + name: "listItems decrypts and yields items", 179 + async fn() { 180 + const keys = generateKeys(); 181 + const testText = "Test item 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.lexicon.item/item1", 194 + value: { 195 + $type: "app.cistern.lexicon.item", 196 + tid: testTid, 197 + ciphertext: encrypted.cipherText, 198 + nonce: encrypted.nonce, 199 + algorithm: "x_wing-xchacha20_poly1305-sha3_512", 200 + pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 201 + payload: encrypted.content, 202 + contentLength: encrypted.length, 203 + contentHash: encrypted.hash, 204 + }, 205 + }, 206 + ], 207 + cursor: undefined, 208 + }, 209 + }); 210 + } 211 + return Promise.resolve({ ok: false, status: 500, data: {} }); 212 + }, 213 + } as unknown as Client; 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: 223 + "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 224 + }, 225 + }, 226 + }); 227 + 228 + const items = []; 229 + for await (const item of consumer.listItems()) { 230 + items.push(item); 231 + } 232 + 233 + expect(items).toHaveLength(1); 234 + expect(items[0].text).toEqual(testText); 235 + expect(items[0].tid).toEqual(testTid); 236 + }, 237 + }); 238 + 239 + Deno.test({ 240 + name: "listItems skips items with mismatched public key", 241 + async fn() { 242 + const keys = generateKeys(); 243 + const testText = "Test item content"; 244 + const encrypted = encryptText(keys.publicKey, testText); 245 + const testTid = now(); 246 + 247 + const mockRpc = { 248 + get: (endpoint: string) => { 249 + if (endpoint === "com.atproto.repo.listRecords") { 250 + return Promise.resolve({ 251 + ok: true, 252 + data: { 253 + records: [ 254 + { 255 + uri: "at://did:plc:test/app.cistern.lexicon.item/item1", 256 + value: { 257 + $type: "app.cistern.lexicon.item", 258 + tid: testTid, 259 + ciphertext: encrypted.cipherText, 260 + nonce: encrypted.nonce, 261 + algorithm: "x_wing-xchacha20_poly1305-sha3_512", 262 + pubkey: 263 + "at://did:plc:test/app.cistern.lexicon.pubkey/different-key", 264 + payload: encrypted.content, 265 + contentLength: encrypted.length, 266 + contentHash: encrypted.hash, 267 + }, 268 + }, 269 + ], 270 + cursor: undefined, 271 + }, 272 + }); 273 + } 274 + return Promise.resolve({ ok: false, status: 500, data: {} }); 275 + }, 276 + } as unknown as Client; 277 + 278 + const consumer = createMockConsumer({ 279 + rpc: mockRpc, 280 + options: { 281 + handle: "test.bsky.social" as Handle, 282 + appPassword: "test-password", 283 + keypair: { 284 + privateKey: keys.secretKey.toBase64(), 285 + publicKey: 286 + "at://did:plc:test/app.cistern.lexicon.pubkey/my-key" as ResourceUri, 287 + }, 288 + }, 289 + }); 290 + 291 + const items = []; 292 + for await (const item of consumer.listItems()) { 293 + items.push(item); 294 + } 295 + 296 + expect(items).toHaveLength(0); 297 + }, 298 + }); 299 + 300 + Deno.test({ 301 + name: "listItems handles pagination", 302 + async fn() { 303 + const keys = generateKeys(); 304 + const text1 = "First item"; 305 + const text2 = "Second item"; 306 + const encrypted1 = encryptText(keys.publicKey, text1); 307 + const encrypted2 = encryptText(keys.publicKey, text2); 308 + const tid1 = now(); 309 + const tid2 = now(); 310 + 311 + let callCount = 0; 312 + const mockRpc = { 313 + get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 314 + if (endpoint === "com.atproto.repo.listRecords") { 315 + callCount++; 316 + 317 + if (callCount === 1) { 318 + return Promise.resolve({ 319 + ok: true, 320 + data: { 321 + records: [ 322 + { 323 + uri: "at://did:plc:test/app.cistern.lexicon.item/item1", 324 + value: { 325 + $type: "app.cistern.lexicon.item", 326 + tid: tid1, 327 + ciphertext: encrypted1.cipherText, 328 + nonce: encrypted1.nonce, 329 + algorithm: "x_wing-xchacha20_poly1305-sha3_512", 330 + pubkey: 331 + "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 332 + payload: encrypted1.content, 333 + contentLength: encrypted1.length, 334 + contentHash: encrypted1.hash, 335 + }, 336 + }, 337 + ], 338 + cursor: "next-page", 339 + }, 340 + }); 341 + } else { 342 + return Promise.resolve({ 343 + ok: true, 344 + data: { 345 + records: [ 346 + { 347 + uri: "at://did:plc:test/app.cistern.lexicon.item/item2", 348 + value: { 349 + $type: "app.cistern.lexicon.item", 350 + tid: tid2, 351 + ciphertext: encrypted2.cipherText, 352 + nonce: encrypted2.nonce, 353 + algorithm: "x_wing-xchacha20_poly1305-sha3_512", 354 + pubkey: 355 + "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 356 + payload: encrypted2.content, 357 + contentLength: encrypted2.length, 358 + contentHash: encrypted2.hash, 359 + }, 360 + }, 361 + ], 362 + cursor: undefined, 363 + }, 364 + }); 365 + } 366 + } 367 + return Promise.resolve({ ok: false, status: 500, data: {} }); 368 + }, 369 + } as unknown as Client; 370 + 371 + const consumer = createMockConsumer({ 372 + rpc: mockRpc, 373 + options: { 374 + handle: "test.bsky.social" as Handle, 375 + appPassword: "test-password", 376 + keypair: { 377 + privateKey: keys.secretKey.toBase64(), 378 + publicKey: 379 + "at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri, 380 + }, 381 + }, 382 + }); 383 + 384 + const items = []; 385 + for await (const item of consumer.listItems()) { 386 + items.push(item); 387 + } 388 + 389 + expect(items).toHaveLength(2); 390 + expect(items[0].text).toEqual(text1); 391 + expect(items[1].text).toEqual(text2); 392 + expect(callCount).toEqual(2); 393 + }, 394 + }); 395 + 396 + Deno.test({ 397 + name: "listItems throws when list request fails", 398 + async fn() { 399 + const mockRpc = { 400 + get: () => 401 + Promise.resolve({ 402 + ok: false, 403 + status: 401, 404 + data: { error: "Unauthorized" }, 405 + }), 406 + } as unknown as Client; 407 + 408 + const consumer = createMockConsumer({ 409 + rpc: mockRpc, 410 + options: { 411 + handle: "test.bsky.social" as Handle, 412 + appPassword: "test-password", 413 + keypair: { 414 + privateKey: new Uint8Array(32).toBase64(), 415 + publicKey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 416 + }, 417 + }, 418 + }); 419 + 420 + const iterator = consumer.listItems(); 421 + await expect(iterator.next()).rejects.toThrow("failed to list items"); 422 + }, 423 + }); 424 + 425 + Deno.test({ 426 + name: "subscribeToItems throws when no keypair is set", 427 + async fn() { 428 + const consumer = createMockConsumer(); 429 + 430 + const iterator = consumer.subscribeToItems(); 431 + await expect(iterator.next()).rejects.toThrow( 432 + "no key pair set; generate a key before subscribing", 433 + ); 434 + }, 435 + }); 436 + 437 + Deno.test({ 438 + name: "deleteItem successfully deletes an item", 439 + async fn() { 440 + let deletedRkey: string | undefined; 441 + 442 + const mockRpc = { 443 + post: (endpoint: string, params: { input: unknown }) => { 444 + if (endpoint === "com.atproto.repo.deleteRecord") { 445 + const input = params.input as { rkey: string }; 446 + deletedRkey = input.rkey; 447 + 448 + return Promise.resolve({ 449 + ok: true, 450 + data: {}, 451 + }); 452 + } 453 + return Promise.resolve({ ok: false, status: 500, data: {} }); 454 + }, 455 + } as unknown as Client; 456 + 457 + const consumer = createMockConsumer({ rpc: mockRpc }); 458 + 459 + await consumer.deleteItem("item123"); 460 + 461 + expect(deletedRkey).toEqual("item123"); 462 + }, 463 + }); 464 + 465 + Deno.test({ 466 + name: "deleteItem throws when delete request fails", 467 + async fn() { 468 + const mockRpc = { 469 + post: () => 470 + Promise.resolve({ 471 + ok: false, 472 + status: 404, 473 + data: { error: "Not Found" }, 474 + }), 475 + } as unknown as Client; 476 + 477 + const consumer = createMockConsumer({ rpc: mockRpc }); 478 + 479 + await expect(consumer.deleteItem("item123")).rejects.toThrow( 480 + "failed to delete item item123", 481 + ); 482 + }, 483 + });