queen-mq 0.12.3 → 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.
@@ -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
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Logger Unit Tests
3
+ *
4
+ * Tests the pluggable logger system in utils/logger.js:
5
+ * - Default console-based logging (gated by QUEEN_CLIENT_LOG)
6
+ * - Custom logger injection via configure()
7
+ * - Level mapping (log->info, warn->warn, error->error, debug->debug)
8
+ * - debug() fallback to info() when custom logger has no debug()
9
+ * - configure(null) resets to default
10
+ * - configure() validation
11
+ * - isEnabled() behavior
12
+ *
13
+ * These are pure unit tests — no Queen server required.
14
+ */
15
+
16
+ import * as logger from '../client-v2/utils/logger.js'
17
+
18
+ function createMockLogger() {
19
+ const calls = { info: [], warn: [], error: [], debug: [] }
20
+ return {
21
+ calls,
22
+ info: (msg) => calls.info.push(msg),
23
+ warn: (msg) => calls.warn.push(msg),
24
+ error: (msg) => calls.error.push(msg),
25
+ debug: (msg) => calls.debug.push(msg)
26
+ }
27
+ }
28
+
29
+ function createMockLoggerWithoutDebug() {
30
+ const calls = { info: [], warn: [], error: [] }
31
+ return {
32
+ calls,
33
+ info: (msg) => calls.info.push(msg),
34
+ warn: (msg) => calls.warn.push(msg),
35
+ error: (msg) => calls.error.push(msg)
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Test that configure() accepts a valid logger and routes log() to info()
41
+ */
42
+ export async function testLoggerCustomLogRouting(_client) {
43
+ const mock = createMockLogger()
44
+ logger.configure(mock)
45
+
46
+ try {
47
+ logger.log('TestOp', 'hello')
48
+ logger.log('TestOp', { key: 'value' })
49
+
50
+ if (mock.calls.info.length !== 2) {
51
+ return { success: false, message: `Expected 2 info calls, got ${mock.calls.info.length}` }
52
+ }
53
+ if (!mock.calls.info[0].includes('[TestOp]') || !mock.calls.info[0].includes('hello')) {
54
+ return { success: false, message: `Unexpected info message: ${mock.calls.info[0]}` }
55
+ }
56
+ if (!mock.calls.info[1].includes('"key":"value"')) {
57
+ return { success: false, message: `Object details not serialized: ${mock.calls.info[1]}` }
58
+ }
59
+
60
+ return { success: true, message: 'log() routes to custom logger info()' }
61
+ } finally {
62
+ logger.configure(null)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Test that warn() routes to custom logger warn()
68
+ */
69
+ export async function testLoggerCustomWarnRouting(_client) {
70
+ const mock = createMockLogger()
71
+ logger.configure(mock)
72
+
73
+ try {
74
+ logger.warn('WarnOp', 'something bad')
75
+ logger.warn('WarnOp', { code: 42 })
76
+
77
+ if (mock.calls.warn.length !== 2) {
78
+ return { success: false, message: `Expected 2 warn calls, got ${mock.calls.warn.length}` }
79
+ }
80
+ if (!mock.calls.warn[0].includes('[WarnOp]')) {
81
+ return { success: false, message: `Missing operation in warn: ${mock.calls.warn[0]}` }
82
+ }
83
+
84
+ return { success: true, message: 'warn() routes to custom logger warn()' }
85
+ } finally {
86
+ logger.configure(null)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Test that error() routes to custom logger error()
92
+ */
93
+ export async function testLoggerCustomErrorRouting(_client) {
94
+ const mock = createMockLogger()
95
+ logger.configure(mock)
96
+
97
+ try {
98
+ logger.error('ErrOp', { error: 'boom' })
99
+
100
+ if (mock.calls.error.length !== 1) {
101
+ return { success: false, message: `Expected 1 error call, got ${mock.calls.error.length}` }
102
+ }
103
+ if (!mock.calls.error[0].includes('[ErrOp]') || !mock.calls.error[0].includes('boom')) {
104
+ return { success: false, message: `Unexpected error message: ${mock.calls.error[0]}` }
105
+ }
106
+
107
+ return { success: true, message: 'error() routes to custom logger error()' }
108
+ } finally {
109
+ logger.configure(null)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Test that debug() routes to custom logger debug() when available
115
+ */
116
+ export async function testLoggerCustomDebugRouting(_client) {
117
+ const mock = createMockLogger()
118
+ logger.configure(mock)
119
+
120
+ try {
121
+ logger.debug('DebugOp', 'verbose detail')
122
+
123
+ if (mock.calls.debug.length !== 1) {
124
+ return { success: false, message: `Expected 1 debug call, got ${mock.calls.debug.length}` }
125
+ }
126
+ if (!mock.calls.debug[0].includes('[DebugOp]')) {
127
+ return { success: false, message: `Missing operation in debug: ${mock.calls.debug[0]}` }
128
+ }
129
+ // info() should NOT have been called
130
+ if (mock.calls.info.length !== 0) {
131
+ return { success: false, message: `debug() should not fall back to info() when debug() exists` }
132
+ }
133
+
134
+ return { success: true, message: 'debug() routes to custom logger debug()' }
135
+ } finally {
136
+ logger.configure(null)
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Test that debug() falls back to info() when custom logger has no debug()
142
+ */
143
+ export async function testLoggerDebugFallbackToInfo(_client) {
144
+ const mock = createMockLoggerWithoutDebug()
145
+ logger.configure(mock)
146
+
147
+ try {
148
+ logger.debug('FallbackOp', 'should go to info')
149
+
150
+ if (mock.calls.info.length !== 1) {
151
+ return { success: false, message: `Expected 1 info call (fallback), got ${mock.calls.info.length}` }
152
+ }
153
+ if (!mock.calls.info[0].includes('[FallbackOp]')) {
154
+ return { success: false, message: `Missing operation in fallback: ${mock.calls.info[0]}` }
155
+ }
156
+
157
+ return { success: true, message: 'debug() falls back to info() when no debug() on logger' }
158
+ } finally {
159
+ logger.configure(null)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Test that configure(null) resets to default console behavior
165
+ */
166
+ export async function testLoggerConfigureNull(_client) {
167
+ const mock = createMockLogger()
168
+ logger.configure(mock)
169
+
170
+ // Verify custom logger is active
171
+ logger.log('Before', 'test')
172
+ if (mock.calls.info.length !== 1) {
173
+ return { success: false, message: 'Custom logger not active before reset' }
174
+ }
175
+
176
+ // Reset
177
+ logger.configure(null)
178
+
179
+ // Now log should NOT go to mock anymore
180
+ logger.log('After', 'test')
181
+ if (mock.calls.info.length !== 1) {
182
+ return { success: false, message: `Expected still 1 info call after reset, got ${mock.calls.info.length}` }
183
+ }
184
+
185
+ return { success: true, message: 'configure(null) resets to default' }
186
+ }
187
+
188
+ /**
189
+ * Test that configure() throws on invalid logger (missing info method)
190
+ */
191
+ export async function testLoggerConfigureValidation(_client) {
192
+ try {
193
+ logger.configure({ warn: () => {}, error: () => {} })
194
+ return { success: false, message: 'Should have thrown for logger missing info()' }
195
+ } catch (e) {
196
+ if (!e.message.includes('info()')) {
197
+ return { success: false, message: `Wrong error message: ${e.message}` }
198
+ }
199
+ } finally {
200
+ logger.configure(null)
201
+ }
202
+
203
+ // Empty object should also fail
204
+ try {
205
+ logger.configure({})
206
+ return { success: false, message: 'Should have thrown for empty object' }
207
+ } catch (e) {
208
+ // expected
209
+ } finally {
210
+ logger.configure(null)
211
+ }
212
+
213
+ return { success: true, message: 'configure() validates logger interface' }
214
+ }
215
+
216
+ /**
217
+ * Test isEnabled() returns true when custom logger is set
218
+ */
219
+ export async function testLoggerIsEnabledWithCustom(_client) {
220
+ // Without custom logger and without QUEEN_CLIENT_LOG, isEnabled depends on env
221
+ const beforeCustom = logger.isEnabled()
222
+
223
+ const mock = createMockLogger()
224
+ logger.configure(mock)
225
+
226
+ try {
227
+ if (!logger.isEnabled()) {
228
+ return { success: false, message: 'isEnabled() should return true with custom logger' }
229
+ }
230
+ return { success: true, message: `isEnabled() returns true with custom logger (was ${beforeCustom} before)` }
231
+ } finally {
232
+ logger.configure(null)
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Test that levels are isolated — log() only hits info(), not warn() or error()
238
+ */
239
+ export async function testLoggerLevelIsolation(_client) {
240
+ const mock = createMockLogger()
241
+ logger.configure(mock)
242
+
243
+ try {
244
+ logger.log('Op', 'info msg')
245
+ logger.warn('Op', 'warn msg')
246
+ logger.error('Op', 'error msg')
247
+ logger.debug('Op', 'debug msg')
248
+
249
+ if (mock.calls.info.length !== 1) {
250
+ return { success: false, message: `info: expected 1, got ${mock.calls.info.length}` }
251
+ }
252
+ if (mock.calls.warn.length !== 1) {
253
+ return { success: false, message: `warn: expected 1, got ${mock.calls.warn.length}` }
254
+ }
255
+ if (mock.calls.error.length !== 1) {
256
+ return { success: false, message: `error: expected 1, got ${mock.calls.error.length}` }
257
+ }
258
+ if (mock.calls.debug.length !== 1) {
259
+ return { success: false, message: `debug: expected 1, got ${mock.calls.debug.length}` }
260
+ }
261
+
262
+ return { success: true, message: 'Each level routes to exactly its corresponding method' }
263
+ } finally {
264
+ logger.configure(null)
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Test that string details are passed through without JSON.stringify
270
+ */
271
+ export async function testLoggerStringDetails(_client) {
272
+ const mock = createMockLogger()
273
+ logger.configure(mock)
274
+
275
+ try {
276
+ logger.log('Op', 'plain string')
277
+
278
+ const msg = mock.calls.info[0]
279
+ if (!msg.includes('plain string')) {
280
+ return { success: false, message: `String details not passed through: ${msg}` }
281
+ }
282
+ // Should NOT be double-quoted as JSON
283
+ if (msg.includes('"plain string"')) {
284
+ return { success: false, message: `String was JSON-serialized: ${msg}` }
285
+ }
286
+
287
+ return { success: true, message: 'String details passed as-is' }
288
+ } finally {
289
+ logger.configure(null)
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Test that object details are JSON-serialized
295
+ */
296
+ export async function testLoggerObjectDetails(_client) {
297
+ const mock = createMockLogger()
298
+ logger.configure(mock)
299
+
300
+ try {
301
+ logger.log('Op', { foo: 'bar', count: 3 })
302
+
303
+ const msg = mock.calls.info[0]
304
+ if (!msg.includes('"foo":"bar"') || !msg.includes('"count":3')) {
305
+ return { success: false, message: `Object not serialized correctly: ${msg}` }
306
+ }
307
+
308
+ return { success: true, message: 'Object details JSON-serialized' }
309
+ } finally {
310
+ logger.configure(null)
311
+ }
312
+ }