Encrypted, ephemeral, private memos on atproto
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});