A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto
pds
1import { test, describe } from 'node:test'
2import assert from 'node:assert'
3import {
4 cborEncode, cborDecode, createCid, cidToString, cidToBytes, base32Encode, createTid,
5 generateKeyPair, importPrivateKey, sign, bytesToHex, hexToBytes,
6 getKeyDepth, varint, base32Decode, buildCarFile
7} from '../src/pds.js'
8
9describe('CBOR Encoding', () => {
10 test('encodes simple map', () => {
11 const encoded = cborEncode({ hello: 'world', num: 42 })
12 // Expected: a2 65 68 65 6c 6c 6f 65 77 6f 72 6c 64 63 6e 75 6d 18 2a
13 const expected = new Uint8Array([
14 0xa2, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64,
15 0x63, 0x6e, 0x75, 0x6d, 0x18, 0x2a
16 ])
17 assert.deepStrictEqual(encoded, expected)
18 })
19
20 test('encodes null', () => {
21 const encoded = cborEncode(null)
22 assert.deepStrictEqual(encoded, new Uint8Array([0xf6]))
23 })
24
25 test('encodes booleans', () => {
26 assert.deepStrictEqual(cborEncode(true), new Uint8Array([0xf5]))
27 assert.deepStrictEqual(cborEncode(false), new Uint8Array([0xf4]))
28 })
29
30 test('encodes small integers', () => {
31 assert.deepStrictEqual(cborEncode(0), new Uint8Array([0x00]))
32 assert.deepStrictEqual(cborEncode(1), new Uint8Array([0x01]))
33 assert.deepStrictEqual(cborEncode(23), new Uint8Array([0x17]))
34 })
35
36 test('encodes integers >= 24', () => {
37 assert.deepStrictEqual(cborEncode(24), new Uint8Array([0x18, 0x18]))
38 assert.deepStrictEqual(cborEncode(255), new Uint8Array([0x18, 0xff]))
39 })
40
41 test('encodes negative integers', () => {
42 assert.deepStrictEqual(cborEncode(-1), new Uint8Array([0x20]))
43 assert.deepStrictEqual(cborEncode(-10), new Uint8Array([0x29]))
44 })
45
46 test('encodes strings', () => {
47 const encoded = cborEncode('hello')
48 // 0x65 = text string of length 5
49 assert.deepStrictEqual(encoded, new Uint8Array([0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f]))
50 })
51
52 test('encodes byte strings', () => {
53 const bytes = new Uint8Array([1, 2, 3])
54 const encoded = cborEncode(bytes)
55 // 0x43 = byte string of length 3
56 assert.deepStrictEqual(encoded, new Uint8Array([0x43, 1, 2, 3]))
57 })
58
59 test('encodes arrays', () => {
60 const encoded = cborEncode([1, 2, 3])
61 // 0x83 = array of length 3
62 assert.deepStrictEqual(encoded, new Uint8Array([0x83, 0x01, 0x02, 0x03]))
63 })
64
65 test('sorts map keys deterministically', () => {
66 const encoded1 = cborEncode({ z: 1, a: 2 })
67 const encoded2 = cborEncode({ a: 2, z: 1 })
68 assert.deepStrictEqual(encoded1, encoded2)
69 // First key should be 'a' (0x61)
70 assert.strictEqual(encoded1[1], 0x61)
71 })
72
73 test('encodes large integers >= 2^31 without overflow', () => {
74 // 2^31 would overflow with bitshift operators (treated as signed 32-bit)
75 const twoTo31 = 2147483648
76 const encoded = cborEncode(twoTo31)
77 const decoded = cborDecode(encoded)
78 assert.strictEqual(decoded, twoTo31)
79
80 // 2^32 - 1 (max unsigned 32-bit)
81 const maxU32 = 4294967295
82 const encoded2 = cborEncode(maxU32)
83 const decoded2 = cborDecode(encoded2)
84 assert.strictEqual(decoded2, maxU32)
85 })
86
87 test('encodes 2^31 with correct byte format', () => {
88 // 2147483648 = 0x80000000
89 // CBOR: major type 0 (unsigned int), additional info 26 (4-byte follows)
90 const encoded = cborEncode(2147483648)
91 assert.strictEqual(encoded[0], 0x1a) // type 0 | info 26
92 assert.strictEqual(encoded[1], 0x80)
93 assert.strictEqual(encoded[2], 0x00)
94 assert.strictEqual(encoded[3], 0x00)
95 assert.strictEqual(encoded[4], 0x00)
96 })
97})
98
99describe('Base32 Encoding', () => {
100 test('encodes bytes to base32lower', () => {
101 const bytes = new Uint8Array([0x01, 0x71, 0x12, 0x20])
102 const encoded = base32Encode(bytes)
103 assert.strictEqual(typeof encoded, 'string')
104 assert.match(encoded, /^[a-z2-7]+$/)
105 })
106})
107
108describe('CID Generation', () => {
109 test('creates CIDv1 with dag-cbor codec', async () => {
110 const data = cborEncode({ test: 'data' })
111 const cid = await createCid(data)
112
113 assert.strictEqual(cid.length, 36) // 2 prefix + 2 multihash header + 32 hash
114 assert.strictEqual(cid[0], 0x01) // CIDv1
115 assert.strictEqual(cid[1], 0x71) // dag-cbor
116 assert.strictEqual(cid[2], 0x12) // sha-256
117 assert.strictEqual(cid[3], 0x20) // 32 bytes
118 })
119
120 test('cidToString returns base32lower with b prefix', async () => {
121 const data = cborEncode({ test: 'data' })
122 const cid = await createCid(data)
123 const cidStr = cidToString(cid)
124
125 assert.strictEqual(cidStr[0], 'b')
126 assert.match(cidStr, /^b[a-z2-7]+$/)
127 })
128
129 test('same input produces same CID', async () => {
130 const data1 = cborEncode({ test: 'data' })
131 const data2 = cborEncode({ test: 'data' })
132 const cid1 = cidToString(await createCid(data1))
133 const cid2 = cidToString(await createCid(data2))
134
135 assert.strictEqual(cid1, cid2)
136 })
137
138 test('different input produces different CID', async () => {
139 const cid1 = cidToString(await createCid(cborEncode({ a: 1 })))
140 const cid2 = cidToString(await createCid(cborEncode({ a: 2 })))
141
142 assert.notStrictEqual(cid1, cid2)
143 })
144})
145
146describe('TID Generation', () => {
147 test('creates 13-character TIDs', () => {
148 const tid = createTid()
149 assert.strictEqual(tid.length, 13)
150 })
151
152 test('uses valid base32-sort characters', () => {
153 const tid = createTid()
154 assert.match(tid, /^[234567abcdefghijklmnopqrstuvwxyz]+$/)
155 })
156
157 test('generates monotonically increasing TIDs', () => {
158 const tid1 = createTid()
159 const tid2 = createTid()
160 const tid3 = createTid()
161
162 assert.ok(tid1 < tid2, `${tid1} should be less than ${tid2}`)
163 assert.ok(tid2 < tid3, `${tid2} should be less than ${tid3}`)
164 })
165
166 test('generates unique TIDs', () => {
167 const tids = new Set()
168 for (let i = 0; i < 100; i++) {
169 tids.add(createTid())
170 }
171 assert.strictEqual(tids.size, 100)
172 })
173})
174
175describe('P-256 Signing', () => {
176 test('generates key pair with correct sizes', async () => {
177 const kp = await generateKeyPair()
178
179 assert.strictEqual(kp.privateKey.length, 32)
180 assert.strictEqual(kp.publicKey.length, 33) // compressed
181 assert.ok(kp.publicKey[0] === 0x02 || kp.publicKey[0] === 0x03)
182 })
183
184 test('can sign data with generated key', async () => {
185 const kp = await generateKeyPair()
186 const key = await importPrivateKey(kp.privateKey)
187 const data = new TextEncoder().encode('test message')
188 const sig = await sign(key, data)
189
190 assert.strictEqual(sig.length, 64) // r (32) + s (32)
191 })
192
193 test('different messages produce different signatures', async () => {
194 const kp = await generateKeyPair()
195 const key = await importPrivateKey(kp.privateKey)
196
197 const sig1 = await sign(key, new TextEncoder().encode('message 1'))
198 const sig2 = await sign(key, new TextEncoder().encode('message 2'))
199
200 assert.notDeepStrictEqual(sig1, sig2)
201 })
202
203 test('bytesToHex and hexToBytes roundtrip', () => {
204 const original = new Uint8Array([0x00, 0x0f, 0xf0, 0xff, 0xab, 0xcd])
205 const hex = bytesToHex(original)
206 const back = hexToBytes(hex)
207
208 assert.strictEqual(hex, '000ff0ffabcd')
209 assert.deepStrictEqual(back, original)
210 })
211
212 test('importPrivateKey rejects invalid key lengths', async () => {
213 // Too short
214 await assert.rejects(
215 () => importPrivateKey(new Uint8Array(31)),
216 /expected 32 bytes, got 31/
217 )
218
219 // Too long
220 await assert.rejects(
221 () => importPrivateKey(new Uint8Array(33)),
222 /expected 32 bytes, got 33/
223 )
224
225 // Empty
226 await assert.rejects(
227 () => importPrivateKey(new Uint8Array(0)),
228 /expected 32 bytes, got 0/
229 )
230 })
231
232 test('importPrivateKey rejects non-Uint8Array input', async () => {
233 // Arrays have .length but aren't Uint8Array
234 await assert.rejects(
235 () => importPrivateKey([1, 2, 3]),
236 /Invalid private key/
237 )
238
239 // Strings don't work either
240 await assert.rejects(
241 () => importPrivateKey('not bytes'),
242 /Invalid private key/
243 )
244
245 // null/undefined
246 await assert.rejects(
247 () => importPrivateKey(null),
248 /Invalid private key/
249 )
250 })
251})
252
253describe('MST Key Depth', () => {
254 test('returns a non-negative integer', async () => {
255 const depth = await getKeyDepth('app.bsky.feed.post/abc123')
256 assert.strictEqual(typeof depth, 'number')
257 assert.ok(depth >= 0)
258 })
259
260 test('is deterministic for same key', async () => {
261 const key = 'app.bsky.feed.post/test123'
262 const depth1 = await getKeyDepth(key)
263 const depth2 = await getKeyDepth(key)
264 assert.strictEqual(depth1, depth2)
265 })
266
267 test('different keys can have different depths', async () => {
268 // Generate many keys and check we get some variation
269 const depths = new Set()
270 for (let i = 0; i < 100; i++) {
271 depths.add(await getKeyDepth(`collection/key${i}`))
272 }
273 // Should have at least 1 unique depth (realistically more)
274 assert.ok(depths.size >= 1)
275 })
276
277 test('handles empty string', async () => {
278 const depth = await getKeyDepth('')
279 assert.strictEqual(typeof depth, 'number')
280 assert.ok(depth >= 0)
281 })
282
283 test('handles unicode strings', async () => {
284 const depth = await getKeyDepth('app.bsky.feed.post/émoji🎉')
285 assert.strictEqual(typeof depth, 'number')
286 assert.ok(depth >= 0)
287 })
288})
289
290describe('CBOR Decoding', () => {
291 test('decodes what encode produces (roundtrip)', () => {
292 const original = { hello: 'world', num: 42 }
293 const encoded = cborEncode(original)
294 const decoded = cborDecode(encoded)
295 assert.deepStrictEqual(decoded, original)
296 })
297
298 test('decodes null', () => {
299 const encoded = cborEncode(null)
300 const decoded = cborDecode(encoded)
301 assert.strictEqual(decoded, null)
302 })
303
304 test('decodes booleans', () => {
305 assert.strictEqual(cborDecode(cborEncode(true)), true)
306 assert.strictEqual(cborDecode(cborEncode(false)), false)
307 })
308
309 test('decodes integers', () => {
310 assert.strictEqual(cborDecode(cborEncode(0)), 0)
311 assert.strictEqual(cborDecode(cborEncode(42)), 42)
312 assert.strictEqual(cborDecode(cborEncode(255)), 255)
313 assert.strictEqual(cborDecode(cborEncode(-1)), -1)
314 assert.strictEqual(cborDecode(cborEncode(-10)), -10)
315 })
316
317 test('decodes strings', () => {
318 assert.strictEqual(cborDecode(cborEncode('hello')), 'hello')
319 assert.strictEqual(cborDecode(cborEncode('')), '')
320 })
321
322 test('decodes arrays', () => {
323 assert.deepStrictEqual(cborDecode(cborEncode([1, 2, 3])), [1, 2, 3])
324 assert.deepStrictEqual(cborDecode(cborEncode([])), [])
325 })
326
327 test('decodes nested structures', () => {
328 const original = { arr: [1, { nested: true }], str: 'test' }
329 const decoded = cborDecode(cborEncode(original))
330 assert.deepStrictEqual(decoded, original)
331 })
332})
333
334describe('CAR File Builder', () => {
335 test('varint encodes small numbers', () => {
336 assert.deepStrictEqual(varint(0), new Uint8Array([0]))
337 assert.deepStrictEqual(varint(1), new Uint8Array([1]))
338 assert.deepStrictEqual(varint(127), new Uint8Array([127]))
339 })
340
341 test('varint encodes multi-byte numbers', () => {
342 // 128 = 0x80 -> [0x80 | 0x00, 0x01] = [0x80, 0x01]
343 assert.deepStrictEqual(varint(128), new Uint8Array([0x80, 0x01]))
344 // 300 = 0x12c -> [0xac, 0x02]
345 assert.deepStrictEqual(varint(300), new Uint8Array([0xac, 0x02]))
346 })
347
348 test('base32 encode/decode roundtrip', () => {
349 const original = new Uint8Array([0x01, 0x71, 0x12, 0x20, 0xab, 0xcd])
350 const encoded = base32Encode(original)
351 const decoded = base32Decode(encoded)
352 assert.deepStrictEqual(decoded, original)
353 })
354
355 test('buildCarFile produces valid structure', async () => {
356 const data = cborEncode({ test: 'data' })
357 const cid = await createCid(data)
358 const cidStr = cidToString(cid)
359
360 const car = buildCarFile(cidStr, [{ cid: cidStr, data }])
361
362 assert.ok(car instanceof Uint8Array)
363 assert.ok(car.length > 0)
364 // First byte should be varint of header length
365 assert.ok(car[0] > 0)
366 })
367})