kersiosocket 0.5.0-dev → 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.
@@ -0,0 +1,59 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { EventEmitter } = require('node:events')
5
+ const { ZapoBackend } = require('../src/backends/zapo')
6
+ const { BaileysBackend } = require('../src/backends/baileys')
7
+
8
+ class FakeZapo extends EventEmitter {
9
+ constructor() { super(); this.sent = []; this.message = { send: async (...args) => { this.sent.push(args); return { ok: true } } }; this.connected = 0 }
10
+ async connect() { this.connected++; this.emit('connection.update', { connection: 'open' }) }
11
+ async disconnect() { this.closed = true }
12
+ }
13
+
14
+ class FakeBaileysSocket {
15
+ constructor() { this.ev = new EventEmitter(); this.sent = []; this.pairCalls = []; this.ended = false }
16
+ async sendMessage(...args) { this.sent.push(args); return { key: { id: 'm1' } } }
17
+ async requestPairingCode(phone) { this.pairCalls.push(phone); return 'ABCD-1234' }
18
+ async end() { this.ended = true }
19
+ }
20
+
21
+ test('Zapo adapter forwards auth/message events and calls public message.send', async () => {
22
+ const raw = new FakeZapo()
23
+ const backend = new ZapoBackend(raw, { sessionId: 'test' })
24
+ const messages = []
25
+ const qr = []
26
+ backend.on('messages.upsert', (x) => messages.push(x))
27
+ backend.on('pairing.qr', (x) => qr.push(x.qr))
28
+ raw.emit('auth_qr', { qr: 'test-qr' })
29
+ const msg = { key: { remoteJid: '1@s.whatsapp.net', id: 'm1' }, message: { conversation: 'hi' } }
30
+ raw.emit('message', msg)
31
+ assert.deepEqual(qr, ['test-qr'])
32
+ assert.equal(messages[0].messages[0], msg)
33
+ await backend.connect()
34
+ await backend.sendMessage('1@s.whatsapp.net', 'pong')
35
+ assert.deepEqual(raw.sent[0], ['1@s.whatsapp.net', 'pong'])
36
+ await backend.close()
37
+ assert.equal(raw.closed, true)
38
+ })
39
+
40
+ test('Baileys adapter forwards connection/messages and maps common methods', async () => {
41
+ const socket = new FakeBaileysSocket()
42
+ const mod = { downloadMediaMessage: async () => Buffer.from('media') }
43
+ const backend = new BaileysBackend(socket, { baileysModule: mod })
44
+ const updates = []
45
+ const incoming = []
46
+ backend.on('pairing.qr', (p) => updates.push(p.qr))
47
+ backend.on('message', (m) => incoming.push(m))
48
+ socket.ev.emit('connection.update', { qr: 'q1' })
49
+ const msg = { key: { id: 'm1', remoteJid: '1@s.whatsapp.net' }, message: { conversation: 'hello' } }
50
+ socket.ev.emit('messages.upsert', { type: 'notify', messages: [msg] })
51
+ await backend.sendMessage('1@s.whatsapp.net', 'pong')
52
+ assert.deepEqual(updates, ['q1'])
53
+ assert.deepEqual(incoming, [msg])
54
+ assert.deepEqual(socket.sent[0][1], { text: 'pong' })
55
+ assert.equal(await backend.requestPairingCode('628123456789'), 'ABCD-1234')
56
+ assert.deepEqual(await backend.downloadMedia(msg), Buffer.from('media'))
57
+ await backend.close()
58
+ assert.equal(socket.ended, true)
59
+ })
@@ -50,7 +50,7 @@ test('koneksi penuh: handshake, terima & kirim frame, stats, end()', async () =>
50
50
  })
51
51
 
52
52
  const sock = makeSocket({
53
- transportFactory: srv, clientPayload: Buffer.from('cp'), reconnect: { enabled: false }
53
+ transportFactory: srv, clientPayload: Buffer.from('cp'), strictServerCertificateVerification: false, reconnect: { enabled: false }
54
54
  })
