tangled
alpha
login
or
join now
graham.systems
/
cistern
3
fork
atom
Encrypted, ephemeral, private memos on atproto
3
fork
atom
overview
issues
pulls
pipelines
test(consumer): add suite
graham.systems
4 months ago
35570813
42a8bfac
verified
This commit was signed with the committer's
known signature
.
graham.systems
SSH Key Fingerprint:
SHA256:Fvaam8TgCBeBlr/Fo7eA6VGAIAWmzjwUqUTw5o6anWA=
+489
-2
3 changed files
expand all
collapse all
unified
split
deno.lock
packages
consumer
deno.jsonc
mod.test.ts
+3
-1
deno.lock
···
215
215
"packages/consumer": {
216
216
"dependencies": [
217
217
"jsr:@puregarlic/randimal@^1.0.1",
218
218
+
"jsr:@std/expect@^1.0.17",
218
219
"npm:@atcute/atproto@^3.1.9",
219
220
"npm:@atcute/client@^4.0.5",
220
221
"npm:@atcute/jetstream@^1.1.2",
221
221
-
"npm:@atcute/lexicons@^1.2.2"
222
222
+
"npm:@atcute/lexicons@^1.2.2",
223
223
+
"npm:@atcute/tid@^1.0.3"
222
224
]
223
225
},
224
226
"packages/crypto": {
+3
-1
packages/consumer/deno.jsonc
···
8
8
"@atcute/client": "npm:@atcute/client@^4.0.5",
9
9
"@atcute/jetstream": "npm:@atcute/jetstream@^1.1.2",
10
10
"@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2",
11
11
-
"@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1"
11
11
+
"@atcute/tid": "npm:@atcute/tid@^1.0.3",
12
12
+
"@puregarlic/randimal": "jsr:@puregarlic/randimal@^1.0.1",
13
13
+
"@std/expect": "jsr:@std/expect@^1.0.17"
12
14
}
13
15
}
+483
packages/consumer/mod.test.ts
···
1
1
+
import { expect } from "@std/expect";
2
2
+
import { Consumer } from "./mod.ts";
3
3
+
import { encryptText, generateKeys } from "@cistern/crypto";
4
4
+
import type { ConsumerParams } from "./types.ts";
5
5
+
import type { Client, CredentialManager } from "@atcute/client";
6
6
+
import type { Did, Handle, ResourceUri } from "@atcute/lexicons";
7
7
+
import { now } from "@atcute/tid";
8
8
+
9
9
+
// Helper to create a mock Consumer instance
10
10
+
function createMockConsumer(
11
11
+
overrides?: Partial<ConsumerParams>,
12
12
+
): Consumer {
13
13
+
const mockParams: ConsumerParams = {
14
14
+
miniDoc: {
15
15
+
did: "did:plc:test123" as Did,
16
16
+
handle: "test.bsky.social" as Handle,
17
17
+
pds: "https://test.pds.example",
18
18
+
signing_key: "test-key",
19
19
+
},
20
20
+
manager: {} as CredentialManager,
21
21
+
rpc: createMockRpcClient(),
22
22
+
options: {
23
23
+
handle: "test.bsky.social" as Handle,
24
24
+
appPassword: "test-password",
25
25
+
},
26
26
+
...overrides,
27
27
+
};
28
28
+
29
29
+
return new Consumer(mockParams);
30
30
+
}
31
31
+
32
32
+
// Helper to create a mock RPC client
33
33
+
function createMockRpcClient(): Client {
34
34
+
return {
35
35
+
get: () => {
36
36
+
throw new Error("Mock RPC get not implemented");
37
37
+
},
38
38
+
post: () => {
39
39
+
throw new Error("Mock RPC post not implemented");
40
40
+
},
41
41
+
} as unknown as Client;
42
42
+
}
43
43
+
44
44
+
Deno.test({
45
45
+
name: "Consumer constructor initializes with provided params",
46
46
+
fn() {
47
47
+
const consumer = createMockConsumer();
48
48
+
49
49
+
expect(consumer.did).toEqual("did:plc:test123");
50
50
+
expect(consumer.keypair).toBeUndefined();
51
51
+
expect(consumer.rpc).toBeDefined();
52
52
+
expect(consumer.manager).toBeDefined();
53
53
+
},
54
54
+
});
55
55
+
56
56
+
Deno.test({
57
57
+
name: "Consumer constructor initializes with existing keypair",
58
58
+
fn() {
59
59
+
const mockKeypair = {
60
60
+
privateKey: new Uint8Array(32).toBase64(),
61
61
+
publicKey:
62
62
+
"at://did:plc:test/app.cistern.lexicon.pubkey/abc123" as ResourceUri,
63
63
+
};
64
64
+
65
65
+
const consumer = createMockConsumer({
66
66
+
options: {
67
67
+
handle: "test.bsky.social" as Handle,
68
68
+
appPassword: "test-password",
69
69
+
keypair: mockKeypair,
70
70
+
},
71
71
+
});
72
72
+
73
73
+
expect(consumer.keypair).toBeDefined();
74
74
+
expect(consumer.keypair?.publicKey).toEqual(mockKeypair.publicKey);
75
75
+
expect(consumer.keypair?.privateKey).toBeInstanceOf(Uint8Array);
76
76
+
},
77
77
+
});
78
78
+
79
79
+
Deno.test({
80
80
+
name: "generateKeyPair creates and uploads a new keypair",
81
81
+
async fn() {
82
82
+
let capturedRecord: unknown;
83
83
+
let capturedCollection: string | undefined;
84
84
+
85
85
+
const mockRpc = {
86
86
+
post: (endpoint: string, params: { input: unknown }) => {
87
87
+
if (endpoint === "com.atproto.repo.createRecord") {
88
88
+
const input = params.input as {
89
89
+
collection: string;
90
90
+
record: unknown;
91
91
+
};
92
92
+
capturedCollection = input.collection;
93
93
+
capturedRecord = input.record;
94
94
+
95
95
+
return Promise.resolve({
96
96
+
ok: true,
97
97
+
data: {
98
98
+
uri: "at://did:plc:test/app.cistern.lexicon.pubkey/generated123",
99
99
+
},
100
100
+
});
101
101
+
}
102
102
+
return Promise.resolve({ ok: false, status: 500, data: {} });
103
103
+
},
104
104
+
} as unknown as Client;
105
105
+
106
106
+
const consumer = createMockConsumer({ rpc: mockRpc });
107
107
+
const keypair = await consumer.generateKeyPair();
108
108
+
109
109
+
expect(keypair).toBeDefined();
110
110
+
expect(keypair.privateKey).toBeInstanceOf(Uint8Array);
111
111
+
expect(keypair.publicKey).toEqual(
112
112
+
"at://did:plc:test/app.cistern.lexicon.pubkey/generated123",
113
113
+
);
114
114
+
expect(consumer.keypair).toEqual(keypair);
115
115
+
116
116
+
expect(capturedCollection).toEqual("app.cistern.lexicon.pubkey");
117
117
+
expect(capturedRecord).toMatchObject({
118
118
+
$type: "app.cistern.lexicon.pubkey",
119
119
+
algorithm: "x_wing",
120
120
+
});
121
121
+
},
122
122
+
});
123
123
+
124
124
+
Deno.test({
125
125
+
name: "generateKeyPair throws when consumer already has a keypair",
126
126
+
async fn() {
127
127
+
const consumer = createMockConsumer({
128
128
+
options: {
129
129
+
handle: "test.bsky.social" as Handle,
130
130
+
appPassword: "test-password",
131
131
+
keypair: {
132
132
+
privateKey: new Uint8Array(32).toBase64(),
133
133
+
publicKey:
134
134
+
"at://did:plc:test/app.cistern.lexicon.pubkey/existing" as ResourceUri,
135
135
+
},
136
136
+
},
137
137
+
});
138
138
+
139
139
+
await expect(consumer.generateKeyPair()).rejects.toThrow(
140
140
+
"client already has a key pair",
141
141
+
);
142
142
+
},
143
143
+
});
144
144
+
145
145
+
Deno.test({
146
146
+
name: "generateKeyPair throws when upload fails",
147
147
+
async fn() {
148
148
+
const mockRpc = {
149
149
+
post: () =>
150
150
+
Promise.resolve({
151
151
+
ok: false,
152
152
+
status: 500,
153
153
+
data: { error: "Internal Server Error" },
154
154
+
}),
155
155
+
} as unknown as Client;
156
156
+
157
157
+
const consumer = createMockConsumer({ rpc: mockRpc });
158
158
+
159
159
+
await expect(consumer.generateKeyPair()).rejects.toThrow(
160
160
+
"failed to save public key",
161
161
+
);
162
162
+
},
163
163
+
});
164
164
+
165
165
+
Deno.test({
166
166
+
name: "listItems throws when no keypair is set",
167
167
+
async fn() {
168
168
+
const consumer = createMockConsumer();
169
169
+
170
170
+
const iterator = consumer.listItems();
171
171
+
await expect(iterator.next()).rejects.toThrow(
172
172
+
"no key pair set; generate a key before listing items",
173
173
+
);
174
174
+
},
175
175
+
});
176
176
+
177
177
+
Deno.test({
178
178
+
name: "listItems decrypts and yields items",
179
179
+
async fn() {
180
180
+
const keys = generateKeys();
181
181
+
const testText = "Test item content";
182
182
+
const encrypted = encryptText(keys.publicKey, testText);
183
183
+
const testTid = now();
184
184
+
185
185
+
const mockRpc = {
186
186
+
get: (endpoint: string) => {
187
187
+
if (endpoint === "com.atproto.repo.listRecords") {
188
188
+
return Promise.resolve({
189
189
+
ok: true,
190
190
+
data: {
191
191
+
records: [
192
192
+
{
193
193
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
194
194
+
value: {
195
195
+
$type: "app.cistern.lexicon.item",
196
196
+
tid: testTid,
197
197
+
ciphertext: encrypted.cipherText,
198
198
+
nonce: encrypted.nonce,
199
199
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
200
200
+
pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1",
201
201
+
payload: encrypted.content,
202
202
+
contentLength: encrypted.length,
203
203
+
contentHash: encrypted.hash,
204
204
+
},
205
205
+
},
206
206
+
],
207
207
+
cursor: undefined,
208
208
+
},
209
209
+
});
210
210
+
}
211
211
+
return Promise.resolve({ ok: false, status: 500, data: {} });
212
212
+
},
213
213
+
} as unknown as Client;
214
214
+
215
215
+
const consumer = createMockConsumer({
216
216
+
rpc: mockRpc,
217
217
+
options: {
218
218
+
handle: "test.bsky.social" as Handle,
219
219
+
appPassword: "test-password",
220
220
+
keypair: {
221
221
+
privateKey: keys.secretKey.toBase64(),
222
222
+
publicKey:
223
223
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri,
224
224
+
},
225
225
+
},
226
226
+
});
227
227
+
228
228
+
const items = [];
229
229
+
for await (const item of consumer.listItems()) {
230
230
+
items.push(item);
231
231
+
}
232
232
+
233
233
+
expect(items).toHaveLength(1);
234
234
+
expect(items[0].text).toEqual(testText);
235
235
+
expect(items[0].tid).toEqual(testTid);
236
236
+
},
237
237
+
});
238
238
+
239
239
+
Deno.test({
240
240
+
name: "listItems skips items with mismatched public key",
241
241
+
async fn() {
242
242
+
const keys = generateKeys();
243
243
+
const testText = "Test item content";
244
244
+
const encrypted = encryptText(keys.publicKey, testText);
245
245
+
const testTid = now();
246
246
+
247
247
+
const mockRpc = {
248
248
+
get: (endpoint: string) => {
249
249
+
if (endpoint === "com.atproto.repo.listRecords") {
250
250
+
return Promise.resolve({
251
251
+
ok: true,
252
252
+
data: {
253
253
+
records: [
254
254
+
{
255
255
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
256
256
+
value: {
257
257
+
$type: "app.cistern.lexicon.item",
258
258
+
tid: testTid,
259
259
+
ciphertext: encrypted.cipherText,
260
260
+
nonce: encrypted.nonce,
261
261
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
262
262
+
pubkey:
263
263
+
"at://did:plc:test/app.cistern.lexicon.pubkey/different-key",
264
264
+
payload: encrypted.content,
265
265
+
contentLength: encrypted.length,
266
266
+
contentHash: encrypted.hash,
267
267
+
},
268
268
+
},
269
269
+
],
270
270
+
cursor: undefined,
271
271
+
},
272
272
+
});
273
273
+
}
274
274
+
return Promise.resolve({ ok: false, status: 500, data: {} });
275
275
+
},
276
276
+
} as unknown as Client;
277
277
+
278
278
+
const consumer = createMockConsumer({
279
279
+
rpc: mockRpc,
280
280
+
options: {
281
281
+
handle: "test.bsky.social" as Handle,
282
282
+
appPassword: "test-password",
283
283
+
keypair: {
284
284
+
privateKey: keys.secretKey.toBase64(),
285
285
+
publicKey:
286
286
+
"at://did:plc:test/app.cistern.lexicon.pubkey/my-key" as ResourceUri,
287
287
+
},
288
288
+
},
289
289
+
});
290
290
+
291
291
+
const items = [];
292
292
+
for await (const item of consumer.listItems()) {
293
293
+
items.push(item);
294
294
+
}
295
295
+
296
296
+
expect(items).toHaveLength(0);
297
297
+
},
298
298
+
});
299
299
+
300
300
+
Deno.test({
301
301
+
name: "listItems handles pagination",
302
302
+
async fn() {
303
303
+
const keys = generateKeys();
304
304
+
const text1 = "First item";
305
305
+
const text2 = "Second item";
306
306
+
const encrypted1 = encryptText(keys.publicKey, text1);
307
307
+
const encrypted2 = encryptText(keys.publicKey, text2);
308
308
+
const tid1 = now();
309
309
+
const tid2 = now();
310
310
+
311
311
+
let callCount = 0;
312
312
+
const mockRpc = {
313
313
+
get: (endpoint: string, _params?: { params?: { cursor?: string } }) => {
314
314
+
if (endpoint === "com.atproto.repo.listRecords") {
315
315
+
callCount++;
316
316
+
317
317
+
if (callCount === 1) {
318
318
+
return Promise.resolve({
319
319
+
ok: true,
320
320
+
data: {
321
321
+
records: [
322
322
+
{
323
323
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item1",
324
324
+
value: {
325
325
+
$type: "app.cistern.lexicon.item",
326
326
+
tid: tid1,
327
327
+
ciphertext: encrypted1.cipherText,
328
328
+
nonce: encrypted1.nonce,
329
329
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
330
330
+
pubkey:
331
331
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1",
332
332
+
payload: encrypted1.content,
333
333
+
contentLength: encrypted1.length,
334
334
+
contentHash: encrypted1.hash,
335
335
+
},
336
336
+
},
337
337
+
],
338
338
+
cursor: "next-page",
339
339
+
},
340
340
+
});
341
341
+
} else {
342
342
+
return Promise.resolve({
343
343
+
ok: true,
344
344
+
data: {
345
345
+
records: [
346
346
+
{
347
347
+
uri: "at://did:plc:test/app.cistern.lexicon.item/item2",
348
348
+
value: {
349
349
+
$type: "app.cistern.lexicon.item",
350
350
+
tid: tid2,
351
351
+
ciphertext: encrypted2.cipherText,
352
352
+
nonce: encrypted2.nonce,
353
353
+
algorithm: "x_wing-xchacha20_poly1305-sha3_512",
354
354
+
pubkey:
355
355
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1",
356
356
+
payload: encrypted2.content,
357
357
+
contentLength: encrypted2.length,
358
358
+
contentHash: encrypted2.hash,
359
359
+
},
360
360
+
},
361
361
+
],
362
362
+
cursor: undefined,
363
363
+
},
364
364
+
});
365
365
+
}
366
366
+
}
367
367
+
return Promise.resolve({ ok: false, status: 500, data: {} });
368
368
+
},
369
369
+
} as unknown as Client;
370
370
+
371
371
+
const consumer = createMockConsumer({
372
372
+
rpc: mockRpc,
373
373
+
options: {
374
374
+
handle: "test.bsky.social" as Handle,
375
375
+
appPassword: "test-password",
376
376
+
keypair: {
377
377
+
privateKey: keys.secretKey.toBase64(),
378
378
+
publicKey:
379
379
+
"at://did:plc:test/app.cistern.lexicon.pubkey/key1" as ResourceUri,
380
380
+
},
381
381
+
},
382
382
+
});
383
383
+
384
384
+
const items = [];
385
385
+
for await (const item of consumer.listItems()) {
386
386
+
items.push(item);
387
387
+
}
388
388
+
389
389
+
expect(items).toHaveLength(2);
390
390
+
expect(items[0].text).toEqual(text1);
391
391
+
expect(items[1].text).toEqual(text2);
392
392
+
expect(callCount).toEqual(2);
393
393
+
},
394
394
+
});
395
395
+
396
396
+
Deno.test({
397
397
+
name: "listItems throws when list request fails",
398
398
+
async fn() {
399
399
+
const mockRpc = {
400
400
+
get: () =>
401
401
+
Promise.resolve({
402
402
+
ok: false,
403
403
+
status: 401,
404
404
+
data: { error: "Unauthorized" },
405
405
+
}),
406
406
+
} as unknown as Client;
407
407
+
408
408
+
const consumer = createMockConsumer({
409
409
+
rpc: mockRpc,
410
410
+
options: {
411
411
+
handle: "test.bsky.social" as Handle,
412
412
+
appPassword: "test-password",
413
413
+
keypair: {
414
414
+
privateKey: new Uint8Array(32).toBase64(),
415
415
+
publicKey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1",
416
416
+
},
417
417
+
},
418
418
+
});
419
419
+
420
420
+
const iterator = consumer.listItems();
421
421
+
await expect(iterator.next()).rejects.toThrow("failed to list items");
422
422
+
},
423
423
+
});
424
424
+
425
425
+
Deno.test({
426
426
+
name: "subscribeToItems throws when no keypair is set",
427
427
+
async fn() {
428
428
+
const consumer = createMockConsumer();
429
429
+
430
430
+
const iterator = consumer.subscribeToItems();
431
431
+
await expect(iterator.next()).rejects.toThrow(
432
432
+
"no key pair set; generate a key before subscribing",
433
433
+
);
434
434
+
},
435
435
+
});
436
436
+
437
437
+
Deno.test({
438
438
+
name: "deleteItem successfully deletes an item",
439
439
+
async fn() {
440
440
+
let deletedRkey: string | undefined;
441
441
+
442
442
+
const mockRpc = {
443
443
+
post: (endpoint: string, params: { input: unknown }) => {
444
444
+
if (endpoint === "com.atproto.repo.deleteRecord") {
445
445
+
const input = params.input as { rkey: string };
446
446
+
deletedRkey = input.rkey;
447
447
+
448
448
+
return Promise.resolve({
449
449
+
ok: true,
450
450
+
data: {},
451
451
+
});
452
452
+
}
453
453
+
return Promise.resolve({ ok: false, status: 500, data: {} });
454
454
+
},
455
455
+
} as unknown as Client;
456
456
+
457
457
+
const consumer = createMockConsumer({ rpc: mockRpc });
458
458
+
459
459
+
await consumer.deleteItem("item123");
460
460
+
461
461
+
expect(deletedRkey).toEqual("item123");
462
462
+
},
463
463
+
});
464
464
+
465
465
+
Deno.test({
466
466
+
name: "deleteItem throws when delete request fails",
467
467
+
async fn() {
468
468
+
const mockRpc = {
469
469
+
post: () =>
470
470
+
Promise.resolve({
471
471
+
ok: false,
472
472
+
status: 404,
473
473
+
data: { error: "Not Found" },
474
474
+
}),
475
475
+
} as unknown as Client;
476
476
+
477
477
+
const consumer = createMockConsumer({ rpc: mockRpc });
478
478
+
479
479
+
await expect(consumer.deleteItem("item123")).rejects.toThrow(
480
480
+
"failed to delete item item123",
481
481
+
);
482
482
+
},
483
483
+
});