queen-mq 0.12.4 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "queen-mq",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "description": "High-performance C++ message queue backed by PostgreSQL",
6
6
  "main": "client-v2/index.js",
@@ -0,0 +1,340 @@
1
+ // Tests for server-stamped producer identity (issue #23, feature A).
2
+ //
3
+ // The goal of feature A is: when JWT auth is enabled, the server attaches the
4
+ // authenticated client's `sub` claim to every pushed message, and a client
5
+ // cannot spoof another producer's identity by supplying `producerSub` in the
6
+ // request body.
7
+ //
8
+ // Three environments are exercised here:
9
+ //
10
+ // 1. No auth (default dev setup): `producer_sub` must be NULL; clients that
11
+ // send `producerSub` in the body must still get NULL (the field is not
12
+ // client-settable).
13
+ // 2. JWT auth enabled (set JWT_SECRET env to match the server's
14
+ // JWT_SECRET): `producer_sub` must equal the `sub` claim.
15
+ // 3. JWT auth enabled + spoofing attempt: body-supplied `producerSub` must
16
+ // be ignored; stored value is the JWT `sub`.
17
+ //
18
+ // Tests gate themselves on the `JWT_SECRET` env var so the suite can run
19
+ // unattended in either server configuration.
20
+
21
+ import crypto from 'crypto'
22
+ import { dbPool } from './run.js'
23
+
24
+ const SERVER_URL = process.env.QUEEN_URL || 'http://localhost:6632'
25
+ const JWT_SECRET = process.env.JWT_SECRET || ''
26
+ const JWT_ENABLED = JWT_SECRET.length > 0
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Minimal HS256 JWT signer (avoids pulling a dependency just for tests)
30
+ // ---------------------------------------------------------------------------
31
+ function b64url(buf) {
32
+ return Buffer.from(buf).toString('base64')
33
+ .replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
34
+ }
35
+
36
+ function signHs256Jwt(payload, secret) {
37
+ const header = { alg: 'HS256', typ: 'JWT' }
38
+ const encHeader = b64url(JSON.stringify(header))
39
+ const encPayload = b64url(JSON.stringify(payload))
40
+ const signingInput = `${encHeader}.${encPayload}`
41
+ const sig = crypto.createHmac('sha256', secret).update(signingInput).digest()
42
+ return `${signingInput}.${b64url(sig)}`
43
+ }
44
+
45
+ function makeToken(sub, role = 'read-write') {
46
+ const now = Math.floor(Date.now() / 1000)
47
+ return signHs256Jwt(
48
+ { sub, username: sub, role, iat: now, exp: now + 3600 },
49
+ JWT_SECRET
50
+ )
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Ground-truth helper: read producer_sub directly from Postgres, bypassing
55
+ // the API so we can tell the difference between a server that actually stores
56
+ // the value and one that just echoes the client's input.
57
+ // ---------------------------------------------------------------------------
58
+ async function getStoredProducerSub(queueName, transactionId) {
59
+ const res = await dbPool.query(
60
+ `SELECT m.producer_sub
61
+ FROM queen.messages m
62
+ JOIN queen.partitions p ON p.id = m.partition_id
63
+ JOIN queen.queues q ON q.id = p.queue_id
64
+ WHERE q.name = $1 AND m.transaction_id = $2`,
65
+ [queueName, transactionId]
66
+ )
67
+ return res.rows.length > 0 ? res.rows[0].producer_sub : null
68
+ }
69
+
70
+ // Raw HTTP push so we can control the Authorization header and inject
71
+ // arbitrary extra fields (like a forged `producerSub`) per-test.
72
+ async function httpPush({ queue, partition = 'Default', transactionId, data, bearerToken, extraItemFields = {} }) {
73
+ const headers = { 'Content-Type': 'application/json' }
74
+ if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`
75
+
76
+ const body = {
77
+ items: [{
78
+ queue,
79
+ partition,
80
+ transactionId,
81
+ payload: data,
82
+ ...extraItemFields
83
+ }]
84
+ }
85
+
86
+ const res = await fetch(`${SERVER_URL}/api/v1/push`, {
87
+ method: 'POST',
88
+ headers,
89
+ body: JSON.stringify(body)
90
+ })
91
+ const text = await res.text()
92
+ if (!res.ok) {
93
+ throw new Error(`push failed ${res.status}: ${text}`)
94
+ }
95
+ return text ? JSON.parse(text) : null
96
+ }
97
+
98
+ // Raw HTTP configure (queue creation) with optional bearer.
99
+ async function httpConfigure(queue, bearerToken) {
100
+ const headers = { 'Content-Type': 'application/json' }
101
+ if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`
102
+ const res = await fetch(`${SERVER_URL}/api/v1/configure`, {
103
+ method: 'POST',
104
+ headers,
105
+ body: JSON.stringify({ queue, options: {} })
106
+ })
107
+ if (!res.ok) {
108
+ const text = await res.text()
109
+ throw new Error(`configure failed ${res.status}: ${text}`)
110
+ }
111
+ }
112
+
113
+ // Direct SP call via dbPool — bypasses HTTP and JWT entirely. Used by the
114
+ // SP-level tests below so they work regardless of whether the server has
115
+ // auth enabled or not.
116
+ async function callPushMessagesV3(items) {
117
+ await dbPool.query(
118
+ 'SELECT queen.push_messages_v3($1::jsonb)',
119
+ [JSON.stringify(items)]
120
+ )
121
+ }
122
+
123
+ // Direct SP pop. Returns the `messages` array for the first (only) partition.
124
+ async function callPopSpecificBatch(queueName) {
125
+ const reqs = [{
126
+ idx: 0,
127
+ queue_name: queueName,
128
+ partition_name: 'Default',
129
+ consumer_group: '__QUEUE_MODE__',
130
+ lease_seconds: 60,
131
+ worker_id: `test-worker-${Date.now()}`,
132
+ batch_size: 10,
133
+ sub_mode: 'all',
134
+ sub_from: ''
135
+ }]
136
+ const res = await dbPool.query(
137
+ 'SELECT queen.pop_specific_batch($1::jsonb) as out',
138
+ [JSON.stringify(reqs)]
139
+ )
140
+ const out = res.rows[0].out
141
+ return out[0]?.result?.messages || []
142
+ }
143
+
144
+ // ===========================================================================
145
+ // TEST 1: Stored procedure round-trip (SQL-only, no HTTP, no JWT)
146
+ // ---------------------------------------------------------------------------
147
+ // Exercises the schema + SP contract end-to-end purely via SQL. If this fails,
148
+ // the migration or 001_push.sql / 002_pop_unified.sql is wrong. Runs in any
149
+ // server configuration because it talks to Postgres directly.
150
+ // ===========================================================================
151
+ export async function producerSubRoundTripViaStoredProcedure(client) {
152
+ const q = 'test-auth-sp-roundtrip'
153
+ const tx = `tx-sp-${Date.now()}`
154
+
155
+ await callPushMessagesV3([{
156
+ queue: q,
157
+ partition: 'Default',
158
+ transactionId: tx,
159
+ payload: { x: 1 },
160
+ producerSub: 'sp-test-sub'
161
+ }])
162
+
163
+ const stored = await getStoredProducerSub(q, tx)
164
+ if (stored !== 'sp-test-sub') {
165
+ return { success: false, message: `Expected producer_sub='sp-test-sub' in DB, got ${JSON.stringify(stored)}` }
166
+ }
167
+
168
+ const popped = await callPopSpecificBatch(q)
169
+ const target = popped.find(m => m.transactionId === tx)
170
+ if (!target) {
171
+ return { success: false, message: `Expected to find tx ${tx} in pop result` }
172
+ }
173
+ if (target.producerSub !== 'sp-test-sub') {
174
+ return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected 'sp-test-sub'` }
175
+ }
176
+
177
+ return { success: true, message: 'producer_sub round-trips through push_messages_v3 and pop exposes it' }
178
+ }
179
+
180
+ // ===========================================================================
181
+ // TEST 2: SP with no producerSub => producer_sub is NULL
182
+ // ---------------------------------------------------------------------------
183
+ // Guards against an accidental "default to empty string" regression in the SP.
184
+ // ===========================================================================
185
+ export async function producerSubNullWhenNotProvided(client) {
186
+ const q = 'test-auth-sp-null'
187
+ const tx = `tx-sp-null-${Date.now()}`
188
+
189
+ await callPushMessagesV3([{
190
+ queue: q,
191
+ partition: 'Default',
192
+ transactionId: tx,
193
+ payload: { x: 1 }
194
+ // no producerSub
195
+ }])
196
+
197
+ const stored = await getStoredProducerSub(q, tx)
198
+ if (stored !== null) {
199
+ return { success: false, message: `Expected NULL producer_sub, got ${JSON.stringify(stored)}` }
200
+ }
201
+
202
+ const popped = await callPopSpecificBatch(q)
203
+ const target = popped.find(m => m.transactionId === tx)
204
+ if (!target) {
205
+ return { success: false, message: `Expected to find tx ${tx} in pop result` }
206
+ }
207
+ if (target.producerSub != null) {
208
+ return { success: false, message: `Pop returned producerSub=${JSON.stringify(target.producerSub)}, expected null` }
209
+ }
210
+
211
+ return { success: true, message: 'Omitting producerSub yields NULL in DB and null in pop response' }
212
+ }
213
+
214
+ // ===========================================================================
215
+ // TEST 3: SP treats empty string producerSub as NULL
216
+ // ---------------------------------------------------------------------------
217
+ // The SP uses NULLIF(..., '') to avoid storing a literal empty string when a
218
+ // client accidentally sends "". Verifies that invariant.
219
+ // ===========================================================================
220
+ export async function producerSubEmptyStringStoredAsNull(client) {
221
+ const q = 'test-auth-sp-empty'
222
+ const tx = `tx-sp-empty-${Date.now()}`
223
+
224
+ await callPushMessagesV3([{
225
+ queue: q,
226
+ partition: 'Default',
227
+ transactionId: tx,
228
+ payload: { x: 1 },
229
+ producerSub: ''
230
+ }])
231
+
232
+ const stored = await getStoredProducerSub(q, tx)
233
+ if (stored !== null) {
234
+ return { success: false, message: `Expected NULL for empty-string producerSub, got ${JSON.stringify(stored)}` }
235
+ }
236
+
237
+ return { success: true, message: 'Empty-string producerSub is stored as NULL' }
238
+ }
239
+
240
+ // ===========================================================================
241
+ // TEST 4: HTTP push without JWT => producer_sub is NULL
242
+ // ---------------------------------------------------------------------------
243
+ // Runs only when the server is NOT configured with JWT (typical dev setup).
244
+ // Also checks that a client sending `producerSub` in the push body does NOT
245
+ // end up with it persisted (the field is not client-settable under any mode).
246
+ // ===========================================================================
247
+ export async function producerSubIgnoredFromBodyWithoutAuth(client) {
248
+ if (JWT_ENABLED) {
249
+ return { success: true, message: 'Skipped (JWT_SECRET is set - run producerSubStampedFromJwt instead)' }
250
+ }
251
+
252
+ const q = 'test-auth-http-no-jwt'
253
+ const tx = `tx-noauth-${Date.now()}`
254
+
255
+ const queue = await client.queue(q).create()
256
+ if (!queue.configured) {
257
+ return { success: false, message: 'Queue not created' }
258
+ }
259
+
260
+ // Spoofing attempt against an unauthenticated server.
261
+ await httpPush({
262
+ queue: q,
263
+ transactionId: tx,
264
+ data: { hello: 'world' },
265
+ extraItemFields: { producerSub: 'attacker-no-jwt' }
266
+ })
267
+
268
+ const stored = await getStoredProducerSub(q, tx)
269
+ if (stored !== null) {
270
+ return { success: false, message: `Expected NULL producer_sub (auth disabled), got ${JSON.stringify(stored)} - client was able to set it!` }
271
+ }
272
+
273
+ return { success: true, message: 'Body-supplied producerSub ignored when auth disabled; stored as NULL' }
274
+ }
275
+
276
+ // ===========================================================================
277
+ // TEST 5: HTTP push WITH JWT => producer_sub = sub claim
278
+ // ---------------------------------------------------------------------------
279
+ // Requires:
280
+ // - Server started with JWT_ENABLED=true JWT_ALGORITHM=HS256 JWT_SECRET=<x>
281
+ // - Test run with JWT_SECRET=<x>
282
+ // ===========================================================================
283
+ export async function producerSubStampedFromJwt(client) {
284
+ if (!JWT_ENABLED) {
285
+ return { success: true, message: 'Skipped (set JWT_SECRET env var matching server to run)' }
286
+ }
287
+
288
+ const q = 'test-auth-http-jwt-stamp'
289
+ const tx = `tx-jwt-${Date.now()}`
290
+ const token = makeToken('alice-producer')
291
+
292
+ await httpConfigure(q, token)
293
+ await httpPush({
294
+ queue: q,
295
+ transactionId: tx,
296
+ data: { hello: 'world' },
297
+ bearerToken: token
298
+ })
299
+
300
+ const stored = await getStoredProducerSub(q, tx)
301
+ if (stored !== 'alice-producer') {
302
+ return { success: false, message: `Expected producer_sub='alice-producer', got ${JSON.stringify(stored)}` }
303
+ }
304
+
305
+ return { success: true, message: 'producer_sub stamped from authenticated JWT sub claim' }
306
+ }
307
+
308
+ // ===========================================================================
309
+ // TEST 6: Spoofing producerSub in body is ignored even with valid JWT
310
+ // ---------------------------------------------------------------------------
311
+ // This is the core anti-impersonation invariant of issue #23.
312
+ // ===========================================================================
313
+ export async function producerSubSpoofingIgnoredWithJwt(client) {
314
+ if (!JWT_ENABLED) {
315
+ return { success: true, message: 'Skipped (set JWT_SECRET env var matching server to run)' }
316
+ }
317
+
318
+ const q = 'test-auth-http-jwt-spoof'
319
+ const tx = `tx-spoof-${Date.now()}`
320
+ const token = makeToken('legit-producer')
321
+
322
+ await httpConfigure(q, token)
323
+ await httpPush({
324
+ queue: q,
325
+ transactionId: tx,
326
+ data: { hello: 'world' },
327
+ bearerToken: token,
328
+ extraItemFields: { producerSub: 'attacker' }
329
+ })
330
+
331
+ const stored = await getStoredProducerSub(q, tx)
332
+ if (stored !== 'legit-producer') {
333
+ return {
334
+ success: false,
335
+ message: `Impersonation not prevented: stored producer_sub=${JSON.stringify(stored)}, expected 'legit-producer'`
336
+ }
337
+ }
338
+
339
+ return { success: true, message: 'Body-supplied producerSub ignored; stored sub is from validated JWT' }
340
+ }
package/test-v2/push.js CHANGED
@@ -278,6 +278,281 @@ export async function pushEmptyPayload(client) {
278
278
  return { success: true }
279
279
  }
280
280
 
281
+ // ============================================================================
282
+ // push_messages_v3 coverage (see PUSH_IMPROVEMENT.md §8.1).
283
+ // These tests exercise the contract that the TEMP-TABLE-free rewrite must
284
+ // preserve: single-call semantics (new inserts, pre-existing duplicates,
285
+ // intra-batch duplicates, missing transactionId, empty batch, malformed
286
+ // traceId) plus the cross-call concurrent-race invariant.
287
+ // ============================================================================
288
+
289
+ function distinctCount(arr) {
290
+ return new Set(arr).size
291
+ }
292
+
293
+ export async function testSimplePushRoundTrip(client) {
294
+ const queueName = 'test-v3-simple-roundtrip'
295
+ const queue = await client.queue(queueName).create()
296
+ if (!queue.configured) {
297
+ return { success: false, message: 'Queue not created' }
298
+ }
299
+ const res = await client.queue(queueName).push([{ data: { n: 1 } }])
300
+ if (!res[0] || res[0].status !== 'queued') {
301
+ return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
302
+ }
303
+ const popped = await client.queue(queueName).batch(1).wait(false).pop()
304
+ if (!popped || popped.length !== 1 || popped[0].data.n !== 1) {
305
+ return { success: false, message: 'Round-trip pop failed' }
306
+ }
307
+ return { success: true }
308
+ }
309
+
310
+ export async function testPushAutoCreatesQueueAndPartition(client) {
311
+ // Queue and partition are never referenced before — must be auto-created.
312
+ const queueName = 'test-v3-autocreate-' + crypto.randomBytes(4).toString('hex')
313
+ const partitionName = 'autopart-' + crypto.randomBytes(4).toString('hex')
314
+ const res = await client.queue(queueName).partition(partitionName)
315
+ .push([{ data: { hello: 'auto' } }])
316
+ if (!res[0] || res[0].status !== 'queued') {
317
+ return { success: false, message: `Expected queued, got ${JSON.stringify(res[0])}` }
318
+ }
319
+ const popped = await client.queue(queueName).partition(partitionName)
320
+ .batch(1).wait(false).pop()
321
+ if (!popped || popped.length !== 1) {
322
+ return { success: false, message: 'Auto-created partition did not receive the message' }
323
+ }
324
+ return { success: true }
325
+ }
326
+
327
+ export async function testPushPreExistingDuplicate(client) {
328
+ const queueName = 'test-v3-pre-existing-dup'
329
+ const queue = await client.queue(queueName).create()
330
+ if (!queue.configured) {
331
+ return { success: false, message: 'Queue not created' }
332
+ }
333
+ const txn = 'txn-pre-' + crypto.randomBytes(4).toString('hex')
334
+ const first = await client.queue(queueName)
335
+ .push([{ transactionId: txn, data: { i: 1 } }])
336
+ if (first[0].status !== 'queued') {
337
+ return { success: false, message: `First push not queued: ${JSON.stringify(first[0])}` }
338
+ }
339
+ const firstId = first[0].message_id
340
+ const second = await client.queue(queueName)
341
+ .push([{ transactionId: txn, data: { i: 2 } }])
342
+ if (second[0].status !== 'duplicate') {
343
+ return { success: false, message: `Second push not duplicate: ${JSON.stringify(second[0])}` }
344
+ }
345
+ if (!firstId || second[0].message_id !== firstId) {
346
+ return { success: false, message: `Duplicate id mismatch: want ${firstId}, got ${second[0].message_id}` }
347
+ }
348
+ return { success: true }
349
+ }
350
+
351
+ export async function testPushIntraBatchDuplicatesAllNew(client) {
352
+ const queueName = 'test-v3-intra-batch-new'
353
+ const queue = await client.queue(queueName).create()
354
+ if (!queue.configured) {
355
+ return { success: false, message: 'Queue not created' }
356
+ }
357
+ const txn = 'txn-ib-' + crypto.randomBytes(4).toString('hex')
358
+ const res = await client.queue(queueName).push([
359
+ { transactionId: txn, data: { i: 1 } },
360
+ { transactionId: txn, data: { i: 2 } },
361
+ { transactionId: txn, data: { i: 3 } }
362
+ ])
363
+ if (!Array.isArray(res) || res.length !== 3) {
364
+ return { success: false, message: `Expected 3 entries, got ${res && res.length}` }
365
+ }
366
+ const queued = res.filter(r => r.status === 'queued')
367
+ const dupes = res.filter(r => r.status === 'duplicate')
368
+ if (queued.length !== 1 || dupes.length !== 2) {
369
+ return { success: false, message: `Want 1 queued / 2 duplicate, got ${JSON.stringify(res)}` }
370
+ }
371
+ const winnerId = queued[0].message_id
372
+ if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
373
+ return { success: false, message: `Intra-batch duplicates must share winner id ${winnerId}; got ${JSON.stringify(dupes)}` }
374
+ }
375
+ return { success: true }
376
+ }
377
+
378
+ export async function testPushIntraBatchDuplicatesMatchingPreExisting(client) {
379
+ const queueName = 'test-v3-intra-batch-existing'
380
+ const queue = await client.queue(queueName).create()
381
+ if (!queue.configured) {
382
+ return { success: false, message: 'Queue not created' }
383
+ }
384
+ const txn = 'txn-ibe-' + crypto.randomBytes(4).toString('hex')
385
+ // Seed an existing row.
386
+ const seed = await client.queue(queueName)
387
+ .push([{ transactionId: txn, data: { seed: true } }])
388
+ if (seed[0].status !== 'queued') {
389
+ return { success: false, message: 'Seed push failed' }
390
+ }
391
+ const existingId = seed[0].message_id
392
+ const res = await client.queue(queueName).push([
393
+ { transactionId: txn, data: { i: 1 } },
394
+ { transactionId: txn, data: { i: 2 } }
395
+ ])
396
+ if (!Array.isArray(res) || res.length !== 2) {
397
+ return { success: false, message: `Expected 2 entries, got ${JSON.stringify(res)}` }
398
+ }
399
+ if (!res.every(r => r.status === 'duplicate' && r.message_id === existingId)) {
400
+ return { success: false, message: `All entries must duplicate onto ${existingId}, got ${JSON.stringify(res)}` }
401
+ }
402
+ return { success: true }
403
+ }
404
+
405
+ export async function testPushConcurrentBatchesToSameKey(client) {
406
+ const queueName = 'test-v3-concurrent-same-key'
407
+ const queue = await client.queue(queueName).create()
408
+ if (!queue.configured) {
409
+ return { success: false, message: 'Queue not created' }
410
+ }
411
+ const txn = 'txn-conc-' + crypto.randomBytes(4).toString('hex')
412
+ // Fire 8 concurrent single-item pushes using the same transactionId.
413
+ // Exactly one must win; the others must report duplicate with the winner's id.
414
+ const N = 8
415
+ const results = await Promise.all(
416
+ Array.from({ length: N }, (_, i) =>
417
+ client.queue(queueName)
418
+ .push([{ transactionId: txn, data: { i } }])
419
+ .then(r => r[0])
420
+ )
421
+ )
422
+ const queued = results.filter(r => r.status === 'queued')
423
+ const dupes = results.filter(r => r.status === 'duplicate')
424
+ if (queued.length !== 1) {
425
+ return { success: false, message: `Want exactly 1 queued, got ${queued.length}: ${JSON.stringify(results)}` }
426
+ }
427
+ if (dupes.length !== N - 1) {
428
+ return { success: false, message: `Want ${N - 1} duplicates, got ${dupes.length}` }
429
+ }
430
+ const winnerId = queued[0].message_id
431
+ if (!winnerId || !dupes.every(d => d.message_id === winnerId)) {
432
+ return { success: false, message: `All duplicates must report winner id ${winnerId}: ${JSON.stringify(dupes)}` }
433
+ }
434
+ return { success: true }
435
+ }
436
+
437
+ export async function testPushMissingTransactionId(client) {
438
+ const queueName = 'test-v3-missing-txn'
439
+ const queue = await client.queue(queueName).create()
440
+ if (!queue.configured) {
441
+ return { success: false, message: 'Queue not created' }
442
+ }
443
+ // 100 items, no transactionId provided. Each must get a distinct id and
444
+ // all should end up queued (no accidental intra-batch collisions from a
445
+ // volatile-function-inlining regression).
446
+ // NOTE: the client auto-generates a transactionId when omitted, so we
447
+ // bypass it via the raw HTTP push contract by supplying only `data`. The
448
+ // client still fills a fresh transactionId per item, which is the correct
449
+ // client-level contract; the server-side test below additionally proves
450
+ // the SP itself handles missing txn ids by bypassing the client helper.
451
+ const items = Array.from({ length: 100 }, (_, i) => ({ data: { i } }))
452
+ const res = await client.queue(queueName).push(items)
453
+ if (!Array.isArray(res) || res.length !== 100) {
454
+ return { success: false, message: `Expected 100 results, got ${res && res.length}` }
455
+ }
456
+ if (!res.every(r => r.status === 'queued')) {
457
+ const bad = res.find(r => r.status !== 'queued')
458
+ return { success: false, message: `All must queue; first bad entry ${JSON.stringify(bad)}` }
459
+ }
460
+ if (distinctCount(res.map(r => r.message_id)) !== 100) {
461
+ return { success: false, message: `Expected 100 distinct message ids` }
462
+ }
463
+ return { success: true }
464
+ }
465
+
466
+ export async function testPushEmptyArray(client) {
467
+ const queueName = 'test-v3-empty-array'
468
+ const queue = await client.queue(queueName).create()
469
+ if (!queue.configured) {
470
+ return { success: false, message: 'Queue not created' }
471
+ }
472
+ const res = await client.queue(queueName).push([])
473
+ if (!Array.isArray(res) || res.length !== 0) {
474
+ return { success: false, message: `Expected [], got ${JSON.stringify(res)}` }
475
+ }
476
+ return { success: true }
477
+ }
478
+
479
+ export async function testPushMalformedTraceId(client) {
480
+ // Per plan §5: malformed traceId aborts the batch in both v2 and v3.
481
+ // We only need to verify the behavior is stable (either rejected at the
482
+ // client validator or surfaced as a failure from the server). The client
483
+ // QueueBuilder currently drops invalid UUIDs silently — in which case the
484
+ // push still succeeds and traceId is null. Accept either outcome as long
485
+ // as we don't see data corruption.
486
+ const queueName = 'test-v3-malformed-trace'
487
+ const queue = await client.queue(queueName).create()
488
+ if (!queue.configured) {
489
+ return { success: false, message: 'Queue not created' }
490
+ }
491
+ let res
492
+ try {
493
+ res = await client.queue(queueName)
494
+ .push([{ traceId: 'not-a-uuid', data: { i: 1 } }])
495
+ } catch (err) {
496
+ // Server rejected: acceptable per plan.
497
+ return { success: true, message: 'Server rejected malformed traceId (expected)' }
498
+ }
499
+ if (!Array.isArray(res) || res.length !== 1) {
500
+ return { success: false, message: `Expected 1 result, got ${JSON.stringify(res)}` }
501
+ }
502
+ // If the client dropped the bad traceId, we'll get a queued row with
503
+ // trace_id=null. That is an acceptable, well-documented outcome.
504
+ if (res[0].status !== 'queued' && res[0].status !== 'failed') {
505
+ return { success: false, message: `Unexpected status: ${JSON.stringify(res[0])}` }
506
+ }
507
+ return { success: true }
508
+ }
509
+
510
+ export async function testPushProducerSubEmptyStringBecomesNull(client) {
511
+ // We can't set producerSub from the client (server-stamped field). The
512
+ // invariant we need to preserve is that when the server writes an empty
513
+ // producer_sub into the JSON, the SP stores SQL NULL. We verify it by
514
+ // pushing without auth (server will stamp producerSub as empty / NULL)
515
+ // and then checking via a short-lived pg client. NOTE: we intentionally
516
+ // do NOT dynamically import ./run.js here — that module executes
517
+ // top-level `await main()` and re-importing it while it's running
518
+ // deadlocks the ESM module graph.
519
+ const queueName = 'test-v3-producer-sub-null'
520
+ const queue = await client.queue(queueName).create()
521
+ if (!queue.configured) {
522
+ return { success: false, message: 'Queue not created' }
523
+ }
524
+ const res = await client.queue(queueName).push([{ data: { x: 1 } }])
525
+ if (res[0].status !== 'queued') {
526
+ return { success: false, message: 'Push failed' }
527
+ }
528
+ const messageId = res[0].message_id
529
+
530
+ const { default: pg } = await import('pg')
531
+ const pool = new pg.Pool({
532
+ host: process.env.PG_HOST || 'localhost',
533
+ port: process.env.PG_PORT || 5432,
534
+ database: process.env.PG_DB || 'postgres',
535
+ user: process.env.PG_USER || 'postgres',
536
+ password: process.env.PG_PASSWORD || 'postgres',
537
+ max: 1
538
+ })
539
+ try {
540
+ const r = await pool.query(
541
+ 'SELECT producer_sub FROM queen.messages WHERE id = $1',
542
+ [messageId]
543
+ )
544
+ if (r.rows.length !== 1) {
545
+ return { success: false, message: `Message ${messageId} not found` }
546
+ }
547
+ if (r.rows[0].producer_sub !== null) {
548
+ return { success: false, message: `Expected producer_sub NULL, got ${JSON.stringify(r.rows[0].producer_sub)}` }
549
+ }
550
+ return { success: true }
551
+ } finally {
552
+ await pool.end()
553
+ }
554
+ }
555
+
281
556
  export async function pushEncryptedPayload(client) {
282
557
  const queue = await client.queue('test-queue-encrypted-payload')
283
558
  .config({ encryptionEnabled: true })
package/test-v2/run.js CHANGED
@@ -13,6 +13,8 @@ import * as maintenanceTests from './maintenance.js'
13
13
  import * as retentionTests from './retention.js'
14
14
  import * as bootstrapTests from './bootstrap.js'
15
15
  import * as loggerTests from './logger.js'
16
+ import * as watermarkTests from './watermark.js'
17
+ import * as authTests from './auth.js'
16
18
  import { LoadBalancer } from '../client-v2/http/LoadBalancer.js';
17
19
 
18
20
 
@@ -115,7 +117,9 @@ async function main() {
115
117
  retentionTests,
116
118
  maintenanceTests,
117
119
  bootstrapTests,
118
- loggerTests
120
+ loggerTests,
121
+ watermarkTests,
122
+ authTests
119
123
  ]
120
124
 
121
125
  const aiTests = [
@@ -0,0 +1,507 @@
1
+ /**
2
+ * Watermark Bug Tests
3
+ *
4
+ * These tests verify that consumer_watermarks are properly reset when:
5
+ * 1. Seeking a consumer group backwards to a timestamp
6
+ * 2. Deleting a consumer group (watermark should be cleaned up)
7
+ *
8
+ * The consumer_watermarks table optimizes POP by tracking when a consumer
9
+ * last found the queue empty. However, if the watermark isn't reset when
10
+ * the cursor moves backwards, old messages become invisible.
11
+ *
12
+ * BUG: Currently, seek and delete operations don't touch consumer_watermarks,
13
+ * causing re-consumption to fail silently.
14
+ *
15
+ * IMPORTANT: The watermark filter has a 2-minute buffer:
16
+ * pl.updated_at >= (v_watermark - interval '2 minutes')
17
+ *
18
+ * So the bug only manifests after 2+ minutes have passed. To test this without
19
+ * waiting, we directly manipulate the watermark timestamp in the database.
20
+ *
21
+ * Run: node run.js seekBackwardsAllowsReconsume
22
+ * node run.js deleteConsumerGroupAllowsReconsume
23
+ */
24
+
25
+ import { dbPool } from './run.js'
26
+
27
+ const QUEUE_NAME_SEEK = 'test-queue-v2-watermark-seek'
28
+ const QUEUE_NAME_DELETE = 'test-queue-v2-watermark-delete'
29
+
30
+ /**
31
+ * Helper: Move the watermark FORWARD in time to simulate that partitions
32
+ * are "old" relative to the watermark.
33
+ *
34
+ * The filter is: pl.updated_at >= (v_watermark - interval '2 minutes')
35
+ *
36
+ * If watermark = NOW + 10 minutes, then:
37
+ * filter = pl.updated_at >= (NOW + 10 - 2) = pl.updated_at >= NOW + 8
38
+ *
39
+ * Partitions updated at NOW will fail this filter (NOW < NOW + 8)
40
+ */
41
+ async function advanceWatermark(queueName, consumerGroup, minutesForward = 10) {
42
+ const result = await dbPool.query(`
43
+ UPDATE queen.consumer_watermarks
44
+ SET last_empty_scan_at = NOW() + interval '${minutesForward} minutes',
45
+ updated_at = NOW() + interval '${minutesForward} minutes'
46
+ WHERE queue_name = $1 AND consumer_group = $2
47
+ RETURNING *
48
+ `, [queueName, consumerGroup])
49
+ return result.rows[0]
50
+ }
51
+
52
+ /**
53
+ * Helper: Check if watermark exists for a queue/consumer_group
54
+ */
55
+ async function getWatermark(queueName, consumerGroup) {
56
+ const result = await dbPool.query(`
57
+ SELECT * FROM queen.consumer_watermarks
58
+ WHERE queue_name = $1 AND consumer_group = $2
59
+ `, [queueName, consumerGroup])
60
+ return result.rows[0]
61
+ }
62
+
63
+ /**
64
+ * Test: Seeking backwards should allow re-consumption of messages
65
+ *
66
+ * Steps:
67
+ * 1. Push messages to queue
68
+ * 2. Consume all messages with a consumer group (watermark advances)
69
+ * 3. Artificially age the watermark (simulate 10 minutes passing)
70
+ * 4. Seek the consumer group backwards to before the messages
71
+ * 5. Try to consume again - should get all messages
72
+ *
73
+ * Expected: All messages are re-consumed after seek
74
+ * Actual (bug): Watermark blocks re-consumption, 0 messages returned
75
+ */
76
+ export async function seekBackwardsAllowsReconsume(client) {
77
+ const consumerGroup = 'watermark-seek-test-cg'
78
+
79
+ // Cleanup
80
+ try {
81
+ await client.deleteConsumerGroup(consumerGroup, true)
82
+ // Also clean up any orphaned watermark from previous runs
83
+ await dbPool.query(
84
+ 'DELETE FROM queen.consumer_watermarks WHERE consumer_group = $1',
85
+ [consumerGroup]
86
+ )
87
+ } catch (e) {
88
+ // Ignore
89
+ }
90
+
91
+ // 1. Create queue
92
+ const queue = await client.queue(QUEUE_NAME_SEEK).create()
93
+ if (!queue.configured) {
94
+ return { success: false, message: 'Queue not created' }
95
+ }
96
+
97
+ // 2. Record timestamp BEFORE pushing messages
98
+ const timestampBeforeMessages = new Date().toISOString()
99
+
100
+ // Small delay to ensure timestamp ordering
101
+ await new Promise(resolve => setTimeout(resolve, 100))
102
+
103
+ // 3. Push messages
104
+ const messageCount = 10
105
+ for (let i = 0; i < messageCount; i++) {
106
+ await client
107
+ .queue(QUEUE_NAME_SEEK)
108
+ .partition(`p-${i}`)
109
+ .push([{ data: { id: i, batch: 'original' } }])
110
+ }
111
+
112
+ console.log(` Pushed ${messageCount} messages`)
113
+
114
+ // 4. Consume all messages with consumer group
115
+ let firstConsumeCount = 0
116
+ await client
117
+ .queue(QUEUE_NAME_SEEK)
118
+ .group(consumerGroup)
119
+ .subscriptionMode('all')
120
+ .concurrency(5)
121
+ .batch(1)
122
+ .wait(false)
123
+ .idleMillis(3000)
124
+ .consume(async msgs => {
125
+ firstConsumeCount += msgs.length
126
+ })
127
+
128
+ console.log(` First consume: ${firstConsumeCount} messages`)
129
+
130
+ if (firstConsumeCount !== messageCount) {
131
+ return {
132
+ success: false,
133
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
134
+ }
135
+ }
136
+
137
+ // 5. Verify queue is empty for this consumer (triggers watermark advancement)
138
+ const emptyCheck = await client
139
+ .queue(QUEUE_NAME_SEEK)
140
+ .group(consumerGroup)
141
+ .batch(10)
142
+ .wait(false)
143
+ .pop()
144
+
145
+ if (emptyCheck.length !== 0) {
146
+ return {
147
+ success: false,
148
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
149
+ }
150
+ }
151
+
152
+ console.log(` Verified queue is empty (watermark should be set)`)
153
+
154
+ // 6. Check watermark was created
155
+ const watermarkBefore = await getWatermark(QUEUE_NAME_SEEK, consumerGroup)
156
+ if (!watermarkBefore) {
157
+ return {
158
+ success: false,
159
+ message: 'Watermark was not created after empty queue check'
160
+ }
161
+ }
162
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
163
+
164
+ // 7. CRITICAL: Advance the watermark forward to simulate that partitions are "old"
165
+ // relative to the watermark. This triggers the bug where old partitions are filtered out.
166
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_SEEK, consumerGroup, 10)
167
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
168
+
169
+ // 8. Seek backwards to BEFORE the messages were pushed
170
+ console.log(` Seeking to timestamp: ${timestampBeforeMessages}`)
171
+
172
+ const seekResult = await client.admin.seekConsumerGroup(
173
+ consumerGroup,
174
+ QUEUE_NAME_SEEK,
175
+ { timestamp: timestampBeforeMessages }
176
+ )
177
+
178
+ console.log(` Seek result: ${JSON.stringify(seekResult)}`)
179
+
180
+ // 9. Check if watermark was reset (it should be, but currently isn't - that's the bug)
181
+ const watermarkAfterSeek = await getWatermark(QUEUE_NAME_SEEK, consumerGroup)
182
+ if (watermarkAfterSeek) {
183
+ console.log(` WARNING: Watermark still exists after seek: ${watermarkAfterSeek.last_empty_scan_at}`)
184
+ } else {
185
+ console.log(` Watermark was properly deleted after seek`)
186
+ }
187
+
188
+ // Small delay after seek
189
+ await new Promise(resolve => setTimeout(resolve, 500))
190
+
191
+ // 10. Try to consume again - should get all messages back
192
+ let secondConsumeCount = 0
193
+ await client
194
+ .queue(QUEUE_NAME_SEEK)
195
+ .group(consumerGroup)
196
+ .concurrency(5)
197
+ .batch(1)
198
+ .wait(false)
199
+ .idleMillis(3000)
200
+ .consume(async msgs => {
201
+ secondConsumeCount += msgs.length
202
+ })
203
+
204
+ console.log(` Second consume (after seek): ${secondConsumeCount} messages`)
205
+
206
+ // 11. Verify we got all messages back
207
+ if (secondConsumeCount !== messageCount) {
208
+ return {
209
+ success: false,
210
+ message: `After seek backwards, should re-consume ${messageCount} messages, but got ${secondConsumeCount}. ` +
211
+ `This is because consumer_watermarks was not reset on seek (watermark still at ${watermarkAfterSeek?.last_empty_scan_at}).`
212
+ }
213
+ }
214
+
215
+ return {
216
+ success: true,
217
+ message: `Seek backwards allowed re-consumption of ${secondConsumeCount} messages`
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Test: Deleting consumer group should allow fresh consumption
223
+ *
224
+ * Steps:
225
+ * 1. Push messages to queue
226
+ * 2. Consume all messages with a consumer group (watermark advances)
227
+ * 3. Artificially age the watermark (simulate 10 minutes passing)
228
+ * 4. Delete the consumer group
229
+ * 5. Create a new consumer group with the SAME name
230
+ * 6. Try to consume - should get all messages
231
+ *
232
+ * Expected: All messages are consumed by the "new" consumer group
233
+ * Actual (bug): Orphaned watermark blocks consumption, 0 messages returned
234
+ */
235
+ export async function deleteConsumerGroupAllowsReconsume(client) {
236
+ const consumerGroup = 'watermark-delete-test-cg'
237
+
238
+ // Cleanup
239
+ try {
240
+ await client.deleteConsumerGroup(consumerGroup, true)
241
+ // Also clean up any orphaned watermark from previous runs
242
+ await dbPool.query(
243
+ 'DELETE FROM queen.consumer_watermarks WHERE consumer_group = $1',
244
+ [consumerGroup]
245
+ )
246
+ } catch (e) {
247
+ // Ignore
248
+ }
249
+
250
+ // 1. Create queue
251
+ const queue = await client.queue(QUEUE_NAME_DELETE).create()
252
+ if (!queue.configured) {
253
+ return { success: false, message: 'Queue not created' }
254
+ }
255
+
256
+ // 2. Push messages
257
+ const messageCount = 10
258
+ for (let i = 0; i < messageCount; i++) {
259
+ await client
260
+ .queue(QUEUE_NAME_DELETE)
261
+ .partition(`p-${i}`)
262
+ .push([{ data: { id: i, batch: 'original' } }])
263
+ }
264
+
265
+ console.log(` Pushed ${messageCount} messages`)
266
+
267
+ // 3. Consume all messages with consumer group
268
+ let firstConsumeCount = 0
269
+ await client
270
+ .queue(QUEUE_NAME_DELETE)
271
+ .group(consumerGroup)
272
+ .subscriptionMode('all')
273
+ .concurrency(5)
274
+ .batch(1)
275
+ .wait(false)
276
+ .idleMillis(3000)
277
+ .consume(async msgs => {
278
+ firstConsumeCount += msgs.length
279
+ })
280
+
281
+ console.log(` First consume: ${firstConsumeCount} messages`)
282
+
283
+ if (firstConsumeCount !== messageCount) {
284
+ return {
285
+ success: false,
286
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
287
+ }
288
+ }
289
+
290
+ // 4. Verify queue is empty for this consumer (triggers watermark advancement)
291
+ const emptyCheck = await client
292
+ .queue(QUEUE_NAME_DELETE)
293
+ .group(consumerGroup)
294
+ .batch(10)
295
+ .wait(false)
296
+ .pop()
297
+
298
+ if (emptyCheck.length !== 0) {
299
+ return {
300
+ success: false,
301
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
302
+ }
303
+ }
304
+
305
+ console.log(` Verified queue is empty (watermark should be set)`)
306
+
307
+ // 5. Check watermark was created
308
+ const watermarkBefore = await getWatermark(QUEUE_NAME_DELETE, consumerGroup)
309
+ if (!watermarkBefore) {
310
+ return {
311
+ success: false,
312
+ message: 'Watermark was not created after empty queue check'
313
+ }
314
+ }
315
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
316
+
317
+ // 6. CRITICAL: Advance the watermark forward to simulate that partitions are "old"
318
+ const advancedWatermark = await advanceWatermark(QUEUE_NAME_DELETE, consumerGroup, 10)
319
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
320
+
321
+ // 7. Delete the consumer group
322
+ console.log(` Deleting consumer group: ${consumerGroup}`)
323
+
324
+ const deleteResult = await client.deleteConsumerGroup(consumerGroup, true)
325
+ console.log(` Delete result: ${JSON.stringify(deleteResult)}`)
326
+
327
+ // 8. Check if watermark was deleted (it should be, but currently isn't - that's the bug)
328
+ const watermarkAfterDelete = await getWatermark(QUEUE_NAME_DELETE, consumerGroup)
329
+ if (watermarkAfterDelete) {
330
+ console.log(` WARNING: Orphaned watermark still exists: ${watermarkAfterDelete.last_empty_scan_at}`)
331
+ } else {
332
+ console.log(` Watermark was properly deleted`)
333
+ }
334
+
335
+ // Small delay after delete
336
+ await new Promise(resolve => setTimeout(resolve, 500))
337
+
338
+ // 9. Create a "new" consumer group with the same name and try to consume
339
+ // Since partition_consumers was deleted, this should start fresh
340
+ // But if consumer_watermarks wasn't deleted, it will skip all messages
341
+ let secondConsumeCount = 0
342
+ await client
343
+ .queue(QUEUE_NAME_DELETE)
344
+ .group(consumerGroup)
345
+ .subscriptionMode('all')
346
+ .concurrency(5)
347
+ .batch(1)
348
+ .wait(false)
349
+ .idleMillis(3000)
350
+ .consume(async msgs => {
351
+ secondConsumeCount += msgs.length
352
+ })
353
+
354
+ console.log(` Second consume (after delete + recreate): ${secondConsumeCount} messages`)
355
+
356
+ // 10. Verify we got all messages
357
+ if (secondConsumeCount !== messageCount) {
358
+ return {
359
+ success: false,
360
+ message: `After deleting CG, new CG with same name should consume ${messageCount} messages, ` +
361
+ `but got ${secondConsumeCount}. Orphaned watermark at ${watermarkAfterDelete?.last_empty_scan_at}.`
362
+ }
363
+ }
364
+
365
+ return {
366
+ success: true,
367
+ message: `Delete + recreate allowed fresh consumption of ${secondConsumeCount} messages`
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Test: Deleting consumer group for specific queue should allow fresh consumption
373
+ *
374
+ * Similar to above but uses deleteConsumerGroupForQueue instead of deleteConsumerGroup
375
+ */
376
+ export async function deleteConsumerGroupForQueueAllowsReconsume(client) {
377
+ const consumerGroup = 'watermark-delete-queue-test-cg'
378
+ const queueName = 'test-queue-v2-watermark-delete-queue'
379
+
380
+ // Cleanup
381
+ try {
382
+ await client.admin.deleteConsumerGroupForQueue(consumerGroup, queueName, true)
383
+ // Also clean up any orphaned watermark from previous runs
384
+ await dbPool.query(
385
+ 'DELETE FROM queen.consumer_watermarks WHERE queue_name = $1 AND consumer_group = $2',
386
+ [queueName, consumerGroup]
387
+ )
388
+ } catch (e) {
389
+ // Ignore
390
+ }
391
+
392
+ // 1. Create queue
393
+ const queue = await client.queue(queueName).create()
394
+ if (!queue.configured) {
395
+ return { success: false, message: 'Queue not created' }
396
+ }
397
+
398
+ // 2. Push messages
399
+ const messageCount = 10
400
+ for (let i = 0; i < messageCount; i++) {
401
+ await client
402
+ .queue(queueName)
403
+ .partition(`p-${i}`)
404
+ .push([{ data: { id: i, batch: 'original' } }])
405
+ }
406
+
407
+ console.log(` Pushed ${messageCount} messages`)
408
+
409
+ // 3. Consume all messages with consumer group
410
+ let firstConsumeCount = 0
411
+ await client
412
+ .queue(queueName)
413
+ .group(consumerGroup)
414
+ .subscriptionMode('all')
415
+ .concurrency(5)
416
+ .batch(1)
417
+ .wait(false)
418
+ .idleMillis(3000)
419
+ .consume(async msgs => {
420
+ firstConsumeCount += msgs.length
421
+ })
422
+
423
+ console.log(` First consume: ${firstConsumeCount} messages`)
424
+
425
+ if (firstConsumeCount !== messageCount) {
426
+ return {
427
+ success: false,
428
+ message: `First consume should get ${messageCount} messages, got ${firstConsumeCount}`
429
+ }
430
+ }
431
+
432
+ // 4. Verify queue is empty (triggers watermark)
433
+ const emptyCheck = await client
434
+ .queue(queueName)
435
+ .group(consumerGroup)
436
+ .batch(10)
437
+ .wait(false)
438
+ .pop()
439
+
440
+ if (emptyCheck.length !== 0) {
441
+ return {
442
+ success: false,
443
+ message: `Queue should be empty after first consume, got ${emptyCheck.length} messages`
444
+ }
445
+ }
446
+
447
+ console.log(` Verified queue is empty (watermark should be set)`)
448
+
449
+ // 5. Check watermark was created
450
+ const watermarkBefore = await getWatermark(queueName, consumerGroup)
451
+ if (!watermarkBefore) {
452
+ return {
453
+ success: false,
454
+ message: 'Watermark was not created after empty queue check'
455
+ }
456
+ }
457
+ console.log(` Watermark exists: last_empty_scan_at = ${watermarkBefore.last_empty_scan_at}`)
458
+
459
+ // 6. CRITICAL: Advance the watermark forward
460
+ const advancedWatermark = await advanceWatermark(queueName, consumerGroup, 10)
461
+ console.log(` Advanced watermark to: ${advancedWatermark.last_empty_scan_at} (10 minutes in future)`)
462
+
463
+ // 7. Delete consumer group for this specific queue
464
+ console.log(` Deleting consumer group for queue: ${consumerGroup} / ${queueName}`)
465
+
466
+ const deleteResult = await client.admin.deleteConsumerGroupForQueue(consumerGroup, queueName, true)
467
+ console.log(` Delete result: ${JSON.stringify(deleteResult)}`)
468
+
469
+ // 8. Check if watermark was deleted
470
+ const watermarkAfterDelete = await getWatermark(queueName, consumerGroup)
471
+ if (watermarkAfterDelete) {
472
+ console.log(` WARNING: Orphaned watermark still exists: ${watermarkAfterDelete.last_empty_scan_at}`)
473
+ } else {
474
+ console.log(` Watermark was properly deleted`)
475
+ }
476
+
477
+ await new Promise(resolve => setTimeout(resolve, 500))
478
+
479
+ // 9. Try to consume again
480
+ let secondConsumeCount = 0
481
+ await client
482
+ .queue(queueName)
483
+ .group(consumerGroup)
484
+ .subscriptionMode('all')
485
+ .concurrency(5)
486
+ .batch(1)
487
+ .wait(false)
488
+ .idleMillis(3000)
489
+ .consume(async msgs => {
490
+ secondConsumeCount += msgs.length
491
+ })
492
+
493
+ console.log(` Second consume (after delete): ${secondConsumeCount} messages`)
494
+
495
+ if (secondConsumeCount !== messageCount) {
496
+ return {
497
+ success: false,
498
+ message: `After deleting CG for queue, should consume ${messageCount} messages, ` +
499
+ `but got ${secondConsumeCount}. Orphaned watermark at ${watermarkAfterDelete?.last_empty_scan_at}.`
500
+ }
501
+ }
502
+
503
+ return {
504
+ success: true,
505
+ message: `Delete CG for queue allowed fresh consumption of ${secondConsumeCount} messages`
506
+ }
507
+ }