55
55
  const states = []
56
56
  sock.ev.on('connection.update', (u) => states.push(u.connection))
@@ -83,7 +83,7 @@ test('listener frame yang melempar error TIDAK memutus koneksi; latensi tercatat
83
83
  setTimeout(() => d.t.emit('message', encodeFrame(d.hs.noise.encrypt(Buffer.from('dua')))), 10)
84
84
  }
85
85
  })
86
- const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: false } })
86
+ const sock = makeSocket({ transportFactory: srv, strictServerCertificateVerification: false, reconnect: { enabled: false } })
87
87
  const handlerErrors = []
88
88
  const frames = []
89
89
  const observerFrames = []
@@ -106,7 +106,7 @@ test('frame rusak (gagal dekripsi) tetap menutup koneksi sebagai error protokol'
106
106
  const srv = fakeServer((type, d) => {
107
107
  if (type === 'server-ready') d.t.emit('message', encodeFrame(Buffer.alloc(40, 1)))
108
108
  })
109
- const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: false } })
109
+ const sock = makeSocket({ transportFactory: srv, strictServerCertificateVerification: false, reconnect: { enabled: false } })
110
110
  const errs = []
111
111
  sock.ev.on('connection.error', (e) => errs.push(e))
112
112
  await new Promise((resolve, reject) => {
@@ -122,7 +122,7 @@ test('backoff TIDAK direset bila server menutup tepat setelah handshake', async
122
122
  const log = { debug() {}, info(m) { const x = /dalam (\d+)ms/.exec(m); if (x) delays.push(+x[1]) }, warn() {}, error() {} }
123
123
  const srv = fakeServer((type, d) => { if (type === 'server-ready') setImmediate(() => d.t.emit('close', 1006, '')) })
124
124
  const sock = makeSocket({
125
- transportFactory: srv, logger: log,
125
+ transportFactory: srv, logger: log, strictServerCertificateVerification: false,
126
126
  reconnect: { minDelay: 10, maxDelay: 1000, factor: 2, stableMs: 30000 }
127
127
  })
128
128
  await new Promise((r) => setTimeout(r, 400))
@@ -145,9 +145,29 @@ test('ClientPayload terstruktur di-encode sebelum dikirim dalam ClientFinish', a
145
145
  if (type === 'server-ready') resolve(d.payload)
146
146
  }),
147
147
  clientPayload: options,
148
+ strictServerCertificateVerification: false,
148
149
  reconnect: { enabled: false }
149
150
  })
150
151
  sock.ev.on('connection.error', (e) => { throw e })
151
152
  })
152
153
  assert.deepEqual(await received, expected)
153
154
  })
