kersiosocket 0.0.0-stage → 0.6.0-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/BUILD-INFO.md +15 -0
  2. package/CHANGELOG.md +30 -0
  3. package/LICENSE +21 -0
  4. package/README.md +191 -2
  5. package/bench/wabinary.js +31 -0
  6. package/bridges/whatsmeow/README.md +42 -0
  7. package/docs/ENGINE-MATRIX.md +23 -0
  8. package/docs/PROTOCOL-ROADMAP.md +74 -0
  9. package/docs/RESEARCH-BASELINE.md +36 -0
  10. package/docs/WABINARY.md +37 -0
  11. package/examples/basic.js +10 -0
  12. package/examples/client-payload.js +21 -0
  13. package/examples/device-props.js +18 -0
  14. package/examples/sessions.js +11 -0
  15. package/examples/wabinary.js +15 -0
  16. package/licenses/THIRD-PARTY-NOTICES.md +12 -0
  17. package/package.json +31 -4
  18. package/scripts/import-token-table.js +95 -0
  19. package/src/backends/baileys.js +121 -0
  20. package/src/backends/base.js +102 -0
  21. package/src/backends/whatsmeow-process.js +202 -0
  22. package/src/backends/zapo.js +123 -0
  23. package/src/client/kersio-client.js +305 -0
  24. package/src/client/session-manager.js +67 -0
  25. package/src/index.js +36 -0
  26. package/src/pairing/adapter.js +115 -0
  27. package/src/pairing/lifecycle.js +120 -0
  28. package/src/pairing/qr.js +38 -0
  29. package/src/pairing/verified-commit.js +46 -0
  30. package/src/proto/client-payload.js +185 -0
  31. package/src/proto/device-props.js +143 -0
  32. package/src/proto/handshake.js +79 -0
  33. package/src/proto/wire.js +137 -0
  34. package/src/protocol/node-protocol.js +155 -0
  35. package/src/session/codec.js +25 -0
  36. package/src/session/encrypted-store.js +107 -0
  37. package/src/session/locking.js +233 -0
  38. package/src/session/manager.js +61 -0
  39. package/src/session/stores.js +154 -0
  40. package/src/socket/connection.js +278 -0
  41. package/src/socket/frame.js +77 -0
  42. package/src/socket/make.js +70 -0
  43. package/src/socket/noise.js +146 -0
  44. package/src/socket/transport.js +39 -0
  45. package/src/utils/crypto.js +52 -0
  46. package/src/utils/latency.js +32 -0
  47. package/src/utils/lru.js +76 -0
  48. package/src/wabinary/codec.js +374 -0
  49. package/src/wabinary/tags.js +25 -0
  50. package/src/wabinary/token-source.js +146 -0
  51. package/src/wabinary/tokens.json +5 -0
  52. package/test/backend-adapters.test.js +59 -0
  53. package/test/connection.test.js +173 -0
  54. package/test/device-props.test.js +67 -0
  55. package/test/encrypted-store.test.js +54 -0
  56. package/test/frame.test.js +37 -0
  57. package/test/helpers.js +43 -0
  58. package/test/locking-archive.test.js +49 -0
  59. package/test/node-protocol.test.js +90 -0
  60. package/test/noise.test.js +88 -0
  61. package/test/pairing-adapter.test.js +71 -0
  62. package/test/pairing.test.js +32 -0
  63. package/test/proto.test.js +64 -0
  64. package/test/session-manager.test.js +34 -0
  65. package/test/session.test.js +134 -0
  66. package/test/token-source.test.js +35 -0
  67. package/test/unified-client.test.js +63 -0
  68. package/test/verified-commit.test.js +43 -0
  69. package/test/wabinary.test.js +147 -0
  70. package/test/whatsmeow-process.test.js +43 -0
