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})