155
+
156
+ test('native connection fails closed by default without Noise certificate verifier', async () => {
157
+ const srv = fakeServer(() => {})
158
+ const sock = makeSocket({ transportFactory: srv, reconnect: { enabled: true, minDelay: 10, maxDelay: 20, factor: 2, stableMs: 1000 } })
159
+ const errors = []
160
+ sock.ev.on('connection.error', (error) => errors.push(error))
161
+ await new Promise((resolve, reject) => {
162
+ const timer = setTimeout(() => reject(new Error('timeout waiting for strict-certificate close')), 1000)
163
+ sock.ev.on('connection.update', (update) => {
164
+ if (update.connection === 'close') { clearTimeout(timer); resolve() }
165
+ })
166
+ })
167
+ await new Promise((resolve) => setTimeout(resolve, 60))
168
+ assert.equal(errors.length, 1)
169
+ assert.equal(errors[0].code, 'ERR_NOISE_CERTIFICATE_VERIFIER_MISSING')
170
+ assert.equal(sock.state, 'close')
171
+ assert.equal(sock.stats().serverCertificateVerified, false)
172
+ sock.end()
173
+ })
@@ -0,0 +1,49 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('node:fs/promises')
5
+ const os = require('node:os')
6
+ const path = require('node:path')
7
+ const { MemoryStore } = require('../src/session/stores')
8
+ const { KeyedMutex, FileLockManager, MessageArchive } = require('../src/session/locking')
9
+
10
+ test('KeyedMutex membuat operasi dengan key sama berjalan serial', async () => {
11
+ const mutex = new KeyedMutex()
12
+ const sequence = []
13
+ await Promise.all([
14
+ mutex.run('a', async () => { sequence.push('a1-start'); await new Promise((r) => setTimeout(r, 20)); sequence.push('a1-end') }),
15
+ mutex.run('a', async () => { sequence.push('a2-start'); sequence.push('a2-end') }),
16
+ mutex.run('b', async () => { sequence.push('b-start'); sequence.push('b-end') })
17
+ ])
18
+ assert.ok(sequence.indexOf('a1-end') < sequence.indexOf('a2-start'))
19
+ })
20
+
21
+ test('FileLockManager mencegah dua operasi concurrent dan melepaskan lock saat error', async () => {
22
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'kersio-lock-'))
23
+ try {
24
+ const a = new FileLockManager(dir, { staleMs: 5000, heartbeatMs: 500, retryMs: 5 })
25
+ const b = new FileLockManager(dir, { staleMs: 5000, heartbeatMs: 500, retryMs: 5 })
26
+ let active = 0, maxActive = 0
27
+ await Promise.all([
28
+ a.withLock('session-a', async () => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 35)); active-- }),
29
+ b.withLock('session-a', async () => { active++; maxActive = Math.max(maxActive, active); await new Promise((r) => setTimeout(r, 10)); active-- })
30
+ ])
31
+ assert.equal(maxActive, 1)
32
+ await assert.rejects(() => a.withLock('throws', async () => { throw new Error('expected') }), /expected/)
33
+ assert.equal((await fs.readdir(dir)).filter((x) => x.endsWith('.lock')).length, 0)
34
+ } finally { await fs.rm(dir, { recursive: true, force: true }) }
35
+ })
36
+
37
+ test('MessageArchive writes bounded history and exposes getMessage()', async () => {
38
+ const store = new MemoryStore()
39
+ const archive = new MessageArchive(store, { sessionId: 'archive-test', maxMessagesPerChat: 2 })
40
+ const mk = (id, text) => ({ key: { id, remoteJid: '1@s.whatsapp.net' }, message: { conversation: text } })
41
+ await archive.put(mk('1', 'one'))
42
+ await archive.put(mk('2', 'two'))
43
+ await archive.put(mk('3', 'three'))
44
+ assert.equal(await archive.getMessage({ id: '3', remoteJid: '1@s.whatsapp.net' }).then((v) => v.conversation), 'three')
45
+ assert.equal(await archive.getRecord({ id: '1', remoteJid: '1@s.whatsapp.net' }), undefined)
46
+ assert.deepEqual((await archive.recent('1@s.whatsapp.net', 10)).map((m) => m.key.id), ['3', '2'])
47
+ await archive.delete({ id: '2', remoteJid: '1@s.whatsapp.net' })
48
+ assert.deepEqual((await archive.recent('1@s.whatsapp.net')).map((m) => m.key.id), ['3'])
49
+ })
@@ -64,3 +64,25 @@ test('Noise: ServerHello dengan hash berbeda (header beda) gagal', () => {
64
64
  const sh = server.processClientHello(client.hello())
65
65
  assert.throws(() => client.processServerHello(sh, Buffer.alloc(0)))
66
66
  })
