Encrypted, ephemeral, private memos on atproto
at main 10 kB view raw
1import { expect } from "@std/expect"; 2import { Producer } from "./mod.ts"; 3import { generateKeys } from "@cistern/crypto"; 4import type { ProducerParams, PublicKeyOption } from "./types.ts"; 5import type { Client, CredentialManager } from "@atcute/client"; 6import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7import type { AppCisternPubkey } from "@cistern/lexicon"; 8import type { XRPCProcedures, XRPCQueries } from "@cistern/shared"; 9 10// Helper to create a mock Producer instance 11function createMockProducer( 12 overrides?: Partial<ProducerParams>, 13): Producer { 14 const mockParams: ProducerParams = { 15 miniDoc: { 16 did: "did:plc:test123" as Did, 17 handle: "test.bsky.social" as Handle, 18 pds: "https://test.pds.example", 19 signing_key: "test-key", 20 }, 21 manager: {} as CredentialManager, 22 rpc: createMockRpcClient(), 23 options: { 24 handle: "test.bsky.social" as Handle, 25 appPassword: "test-password", 26 }, 27 ...overrides, 28 }; 29 30 return new Producer(mockParams); 31} 32 33// Helper to create a mock RPC client 34function createMockRpcClient(): Client<XRPCQueries, XRPCProcedures> { 35 return { 36 get: () => { 37 throw new Error("Mock RPC get not implemented"); 38 }, 39 post: () => { 40 throw new Error("Mock RPC post not implemented"); 41 }, 42 } as unknown as Client<XRPCQueries, XRPCProcedures>; 43} 44 45Deno.test({ 46 name: "Producer constructor initializes with provided params", 47 fn() { 48 const producer = createMockProducer(); 49 50 expect(producer.did).toEqual("did:plc:test123"); 51 expect(producer.publicKey).toBeUndefined(); 52 expect(producer.rpc).toBeDefined(); 53 }, 54}); 55 56Deno.test({ 57 name: "Producer constructor initializes with existing public key", 58 fn() { 59 const mockPublicKey: PublicKeyOption = { 60 uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 61 name: "Test Key", 62 content: new Uint8Array(32).toBase64(), 63 }; 64 65 const producer = createMockProducer({ 66 publicKey: mockPublicKey, 67 }); 68 69 expect(producer.publicKey).toBeDefined(); 70 expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); 71 expect(producer.publicKey?.name).toEqual("Test Key"); 72 }, 73}); 74 75Deno.test({ 76 name: "createMemo successfully creates and uploads an encrypted memo", 77 async fn() { 78 const keys = generateKeys(); 79 let capturedRecord: unknown; 80 let capturedCollection: string | undefined; 81 82 const mockRpc = { 83 post: (endpoint: string, params: { input: unknown }) => { 84 if (endpoint === "com.atproto.repo.createRecord") { 85 const input = params.input as { 86 collection: string; 87 record: unknown; 88 }; 89 capturedCollection = input.collection; 90 capturedRecord = input.record; 91 92 return Promise.resolve({ 93 ok: true, 94 data: { 95 uri: "at://did:plc:test/app.cistern.memo/memo123" as ResourceUri, 96 }, 97 }); 98 } 99 return Promise.resolve({ ok: false, status: 500, data: {} }); 100 }, 101 } as unknown as Client<XRPCQueries, XRPCProcedures>; 102 103 const producer = createMockProducer({ 104 rpc: mockRpc, 105 publicKey: { 106 uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 107 name: "Test Key", 108 content: keys.publicKey.toBase64(), 109 }, 110 }); 111 112 const uri = await producer.createMemo("Test message"); 113 114 expect(uri).toEqual("at://did:plc:test/app.cistern.memo/memo123"); 115 expect(capturedCollection).toEqual("app.cistern.memo"); 116 expect(capturedRecord).toMatchObject({ 117 $type: "app.cistern.memo", 118 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 119 }); 120 }, 121}); 122 123Deno.test({ 124 name: "createMemo throws when no public key is set", 125 async fn() { 126 const producer = createMockProducer(); 127 128 await expect(producer.createMemo("Test message")).rejects.toThrow( 129 "no public key set; select a public key before creating a memo", 130 ); 131 }, 132}); 133 134Deno.test({ 135 name: "createMemo throws when upload fails", 136 async fn() { 137 const keys = generateKeys(); 138 const mockRpc = { 139 post: () => 140 Promise.resolve({ 141 ok: false, 142 status: 500, 143 data: { error: "Internal Server Error" }, 144 }), 145 } as unknown as Client<XRPCQueries, XRPCProcedures>; 146 147 const producer = createMockProducer({ 148 rpc: mockRpc, 149 publicKey: { 150 uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 151 name: "Test Key", 152 content: keys.publicKey.toBase64(), 153 }, 154 }); 155 156 await expect(producer.createMemo("Test message")).rejects.toThrow( 157 "failed to create new memo", 158 ); 159 }, 160}); 161 162Deno.test({ 163 name: "listPublicKeys yields public keys from PDS", 164 async fn() { 165 const mockRpc = { 166 get: (endpoint: string) => { 167 if (endpoint === "com.atproto.repo.listRecords") { 168 return Promise.resolve({ 169 ok: true, 170 data: { 171 records: [ 172 { 173 uri: "at://did:plc:test/app.cistern.pubkey/key1", 174 value: { 175 $type: "app.cistern.pubkey", 176 name: "Key 1", 177 algorithm: "x_wing", 178 content: { $bytes: new Uint8Array(32).toBase64() }, 179 createdAt: new Date().toISOString(), 180 } as AppCisternPubkey.Main, 181 }, 182 { 183 uri: "at://did:plc:test/app.cistern.pubkey/key2", 184 value: { 185 $type: "app.cistern.pubkey", 186 name: "Key 2", 187 algorithm: "x_wing", 188 content: { $bytes: new Uint8Array(32).toBase64() }, 189 createdAt: new Date().toISOString(), 190 } as AppCisternPubkey.Main, 191 }, 192 ], 193 cursor: undefined, 194 }, 195 }); 196 } 197 return Promise.resolve({ ok: false, status: 500, data: {} }); 198 }, 199 } as unknown as Client<XRPCQueries, XRPCProcedures>; 200 201 const producer = createMockProducer({ rpc: mockRpc }); 202 203 const keys = []; 204 for await (const key of producer.listPublicKeys()) { 205 keys.push(key); 206 } 207 208 expect(keys).toHaveLength(2); 209 expect(keys[0].name).toEqual("Key 1"); 210 expect(keys[1].name).toEqual("Key 2"); 211 }, 212}); 213 214Deno.test({ 215 name: "listPublicKeys handles pagination", 216 async fn() { 217 let callCount = 0; 218 const mockRpc = { 219 get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 220 if (endpoint === "com.atproto.repo.listRecords") { 221 callCount++; 222 223 if (callCount === 1) { 224 return Promise.resolve({ 225 ok: true, 226 data: { 227 records: [ 228 { 229 uri: "at://did:plc:test/app.cistern.pubkey/key1", 230 value: { 231 $type: "app.cistern.pubkey", 232 name: "Key 1", 233 algorithm: "x_wing", 234 content: { $bytes: new Uint8Array(32).toBase64() }, 235 createdAt: new Date().toISOString(), 236 } as AppCisternPubkey.Main, 237 }, 238 ], 239 cursor: "next-page", 240 }, 241 }); 242 } else { 243 return Promise.resolve({ 244 ok: true, 245 data: { 246 records: [ 247 { 248 uri: "at://did:plc:test/app.cistern.pubkey/key2", 249 value: { 250 $type: "app.cistern.pubkey", 251 name: "Key 2", 252 algorithm: "x_wing", 253 content: { $bytes: new Uint8Array(32).toBase64() }, 254 createdAt: new Date().toISOString(), 255 } as AppCisternPubkey.Main, 256 }, 257 ], 258 cursor: undefined, 259 }, 260 }); 261 } 262 } 263 return Promise.resolve({ ok: false, status: 500, data: {} }); 264 }, 265 } as unknown as Client<XRPCQueries, XRPCProcedures>; 266 267 const producer = createMockProducer({ rpc: mockRpc }); 268 269 const keys = []; 270 for await (const key of producer.listPublicKeys()) { 271 keys.push(key); 272 } 273 274 expect(keys).toHaveLength(2); 275 expect(keys[0].name).toEqual("Key 1"); 276 expect(keys[1].name).toEqual("Key 2"); 277 expect(callCount).toEqual(2); 278 }, 279}); 280 281Deno.test({ 282 name: "listPublicKeys throws when request fails", 283 async fn() { 284 const mockRpc = { 285 get: () => 286 Promise.resolve({ 287 ok: false, 288 status: 401, 289 data: { error: "Unauthorized" }, 290 }), 291 } as unknown as Client<XRPCQueries, XRPCProcedures>; 292 293 const producer = createMockProducer({ rpc: mockRpc }); 294 295 const iterator = producer.listPublicKeys(); 296 await expect(iterator.next()).rejects.toThrow("failed to list public keys"); 297 }, 298}); 299 300Deno.test({ 301 name: "selectPublicKey sets the active public key", 302 fn() { 303 const producer = createMockProducer(); 304 305 const mockPublicKey: PublicKeyOption = { 306 uri: "at://did:plc:test/app.cistern.pubkey/key1" as ResourceUri, 307 name: "Selected Key", 308 content: new Uint8Array(32).toBase64(), 309 }; 310 311 expect(producer.publicKey).toBeUndefined(); 312 313 producer.selectPublicKey(mockPublicKey); 314 315 expect(producer.publicKey).toBeDefined(); 316 expect(producer.publicKey?.uri).toEqual(mockPublicKey.uri); 317 expect(producer.publicKey?.name).toEqual("Selected Key"); 318 }, 319}); 320 321Deno.test({ 322 name: "selectPublicKey can change the active key", 323 fn() { 324 const producer = createMockProducer({ 325 publicKey: { 326 uri: "at://did:plc:test/app.cistern.pubkey/old" as ResourceUri, 327 name: "Old Key", 328 content: new Uint8Array(32).toBase64(), 329 }, 330 }); 331 332 expect(producer.publicKey?.name).toEqual("Old Key"); 333 334 const newKey: PublicKeyOption = { 335 uri: "at://did:plc:test/app.cistern.pubkey/new" as ResourceUri, 336 name: "New Key", 337 content: new Uint8Array(32).toBase64(), 338 }; 339 340 producer.selectPublicKey(newKey); 341 342 expect(producer.publicKey?.name).toEqual("New Key"); 343 expect(producer.publicKey?.uri).toEqual(newKey.uri); 344 }, 345});