Encrypted, ephemeral, private memos on atproto

test(producer): add suite

graham.systems 683dd271 95448e96

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