Encrypted, ephemeral, private memos on atproto
1import { expect } from "@std/expect";
2import { createConsumer } from "@cistern/consumer";
3import { createProducer } from "@cistern/producer";
4import type { Handle } from "@atcute/lexicons";
5
6/**
7 * End-to-end integration test for Cistern
8 *
9 * This test requires the following environment variables:
10 * - CISTERN_HANDLE: Your Bluesky handle (e.g., "user.bsky.social")
11 * - CISTERN_APP_PASSWORD: Your app password
12 *
13 * To run this test:
14 * ```bash
15 * CISTERN_HANDLE="your.handle" CISTERN_APP_PASSWORD="your-app-password" deno test --allow-env --allow-net e2e.test.ts
16 * ```
17 */
18
19const SKIP_E2E = !Deno.env.get("CISTERN_HANDLE") ||
20 !Deno.env.get("CISTERN_APP_PASSWORD");
21
22Deno.test({
23 name: "E2E: Full encryption workflow",
24 ignore: SKIP_E2E,
25 async fn(t) {
26 const handle = Deno.env.get("CISTERN_HANDLE") as Handle;
27 const appPassword = Deno.env.get("CISTERN_APP_PASSWORD")!;
28
29 let consumer: Awaited<ReturnType<typeof createConsumer>>;
30 let producer: Awaited<ReturnType<typeof createProducer>>;
31 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>;
32 let memoUri: string;
33 let testMessage: string;
34
35 await t.step("Create consumer", async () => {
36 consumer = await createConsumer({
37 handle,
38 appPassword,
39 });
40
41 expect(consumer.did).toBeDefined();
42 expect(consumer.rpc).toBeDefined();
43 });
44
45 await t.step("Generate keypair", async () => {
46 keypair = await consumer.generateKeyPair();
47
48 expect(keypair.privateKey).toBeInstanceOf(Uint8Array);
49 expect(keypair.publicKey).toBeDefined();
50 expect(keypair.publicKey).toContain("app.cistern.pubkey");
51 });
52
53 try {
54 await t.step("Create producer with public key", async () => {
55 const publicKeyRkey = keypair.publicKey.split("/").pop()!;
56 producer = await createProducer({
57 handle,
58 appPassword,
59 publicKey: publicKeyRkey,
60 });
61
62 expect(producer.publicKey).toBeDefined();
63 expect(producer.publicKey?.uri).toEqual(keypair.publicKey);
64 });
65
66 await t.step("Create encrypted memo", async () => {
67 testMessage = `E2E Test - ${new Date().toISOString()}`;
68 memoUri = await producer.createMemo(testMessage);
69
70 expect(memoUri).toBeDefined();
71 expect(memoUri).toContain("app.cistern.memo");
72 });
73
74 await t.step("List and decrypt memos", async () => {
75 const memos = [];
76 for await (const memo of consumer.listMemos()) {
77 memos.push(memo);
78 }
79
80 expect(memos.length).toBeGreaterThan(0);
81
82 const ourMemo = memos.find((memo) => memo.text === testMessage);
83 expect(ourMemo).toBeDefined();
84 expect(ourMemo!.text).toEqual(testMessage);
85 });
86
87 await t.step("Delete memo", async () => {
88 const memoRkey = memoUri.split("/").pop()!;
89 await consumer.deleteMemo(memoRkey);
90
91 // Verify deletion
92 const memosAfterDelete = [];
93 for await (const memo of consumer.listMemos()) {
94 memosAfterDelete.push(memo);
95 }
96
97 const deletedMemo = memosAfterDelete.find(
98 (memo) => memo.text === testMessage,
99 );
100 expect(deletedMemo).toBeUndefined();
101 });
102
103 await t.step("List public keys", async () => {
104 const keys = [];
105 for await (const key of producer.listPublicKeys()) {
106 keys.push(key);
107 }
108
109 expect(keys.length).toBeGreaterThan(0);
110
111 const ourKey = keys.find((key) => key.uri === keypair.publicKey);
112 expect(ourKey).toBeDefined();
113 expect(ourKey!.uri).toEqual(keypair.publicKey);
114 });
115 } finally {
116 await t.step("Cleanup: Delete test keypair", async () => {
117 const publicKeyRkey = keypair.publicKey.split("/").pop()!;
118
119 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", {
120 input: {
121 collection: "app.cistern.pubkey",
122 repo: consumer.did,
123 rkey: publicKeyRkey,
124 },
125 });
126
127 expect(res.ok).toBe(true);
128 });
129 }
130 },
131});
132
133Deno.test({
134 name: "E2E: Multiple memos with same keypair",
135 ignore: SKIP_E2E,
136 async fn(t) {
137 const handle = Deno.env.get("CISTERN_HANDLE") as Handle;
138 const appPassword = Deno.env.get("CISTERN_APP_PASSWORD")!;
139
140 let consumer: Awaited<ReturnType<typeof createConsumer>>;
141 let producer: Awaited<ReturnType<typeof createProducer>>;
142 let keypair: Awaited<ReturnType<typeof consumer.generateKeyPair>>;
143 let messages: string[];
144 let memoUris: string[];
145
146 await t.step("Create consumer and generate keypair", async () => {
147 consumer = await createConsumer({
148 handle,
149 appPassword,
150 });
151
152 keypair = await consumer.generateKeyPair();
153
154 expect(keypair.privateKey).toBeInstanceOf(Uint8Array);
155 expect(keypair.publicKey).toBeDefined();
156 });
157
158 try {
159 await t.step("Create producer", async () => {
160 const publicKeyRkey = keypair.publicKey.split("/").pop()!;
161 producer = await createProducer({
162 handle,
163 appPassword,
164 publicKey: publicKeyRkey,
165 });
166
167 expect(producer.publicKey?.uri).toEqual(keypair.publicKey);
168 });
169
170 await t.step("Create multiple encrypted memos", async () => {
171 messages = [
172 `E2E Memo 1 - ${new Date().toISOString()}`,
173 `E2E Memo 2 - ${new Date().toISOString()}`,
174 `E2E Memo 3 - ${new Date().toISOString()}`,
175 ];
176
177 memoUris = [];
178 for (const message of messages) {
179 const uri = await producer.createMemo(message);
180 memoUris.push(uri);
181 }
182
183 expect(memoUris).toHaveLength(3);
184 });
185
186 await t.step("Decrypt all memos", async () => {
187 const memos = [];
188 for await (const memo of consumer.listMemos()) {
189 memos.push(memo);
190 }
191
192 expect(memos.length).toBeGreaterThanOrEqual(3);
193
194 // Verify all test messages are present
195 for (const message of messages) {
196 const memo = memos.find((m) => m.text === message);
197 expect(memo).toBeDefined();
198 expect(memo!.text).toEqual(message);
199 }
200 });
201
202 await t.step("Cleanup: Delete test memos", async () => {
203 for (const uri of memoUris) {
204 const rkey = uri.split("/").pop()!;
205 await consumer.deleteMemo(rkey);
206 }
207
208 // Verify all memos deleted
209 const remainingMemos = [];
210 for await (const memo of consumer.listMemos()) {
211 remainingMemos.push(memo);
212 }
213
214 for (const message of messages) {
215 const memo = remainingMemos.find((m) => m.text === message);
216 expect(memo).toBeUndefined();
217 }
218 });
219 } finally {
220 await t.step("Cleanup: Delete test keypair", async () => {
221 const publicKeyRkey = keypair.publicKey.split("/").pop()!;
222
223 const res = await consumer.rpc.post("com.atproto.repo.deleteRecord", {
224 input: {
225 collection: "app.cistern.pubkey",
226 repo: consumer.did,
227 rkey: publicKeyRkey,
228 },
229 });
230
231 expect(res.ok).toBe(true);
232 });
233 }
234 },
235});
236
237if (SKIP_E2E) {
238 console.log(`
239⚠️ E2E tests skipped - missing environment variables
240
241To run E2E tests, set the following environment variables:
242 CISTERN_HANDLE="your.bsky.social"
243 CISTERN_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
244
245Then run:
246 deno test --allow-env --allow-net e2e.test.ts
247`);
248}