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