switchroom 0.18.12 → 0.18.14
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.
- package/dist/agent-scheduler/index.js +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the forwarded-message origin helpers
|
|
3
|
+
* (telegram-plugin/gateway/forward-origin.ts).
|
|
4
|
+
*
|
|
5
|
+
* These pin the pure pieces of the forward-origin metadata path that live
|
|
6
|
+
* outside gateway.ts so they can be exercised without loadAccess()/IPC:
|
|
7
|
+
* 1. parseForwardOrigin — all four Bot API 7.0 origin shapes, missing
|
|
8
|
+
* fields, hostile (attacker-controlled) names, hidden_user marking.
|
|
9
|
+
* 2. buildForwardOriginMeta — fixed attribute order, XML escaping at the
|
|
10
|
+
* channel-meta boundary, numbered `_2..` siblings for multi-origin
|
|
11
|
+
* bursts (same convention as image_path_2 / attachment_file_id_2).
|
|
12
|
+
* 3. dedupeForwardOrigins — the coalescer's burst collapse: a
|
|
13
|
+
* single-origin album emits one attr set; distinct origins keep
|
|
14
|
+
* arrival order and get numbered.
|
|
15
|
+
*
|
|
16
|
+
* Trust model under test: origin info rides the ATTRS lane only, names are
|
|
17
|
+
* truncated + escaped (they're attacker-controlled), and hidden_user is
|
|
18
|
+
* explicitly marked so agents don't treat a self-reported display name as
|
|
19
|
+
* an authenticated identity.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from 'vitest'
|
|
23
|
+
import type { MessageOrigin } from 'grammy/types'
|
|
24
|
+
import {
|
|
25
|
+
parseForwardOrigin,
|
|
26
|
+
buildForwardOriginMeta,
|
|
27
|
+
dedupeForwardOrigins,
|
|
28
|
+
forwardOriginKey,
|
|
29
|
+
forwardOriginDateIso,
|
|
30
|
+
FORWARDED_FROM_NAME_MAX,
|
|
31
|
+
type ForwardOriginInfo,
|
|
32
|
+
} from '../gateway/forward-origin.js'
|
|
33
|
+
|
|
34
|
+
// Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
|
|
35
|
+
const DATE = 1750000000 // unix seconds
|
|
36
|
+
const DATE_ISO = new Date(DATE * 1000).toISOString()
|
|
37
|
+
|
|
38
|
+
function userOrigin(overrides: Partial<{
|
|
39
|
+
first_name: string
|
|
40
|
+
last_name?: string
|
|
41
|
+
username?: string
|
|
42
|
+
id: number
|
|
43
|
+
}> = {}): MessageOrigin {
|
|
44
|
+
return {
|
|
45
|
+
type: 'user',
|
|
46
|
+
date: DATE,
|
|
47
|
+
sender_user: {
|
|
48
|
+
id: 42,
|
|
49
|
+
is_bot: false,
|
|
50
|
+
first_name: 'Ada',
|
|
51
|
+
last_name: 'Lovelace',
|
|
52
|
+
username: 'adalove',
|
|
53
|
+
...overrides,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('parseForwardOrigin — the four origin shapes', () => {
|
|
59
|
+
it('user: first + last name plus (@username), id and date captured', () => {
|
|
60
|
+
const info = parseForwardOrigin(userOrigin())
|
|
61
|
+
expect(info).toEqual({
|
|
62
|
+
name: 'Ada Lovelace (@adalove)',
|
|
63
|
+
type: 'user',
|
|
64
|
+
id: 42,
|
|
65
|
+
date: DATE,
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('user: first name only, no username', () => {
|
|
70
|
+
const info = parseForwardOrigin(userOrigin({ last_name: undefined, username: undefined }))
|
|
71
|
+
expect(info?.name).toBe('Ada')
|
|
72
|
+
expect(info?.id).toBe(42)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('hidden_user: self-reported name, marked hidden_user, NO id', () => {
|
|
76
|
+
const info = parseForwardOrigin({
|
|
77
|
+
type: 'hidden_user',
|
|
78
|
+
date: DATE,
|
|
79
|
+
sender_user_name: 'Mystery Sender',
|
|
80
|
+
})
|
|
81
|
+
expect(info).toEqual({
|
|
82
|
+
name: 'Mystery Sender',
|
|
83
|
+
type: 'hidden_user',
|
|
84
|
+
date: DATE,
|
|
85
|
+
})
|
|
86
|
+
// The whole point of the type marker: no verifiable id exists.
|
|
87
|
+
expect(info?.id).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('chat: sender_chat title plus (@username)', () => {
|
|
91
|
+
const info = parseForwardOrigin({
|
|
92
|
+
type: 'chat',
|
|
93
|
+
date: DATE,
|
|
94
|
+
sender_chat: { id: -100200300, type: 'supergroup', title: 'Ops Room', username: 'opsroom' } as never,
|
|
95
|
+
})
|
|
96
|
+
expect(info).toEqual({
|
|
97
|
+
name: 'Ops Room (@opsroom)',
|
|
98
|
+
type: 'chat',
|
|
99
|
+
id: -100200300,
|
|
100
|
+
date: DATE,
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('channel: chat.title plus (@username), message_id captured', () => {
|
|
105
|
+
const info = parseForwardOrigin({
|
|
106
|
+
type: 'channel',
|
|
107
|
+
date: DATE,
|
|
108
|
+
message_id: 555,
|
|
109
|
+
chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
|
|
110
|
+
})
|
|
111
|
+
expect(info).toEqual({
|
|
112
|
+
name: 'Release Notes (@relnotes)',
|
|
113
|
+
type: 'channel',
|
|
114
|
+
id: -100400500,
|
|
115
|
+
date: DATE,
|
|
116
|
+
messageId: 555,
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('parseForwardOrigin — missing / malformed fields', () => {
|
|
122
|
+
it('returns undefined for a non-forwarded message (no origin)', () => {
|
|
123
|
+
expect(parseForwardOrigin(undefined)).toBeUndefined()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns undefined for an unknown future origin type', () => {
|
|
127
|
+
expect(parseForwardOrigin({ type: 'giveaway', date: DATE } as never)).toBeUndefined()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('user origin without sender_user is dropped', () => {
|
|
131
|
+
expect(parseForwardOrigin({ type: 'user', date: DATE } as never)).toBeUndefined()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('hidden_user with an empty name is dropped (nothing to show)', () => {
|
|
135
|
+
expect(
|
|
136
|
+
parseForwardOrigin({ type: 'hidden_user', date: DATE, sender_user_name: '' }),
|
|
137
|
+
).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('user with no printable name falls back to the numeric id as name', () => {
|
|
141
|
+
const info = parseForwardOrigin({
|
|
142
|
+
type: 'user',
|
|
143
|
+
date: DATE,
|
|
144
|
+
sender_user: { id: 42, is_bot: false, first_name: '' },
|
|
145
|
+
})
|
|
146
|
+
expect(info).toEqual({ name: '42', type: 'user', id: 42, date: DATE })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('missing date yields no date (and later no forwarded_date attr)', () => {
|
|
150
|
+
const info = parseForwardOrigin({
|
|
151
|
+
type: 'hidden_user',
|
|
152
|
+
sender_user_name: 'Mystery Sender',
|
|
153
|
+
} as never)
|
|
154
|
+
expect(info?.date).toBeUndefined()
|
|
155
|
+
expect(buildForwardOriginMeta([info!]).forwarded_date).toBeUndefined()
|
|
156
|
+
expect(forwardOriginDateIso(info)).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('channel origin without a title falls back to first/last name shape, then id', () => {
|
|
160
|
+
const noName = parseForwardOrigin({
|
|
161
|
+
type: 'channel',
|
|
162
|
+
date: DATE,
|
|
163
|
+
message_id: 9,
|
|
164
|
+
chat: { id: -100777888, type: 'channel' } as never,
|
|
165
|
+
})
|
|
166
|
+
expect(noName).toEqual({
|
|
167
|
+
name: '-100777888',
|
|
168
|
+
type: 'channel',
|
|
169
|
+
id: -100777888,
|
|
170
|
+
date: DATE,
|
|
171
|
+
messageId: 9,
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('parseForwardOrigin — hostile names (attacker-controlled)', () => {
|
|
177
|
+
it('truncates a 4KB name to the cap (raw, ellipsis-terminated)', () => {
|
|
178
|
+
const bomb = 'x'.repeat(4096)
|
|
179
|
+
const info = parseForwardOrigin(userOrigin({ first_name: bomb, last_name: undefined, username: undefined }))
|
|
180
|
+
expect(info?.name).toHaveLength(FORWARDED_FROM_NAME_MAX)
|
|
181
|
+
expect(info?.name?.endsWith('…')).toBe(true)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps XML metacharacters RAW in the parsed record (SQLite lane)', () => {
|
|
185
|
+
const info = parseForwardOrigin(
|
|
186
|
+
userOrigin({ first_name: '<b>"Bob"&\'friends\'</b>', last_name: undefined, username: undefined }),
|
|
187
|
+
)
|
|
188
|
+
// Escaping happens at the channel-meta boundary, not at parse time —
|
|
189
|
+
// the history buffer stores what the user actually saw.
|
|
190
|
+
expect(info?.name).toBe('<b>"Bob"&\'friends\'</b>')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('buildForwardOriginMeta — channel-tag attrs', () => {
|
|
195
|
+
it('single origin: bare keys in the fixed order name, type, id, date', () => {
|
|
196
|
+
const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
|
|
197
|
+
expect(Object.keys(meta)).toEqual([
|
|
198
|
+
'forwarded_from',
|
|
199
|
+
'forwarded_from_type',
|
|
200
|
+
'forwarded_from_id',
|
|
201
|
+
'forwarded_date',
|
|
202
|
+
])
|
|
203
|
+
expect(meta).toEqual({
|
|
204
|
+
forwarded_from: 'Ada Lovelace (@adalove)',
|
|
205
|
+
forwarded_from_type: 'user',
|
|
206
|
+
forwarded_from_id: '42',
|
|
207
|
+
forwarded_date: DATE_ISO,
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('id-less origin (hidden_user) omits forwarded_from_id entirely', () => {
|
|
212
|
+
const meta = buildForwardOriginMeta([
|
|
213
|
+
{ name: 'Mystery Sender', type: 'hidden_user', date: DATE },
|
|
214
|
+
])
|
|
215
|
+
expect(Object.keys(meta)).toEqual([
|
|
216
|
+
'forwarded_from',
|
|
217
|
+
'forwarded_from_type',
|
|
218
|
+
'forwarded_date',
|
|
219
|
+
])
|
|
220
|
+
expect(meta.forwarded_from_type).toBe('hidden_user')
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('XML-escapes hostile names — the rendered attribute value cannot break out', () => {
|
|
224
|
+
const meta = buildForwardOriginMeta([
|
|
225
|
+
{ name: '<script>"a"&\'b\'</script>', type: 'user', id: 42, date: DATE },
|
|
226
|
+
])
|
|
227
|
+
expect(meta.forwarded_from).toBe(
|
|
228
|
+
'<script>"a"&'b'</script>',
|
|
229
|
+
)
|
|
230
|
+
// Outcome assertion: rendered into a channel tag, the value carries no
|
|
231
|
+
// raw quote/angle characters that could terminate the attribute.
|
|
232
|
+
const rendered = `<channel source="telegram" forwarded_from="${meta.forwarded_from}">`
|
|
233
|
+
expect(rendered).not.toMatch(/forwarded_from="[^"]*[<>][^"]*"/)
|
|
234
|
+
expect((rendered.match(/"/g) ?? []).length).toBe(4) // only the delimiters
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('defense in depth: a name that skipped parse-time truncation is capped here too', () => {
|
|
238
|
+
const meta = buildForwardOriginMeta([
|
|
239
|
+
{ name: 'y'.repeat(4096), type: 'user', id: 42, date: DATE },
|
|
240
|
+
])
|
|
241
|
+
// 99 chars + '…' — no 4KB payload reaches the tag.
|
|
242
|
+
expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('no origins → empty record (no attrs on a normal message)', () => {
|
|
246
|
+
expect(buildForwardOriginMeta([])).toEqual({})
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
describe('coalesced bursts — dedupe + numbered siblings', () => {
|
|
251
|
+
const alice: ForwardOriginInfo = { name: 'Alice Q (@aliceq)', type: 'user', id: 42, date: DATE }
|
|
252
|
+
const relnotes: ForwardOriginInfo = {
|
|
253
|
+
name: 'Release Notes (@relnotes)',
|
|
254
|
+
type: 'channel',
|
|
255
|
+
id: -100400500,
|
|
256
|
+
date: DATE + 60,
|
|
257
|
+
messageId: 7,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
it('single-origin album (N parts, one sender) collapses to ONE attr set', () => {
|
|
261
|
+
const origins = dedupeForwardOrigins([alice, { ...alice, date: DATE + 5 }, { ...alice, date: DATE + 9 }])
|
|
262
|
+
expect(origins).toHaveLength(1)
|
|
263
|
+
const meta = buildForwardOriginMeta(origins)
|
|
264
|
+
expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
|
|
265
|
+
expect(meta.forwarded_from_2).toBeUndefined()
|
|
266
|
+
// First occurrence wins — the emitted date is the first part's.
|
|
267
|
+
expect(meta.forwarded_date).toBe(DATE_ISO)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
|
|
271
|
+
const meta = buildForwardOriginMeta(dedupeForwardOrigins([alice, relnotes]))
|
|
272
|
+
expect(Object.keys(meta)).toEqual([
|
|
273
|
+
'forwarded_from',
|
|
274
|
+
'forwarded_from_type',
|
|
275
|
+
'forwarded_from_id',
|
|
276
|
+
'forwarded_date',
|
|
277
|
+
'forwarded_from_2',
|
|
278
|
+
'forwarded_from_type_2',
|
|
279
|
+
'forwarded_from_id_2',
|
|
280
|
+
'forwarded_date_2',
|
|
281
|
+
])
|
|
282
|
+
expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
|
|
283
|
+
expect(meta.forwarded_from_type_2).toBe('channel')
|
|
284
|
+
expect(meta.forwarded_from_id_2).toBe('-100400500')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('non-forwarded entries in the burst are skipped', () => {
|
|
288
|
+
expect(dedupeForwardOrigins([undefined, alice, undefined])).toEqual([alice])
|
|
289
|
+
expect(dedupeForwardOrigins([undefined, undefined])).toEqual([])
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('id-less origins dedupe by name; same type+id dedupes despite name drift', () => {
|
|
293
|
+
const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
|
|
294
|
+
expect(dedupeForwardOrigins([hidden, { ...hidden, date: DATE + 3 }])).toHaveLength(1)
|
|
295
|
+
// Same user id, renamed mid-burst — still one origin.
|
|
296
|
+
expect(dedupeForwardOrigins([alice, { ...alice, name: 'Alice Renamed' }])).toHaveLength(1)
|
|
297
|
+
// Same numeric id but different type stays distinct.
|
|
298
|
+
expect(forwardOriginKey(alice)).not.toBe(forwardOriginKey({ ...alice, type: 'chat' }))
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('arrival order is preserved across three distinct origins', () => {
|
|
302
|
+
const hidden: ForwardOriginInfo = { name: 'Mystery Sender', type: 'hidden_user', date: DATE }
|
|
303
|
+
const meta = buildForwardOriginMeta(dedupeForwardOrigins([relnotes, hidden, alice]))
|
|
304
|
+
expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
|
|
305
|
+
expect(meta.forwarded_from_2).toBe('Mystery Sender')
|
|
306
|
+
expect(meta.forwarded_from_3).toBe('Alice Q (@aliceq)')
|
|
307
|
+
expect(meta.forwarded_from_type_3).toBe('user')
|
|
308
|
+
})
|
|
309
|
+
})
|
|
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
77
77
|
expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
|
|
81
|
+
// F1 — ask_user sent the question + button labels unredacted. The scrub
|
|
82
|
+
// must run at the top of executeAskUser (via redactAskUserFields), before
|
|
83
|
+
// the keyboard is built and the question is sent to Telegram.
|
|
84
|
+
const start = src.indexOf('async function executeAskUser(')
|
|
85
|
+
const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
|
|
86
|
+
const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
|
|
87
|
+
const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
|
|
88
|
+
expect(start).toBeGreaterThan(0)
|
|
89
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
90
|
+
expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
|
|
91
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
|
|
95
|
+
// F2 — checklist title + task strings were sent unredacted.
|
|
96
|
+
const start = src.indexOf('async function executeSendChecklist(')
|
|
97
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
98
|
+
const sendIdx = src.indexOf('rawSendChecklist({', start)
|
|
99
|
+
expect(start).toBeGreaterThan(0)
|
|
100
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
101
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
|
|
105
|
+
// F2 sibling — update_checklist shares the identical leak class.
|
|
106
|
+
const start = src.indexOf('async function executeUpdateChecklist(')
|
|
107
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
108
|
+
const editIdx = src.indexOf('rawEditMessageChecklist({', start)
|
|
109
|
+
expect(start).toBeGreaterThan(0)
|
|
110
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
111
|
+
expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
|
|
115
|
+
// F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
|
|
116
|
+
// forwards straight to the draft-preview stream. Mask at the top of
|
|
117
|
+
// handlePtyPartial, before it hands off to handlePtyPartialPure.
|
|
118
|
+
const start = src.indexOf('function handlePtyPartial(text: string): void {')
|
|
119
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
|
|
120
|
+
const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
|
|
121
|
+
expect(start).toBeGreaterThan(0)
|
|
122
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
123
|
+
expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
|
|
127
|
+
// F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
|
|
128
|
+
// is durable if it is ever re-armed.
|
|
129
|
+
const start = src.indexOf('function handlePtyActivity(text: string): void {')
|
|
130
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
|
|
131
|
+
const streamIdx = src.indexOf('handleStreamReply(', start)
|
|
132
|
+
expect(start).toBeGreaterThan(0)
|
|
133
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
134
|
+
expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
|
|
135
|
+
})
|
|
136
|
+
|
|
80
137
|
it('does not log the secret value when a mask fires', () => {
|
|
81
138
|
const idx = src.indexOf('function redactOutboundText(')
|
|
82
139
|
const body = src.slice(idx, idx + 400)
|
|
@@ -2,6 +2,9 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
2
2
|
import { mkdtempSync, statSync, rmSync, existsSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
5
|
+
// bun-only (this file is vitest-excluded and runs under `bun test`): used to
|
|
6
|
+
// build an OLD-schema DB file for the additive-migration test.
|
|
7
|
+
import { Database } from 'bun:sqlite'
|
|
5
8
|
import {
|
|
6
9
|
initHistory,
|
|
7
10
|
recordInbound,
|
|
@@ -542,6 +545,121 @@ describe('hasOutboundDeliveredSince', () => {
|
|
|
542
545
|
})
|
|
543
546
|
})
|
|
544
547
|
|
|
548
|
+
// Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
|
|
549
|
+
// NOT dedupe thread-less rows because SQLite treats NULL as distinct from NULL
|
|
550
|
+
// in a UNIQUE/PK index. The documented at-least-once boot replay re-records an
|
|
551
|
+
// already-stored DM/non-topic message, so `INSERT OR REPLACE` appended a
|
|
552
|
+
// DUPLICATE row instead of replacing — over-counting the silence / over-ping
|
|
553
|
+
// detectors. The COALESCE(thread_id,'') unique index makes the upsert
|
|
554
|
+
// idempotent. These tests fail (duplicate rows) without the fix.
|
|
555
|
+
describe('idempotent upsert for null-thread rows (H5)', () => {
|
|
556
|
+
beforeEach(() => initHistory(stateDir, 30))
|
|
557
|
+
|
|
558
|
+
it('re-recording the same null-thread inbound yields exactly ONE row', () => {
|
|
559
|
+
const msg = {
|
|
560
|
+
chat_id: '-100',
|
|
561
|
+
thread_id: null,
|
|
562
|
+
message_id: 7,
|
|
563
|
+
user: 'alice',
|
|
564
|
+
user_id: '111',
|
|
565
|
+
ts: 1000,
|
|
566
|
+
text: 'hello',
|
|
567
|
+
}
|
|
568
|
+
// Simulate the at-least-once replay: record the same message twice.
|
|
569
|
+
recordInbound(msg)
|
|
570
|
+
recordInbound(msg)
|
|
571
|
+
const rows = query({ chat_id: '-100' })
|
|
572
|
+
expect(rows).toHaveLength(1)
|
|
573
|
+
expect(rows[0]).toMatchObject({ message_id: 7, role: 'user', text: 'hello' })
|
|
574
|
+
// The over-count surface the finding calls out must stay accurate.
|
|
575
|
+
expect(getRecentOutboundCount('-100', 999_999)).toBe(0)
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('re-recording the same null-thread outbound yields exactly ONE row', () => {
|
|
579
|
+
const now = Math.floor(Date.now() / 1000)
|
|
580
|
+
const send = {
|
|
581
|
+
chat_id: '-100',
|
|
582
|
+
thread_id: null,
|
|
583
|
+
message_ids: [42],
|
|
584
|
+
texts: ['the answer'],
|
|
585
|
+
ts: now,
|
|
586
|
+
}
|
|
587
|
+
recordOutbound(send)
|
|
588
|
+
recordOutbound(send)
|
|
589
|
+
const rows = query({ chat_id: '-100' })
|
|
590
|
+
expect(rows).toHaveLength(1)
|
|
591
|
+
// hasOutboundDeliveredSince / getRecentOutboundCount read this table; a
|
|
592
|
+
// duplicate here would double-count and over-ping.
|
|
593
|
+
expect(getRecentOutboundCount('-100', 60)).toBe(1)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('a re-record REPLACES rather than appends (newest text wins)', () => {
|
|
597
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'first version' })
|
|
598
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: 1000, text: 'second version' })
|
|
599
|
+
const rows = query({ chat_id: '-100' })
|
|
600
|
+
expect(rows).toHaveLength(1)
|
|
601
|
+
expect(rows[0]?.text).toBe('second version')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
it('a topic row and a general row with the SAME message_id stay DISTINCT', () => {
|
|
605
|
+
// Same chat_id + message_id, but one is in a forum topic (thread 5) and one
|
|
606
|
+
// is the general/DM row (null thread). These are different logical messages
|
|
607
|
+
// and must both survive — the COALESCE sentinel keeps them separate.
|
|
608
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in topic 5' })
|
|
609
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'in general' })
|
|
610
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
611
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['in topic 5'])
|
|
612
|
+
expect(query({ chat_id: '-100', thread_id: null }).map(r => r.text)).toEqual(['in general'])
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('two distinct forum topics with the same message_id stay DISTINCT', () => {
|
|
616
|
+
recordInbound({ chat_id: '-100', thread_id: 5, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic5' })
|
|
617
|
+
recordInbound({ chat_id: '-100', thread_id: 6, message_id: 9, user: 'a', user_id: '1', ts: 100, text: 'topic6' })
|
|
618
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
619
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic5'])
|
|
620
|
+
expect(query({ chat_id: '-100', thread_id: 6 }).map(r => r.text)).toEqual(['topic6'])
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('migration de-dupes pre-existing null-thread duplicates from a legacy DB', async () => {
|
|
624
|
+
// Simulate a DB written by the pre-fix build: insert duplicate null-thread
|
|
625
|
+
// rows DIRECTLY, bypassing the (now-fixed) upsert, to reproduce the exact
|
|
626
|
+
// corruption the finding describes. Then re-init to fire the migration.
|
|
627
|
+
const { Database } = await import('bun:sqlite')
|
|
628
|
+
const dbPath = join(stateDir, 'history.db')
|
|
629
|
+
const now = Math.floor(Date.now() / 1000) // recent ts so the retention sweep keeps them
|
|
630
|
+
_resetForTests()
|
|
631
|
+
const raw = new Database(dbPath)
|
|
632
|
+
// Drop the logical-key index so we're back to the legacy, dupe-permitting
|
|
633
|
+
// schema, then append two rows sharing the same (chat, null-thread, msg).
|
|
634
|
+
raw.exec('DROP INDEX IF EXISTS idx_messages_logical_key')
|
|
635
|
+
const insert = raw.prepare(
|
|
636
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
637
|
+
VALUES (?, NULL, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
638
|
+
)
|
|
639
|
+
insert.run('-100', 7, now, 'stale copy')
|
|
640
|
+
insert.run('-100', 7, now + 1, 'newest copy')
|
|
641
|
+
// A distinct topic row with the same message_id must survive the migration.
|
|
642
|
+
raw.prepare(
|
|
643
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text, group_id)
|
|
644
|
+
VALUES (?, 5, ?, 'user', 'a', '1', ?, ?, NULL)`,
|
|
645
|
+
).run('-100', 7, now, 'topic row')
|
|
646
|
+
raw.close()
|
|
647
|
+
|
|
648
|
+
// Re-open through initHistory → runs the H5 migration (dedupe + index).
|
|
649
|
+
initHistory(stateDir, 30)
|
|
650
|
+
const general = query({ chat_id: '-100', thread_id: null })
|
|
651
|
+
expect(general).toHaveLength(1)
|
|
652
|
+
expect(general[0]?.text).toBe('newest copy') // kept the newest (highest ts/rowid)
|
|
653
|
+
// The topic row with the same message_id is untouched.
|
|
654
|
+
expect(query({ chat_id: '-100', thread_id: 5 }).map(r => r.text)).toEqual(['topic row'])
|
|
655
|
+
expect(query({ chat_id: '-100' })).toHaveLength(2)
|
|
656
|
+
|
|
657
|
+
// And the upsert is idempotent going forward.
|
|
658
|
+
recordInbound({ chat_id: '-100', thread_id: null, message_id: 7, user: 'a', user_id: '1', ts: now + 2, text: 'replay' })
|
|
659
|
+
expect(query({ chat_id: '-100', thread_id: null })).toHaveLength(1)
|
|
660
|
+
})
|
|
661
|
+
})
|
|
662
|
+
|
|
545
663
|
describe('secret redaction at persistence (both directions)', () => {
|
|
546
664
|
beforeEach(() => initHistory(stateDir, 30))
|
|
547
665
|
|
|
@@ -600,3 +718,157 @@ describe('secret redaction at persistence (both directions)', () => {
|
|
|
600
718
|
expect(query({ chat_id: '-100' })[0]!.text).toBe('hello, how are you?')
|
|
601
719
|
})
|
|
602
720
|
})
|
|
721
|
+
|
|
722
|
+
describe('forwarded-message origin columns', () => {
|
|
723
|
+
it('round-trips forwarded_* fields on an inbound row', () => {
|
|
724
|
+
initHistory(stateDir, 30)
|
|
725
|
+
recordInbound({
|
|
726
|
+
chat_id: '-100',
|
|
727
|
+
thread_id: null,
|
|
728
|
+
message_id: 7,
|
|
729
|
+
user: 'alice',
|
|
730
|
+
user_id: '111',
|
|
731
|
+
ts: 1000,
|
|
732
|
+
text: 'fwd: look at this',
|
|
733
|
+
forwarded_from: 'Release Notes (@relnotes)',
|
|
734
|
+
forwarded_from_type: 'channel',
|
|
735
|
+
forwarded_from_id: '-100400500',
|
|
736
|
+
forwarded_date: '2026-06-15T13:46:40.000Z',
|
|
737
|
+
forwarded_message_id: 555,
|
|
738
|
+
})
|
|
739
|
+
const row = query({ chat_id: '-100' })[0]!
|
|
740
|
+
expect(row).toMatchObject({
|
|
741
|
+
forwarded_from: 'Release Notes (@relnotes)',
|
|
742
|
+
forwarded_from_type: 'channel',
|
|
743
|
+
forwarded_from_id: '-100400500',
|
|
744
|
+
forwarded_date: '2026-06-15T13:46:40.000Z',
|
|
745
|
+
forwarded_message_id: 555,
|
|
746
|
+
})
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('non-forwarded inbound stores NULL origin fields', () => {
|
|
750
|
+
initHistory(stateDir, 30)
|
|
751
|
+
recordInbound({
|
|
752
|
+
chat_id: '-100',
|
|
753
|
+
thread_id: null,
|
|
754
|
+
message_id: 8,
|
|
755
|
+
user: 'alice',
|
|
756
|
+
user_id: '111',
|
|
757
|
+
ts: 1000,
|
|
758
|
+
text: 'plain message',
|
|
759
|
+
})
|
|
760
|
+
const row = query({ chat_id: '-100' })[0]!
|
|
761
|
+
expect(row.forwarded_from).toBeNull()
|
|
762
|
+
expect(row.forwarded_from_type).toBeNull()
|
|
763
|
+
expect(row.forwarded_from_id).toBeNull()
|
|
764
|
+
expect(row.forwarded_date).toBeNull()
|
|
765
|
+
expect(row.forwarded_message_id).toBeNull()
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('migrates additively: pre-existing DB without the columns gains them and round-trips', () => {
|
|
769
|
+
// Build an OLD-schema DB file the way a pre-forward-origin build would
|
|
770
|
+
// have left it: messages table without any forwarded_* columns, one row
|
|
771
|
+
// already stored.
|
|
772
|
+
const dbPath = join(stateDir, 'history.db')
|
|
773
|
+
const old = new Database(dbPath, { create: true })
|
|
774
|
+
old.exec(`
|
|
775
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
776
|
+
chat_id TEXT NOT NULL,
|
|
777
|
+
thread_id INTEGER,
|
|
778
|
+
message_id INTEGER NOT NULL,
|
|
779
|
+
role TEXT NOT NULL,
|
|
780
|
+
user TEXT,
|
|
781
|
+
user_id TEXT,
|
|
782
|
+
ts INTEGER NOT NULL,
|
|
783
|
+
text TEXT NOT NULL,
|
|
784
|
+
attachment_kind TEXT,
|
|
785
|
+
group_id INTEGER,
|
|
786
|
+
reply_to_message_id INTEGER,
|
|
787
|
+
reply_to_text TEXT,
|
|
788
|
+
PRIMARY KEY (chat_id, thread_id, message_id)
|
|
789
|
+
)
|
|
790
|
+
`)
|
|
791
|
+
// Recent ts — initHistory's retention sweep deletes rows older than the
|
|
792
|
+
// cutoff, and this test is about migration, not retention.
|
|
793
|
+
const now = Math.floor(Date.now() / 1000)
|
|
794
|
+
old.exec(
|
|
795
|
+
`INSERT INTO messages (chat_id, thread_id, message_id, role, user, user_id, ts, text) ` +
|
|
796
|
+
`VALUES ('-100', NULL, 1, 'user', 'alice', '111', ${now - 60}, 'pre-migration row')`,
|
|
797
|
+
)
|
|
798
|
+
old.close()
|
|
799
|
+
|
|
800
|
+
// Re-open through the real init path — the additive ALTER TABLE loop
|
|
801
|
+
// must add the forwarded_* columns without touching existing rows.
|
|
802
|
+
initHistory(stateDir, 30)
|
|
803
|
+
|
|
804
|
+
const oldRow = query({ chat_id: '-100' })[0]!
|
|
805
|
+
expect(oldRow.text).toBe('pre-migration row')
|
|
806
|
+
expect(oldRow.forwarded_from).toBeNull()
|
|
807
|
+
|
|
808
|
+
recordInbound({
|
|
809
|
+
chat_id: '-100',
|
|
810
|
+
thread_id: null,
|
|
811
|
+
message_id: 2,
|
|
812
|
+
user: 'alice',
|
|
813
|
+
user_id: '111',
|
|
814
|
+
ts: now,
|
|
815
|
+
text: 'forwarded after migration',
|
|
816
|
+
forwarded_from: 'Ada Lovelace (@adalove)',
|
|
817
|
+
forwarded_from_type: 'user',
|
|
818
|
+
forwarded_from_id: '42',
|
|
819
|
+
forwarded_date: '2026-06-15T13:46:40.000Z',
|
|
820
|
+
})
|
|
821
|
+
const rows = query({ chat_id: '-100' })
|
|
822
|
+
expect(rows).toHaveLength(2)
|
|
823
|
+
const fwd = rows.find((r) => r.message_id === 2)!
|
|
824
|
+
expect(fwd.forwarded_from).toBe('Ada Lovelace (@adalove)')
|
|
825
|
+
expect(fwd.forwarded_from_type).toBe('user')
|
|
826
|
+
expect(fwd.forwarded_from_id).toBe('42')
|
|
827
|
+
expect(fwd.forwarded_date).toBe('2026-06-15T13:46:40.000Z')
|
|
828
|
+
expect(fwd.forwarded_message_id).toBeNull()
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('hostile origin name is stored raw (XML metachars belong to the meta lane)', () => {
|
|
832
|
+
initHistory(stateDir, 30)
|
|
833
|
+
// Raw XML metacharacters are EXPECTED here — escaping belongs to the
|
|
834
|
+
// channel-meta lane, the history buffer stores what the user saw.
|
|
835
|
+
recordInbound({
|
|
836
|
+
chat_id: '-100',
|
|
837
|
+
thread_id: null,
|
|
838
|
+
message_id: 9,
|
|
839
|
+
user: 'alice',
|
|
840
|
+
user_id: '111',
|
|
841
|
+
ts: 1000,
|
|
842
|
+
text: 'fwd',
|
|
843
|
+
forwarded_from: '<b>"Bob"&\'friends\'</b>',
|
|
844
|
+
forwarded_from_type: 'user',
|
|
845
|
+
forwarded_from_id: '42',
|
|
846
|
+
})
|
|
847
|
+
expect(query({ chat_id: '-100' })[0]!.forwarded_from).toBe('<b>"Bob"&\'friends\'</b>')
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('masks a secret-shaped origin name before it is stored (redaction backstop)', () => {
|
|
851
|
+
initHistory(stateDir, 30)
|
|
852
|
+
// Built by concatenation so the source never holds a contiguous
|
|
853
|
+
// secret-shaped literal (repo Push Protection / no-pii lint). Same
|
|
854
|
+
// pattern as the text/reply_to_text redaction tests above — a display
|
|
855
|
+
// name is user-controlled text and rides the same redact() backstop.
|
|
856
|
+
const GH_PAT = `ghp_${'F6g7H8i9J0'.repeat(3)}` // ghp_<30 base62>
|
|
857
|
+
recordInbound({
|
|
858
|
+
chat_id: '-100',
|
|
859
|
+
thread_id: null,
|
|
860
|
+
message_id: 10,
|
|
861
|
+
user: 'alice',
|
|
862
|
+
user_id: '111',
|
|
863
|
+
ts: 1000,
|
|
864
|
+
text: 'fwd',
|
|
865
|
+
forwarded_from: `Bob ${GH_PAT}`,
|
|
866
|
+
forwarded_from_type: 'user',
|
|
867
|
+
forwarded_from_id: '42',
|
|
868
|
+
})
|
|
869
|
+
const stored = query({ chat_id: '-100' })[0]!.forwarded_from as string
|
|
870
|
+
expect(stored).not.toContain(GH_PAT)
|
|
871
|
+
expect(stored).toContain('[REDACTED')
|
|
872
|
+
expect(stored).toContain('Bob') // surrounding name preserved
|
|
873
|
+
})
|
|
874
|
+
})
|
|
@@ -206,7 +206,11 @@ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
|
|
|
206
206
|
const fnStart = SRC.indexOf('async function handleAckOnly(')
|
|
207
207
|
const fnSlice = SRC.slice(fnStart, fnStart + 2000)
|
|
208
208
|
expect(fnSlice).toContain('gate(ctx)')
|
|
209
|
-
|
|
209
|
+
// #3155: the reaction now routes through the gated `sendReaction` helper
|
|
210
|
+
// (send gate + flood breaker, cosmetic) instead of a raw
|
|
211
|
+
// `bot.api.setMessageReaction`. The invariant is unchanged: it gates, THEN
|
|
212
|
+
// reacts.
|
|
213
|
+
expect(fnSlice).toContain('sendReaction(')
|
|
210
214
|
})
|
|
211
215
|
|
|
212
216
|
it('handleAckOnly drops non-allowlisted senders silently', () => {
|