@@ -0,0 +1,71 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { PairingLifecycle } = require('../src/pairing/lifecycle')
6
+ const { attachQrPairingAdapter } = require('../src/pairing/adapter')
7
+
8
+ function mockSocket(credentials) {
9
+ const ev = new EventEmitter()
10
+ ev._safeEmit = (event, ...args) => {
11
+ for (const listener of ev.rawListeners(event)) {
12
+ try { Reflect.apply(listener, ev, args) } catch { /* test helper isolates events */ }
13
+ }
14
+ }
15
+ return {
16
+ ev,
17
+ sessionId: 'fixture-session',
18
+ pairing: new PairingLifecycle({ sessionId: 'fixture-session' }),
19
+ protocol: {},
20
+ sentNodes: [],
21
+ sendNode(node) { this.sentNodes.push(node) },
22
+ credentials
23
+ }
24
+ }
25
+
26
+ test('QR adapter acknowledges pair-device and emits references from decoded node', async () => {
27
+ const creds = {
28
+ noiseKey: { public: Buffer.alloc(32, 1) },
29
+ signedIdentityKey: { public: Buffer.alloc(32, 2) },
30
+ advSecretKey: Buffer.alloc(32, 3)
31
+ }
32
+ const sock = mockSocket(creds)
33
+ const firstQr = new Promise((resolve) => sock.pairing.once('pairing.qr', resolve))
34
+ sock.pairing.begin('qr')
35
+ const detach = attachQrPairingAdapter(sock, { credentials: creds, clientType: '1', firstQrMs: 60000, nextQrMs: 20000 })
36
+ sock.ev.emit('node', {
37
+ tag: 'iq', attrs: { type: 'set', from: 'server@c.us', id: 'pair-1' },
38
+ content: [{ tag: 'pair-device', attrs: {}, content: [
39
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-fixture-1') },
40
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-fixture-2') }
41
+ ] }]
42
+ })
43
+ const qr = await firstQr
44
+ assert.match(qr.qr, /^https:\/\/wa\.me\/settings\/linked_devices#ref-fixture-1,/)
45
+ assert.equal(qr.sessionId, 'fixture-session')
46
+ assert.deepEqual(sock.sentNodes, [{ tag: 'iq', attrs: { to: 's.whatsapp.net', type: 'result', id: 'pair-1' } }])
47
+ detach()
48
+ sock.pairing.close()
49
+ })
50
+
51
+ test('QR adapter does not invent keys and never marks pair-success verified', async () => {
52
+ const sock = mockSocket(undefined)
53
+ const errors = []
54
+ let candidate
55
+ sock.pairing.on('pairing.error', (event) => errors.push(event.error.message))
56
+ sock.ev.on('pairing.success.candidate', (event) => { candidate = event })
57
+ sock.pairing.begin('qr')
58
+ const detach = attachQrPairingAdapter(sock, { credentials: undefined, clientType: '1' })
59
+ sock.ev.emit('node', {
60
+ tag: 'iq', attrs: { type: 'set', from: 's.whatsapp.net', id: 'pair-2' },
61
+ content: [{ tag: 'pair-device', attrs: {}, content: [{ tag: 'ref', attrs: {}, content: Buffer.from('ref') }] }]
62
+ })
63
+ await new Promise((resolve) => setImmediate(resolve))
64
+ assert.ok(errors.length >= 1)
65
+ assert.match(errors[0], /kunci|key|credential|bytes/i)
66
+ sock.ev.emit('node', { tag: 'iq', attrs: { type: 'set', from: 's.whatsapp.net', id: 'pair-3' }, content: [{ tag: 'pair-success', attrs: {}, content: [] }] })
67
+ assert.ok(candidate)
68
+ assert.notEqual(sock.pairing.state, 'paired')
69
+ detach()
70
+ sock.pairing.close()
71
+ })
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { PairingLifecycle } = require('../src')
5
+
6
+ test('PairingLifecycle QR: update -> QR -> paired, terisolasi per session', () => {
7
+ const a = new PairingLifecycle({ sessionId: 'nomor-a' })
8
+ const b = new PairingLifecycle({ sessionId: 'nomor-b' })
9
+ const updates = [], qrs = [], success = []
10
+ a.on('pairing.update', (e) => updates.push(e.state))
11
+ a.on('pairing.qr', (e) => qrs.push(e.qr))
12
+ a.on('pairing.success', (e) => success.push(e.sessionId))
13
+ a.begin('qr').acceptQr('test-only-qr-payload').complete({ registered: true })
14
+ assert.deepEqual(updates, ['waiting-qr', 'paired'])
15
+ assert.deepEqual(qrs, ['test-only-qr-payload'])
16
+ assert.deepEqual(success, ['nomor-a'])
17
+ assert.equal(b.state, 'idle')
18
+ })
19
+
20
+ test('PairingLifecycle code: validasi transisi dan invalidasi state setelah close', () => {
21
+ const flow = new PairingLifecycle({ sessionId: 'nomor-c' })
22
+ let code = null
23
+ flow.on('pairing.code', (e) => { code = e.code })
24
+ flow.begin('code').acceptCode('AB12-CD34')
25
+ assert.equal(code, 'AB12-CD34')
26
+ assert.throws(() => flow.acceptQr('qr'), /QR tidak diharapkan/)
27
+ flow.fail(new Error('contoh gagal'))
28
+ assert.equal(flow.state, 'failed')
29
+ flow.reset().close()
30
+ assert.equal(flow.state, 'closed')
31
+ assert.throws(() => flow.begin('qr'), /ditutup/)
32
+ })
@@ -0,0 +1,64 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { encodeVarint, fieldVarint, fieldBool, fieldString, fieldBytes, fieldFixed32, pack, parseFields } = require('../src/proto/wire')
5
+ const { encodeClientPayload, PLATFORM, CONNECT_REASON } = require('../src/proto/client-payload')
6
+
7
+ const userAgent = {
8
+ platform: 'WEB', appVersion: [2, 3000, 1040000000],
9
+ releaseChannel: 'RELEASE', localeLanguage: 'id', localeCountry: 'ID'
10
+ }
11
+ const webInfo = { version: 'test-version-only', webSubPlatform: 'WEB_BROWSER' }
12
+ const registration = {
13
+ mode: 'registration', passive: false, pushName: 'Kersio test', userAgent, webInfo,
14
+ connectReason: 'USER_ACTIVATED', connectType: 'WIFI_UNKNOWN', connectAttemptCount: 0,
15
+ devicePairingData: {
16
+ eRegid: Buffer.from([0, 0, 0, 1]), eKeytype: Buffer.from([5]), eIdent: Buffer.alloc(32, 1),
17
+ eSkeyId: Buffer.from([0, 0, 1]), eSkeyVal: Buffer.alloc(32, 2), eSkeySig: Buffer.alloc(64, 3),
18
+ buildHash: Buffer.alloc(16, 4), deviceProps: Buffer.from([0x0a, 0x01, 0x78])
19
+ }
20
+ }
21
+
22
+ test('protobuf wire: varint, string, bool, bytes, sfixed32 round-trip ke entri wire', () => {
23
+ const payload = pack([
24
+ fieldVarint(1, 300), fieldBool(2, false), fieldString(3, 'kersio'),
25
+ fieldBytes(4, Buffer.from([1, 2, 3])), fieldFixed32(5, -42)
26
+ ])
27
+ const f = parseFields(payload)
28
+ assert.deepEqual(f.map((x) => [x.fieldNumber, x.wireType]), [[1, 0], [2, 0], [3, 2], [4, 2], [5, 5]])
29
+ assert.equal(f[0].value, 300n)
30
+ assert.equal(f[1].value, 0n)
31
+ assert.equal(f[2].value.toString(), 'kersio')
32
+ assert.deepEqual(f[3].value, Buffer.from([1, 2, 3]))
33
+ assert.equal(f[4].value.readInt32LE(), -42)
34
+ assert.deepEqual(encodeVarint(0), Buffer.from([0]))
35
+ })
36
+
37
+ test('ClientPayload registration: membuat bytes berisi field utama dan DevicePairingRegistrationData', () => {
38
+ const out = encodeClientPayload(registration)
39
+ assert.ok(out.length > 100)
40
+ const fields = parseFields(out)
41
+ assert.ok(fields.some((f) => f.fieldNumber === 3 && f.wireType === 0 && f.value === 0n))
42
+ assert.ok(fields.some((f) => f.fieldNumber === 5 && f.wireType === 2))
43
+ assert.ok(fields.some((f) => f.fieldNumber === 6 && f.wireType === 2))
44
+ assert.ok(fields.some((f) => f.fieldNumber === 19 && f.wireType === 2))
45
+ assert.equal(PLATFORM.WEB, 14)
46
+ assert.equal(CONNECT_REASON.USER_ACTIVATED, 1)
47
+ })
48
+
49
+ test('ClientPayload login: username harus nomor digit dan tanpa pairing registration data', () => {
50
+ const out = encodeClientPayload({ mode: 'login', username: '6281234567890', userAgent, webInfo, passive: false })
51
+ const first = parseFields(out)[0]
52
+ assert.equal(first.fieldNumber, 1)
53
+ assert.equal(first.value, 6281234567890n)
54
+ assert.throws(() => encodeClientPayload({ mode: 'login', username: '+6281234567890', userAgent, webInfo }), /digit nomor/)
55
+ assert.throws(() => encodeClientPayload({ mode: 'registration', userAgent, webInfo }), /devicePairingData lengkap/)
56
+ })
57
+
58
+ test('ClientPayload: memvalidasi ukuran kunci dan schema wajib', () => {
59
+ const bad = structuredClone(registration)
60
+ bad.devicePairingData.eSkeySig = Buffer.alloc(32)
61
+ assert.throws(() => encodeClientPayload(bad), /eSkeySig harus 64 byte/)
62
+ assert.throws(() => encodeClientPayload({ mode: 'login', username: '6281234567890', webInfo }), /userAgent wajib diisi/)
63
+ assert.throws(() => parseFields(Buffer.from([0x0a, 0x05, 0x01])), /panjang field melebihi buffer/)
64
+ })
@@ -0,0 +1,34 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { KersioSessionManager } = require('../src/client/session-manager')
6
+ const { KersioClient } = require('../src/client/kersio-client')
7
+
8
+ class StubBackend extends EventEmitter {
9
+ constructor(engine) { super(); this.engine = engine; this.closed = false }
10
+ async connect() { this.emit('connection.update', { connection: 'open' }) }
11
+ async sendMessage(jid, content) { return { jid, content } }
12
+ async close() { this.closed = true }
13
+ capabilities() { return { engine: this.engine, live: false, messaging: true } }
14
+ }
15
+
16
+ test('session manager keeps independent sessions on different engines and closes them', async () => {
17
+ const made = []
18
+ const manager = new KersioSessionManager({ clientFactory: async ({ engine, sessionId }) => {
19
+ const backend = new StubBackend(engine)
20
+ made.push(backend)
21
+ return new KersioClient(backend, { sessionId })
22
+ } })
23
+ const zapo = await manager.open('acc-a', { engine: 'zapo' })
24
+ const baileys = await manager.open('acc-b', { engine: 'baileys' })
25
+ assert.equal(zapo.engine, 'zapo')
26
+ assert.equal(baileys.engine, 'baileys')
27
+ assert.equal(manager.stats().open, 2)
28
+ assert.equal(manager.get('acc-a'), zapo)
29
+ await assert.rejects(() => manager.open('acc-a', { engine: 'zapo' }), /sudah dibuka/)
30
+ assert.equal(await manager.close('acc-a'), true)
31
+ await manager.closeAll()
32
+ assert.equal(made.every((backend) => backend.closed), true)
33
+ assert.equal(manager.stats().total, 0)
34
+ })
@@ -0,0 +1,134 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('node:fs')
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const { MemoryStore, FileStore, SQLiteStore, RedisStore, MongoStore, LRUCache, createSessionManager, makeSocket } = require('../src')
8
+
9
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'kersio-'))
10
+
11
+ async function contract(store) {
12
+ assert.equal(await store.get('a:x'), undefined)
13
+ const v = { buf: Buffer.from([1, 2, 3]), big: 123456789012345678901234567890n, n: 5, s: 'é/:*.', nested: { u: new Uint8Array([9, 8]) } }
14
+ await store.set('a:x', v)
15
+ const r = await store.get('a:x')
16
+ assert.ok(Buffer.isBuffer(r.buf)); assert.deepEqual(r.buf, v.buf)
17
+ assert.equal(r.big, v.big); assert.equal(r.n, 5); assert.equal(r.s, v.s)
18
+ assert.deepEqual([...r.nested.u], [9, 8])
19
+ await store.set('a:x', { n: 6 })
20
+ assert.equal((await store.get('a:x')).n, 6)
21
+ await store.set('b:y', 1)
22
+ assert.deepEqual((await store.keys('a:')).sort(), ['a:x'])
23
+ assert.deepEqual((await store.keys()).sort(), ['a:x', 'b:y'])
24
+ await store.delete('a:x')
25
+ assert.equal(await store.get('a:x'), undefined)
26
+ }
27
+
28
+ test('kontrak store: memory', () => contract(new MemoryStore()))
29
+ test('kontrak store: file', () => contract(new FileStore(tmp())))
30
+ test('kontrak store: sqlite', async () => { const s = new SQLiteStore(':memory:'); await contract(s); await s.close() })
31
+
32
+ test('kontrak store: redis (klien palsu)', async () => {
33
+ const m = new Map()
34
+ const c = { get: async (k) => m.get(k) ?? null, set: async (k, v) => void m.set(k, v), del: async (k) => void m.delete(k),
35
+ keys: async (p) => [...m.keys()].filter((k) => k.startsWith(p.replace(/\\/g, '').slice(0, -1))) }
36
+ await contract(new RedisStore(c))
37
+ })
38
+
39
+ test('kontrak store: mongo (collection palsu)', async () => {
40
+ const m = new Map()
41
+ const col = {
42
+ findOne: async ({ _id }) => m.get(_id) ?? null,
43
+ replaceOne: async ({ _id }, doc) => void m.set(_id, doc),
44
+ deleteOne: async ({ _id }) => void m.delete(_id),
45
+ find: ({ _id: { $regex } }) => ({ toArray: async () => [...m.values()].filter((d) => new RegExp($regex).test(d._id)) })
46
+ }
47
+ await contract(new MongoStore(col))
48
+ })
49
+
50
+ test('FileStore: tulis atomik, tulis berurutan, tmp dibersihkan, file rusak dikarantina', async () => {
51
+ const dir = tmp()
52
+ fs.writeFileSync(path.join(dir, 'sisa.json.1.abcd.tmp'), 'setengah')
53
+ const s = new FileStore(dir)
54
+ assert.equal(fs.readdirSync(dir).some((f) => f.endsWith('.tmp')), false)
55
+
56
+ await Promise.all(Array.from({ length: 50 }, (_, i) => s.set('k', { i })))
57
+ assert.equal((await s.get('k')).i, 49) // urutan terjaga
58
+ assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
59
+
60
+ fs.writeFileSync(path.join(dir, 'rusak.json'), '{"setengah":')
61
+ assert.equal(await s.get('rusak'), undefined)
62
+ assert.ok(fs.readdirSync(dir).some((f) => f.startsWith('rusak.json.') && f.endsWith('.corrupt')))
63
+ })
64
+
65
+ test('FileStore: kegagalan rename tidak merusak file lama', async () => {
66
+ const dir = tmp()
67
+ const s = new FileStore(dir)
68
+ await s.set('k', { v: 'lama' })
69
+ const orig = fs.promises.rename
70
+ fs.promises.rename = async () => { throw new Error('mati di tengah') }
71
+ try { await assert.rejects(s.set('k', { v: 'baru' }), /mati di tengah/) } finally { fs.promises.rename = orig }
72
+ assert.equal((await s.get('k')).v, 'lama')
73
+ assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith('.tmp')), [])
74
+ })
75
+
76
+ test('makeSocket + store: kunci dibuat sekali lalu dipakai ulang antar restart', async () => {
77
+ const store = new FileStore(tmp())
78
+ const a = makeSocket({ store, sessionId: 'nomor-a', autoConnect: false })
79
+ await a.ready
80
+ const k1 = a.creds.staticKey.public
81
+ const b = makeSocket({ store, sessionId: 'nomor-a', autoConnect: false })
82
+ await b.ready
83
+ assert.deepEqual(b.creds.staticKey.public, k1)
84
+ assert.ok(Buffer.isBuffer(b.creds.staticKey.private))
85
+ const c = makeSocket({ store, sessionId: 'nomor-b', autoConnect: false })
86
+ await c.ready
87
+ assert.notDeepEqual(c.creds.staticKey.public, k1)
88
+ })
89
+
90
+ test('SessionManager: banyak sesi, list, close + purge', async () => {
91
+ const store = new MemoryStore()
92
+ const mgr = createSessionManager({ store, autoConnect: false })
93
+ const a = mgr.open('a'), b = mgr.open('b')
94
+ assert.equal(mgr.open('a'), a)
95
+ await Promise.all([a.ready, b.ready])
96
+ assert.deepEqual((await mgr.list()).sort(), ['a', 'b'])
97
+ assert.equal(Object.keys(mgr.stats().sessions).length, 2)
98
+ await mgr.close('a', { purge: true })
99
+ assert.deepEqual(await mgr.list(), ['b'])
100
+ assert.throws(() => mgr.open('x:y'), /tidak valid/)
101
+ await mgr.closeAll()
102
+ })
103
+
104
+ test('LRUCache: batas entri, batas byte, TTL, urutan pakai', async () => {
105
+ const evicted = []
106
+ const c = new LRUCache({ max: 2, onEvict: (k, v, why) => evicted.push([k, why]) })
107
+ c.set('a', 1).set('b', 2); c.get('a'); c.set('c', 3)
108
+ assert.equal(c.has('b'), false); assert.equal(c.get('a'), 1)
109
+ assert.deepEqual(evicted, [['b', 'evicted']])
110
+
111
+ const m = new LRUCache({ max: 100, maxBytes: 10, sizeOf: (v) => v.length })
112
+ m.set('x', Buffer.alloc(6)); m.set('y', Buffer.alloc(6))
113
+ assert.equal(m.has('x'), false); assert.equal(m.bytes, 6)
114
+ m.set('gede', Buffer.alloc(50)) // entri tunggal raksasa tidak membuat cache kosong total
115
+ assert.equal(m.size, 1)
116
+
117
+ const t = new LRUCache({ ttl: 10 })
118
+ t.set('k', 1)
119
+ await new Promise((r) => setTimeout(r, 25))
120
+ assert.equal(t.get('k'), undefined)
121
+ assert.ok(c.stats().hitRate > 0)
122
+ })
123
+
124
+ test('multi-session: pairing lifecycle dan state tiap socket terisolasi', async () => {
125
+ const mgr = createSessionManager({ store: new MemoryStore(), autoConnect: false })
126
+ const a = mgr.open('nomor-a')
127
+ const b = mgr.open('nomor-b')
128
+ await Promise.all([a.ready, b.ready])
129
+ assert.notEqual(a.pairing, b.pairing)
130
+ a.pairing.begin('qr')
131
+ assert.equal(a.pairing.state, 'waiting-qr')
132
+ assert.equal(b.pairing.state, 'idle')
133
+ await mgr.closeAll()
134
+ })
@@ -0,0 +1,35 @@
1
+ 'use strict'
2
+
3
+ const test = require('node:test')
4
+ const assert = require('node:assert/strict')
5
+ const { parseGoTokenTable, validateTokenTable } = require('../src/wabinary/token-source')
6
+
7
+ const fixture = String.raw`package token
8
+ var (
9
+ SingleByteTokens = [...]string{"", "iq", "quoted\\\"token", "line\\nfeed", "hex\\x41"}
10
+ DoubleByteTokens = [...][]string{
11
+ {"first", "second"},
12
+ {"third"},
13
+ {},
14
+ {"fourth"},
15
+ }
16
+ )
17
+ const DictVersion = 7
18
+ `
19
+
20
+ test('Go token-data parser reads string escapes and page boundaries', () => {
21
+ const parsed = parseGoTokenTable(fixture)
22
+ assert.equal(parsed.dictVersion, 7)
23
+ assert.deepEqual(parsed.single, ['', 'iq', 'quoted\\"token', 'line\\nfeed', 'hex\\x41'])
24
+ assert.deepEqual(parsed.double, [['first', 'second'], ['third'], [], ['fourth']])
25
+ })
26
+
27
+ test('token-data validator rejects the wrong dictionary version', () => {
28
+ const table = { dictVersion: 2, single: Array(236).fill('x'), double: Array.from({ length: 4 }, () => Array(256).fill('x')) }
29
+ table.single[0] = ''
30
+ assert.throws(() => validateTokenTable(table, { dictVersion: 3 }), /DictVersion mismatch/)
31
+ })
32
+
33
+ test('token-data validator requires exact versioned table dimensions', () => {
34
+ assert.throws(() => validateTokenTable({ dictVersion: 3, single: [], double: [[], [], [], []] }), /single-byte token table shape unexpected/)
35
+ })
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { createKersioClient, KersioClient } = require('../src/client/kersio-client')
6
+ const { BackendNotSupportedError } = require('../src/backends/base')
7
+
8
+ class FakeBackend extends EventEmitter {
9
+ constructor() { super(); this.engine = 'fake'; this.sent = []; this.connected = false; this.closed = false }
10
+ async connect() { this.connected = true; this.emit('connection.update', { connection: 'open' }) }
11
+ async sendMessage(jid, content, options) { this.sent.push({ jid, content, options }); return { key: { id: `m${this.sent.length}` } } }
12
+ async close() { this.closed = true }
13
+ capabilities() { return { engine: this.engine, live: true, messaging: true, media: true, groups: false, historySync: false } }
14
+ }
15
+
16
+ test('unified client normalizes text/media/reaction API and validates user input', async () => {
17
+ const backend = new FakeBackend()
18
+ const client = new KersioClient(backend)
19
+ await client.connect()
20
+ await client.sendText('123@s.whatsapp.net', 'halo')
21
+ await client.sendImage('123@s.whatsapp.net', Buffer.from('image'), { caption: 'foto' })
22
+ await client.react('123@s.whatsapp.net', { id: 'old', remoteJid: '123@s.whatsapp.net' }, '👍')
23
+ assert.deepEqual(backend.sent[0].content, { text: 'halo' })
24
+ assert.equal(backend.sent[1].content.caption, 'foto')
25
+ assert.equal(backend.sent[2].content.react.text, '👍')
26
+ await assert.rejects(() => client.sendText('', 'x'), /jid/)
27
+ await assert.rejects(() => client.sendImage('123@s.whatsapp.net', null), /wajib diisi/)
28
+ await client.close()
29
+ assert.equal(backend.closed, true)
30
+ })
31
+
32
+ test('connect() tidak menganggap koneksi berhasil hanya dari Promise resolve', async () => {
33
+ class NotReadyBackend extends FakeBackend { async connect() {} }
34
+ const client = new KersioClient(new NotReadyBackend())
35
+ await client.connect()
36
+ assert.equal(client.state, 'connecting')
37
+ await assert.rejects(() => client.sendText('123@s.whatsapp.net', 'belum siap'), /belum open/)
38
+ await client.close()
39
+ })
40
+
41
+ test('backend QR event yang sudah terjadi direplay ketika facade terpasang', async () => {
42
+ class ReplayBackend extends FakeBackend {
43
+ constructor() { super(); this.last = new Map([['pairing.qr', { qr: 'candidate' }]]) }
44
+ replay(event, handler) { if (this.last.has(event)) handler(this.last.get(event)) }
45
+ }
46
+ const client = new KersioClient(new ReplayBackend())
47
+ const qr = []
48
+ client.on('pairing.qr', (v) => qr.push(v.qr))
49
+ await new Promise((resolve) => setImmediate(resolve))
50
+ assert.deepEqual(qr, ['candidate'])
51
+ await client.close()
52
+ })
53
+
54
+ test('factory menolak native sebagai message engine yang belum lengkap', async () => {
55
+ await assert.rejects(() => createKersioClient({ engine: 'native' }), /belum complete/)
56
+ })
57
+
58
+ test('feature yang tidak didukung menghasilkan error eksplisit, tidak mengembalikan sukses palsu', async () => {
59
+ const client = await createKersioClient({ backend: new FakeBackend() })
60
+ await client.connect()
61
+ await assert.rejects(() => client.downloadMedia({}), BackendNotSupportedError)
62
+ await client.close()
63
+ })
@@ -0,0 +1,43 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { MemoryStore } = require('../src/session/stores')
5
+ const { PairingLifecycle } = require('../src/pairing/lifecycle')
6
+ const { commitVerifiedPairSuccess } = require('../src/pairing/verified-commit')
7
+
8
+ test('pair success tidak dipersistenkan atau ditandai paired bila pemeriksaan gagal', async () => {
9
+ const store = new MemoryStore()
10
+ const lifecycle = new PairingLifecycle({ sessionId: 'secure-test' })
11
+ lifecycle.begin('qr')
12
+ await assert.rejects(() => commitVerifiedPairSuccess({
13
+ node: { tag: 'iq' },
14
+ verifyPairSuccess: async () => ({ verified: true, checks: { deviceIdentity: true, accountSignature: true, advHmac: false }, credentials: { me: 'x' }, replyNode: { tag: 'iq' } }),
15
+ store, lifecycle, sendNode: async () => {}
16
+ }), { code: 'ERR_PAIR_SUCCESS_UNVERIFIED' })
17
+ assert.equal(lifecycle.state, 'waiting-qr')
18
+ assert.equal(await store.get('auth:secure-test:credentials'), undefined)
19
+ })
20
+
21
+ test('pair success commit membutuhkan semua check + credentials, menyimpan lalu membalas sebelum paired', async () => {
22
+ const store = new MemoryStore()
23
+ const lifecycle = new PairingLifecycle({ sessionId: 'secure-test' })
24
+ lifecycle.begin('qr')
25
+ const steps = []
26
+ const result = await commitVerifiedPairSuccess({
27
+ node: { tag: 'iq' },
28
+ verifyPairSuccess: async () => ({
29
+ verified: true,
30
+ checks: { deviceIdentity: true, accountSignature: true, advHmac: true },
31
+ credentials: { me: '123@s.whatsapp.net' },
32
+ replyNode: { tag: 'iq', attrs: { type: 'set' }, content: [{ tag: 'pair-device-sign', attrs: {} }] }
33
+ }),
34
+ store: { set: async (key, value) => { steps.push('store'); return store.set(key, value) } },
35
+ lifecycle,
36
+ sendNode: async (node) => { steps.push('reply'); assert.equal(node.content[0].tag, 'pair-device-sign') }
37
+ })
38
+ steps.push('after')
39
+ assert.deepEqual(steps, ['store', 'reply', 'after'])
40
+ assert.equal(result.verified, true)
41
+ assert.equal(lifecycle.state, 'paired')
42
+ assert.deepEqual(await store.get('auth:secure-test:credentials'), { me: '123@s.whatsapp.net' })
43
+ })
@@ -0,0 +1,147 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const zlib = require('node:zlib')
5
+ const { encodeBinaryNode, decodeBinaryNode, TAGS } = require('../src/wabinary/codec')
6
+ const { buildPairingQrPayload, extractPairingRefs, LINKED_DEVICES_PREFIX } = require('../src/pairing/qr')
7
+
8
+ const emptyTokens = { single: [], double: [[], [], [], []] }
9
+
10
+ test('WABinary: round-trip node attrs, nested children, strings and binary content', () => {
11
+ const input = {
12
+ tag: 'iq', attrs: { id: '42', type: 'result', to: 's.whatsapp.net' },
13
+ content: [
14
+ { tag: 'pair-device', attrs: {}, content: [{ tag: 'ref', attrs: {}, content: Buffer.from([0x01, 0x02, 0xff]) }] },
15
+ { tag: 'success', attrs: { t: '123' } }
16
+ ]
17
+ }
18
+ const wire = encodeBinaryNode(input, { tokens: emptyTokens })
19
+ const out = decodeBinaryNode(wire, { tokens: emptyTokens })
20
+ assert.equal(out.tag, 'iq')
21
+ assert.equal(out.attrs.id, '42')
22
+ assert.equal(out.content[0].tag, 'pair-device')
23
+ assert.deepEqual(out.content[0].content[0].content, Buffer.from([1, 2, 255]))
24
+ assert.equal(out.content[1].tag, 'success')
25
+ })
26
+
27
+ test('WABinary: raw JID pair, injected single/double token dictionaries', () => {
28
+ const tokens = { single: [null, 'iq', 'to'], double: [['pair-device'], [], [], []] }
29
+ const node = { tag: 'iq', attrs: { to: '123@s.whatsapp.net' }, content: [{ tag: 'pair-device', attrs: {} }] }
30
+ const out = decodeBinaryNode(encodeBinaryNode(node, { tokens }), { tokens })
31
+ assert.equal(out.tag, 'iq')
32
+ assert.equal(out.attrs.to, '123@s.whatsapp.net')
33
+ assert.equal(out.content[0].tag, 'pair-device')
34
+ })
35
+
36
+ test('WABinary: compressed and uncompressed transport prefixes', () => {
37
+ const node = { tag: 'message', attrs: { id: 'x' }, content: Buffer.from('hello') }
38
+ const raw = encodeBinaryNode(node, { tokens: emptyTokens })
39
+ const plain = decodeBinaryNode(raw, { tokens: emptyTokens })
40
+ const body = raw[0] === 0 ? raw.subarray(1) : raw
41
+ const compressed = decodeBinaryNode(Buffer.concat([Buffer.from([2]), zlib.deflateSync(body)]), { tokens: emptyTokens })
42
+ for (const out of [plain, compressed]) {
43
+ assert.equal(out.tag, node.tag)
44
+ assert.equal(out.attrs.id, node.attrs.id)
45
+ assert.deepEqual(out.content, node.content)
46
+ }
47
+ })
48
+
49
+ test('WABinary: truncated/unknown token inputs fail closed', () => {
50
+ assert.throws(() => decodeBinaryNode(Buffer.from([TAGS.LIST_8]), { tokens: emptyTokens, decompress: false }), /terpotong/)
51
+ assert.throws(() => decodeBinaryNode(Buffer.from([TAGS.LIST_8, 1, 1]), { tokens: emptyTokens, decompress: false }), /token tunggal/)
52
+ })
53
+
54
+
55
+
56
+ test('WABinary default encoder emits protocol 0x00 prefix and a pinned golden byte sequence', () => {
57
+ const single = Array(236).fill('')
58
+ single[3] = 's.whatsapp.net'
59
+ single[4] = 'type'
60
+ single[8] = 'id'
61
+ single[17] = 'to'
62
+ single[25] = 'iq'
63
+ single[41] = 'get'
64
+ single[86] = 'ping'
65
+ const tokens = { single, double: [Array(256).fill(''), Array(256).fill(''), Array(256).fill(''), Array(256).fill('')] }
66
+ const node = { tag: 'iq', attrs: { id: 'kersio-local-test', type: 'get', to: 's.whatsapp.net' }, content: [{ tag: 'ping', attrs: {} }] }
67
+ const expected = '00f8081908fc116b657273696f2d6c6f63616c2d7465737404291103f801f80156'
68
+ const encoded = encodeBinaryNode(node, { tokens })
69
+ assert.equal(encoded.toString('hex'), expected)
70
+ const decoded = decodeBinaryNode(encoded, { tokens })
71
+ assert.equal(decoded.tag, node.tag)
72
+ assert.deepEqual({ ...decoded.attrs }, node.attrs)
73
+ assert.equal(decoded.content[0].tag, 'ping')
74
+ })
75
+
76
+ test('WABinary packs digit/hex strings and round-trips device JID formats', () => {
77
+ const tokens = { single: [], double: [[], [], [], []] }
78
+ for (const value of ['1234567890', '12-34.56', 'ABCDEF09', '12345:2@s.whatsapp.net', '12345:7@lid', 'user@g.us']) {
79
+ const input = { tag: 'node', attrs: { value } }
80
+ const wire = encodeBinaryNode(input, { tokens })
81
+ assert.equal(wire[0], 0x00)
82
+ const output = decodeBinaryNode(wire, { tokens })
83
+ assert.equal(output.attrs.value, value)
84
+ }
85
+ const packed = encodeBinaryNode({ tag: 'x', attrs: { v: '12345' } }, { tokens })
86
+ assert.ok(packed.includes(0xff)) // HEX_8 or NIBBLE_8 marker in encoded attributes
87
+ })
88
+
89
+
90
+
91
+ test('WABinary decodes FB_JID and INTEROP_JID marker fixtures', () => {
92
+ const rawString = (value) => {
93
+ const b = Buffer.from(value, 'utf8')
94
+ assert.ok(b.length < 256)
95
+ return Buffer.concat([Buffer.from([TAGS.BINARY_8, b.length]), b])
96
+ }
97
+ const wrapValue = (encodedValue) => Buffer.concat([
98
+ Buffer.from([0x00, TAGS.LIST_8, 3]), rawString('x'), rawString('jid'), encodedValue
99
+ ])
100
+ const fb = Buffer.concat([Buffer.from([TAGS.FB_JID]), rawString('alice'), Buffer.from([0x00, 0x02]), rawString('s.whatsapp.net')])
101
+ const fbNode = decodeBinaryNode(wrapValue(fb), { tokens: emptyTokens, requirePrefix: true })
102
+ assert.equal(fbNode.attrs.jid, 'alice:2@s.whatsapp.net')
103
+
104
+ const interop = Buffer.concat([Buffer.from([TAGS.INTEROP_JID]), rawString('user'), Buffer.from([0x00, 0x07, 0x00, 0x03]), rawString('interop.example')])
105
+ const interopNode = decodeBinaryNode(wrapValue(interop), { tokens: emptyTokens, requirePrefix: true })
106
+ assert.equal(interopNode.attrs.jid, '3-user:7@interop.example')
107
+ })
108
+
109
+ test('WABinary leaves lowercase hex-looking text as raw UTF-8 and normalizes AD_JID device zero', () => {
110
+ const tokens = { single: [], double: [[], [], [], []] }
111
+ const lower = { tag: 'x', attrs: { value: 'deadbeef' } }
112
+ const lowerBytes = encodeBinaryNode(lower, { tokens })
113
+ assert.deepEqual(decodeBinaryNode(lowerBytes, { tokens }).attrs.value, 'deadbeef')
114
+ const encoded = encodeBinaryNode({ tag: 'x', attrs: { jid: '123:0@s.whatsapp.net' } }, { tokens })
115
+ const decoded = decodeBinaryNode(encoded, { tokens })
116
+ assert.equal(decoded.attrs.jid, '123@s.whatsapp.net')
117
+ })
118
+
119
+ test('WABinary compression option emits 0x02 prefix and respects output limits', () => {
120
+ const tokens = { single: [], double: [[], [], [], []] }
121
+ const node = { tag: 'message', attrs: { id: 'a'.repeat(2000) }, content: Buffer.from('payload') }
122
+ const compressed = encodeBinaryNode(node, { tokens, compress: true })
123
+ assert.equal(compressed[0], 0x02)
124
+ const decoded = decodeBinaryNode(compressed, { tokens })
125
+ assert.equal(decoded.tag, node.tag)
126
+ assert.equal(decoded.attrs.id, node.attrs.id)
127
+ assert.deepEqual(decoded.content, node.content)
128
+ assert.throws(() => encodeBinaryNode(node, { tokens, limits: { maxBytes: 100 } }), /maxBytes/)
129
+ })
130
+
131
+ test('pairing QR helper formats the observed fields and extracts pair-device refs', () => {
132
+ const payload = buildPairingQrPayload({
133
+ ref: Buffer.from('ref-1'), noisePublicKey: Buffer.alloc(32, 1),
134
+ identityPublicKey: Buffer.alloc(32, 2), advSecretKey: Buffer.alloc(32, 3), clientType: '1'
135
+ })
136
+ assert.ok(payload.startsWith(LINKED_DEVICES_PREFIX + 'ref-1,'))
137
+ const refs = extractPairingRefs({ tag: 'iq', attrs: {}, content: [{ tag: 'pair-device', attrs: {}, content: [
138
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-a') },
139
+ { tag: 'ref', attrs: {}, content: Buffer.from('ref-b') },
140
+ { tag: 'not-ref', attrs: {}, content: Buffer.from('ignore') }
141
+ ] }] })
142
+ assert.deepEqual(refs, [Buffer.from('ref-a'), Buffer.from('ref-b')])
143
+ })
144
+
145
+ test('pairing QR helper rejects incomplete key material', () => {
146
+ assert.throws(() => buildPairingQrPayload({ ref: 'ref', noisePublicKey: Buffer.alloc(31), identityPublicKey: Buffer.alloc(32), advSecretKey: Buffer.alloc(32), clientType: '1' }), /32 byte/)
147
+ })