@ihazz/bitrix24 0.2.5 → 1.0.1

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.
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
- import { checkAccess, normalizeAllowEntry, checkAccessWithPairing } from '../src/access-control.js';
2
+ import {
3
+ checkAccess,
4
+ checkAccessWithPairing,
5
+ getWebhookUserId,
6
+ normalizeAllowEntry,
7
+ normalizeAllowList,
8
+ } from '../src/access-control.js';
3
9
  import type { PluginRuntime, ChannelPairingAdapter } from '../src/runtime.js';
4
10
 
5
11
  describe('normalizeAllowEntry', () => {
@@ -30,51 +36,72 @@ describe('normalizeAllowEntry', () => {
30
36
  });
31
37
  });
32
38
 
33
- describe('checkAccess', () => {
34
- it('allows everyone in open mode', () => {
35
- expect(checkAccess('1', { dmPolicy: 'open' })).toBe(true);
36
- expect(checkAccess('999', { dmPolicy: 'open' })).toBe(true);
39
+ describe('normalizeAllowList', () => {
40
+ it('normalizes, deduplicates and filters empty allowFrom entries', () => {
41
+ expect(normalizeAllowList([' bitrix24:42 ', '42', 'b24:7', ''])).toEqual(['42', '7']);
37
42
  });
38
43
 
39
- it('defaults to pairing (returns false without runtime)', () => {
40
- expect(checkAccess('1', {})).toBe(false);
44
+ it('returns empty list for missing allowFrom config', () => {
45
+ expect(normalizeAllowList(undefined)).toEqual([]);
41
46
  });
47
+ });
42
48
 
43
- it('allows listed users in allowlist mode', () => {
44
- const config = { dmPolicy: 'allowlist' as const, allowFrom: ['1', '42'] };
45
- expect(checkAccess('1', config)).toBe(true);
46
- expect(checkAccess('42', config)).toBe(true);
49
+ describe('getWebhookUserId', () => {
50
+ it('extracts owner ID from webhook URL', () => {
51
+ expect(getWebhookUserId('https://test.bitrix24.com/rest/42/token/')).toBe('42');
47
52
  });
48
53
 
49
- it('denies unlisted users in allowlist mode', () => {
50
- const config = { dmPolicy: 'allowlist' as const, allowFrom: ['1', '42'] };
51
- expect(checkAccess('99', config)).toBe(false);
54
+ it('normalizes prefixed user IDs', () => {
55
+ expect(getWebhookUserId('https://test.bitrix24.com/rest/b24:42/token/')).toBe('42');
56
+ });
57
+
58
+ it('returns null for invalid URLs', () => {
59
+ expect(getWebhookUserId('not-a-url')).toBeNull();
52
60
  });
53
61
 
54
- it('handles allowlist with prefixes', () => {
55
- const config = { dmPolicy: 'allowlist' as const, allowFrom: ['b24:1', 'bitrix24:42'] };
56
- expect(checkAccess('1', config)).toBe(true);
62
+ it('returns null when rest segment is missing', () => {
63
+ expect(getWebhookUserId('https://test.bitrix24.com/api/42/token/')).toBeNull();
64
+ });
65
+ });
66
+
67
+ describe('checkAccess', () => {
68
+ it('defaults to webhookUser and denies without a valid webhook owner', () => {
69
+ expect(checkAccess('1', {})).toBe(false);
70
+ });
71
+
72
+ it('allows only the webhook owner in webhookUser mode', () => {
73
+ const config = {
74
+ dmPolicy: 'webhookUser' as const,
75
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
76
+ };
77
+
57
78
  expect(checkAccess('42', config)).toBe(true);
58
79
  expect(checkAccess('99', config)).toBe(false);
59
80
  });
60
81
 
61
- it('denies everyone when allowlist is empty', () => {
62
- const config = { dmPolicy: 'allowlist' as const, allowFrom: [] };
63
- expect(checkAccess('1', config)).toBe(false);
82
+ it('allows webhook owner when dmPolicy is omitted', () => {
83
+ expect(checkAccess('42', { webhookUrl: 'https://test.bitrix24.com/rest/42/token/' })).toBe(true);
64
84
  });
65
85
 
66
- it('denies when allowlist is undefined', () => {
67
- const config = { dmPolicy: 'allowlist' as const };
68
- expect(checkAccess('1', config)).toBe(false);
86
+ it('keeps senderId as the access identity even when direct dialogId differs', () => {
87
+ const config = {
88
+ dmPolicy: 'webhookUser' as const,
89
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
90
+ };
91
+
92
+ expect(checkAccess('42', config, { dialogId: '2386', isDirect: true })).toBe(true);
93
+ expect(checkAccess('77', config, { dialogId: '42', isDirect: true })).toBe(false);
94
+ });
95
+
96
+ it('denies when webhookUser mode has no valid webhook owner', () => {
97
+ expect(checkAccess('42', { dmPolicy: 'webhookUser', webhookUrl: 'invalid' })).toBe(false);
69
98
  });
70
99
 
71
- it('returns false for pairing mode (requires runtime)', () => {
100
+ it('returns false for pairing mode without runtime state', () => {
72
101
  expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(false);
73
102
  });
74
103
  });
75
104
 
76
- // ─── checkAccessWithPairing ─────────────────────────────────────────────────
77
-
78
105
  function makeMockRuntime(storeAllowFrom: string[] = []): PluginRuntime {
79
106
  return {
80
107
  config: { loadConfig: () => ({}) },
@@ -88,7 +115,7 @@ function makeMockRuntime(storeAllowFrom: string[] = []): PluginRuntime {
88
115
  pairing: {
89
116
  readAllowFromStore: vi.fn().mockResolvedValue(storeAllowFrom),
90
117
  upsertPairingRequest: vi.fn().mockResolvedValue({ code: 'ABCD1234', created: true }),
91
- buildPairingReply: vi.fn().mockReturnValue({ text: 'Your pairing code: ABCD1234' }),
118
+ buildPairingReply: vi.fn().mockReturnValue('Your pairing code: ABCD1234'),
92
119
  },
93
120
  },
94
121
  logging: { getChildLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }) },
@@ -100,41 +127,112 @@ const mockAdapter: ChannelPairingAdapter = {
100
127
  normalizeAllowEntry: (entry) => entry.replace(/^(bitrix24|b24|bx24):/i, ''),
101
128
  };
102
129
 
103
- const silentLogger = { debug: () => {} };
130
+ const silentLogger = { debug: vi.fn() };
104
131
 
105
132
  describe('checkAccessWithPairing', () => {
106
- it('returns allow for open policy', async () => {
133
+ it('defaults to webhookUser when dmPolicy is omitted', async () => {
107
134
  const runtime = makeMockRuntime();
135
+
108
136
  const result = await checkAccessWithPairing({
109
- senderId: '1',
110
- config: { dmPolicy: 'open' },
137
+ senderId: '42',
138
+ config: { webhookUrl: 'https://test.bitrix24.com/rest/42/token/' },
111
139
  runtime,
112
140
  accountId: 'default',
113
141
  pairingAdapter: mockAdapter,
114
142
  sendReply: vi.fn(),
115
143
  logger: silentLogger,
116
144
  });
145
+
117
146
  expect(result).toBe('allow');
118
- // Should NOT read store for open policy
119
147
  expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
120
148
  });
121
149
 
122
- it('returns allow when sender is in config allowFrom', async () => {
150
+ it('allows only the webhook owner in webhookUser mode', async () => {
123
151
  const runtime = makeMockRuntime();
152
+ const sendReply = vi.fn();
153
+
124
154
  const result = await checkAccessWithPairing({
125
155
  senderId: '42',
126
- config: { dmPolicy: 'pairing', allowFrom: ['42'] },
156
+ config: {
157
+ dmPolicy: 'webhookUser',
158
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
159
+ },
127
160
  runtime,
128
161
  accountId: 'default',
129
162
  pairingAdapter: mockAdapter,
130
- sendReply: vi.fn(),
163
+ sendReply,
131
164
  logger: silentLogger,
132
165
  });
166
+
133
167
  expect(result).toBe('allow');
168
+ expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
169
+ expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
170
+ expect(sendReply).not.toHaveBeenCalled();
134
171
  });
135
172
 
136
- it('returns allow when sender is in file store', async () => {
173
+ it('keeps senderId as the identity in webhookUser mode even when dialogId differs', async () => {
174
+ const runtime = makeMockRuntime();
175
+
176
+ const allowed = await checkAccessWithPairing({
177
+ senderId: '42',
178
+ dialogId: '2386',
179
+ isDirect: true,
180
+ config: {
181
+ dmPolicy: 'webhookUser',
182
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
183
+ },
184
+ runtime,
185
+ accountId: 'default',
186
+ pairingAdapter: mockAdapter,
187
+ sendReply: vi.fn(),
188
+ logger: silentLogger,
189
+ });
190
+
191
+ const denied = await checkAccessWithPairing({
192
+ senderId: '77',
193
+ dialogId: '42',
194
+ isDirect: true,
195
+ config: {
196
+ dmPolicy: 'webhookUser',
197
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
198
+ },
199
+ runtime,
200
+ accountId: 'default',
201
+ pairingAdapter: mockAdapter,
202
+ sendReply: vi.fn(),
203
+ logger: silentLogger,
204
+ });
205
+
206
+ expect(allowed).toBe('allow');
207
+ expect(denied).toBe('deny');
208
+ });
209
+
210
+ it('denies non-owner users in webhookUser mode', async () => {
211
+ const runtime = makeMockRuntime();
212
+ const sendReply = vi.fn();
213
+
214
+ const result = await checkAccessWithPairing({
215
+ senderId: '77',
216
+ config: {
217
+ dmPolicy: 'webhookUser',
218
+ webhookUrl: 'https://test.bitrix24.com/rest/42/token/',
219
+ },
220
+ runtime,
221
+ accountId: 'default',
222
+ pairingAdapter: mockAdapter,
223
+ sendReply,
224
+ logger: silentLogger,
225
+ });
226
+
227
+ expect(result).toBe('deny');
228
+ expect(runtime.channel.pairing.readAllowFromStore).not.toHaveBeenCalled();
229
+ expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
230
+ expect(sendReply).not.toHaveBeenCalled();
231
+ });
232
+
233
+ it('returns allow when sender is in pairing store', async () => {
137
234
  const runtime = makeMockRuntime(['42']);
235
+
138
236
  const result = await checkAccessWithPairing({
139
237
  senderId: '42',
140
238
  config: { dmPolicy: 'pairing' },
@@ -144,40 +242,68 @@ describe('checkAccessWithPairing', () => {
144
242
  sendReply: vi.fn(),
145
243
  logger: silentLogger,
146
244
  });
245
+
147
246
  expect(result).toBe('allow');
148
247
  });
149
248
 
150
- it('merges config and store allowFrom (deduped)', async () => {
151
- const runtime = makeMockRuntime(['42', '99']);
249
+ it('returns allow when sender is in config allowFrom', async () => {
250
+ const runtime = makeMockRuntime();
251
+
152
252
  const result = await checkAccessWithPairing({
153
- senderId: '99',
154
- config: { dmPolicy: 'allowlist', allowFrom: ['42'] },
253
+ senderId: '42',
254
+ config: {
255
+ dmPolicy: 'pairing',
256
+ allowFrom: ['bitrix24:42'],
257
+ },
155
258
  runtime,
156
259
  accountId: 'default',
157
260
  pairingAdapter: mockAdapter,
158
261
  sendReply: vi.fn(),
159
262
  logger: silentLogger,
160
263
  });
264
+
161
265
  expect(result).toBe('allow');
266
+ expect(runtime.channel.pairing.upsertPairingRequest).not.toHaveBeenCalled();
162
267
  });
163
268
 
164
- it('returns deny for allowlist when sender not in merged list', async () => {
269
+ it('uses senderId as the pairing identity even when direct dialogId differs', async () => {
165
270
  const runtime = makeMockRuntime(['42']);
271
+
166
272
  const result = await checkAccessWithPairing({
167
- senderId: '999',
168
- config: { dmPolicy: 'allowlist', allowFrom: ['1'] },
273
+ senderId: '42',
274
+ dialogId: '42',
275
+ isDirect: true,
276
+ config: { dmPolicy: 'pairing' },
169
277
  runtime,
170
278
  accountId: 'default',
171
279
  pairingAdapter: mockAdapter,
172
280
  sendReply: vi.fn(),
173
281
  logger: silentLogger,
174
282
  });
175
- expect(result).toBe('deny');
283
+
284
+ expect(result).toBe('allow');
285
+ });
286
+
287
+ it('normalizes prefixed entries from pairing store', async () => {
288
+ const runtime = makeMockRuntime(['b24:42']);
289
+
290
+ const result = await checkAccessWithPairing({
291
+ senderId: '42',
292
+ config: { dmPolicy: 'pairing' },
293
+ runtime,
294
+ accountId: 'default',
295
+ pairingAdapter: mockAdapter,
296
+ sendReply: vi.fn(),
297
+ logger: silentLogger,
298
+ });
299
+
300
+ expect(result).toBe('allow');
176
301
  });
177
302
 
178
303
  it('upserts pairing request and sends reply for new pairing', async () => {
179
304
  const runtime = makeMockRuntime();
180
305
  const sendReply = vi.fn();
306
+
181
307
  const result = await checkAccessWithPairing({
182
308
  senderId: '77',
183
309
  config: { dmPolicy: 'pairing' },
@@ -187,7 +313,9 @@ describe('checkAccessWithPairing', () => {
187
313
  sendReply,
188
314
  logger: silentLogger,
189
315
  });
316
+
190
317
  expect(result).toBe('pairing');
318
+ expect(runtime.channel.pairing.readAllowFromStore).toHaveBeenCalledWith('bitrix24', '', 'default');
191
319
  expect(runtime.channel.pairing.upsertPairingRequest).toHaveBeenCalledWith({
192
320
  channel: 'bitrix24',
193
321
  id: '77',
@@ -210,6 +338,7 @@ describe('checkAccessWithPairing', () => {
210
338
  created: false,
211
339
  });
212
340
  const sendReply = vi.fn();
341
+
213
342
  const result = await checkAccessWithPairing({
214
343
  senderId: '77',
215
344
  config: { dmPolicy: 'pairing' },
@@ -219,21 +348,8 @@ describe('checkAccessWithPairing', () => {
219
348
  sendReply,
220
349
  logger: silentLogger,
221
350
  });
351
+
222
352
  expect(result).toBe('pairing');
223
353
  expect(sendReply).not.toHaveBeenCalled();
224
354
  });
225
-
226
- it('normalizes config allowFrom entries with prefixes', async () => {
227
- const runtime = makeMockRuntime();
228
- const result = await checkAccessWithPairing({
229
- senderId: '42',
230
- config: { dmPolicy: 'pairing', allowFrom: ['b24:42'] },
231
- runtime,
232
- accountId: 'default',
233
- pairingAdapter: mockAdapter,
234
- sendReply: vi.fn(),
235
- logger: silentLogger,
236
- });
237
- expect(result).toBe('allow');
238
- });
239
355
  });
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { Bitrix24Api } from '../src/api.js';
3
+ import { Bitrix24ApiError } from '../src/utils.js';
4
+
5
+ const silentLogger = {
6
+ info: vi.fn(),
7
+ warn: vi.fn(),
8
+ error: vi.fn(),
9
+ debug: vi.fn(),
10
+ };
11
+
12
+ describe('Bitrix24Api', () => {
13
+ afterEach(() => {
14
+ vi.useRealTimers();
15
+ vi.restoreAllMocks();
16
+ });
17
+
18
+ it('calls webhook with a request timeout signal', async () => {
19
+ const api = new Bitrix24Api({ logger: silentLogger });
20
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
21
+ new Response(JSON.stringify({ result: { ok: true } }), { status: 200 }),
22
+ );
23
+
24
+ const result = await api.callWebhook(
25
+ 'https://test.bitrix24.com/rest/1/token/',
26
+ 'profile',
27
+ { foo: 'bar' },
28
+ );
29
+
30
+ expect(result).toEqual({ result: { ok: true } });
31
+ expect(fetchSpy).toHaveBeenCalledWith(
32
+ 'https://test.bitrix24.com/rest/1/token/profile.json',
33
+ expect.objectContaining({
34
+ method: 'POST',
35
+ body: JSON.stringify({ foo: 'bar' }),
36
+ signal: expect.any(AbortSignal),
37
+ }),
38
+ );
39
+ });
40
+
41
+ it('maps timeout errors to Bitrix24ApiError', async () => {
42
+ const api = new Bitrix24Api({ logger: silentLogger });
43
+ vi.useFakeTimers();
44
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(
45
+ Object.assign(new Error('timed out'), { name: 'TimeoutError' }),
46
+ );
47
+
48
+ const assertion = expect(
49
+ api.callWebhook('https://test.bitrix24.com/rest/1/token/', 'profile'),
50
+ ).rejects.toMatchObject({
51
+ name: 'Bitrix24ApiError',
52
+ code: 'TIMEOUT',
53
+ });
54
+ await vi.runAllTimersAsync();
55
+ await assertion;
56
+ });
57
+
58
+ it('retries transient network fetch failures', async () => {
59
+ const api = new Bitrix24Api({ logger: silentLogger });
60
+ vi.useFakeTimers();
61
+ const fetchSpy = vi.spyOn(globalThis, 'fetch')
62
+ .mockRejectedValueOnce(Object.assign(new TypeError('fetch failed'), {
63
+ cause: { code: 'ECONNRESET' },
64
+ }))
65
+ .mockResolvedValueOnce(
66
+ new Response(JSON.stringify({ result: { ok: true } }), { status: 200 }),
67
+ );
68
+
69
+ const assertion = expect(api.callWebhook(
70
+ 'https://test.bitrix24.com/rest/1/token/',
71
+ 'profile',
72
+ { foo: 'bar' },
73
+ )).resolves.toEqual({ result: { ok: true } });
74
+ await vi.runAllTimersAsync();
75
+ await assertion;
76
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
77
+ });
78
+
79
+ it('throws INVALID_RESPONSE when sendMessage result has no valid id', async () => {
80
+ const api = new Bitrix24Api({ logger: silentLogger });
81
+ vi.spyOn(api, 'callWebhook').mockResolvedValueOnce({
82
+ result: {} as never,
83
+ });
84
+
85
+ await expect(api.sendMessage(
86
+ 'https://test.bitrix24.com/rest/1/token/',
87
+ { botId: 7, botToken: 'bot_token' },
88
+ '42',
89
+ 'hello',
90
+ )).rejects.toMatchObject({
91
+ name: 'Bitrix24ApiError',
92
+ code: 'INVALID_RESPONSE',
93
+ });
94
+ });
95
+ });