67
+
68
+ test('Noise strict mode menolak server certificate bila verifier tidak dikonfigurasi', () => {
69
+ const server = new ServerHandshake({ certificate: Buffer.from('untrusted') })
70
+ const client = new ClientHandshake({ staticKey: generateKeyPair(), requireServerCertificateVerification: true })
71
+ const serverHello = server.processClientHello(client.hello())
72
+ assert.throws(() => client.processServerHello(serverHello, Buffer.alloc(0)), /verifier sertifikat server belum dikonfigurasi/)
73
+ assert.equal(client.serverCertificateVerified, false)
74
+ })
75
+
76
+ test('Noise strict mode hanya menerima certificate bila verifier menyatakan true', () => {
77
+ const expected = Buffer.from('pinned-test-certificate')
78
+ const server = new ServerHandshake({ certificate: expected })
79
+ const client = new ClientHandshake({
80
+ staticKey: generateKeyPair(),
81
+ requireServerCertificateVerification: true,
82
+ verifyServerCertificate: (certificate) => certificate.equals(expected)
83
+ })
84
+ const serverHello = server.processClientHello(client.hello())
85
+ const finish = client.processServerHello(serverHello, Buffer.alloc(0))
86
+ server.processClientFinish(finish)
87
+ assert.equal(client.serverCertificateVerified, true)
88
+ })
@@ -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,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,43 @@
1
+ 'use strict'
2
+ const test = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const { WhatsmeowProcessBackend, jsonSafe } = require('../src/backends/whatsmeow-process')
5
+
6
+ const fakeBridge = String.raw`
7
+ const readline = require('node:readline');
8
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
9
+ rl.on('line', (line) => {
10
+ let x; try { x = JSON.parse(line) } catch { process.exit(9) }
11
+ if (x.method === 'close') { process.stdout.write(JSON.stringify({ id:x.id, result:{closed:true} })+'\n'); setImmediate(() => process.exit(0)); return; }
12
+ if (x.method === 'connect') { process.stdout.write(JSON.stringify({ id:x.id, result:{connected:true} })+'\n'); process.stdout.write(JSON.stringify({ event:'messages.upsert', data:{type:'notify', messages:[{key:{id:'in1',remoteJid:'1@s.whatsapp.net'},message:{conversation:'hi'}}]} })+'\n'); return; }
13
+ if (x.method === 'sendMessage') { process.stdout.write(JSON.stringify({ id:x.id, result:{key:{id:'out1'}, accepted:true, params:x.params} })+'\n'); return; }
14
+ if (x.method === 'capabilities') { process.stdout.write(JSON.stringify({id:x.id,result:{messaging:true,media:true,groups:true}})+'\n'); return; }
15
+ process.stdout.write(JSON.stringify({id:x.id,error:{code:'NO_METHOD',message:'unknown method '+x.method}})+'\n');
16
+ });
17
+ `
18
+
19
+ test('Go process adapter uses JSONL RPC, forwards events, and handles errors', async () => {
20
+ const bridge = new WhatsmeowProcessBackend({ command: process.execPath, args: ['-e', fakeBridge], sessionId: 'test' })
21
+ const msgs = []
22
+ let gotMessageResolve
23
+ const gotMessage = new Promise((resolve) => { gotMessageResolve = resolve })
24
+ bridge.on('messages.upsert', (event) => { msgs.push(event); gotMessageResolve() })
25
+ const cleanup = () => bridge.close().catch(() => {})
26
+ try {
27
+ await bridge.connect()
28
+ await Promise.race([gotMessage, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout waiting for mock event')), 1000))])
29
+ assert.equal(msgs.length, 1)
30
+ const sent = await bridge.sendMessage('1@s.whatsapp.net', { text: 'hello' })
31
+ assert.equal(sent.accepted, true)
32
+ const caps = await bridge.capabilities()
33
+ assert.equal(caps.groups, true)
34
+ await assert.rejects(() => bridge.invoke('nonexistent'), /unknown method/)
35
+ } finally { await cleanup() }
36
+ })
37
+
38
+ test('bridge JSON serialisation marks binary as base64 and rejects cycles/functions', () => {
39
+ assert.deepEqual(jsonSafe(Buffer.from('abc')), { $base64: 'YWJj' })
40
+ const cycle = {}; cycle.self = cycle
41
+ assert.throws(() => jsonSafe(cycle), /siklik/)
42
+ assert.throws(() => jsonSafe(() => {}), /tidak dapat dikirim/)
43
+ })