whatsapp_notifier 0.6.0 → 0.7.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,585 @@
1
+ import { test, expect, beforeEach, afterEach, afterAll } from 'bun:test';
2
+ import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync, utimesSync } from 'fs';
3
+ import { tmpdir } from 'os';
4
+ import { join, resolve, sep } from 'path';
5
+ import {
6
+ INLINE_MEDIA_MAX_BYTES,
7
+ configureMedia,
8
+ sanitizeId,
9
+ mediaPaths,
10
+ writeMedia,
11
+ readMedia,
12
+ deleteMedia,
13
+ clearUserMedia,
14
+ mediaExists,
15
+ mediaDiskBytes,
16
+ mediaTtlMs,
17
+ maxDocumentBytes,
18
+ maxDiskBytes,
19
+ downloadPolicy,
20
+ sweepExpired,
21
+ resolveMediaForMessage,
22
+ verifyMediaToken,
23
+ mediaGetResponse,
24
+ mediaDeleteResponse,
25
+ resetMediaState
26
+ } from './media';
27
+
28
+ const root = mkdtempSync(join(tmpdir(), 'wa-media-'));
29
+ let mediaRoot: string;
30
+ let caseId = 0;
31
+
32
+ const ENV_KEYS = ['WHATSAPP_MEDIA_TTL_MS', 'WHATSAPP_MEDIA_MAX_BYTES', 'WHATSAPP_MEDIA_MAX_DISK_BYTES'];
33
+ const savedEnv: Record<string, string | undefined> = {};
34
+
35
+ beforeEach(() => {
36
+ for (const key of ENV_KEYS) { savedEnv[key] = process.env[key]; delete process.env[key]; }
37
+ mediaRoot = join(root, `media-${caseId++}`);
38
+ configureMedia(() => mediaRoot);
39
+ resetMediaState();
40
+ });
41
+
42
+ afterEach(() => {
43
+ for (const key of ENV_KEYS) {
44
+ if (savedEnv[key] === undefined) delete process.env[key];
45
+ else process.env[key] = savedEnv[key];
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ rmSync(root, { recursive: true, force: true });
51
+ });
52
+
53
+ const USER = '42';
54
+ const MSG_ID = 'true_919999000001@c.us_ABC';
55
+
56
+ function bytes(text: string) {
57
+ return new TextEncoder().encode(text);
58
+ }
59
+
60
+ // ── sanitizeId ──
61
+
62
+ test('sanitizeId passes real ids through and strips hostile characters', () => {
63
+ expect(sanitizeId(MSG_ID)).toBe(MSG_ID);
64
+ expect(sanitizeId('42')).toBe('42');
65
+ expect(sanitizeId('a/b\\c d#e?f')).toBe('abcdef'); // path + query chars stripped
66
+ expect(sanitizeId('../../etc/passwd')).toBe('....etcpasswd'); // traversal neutered
67
+ });
68
+
69
+ test('sanitizeId rejects empty, dot-only, oversized and non-string ids', () => {
70
+ expect(sanitizeId('')).toBeNull();
71
+ expect(sanitizeId('///')).toBeNull(); // strips to empty
72
+ expect(sanitizeId('.')).toBeNull();
73
+ expect(sanitizeId('..')).toBeNull();
74
+ expect(sanitizeId('...')).toBeNull();
75
+ expect(sanitizeId('x'.repeat(201))).toBeNull();
76
+ expect(sanitizeId(null)).toBeNull();
77
+ expect(sanitizeId(undefined)).toBeNull();
78
+ expect(sanitizeId(42 as any)).toBeNull();
79
+ });
80
+
81
+ // ── mediaPaths ──
82
+
83
+ test('mediaPaths lays files out under <root>/<user>/<messageId> and never escapes the root', () => {
84
+ const paths = mediaPaths(USER, MSG_ID)!;
85
+ expect(paths.dir).toBe(resolve(mediaRoot, USER));
86
+ expect(paths.dataPath).toBe(resolve(mediaRoot, USER, MSG_ID));
87
+ // '~' is outside the sanitize charset, so no message id can ever name a sidecar.
88
+ expect(paths.metaPath).toBe(`${paths.dataPath}~meta.json`);
89
+ expect(paths.dataPath.startsWith(resolve(mediaRoot) + sep)).toBe(true);
90
+
91
+ const hostile = mediaPaths('../../outside', '../../../etc/passwd')!;
92
+ expect(hostile.dataPath.startsWith(resolve(mediaRoot) + sep)).toBe(true);
93
+ });
94
+
95
+ // Regression: a sanitized message id ending in ".json" used to land in the
96
+ // sidecar namespace — skipped by accounting, deletable as an orphan, and able
97
+ // to overwrite another message's sidecar.
98
+ test('a message id ending in .json cannot collide with a sidecar', () => {
99
+ writeMedia(USER, 'm1', bytes('real-payload'), { mime: 'image/png' });
100
+ // Hostile/unlucky id: exactly the OLD sidecar name of message m1.
101
+ writeMedia(USER, 'm1.json', bytes('12345'), { mime: 'application/json' });
102
+
103
+ // m1's sidecar survives intact — the second write touched different files.
104
+ expect(mediaExists(USER, 'm1')!.mime).toBe('image/png');
105
+ expect(mediaExists(USER, 'm1.json')!.size).toBe(5);
106
+
107
+ // Accounting counts BOTH payloads (the .json one is data, not a sidecar).
108
+ expect(mediaDiskBytes()).toBe(17);
109
+
110
+ // The sweep treats it as data too: fresh → kept, not reaped as an orphan.
111
+ expect(sweepExpired()).toBe(0);
112
+ expect(mediaExists(USER, 'm1.json')).not.toBeNull();
113
+ expect(mediaDiskBytes()).toBe(17);
114
+
115
+ // And deleting one message never touches the other's files.
116
+ expect(deleteMedia(USER, 'm1')).toBe(true);
117
+ expect(mediaExists(USER, 'm1')).toBeNull();
118
+ expect(readMedia(USER, 'm1.json')!.data.toString()).toBe('12345');
119
+ });
120
+
121
+ test('mediaPaths returns null when either id fails sanitization', () => {
122
+ expect(mediaPaths('..', MSG_ID)).toBeNull();
123
+ expect(mediaPaths(USER, '//')).toBeNull();
124
+ });
125
+
126
+ // ── write / read / delete / exists ──
127
+
128
+ test('writeMedia + mediaExists + readMedia roundtrip bytes and sidecar metadata', () => {
129
+ const data = bytes('jpeg-bytes');
130
+ expect(writeMedia(USER, MSG_ID, data, { mime: 'image/jpeg', filename: 'beach.jpg' })).toBe(true);
131
+
132
+ const meta = mediaExists(USER, MSG_ID)!;
133
+ expect(meta.mime).toBe('image/jpeg');
134
+ expect(meta.filename).toBe('beach.jpg');
135
+ expect(meta.size).toBe(data.byteLength);
136
+ expect(meta.capturedAt).toBeGreaterThan(0);
137
+
138
+ const found = readMedia(USER, MSG_ID)!;
139
+ expect(new Uint8Array(found.data)).toEqual(data);
140
+ expect(found.meta.mime).toBe('image/jpeg');
141
+
142
+ // Sidecar is real JSON on disk next to the payload.
143
+ const sidecar = JSON.parse(readFileSync(mediaPaths(USER, MSG_ID)!.metaPath, 'utf8'));
144
+ expect(sidecar.size).toBe(data.byteLength);
145
+ });
146
+
147
+ test('writeMedia stores a null filename and refuses invalid ids and fs failures', () => {
148
+ expect(writeMedia(USER, MSG_ID, bytes('x'), { mime: 'audio/ogg' })).toBe(true);
149
+ expect(mediaExists(USER, MSG_ID)!.filename).toBeNull();
150
+
151
+ expect(writeMedia('..', MSG_ID, bytes('x'), { mime: 'audio/ogg' })).toBe(false);
152
+
153
+ // A FILE squatting on the user dir path makes mkdir/write explode → false.
154
+ mkdirSync(mediaRoot, { recursive: true });
155
+ writeFileSync(join(mediaRoot, 'blocked'), 'not-a-dir');
156
+ expect(writeMedia('blocked', MSG_ID, bytes('x'), { mime: 'image/png' })).toBe(false);
157
+ });
158
+
159
+ test('mediaExists is null for missing pairs, half-written pairs and corrupt sidecars', () => {
160
+ expect(mediaExists(USER, 'never-written')).toBeNull();
161
+ expect(mediaExists('..', MSG_ID)).toBeNull();
162
+
163
+ const paths = mediaPaths(USER, MSG_ID)!;
164
+ mkdirSync(paths.dir, { recursive: true });
165
+ writeFileSync(paths.dataPath, 'payload-without-sidecar');
166
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
167
+
168
+ writeFileSync(paths.metaPath, 'not json');
169
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
170
+ });
171
+
172
+ test('mediaExists tolerates a sidecar with junk field types', () => {
173
+ const paths = mediaPaths(USER, MSG_ID)!;
174
+ mkdirSync(paths.dir, { recursive: true });
175
+ writeFileSync(paths.dataPath, 'x');
176
+ writeFileSync(paths.metaPath, JSON.stringify({ mime: 5, filename: 7, size: 'big', capturedAt: 'now' }));
177
+
178
+ const meta = mediaExists(USER, MSG_ID)!;
179
+ expect(meta.mime).toBe('application/octet-stream');
180
+ expect(meta.filename).toBeNull();
181
+ expect(meta.size).toBe(0);
182
+ expect(meta.capturedAt).toBe(0);
183
+ });
184
+
185
+ test('readMedia is null when the payload vanished after the sidecar check', () => {
186
+ writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/png' });
187
+ rmSync(mediaPaths(USER, MSG_ID)!.dataPath);
188
+ // Sidecar alone → mediaExists already says no.
189
+ expect(readMedia(USER, MSG_ID)).toBeNull();
190
+ });
191
+
192
+ test('deleteMedia removes the pair, is idempotent, and rejects invalid ids', () => {
193
+ writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/png' });
194
+ expect(deleteMedia(USER, MSG_ID)).toBe(true);
195
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
196
+ expect(existsSync(mediaPaths(USER, MSG_ID)!.dataPath)).toBe(false);
197
+
198
+ expect(deleteMedia(USER, MSG_ID)).toBe(true); // second delete: still fine
199
+ expect(deleteMedia('..', MSG_ID)).toBe(false);
200
+ });
201
+
202
+ // ── clearUserMedia (logout privacy contract) ──
203
+
204
+ test('clearUserMedia removes every file for the user and fixes the accounting', () => {
205
+ writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
206
+ writeMedia(USER, 'm2', bytes('1234567890'), { mime: 'application/pdf', filename: 'doc.pdf' });
207
+ writeMedia('7', 'other', bytes('123'), { mime: 'image/png' });
208
+ expect(mediaDiskBytes()).toBe(18);
209
+
210
+ expect(clearUserMedia(USER)).toBe(true);
211
+
212
+ expect(mediaExists(USER, 'm1')).toBeNull();
213
+ expect(mediaExists(USER, 'm2')).toBeNull();
214
+ expect(existsSync(join(mediaRoot, USER))).toBe(false); // dir itself gone
215
+ expect(mediaExists('7', 'other')).not.toBeNull(); // scoped per user
216
+ expect(mediaDiskBytes()).toBe(3); // cap accounting refreshed
217
+ });
218
+
219
+ test('clearUserMedia is idempotent and safe before any media was stored', () => {
220
+ expect(clearUserMedia(USER)).toBe(true); // nothing on disk yet
221
+ writeMedia(USER, 'm1', bytes('x'), { mime: 'image/png' });
222
+ expect(clearUserMedia(USER)).toBe(true);
223
+ expect(clearUserMedia(USER)).toBe(true); // repeat is fine
224
+ });
225
+
226
+ test('clearUserMedia refuses traversal-hostile user ids without touching the root', () => {
227
+ writeMedia(USER, 'm1', bytes('payload'), { mime: 'image/png' });
228
+
229
+ expect(clearUserMedia('..')).toBe(false);
230
+ expect(clearUserMedia('../..')).toBe(false);
231
+ expect(clearUserMedia('')).toBe(false);
232
+ expect(clearUserMedia(null as any)).toBe(false);
233
+
234
+ expect(mediaExists(USER, 'm1')).not.toBeNull(); // nothing collateral
235
+ expect(existsSync(mediaRoot)).toBe(true); // root untouched
236
+ });
237
+
238
+ // ── disk accounting ──
239
+
240
+ test('mediaDiskBytes counts payload bytes only and tracks writes and deletes', () => {
241
+ expect(mediaDiskBytes()).toBe(0);
242
+ writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
243
+ writeMedia('7', 'm2', bytes('1234567890'), { mime: 'image/png' });
244
+ expect(mediaDiskBytes()).toBe(15); // sidecar JSON not counted
245
+
246
+ deleteMedia(USER, 'm1');
247
+ expect(mediaDiskBytes()).toBe(10);
248
+
249
+ // Fresh process (cache reset) recomputes the same truth from disk.
250
+ resetMediaState();
251
+ expect(mediaDiskBytes()).toBe(10);
252
+ });
253
+
254
+ // ── downloadPolicy ──
255
+
256
+ test('downloadPolicy allows image/audio/ptt up to 16MB and rejects above', () => {
257
+ for (const type of ['image', 'audio', 'ptt']) {
258
+ expect(downloadPolicy(type, INLINE_MEDIA_MAX_BYTES)).toEqual({ download: true });
259
+ expect(downloadPolicy(type, INLINE_MEDIA_MAX_BYTES + 1)).toEqual({ download: false, reason: 'too_large' });
260
+ }
261
+ });
262
+
263
+ test('downloadPolicy caps documents at WHATSAPP_MEDIA_MAX_BYTES (default 25MB)', () => {
264
+ expect(maxDocumentBytes()).toBe(25 * 1024 * 1024);
265
+ expect(downloadPolicy('document', 25 * 1024 * 1024)).toEqual({ download: true });
266
+ expect(downloadPolicy('document', 25 * 1024 * 1024 + 1)).toEqual({ download: false, reason: 'too_large' });
267
+
268
+ process.env.WHATSAPP_MEDIA_MAX_BYTES = '10';
269
+ expect(downloadPolicy('document', 10)).toEqual({ download: true });
270
+ expect(downloadPolicy('document', 11)).toEqual({ download: false, reason: 'too_large' });
271
+ });
272
+
273
+ test('downloadPolicy skips stickers, videos, unknown types and view-once media', () => {
274
+ expect(downloadPolicy('sticker', 10)).toEqual({ download: false, reason: 'unsupported_type' });
275
+ expect(downloadPolicy('video', 10)).toEqual({ download: false, reason: 'unsupported_type' });
276
+ expect(downloadPolicy('chat', 10)).toEqual({ download: false, reason: 'unsupported_type' });
277
+ expect(downloadPolicy('image', 10, true)).toEqual({ download: false, reason: 'unsupported_type' });
278
+ });
279
+
280
+ // Malformed env → NaN → every comparison false → sweep + cap silently off.
281
+ test('malformed limit envs fall back to the defaults instead of NaN', () => {
282
+ process.env.WHATSAPP_MEDIA_TTL_MS = '2 days';
283
+ process.env.WHATSAPP_MEDIA_MAX_BYTES = 'garbage';
284
+ process.env.WHATSAPP_MEDIA_MAX_DISK_BYTES = '50GB';
285
+
286
+ expect(mediaTtlMs()).toBe(48 * 60 * 60 * 1000);
287
+ expect(maxDocumentBytes()).toBe(25 * 1024 * 1024);
288
+ expect(maxDiskBytes()).toBe(5 * 1024 * 1024 * 1024);
289
+
290
+ // The guards stay live: the document cap still rejects oversize media...
291
+ expect(downloadPolicy('document', 25 * 1024 * 1024 + 1))
292
+ .toEqual({ download: false, reason: 'too_large' });
293
+
294
+ // ...and the TTL sweep still evicts media older than the default 48h.
295
+ writeMedia(USER, 'old', bytes('x'), { mime: 'image/png' });
296
+ expect(sweepExpired(Date.now() + 48 * 60 * 60 * 1000 + 1000)).toBe(1);
297
+ });
298
+
299
+ test('downloadPolicy refuses a download that would blow the disk cap', () => {
300
+ expect(maxDiskBytes()).toBe(5 * 1024 * 1024 * 1024);
301
+ process.env.WHATSAPP_MEDIA_MAX_DISK_BYTES = '12';
302
+ writeMedia(USER, 'taken', bytes('1234567890'), { mime: 'image/png' }); // 10 bytes used
303
+
304
+ expect(downloadPolicy('image', 2)).toEqual({ download: true });
305
+ expect(downloadPolicy('image', 3)).toEqual({ download: false, reason: 'disk_full' });
306
+ });
307
+
308
+ // ── sweepExpired ──
309
+
310
+ test('sweepExpired removes media past the TTL, keeps fresh media, refreshes the cap total', () => {
311
+ expect(mediaTtlMs()).toBe(48 * 60 * 60 * 1000);
312
+ writeMedia(USER, 'old', bytes('old-bytes'), { mime: 'image/png' });
313
+ writeMedia(USER, 'fresh', bytes('fresh'), { mime: 'image/png' });
314
+
315
+ // Backdate the sidecar's capturedAt beyond the TTL.
316
+ const oldPaths = mediaPaths(USER, 'old')!;
317
+ const sidecar = JSON.parse(readFileSync(oldPaths.metaPath, 'utf8'));
318
+ sidecar.capturedAt = Date.now() - mediaTtlMs() - 1000;
319
+ writeFileSync(oldPaths.metaPath, JSON.stringify(sidecar));
320
+
321
+ expect(sweepExpired()).toBe(1);
322
+ expect(mediaExists(USER, 'old')).toBeNull();
323
+ expect(existsSync(oldPaths.metaPath)).toBe(false);
324
+ expect(mediaExists(USER, 'fresh')).not.toBeNull();
325
+ expect(mediaDiskBytes()).toBe(5); // 'fresh' only
326
+ });
327
+
328
+ test('sweepExpired honours the WHATSAPP_MEDIA_TTL_MS override', () => {
329
+ writeMedia(USER, 'm1', bytes('x'), { mime: 'image/png' });
330
+ process.env.WHATSAPP_MEDIA_TTL_MS = '50';
331
+ expect(sweepExpired(Date.now() + 200)).toBe(1);
332
+ });
333
+
334
+ test('sweepExpired falls back to file mtime for payloads with no sidecar', () => {
335
+ const paths = mediaPaths(USER, 'orphan')!;
336
+ mkdirSync(paths.dir, { recursive: true });
337
+ writeFileSync(paths.dataPath, 'orphan-bytes');
338
+ const past = (Date.now() - mediaTtlMs() - 60000) / 1000;
339
+ utimesSync(paths.dataPath, past, past);
340
+
341
+ expect(sweepExpired()).toBe(1);
342
+ expect(existsSync(paths.dataPath)).toBe(false);
343
+ });
344
+
345
+ test('sweepExpired removes orphaned sidecars and survives a missing media root', () => {
346
+ const paths = mediaPaths(USER, 'gone')!;
347
+ mkdirSync(paths.dir, { recursive: true });
348
+ writeFileSync(paths.metaPath, JSON.stringify({ mime: 'image/png', size: 1, capturedAt: Date.now() }));
349
+
350
+ expect(sweepExpired()).toBe(0); // orphans don't count as expired media
351
+ expect(existsSync(paths.metaPath)).toBe(false);
352
+
353
+ configureMedia(() => join(root, 'never-created'));
354
+ expect(sweepExpired()).toBe(0);
355
+ });
356
+
357
+ // ── resolveMediaForMessage ──
358
+
359
+ function mediaMsg(overrides: any = {}, dataOverrides: any = {}) {
360
+ return {
361
+ from: '919999000001@c.us',
362
+ id: { _serialized: MSG_ID },
363
+ timestamp: 1717000000,
364
+ type: 'image',
365
+ hasMedia: true,
366
+ _data: { size: 1024, mimetype: 'image/jpeg', ...dataOverrides },
367
+ downloadMedia: async () => ({
368
+ data: Buffer.from('jpeg-bytes').toString('base64'),
369
+ mimetype: 'image/jpeg',
370
+ filename: 'beach.jpg'
371
+ }),
372
+ ...overrides
373
+ };
374
+ }
375
+
376
+ test('resolveMediaForMessage downloads, persists and reports an available verdict', async () => {
377
+ const verdict = await resolveMediaForMessage(USER, mediaMsg());
378
+
379
+ expect(verdict).toEqual({
380
+ mediaStatus: 'available',
381
+ mediaMime: 'image/jpeg',
382
+ mediaFilename: 'beach.jpg',
383
+ mediaSize: 10
384
+ });
385
+ const stored = readMedia(USER, MSG_ID)!;
386
+ expect(stored.data.toString()).toBe('jpeg-bytes');
387
+ });
388
+
389
+ test('resolveMediaForMessage short-circuits when the media is already on disk (backfill)', async () => {
390
+ writeMedia(USER, MSG_ID, bytes('cached'), { mime: 'image/png', filename: 'c.png' });
391
+
392
+ let downloads = 0;
393
+ const msg = mediaMsg({ downloadMedia: async () => { downloads += 1; return null; } });
394
+ const verdict = await resolveMediaForMessage(USER, msg);
395
+
396
+ expect(downloads).toBe(0);
397
+ expect(verdict).toEqual({
398
+ mediaStatus: 'available', mediaMime: 'image/png', mediaFilename: 'c.png', mediaSize: 6
399
+ });
400
+ });
401
+
402
+ test('resolveMediaForMessage skips unsupported types and view-once without downloading', async () => {
403
+ let downloads = 0;
404
+ const dl = async () => { downloads += 1; return null; };
405
+
406
+ expect(await resolveMediaForMessage(USER, mediaMsg({ type: 'video', downloadMedia: dl })))
407
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'unsupported_type' });
408
+ expect(await resolveMediaForMessage(USER, mediaMsg({ downloadMedia: dl }, { isViewOnce: true })))
409
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'unsupported_type' });
410
+ expect(downloads).toBe(0);
411
+ });
412
+
413
+ test('resolveMediaForMessage rejects on the declared size before downloading', async () => {
414
+ const msg = mediaMsg({}, { size: INLINE_MEDIA_MAX_BYTES + 1 });
415
+ expect(await resolveMediaForMessage(USER, msg))
416
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'too_large' });
417
+ });
418
+
419
+ test('resolveMediaForMessage re-checks the real byte size after download', async () => {
420
+ process.env.WHATSAPP_MEDIA_MAX_BYTES = '5';
421
+ // Declared size lies (says 3), actual payload is 10 bytes.
422
+ const msg = mediaMsg({ type: 'document' }, { size: 3, mimetype: 'application/pdf' });
423
+ expect(await resolveMediaForMessage(USER, msg))
424
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'too_large' });
425
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
426
+ });
427
+
428
+ test('resolveMediaForMessage reports disk_full when the actual bytes blow the cap', async () => {
429
+ process.env.WHATSAPP_MEDIA_MAX_DISK_BYTES = '12';
430
+ writeMedia(USER, 'taken', bytes('12345678'), { mime: 'image/png' }); // 8 used
431
+ // Unknown declared size sails through the pre-check; the 10 real bytes don't fit.
432
+ const msg = mediaMsg({}, { size: undefined });
433
+ expect(await resolveMediaForMessage(USER, msg))
434
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'disk_full' });
435
+ });
436
+
437
+ test('resolveMediaForMessage maps undefined download results to expired', async () => {
438
+ const noMedia = mediaMsg({ downloadMedia: async () => undefined });
439
+ expect(await resolveMediaForMessage(USER, noMedia))
440
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'expired' });
441
+
442
+ const dataless = mediaMsg({ downloadMedia: async () => ({ mimetype: 'image/jpeg' }) });
443
+ expect(await resolveMediaForMessage(USER, dataless))
444
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'expired' });
445
+ });
446
+
447
+ test('resolveMediaForMessage maps a throwing or hanging download to download_failed', async () => {
448
+ const throwing = mediaMsg({ downloadMedia: async () => { throw new Error('boom'); } });
449
+ expect(await resolveMediaForMessage(USER, throwing))
450
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
451
+
452
+ const syncThrow = mediaMsg({ downloadMedia: () => { throw new Error('sync boom'); } });
453
+ expect(await resolveMediaForMessage(USER, syncThrow))
454
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
455
+
456
+ const hanging = mediaMsg({ downloadMedia: () => new Promise(() => {}) });
457
+ expect(await resolveMediaForMessage(USER, hanging, { timeoutMs: 20 }))
458
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
459
+ });
460
+
461
+ test('resolveMediaForMessage reports invalid_id for unsanitizable ids', async () => {
462
+ expect(await resolveMediaForMessage('..', mediaMsg()))
463
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'invalid_id' });
464
+ expect(await resolveMediaForMessage(USER, mediaMsg({ id: { _serialized: '//' } })))
465
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'invalid_id' });
466
+ });
467
+
468
+ test('resolveMediaForMessage falls back to the from-timestamp message id', async () => {
469
+ const msg = mediaMsg({ id: undefined });
470
+ const verdict = await resolveMediaForMessage(USER, msg);
471
+ expect(verdict.mediaStatus).toBe('available');
472
+ // Same fallback normalizeInbound uses → host can address the file.
473
+ expect(mediaExists(USER, '919999000001@c.us-1717000000')).not.toBeNull();
474
+ });
475
+
476
+ test('resolveMediaForMessage fills mime/filename gaps and omits a missing filename', async () => {
477
+ const msg = mediaMsg(
478
+ { downloadMedia: async () => ({ data: Buffer.from('x').toString('base64') }) },
479
+ { mimetype: 'image/webp' }
480
+ );
481
+ const verdict = await resolveMediaForMessage(USER, msg);
482
+
483
+ expect(verdict.mediaMime).toBe('image/webp'); // from _data when payload lacks it
484
+ expect(verdict.mediaSize).toBe(1);
485
+ expect('mediaFilename' in verdict).toBe(false); // no filename anywhere → omitted
486
+
487
+ const bare = mediaMsg({
488
+ id: { _serialized: 'bare-1' },
489
+ downloadMedia: async () => ({ data: Buffer.from('y').toString('base64') }),
490
+ _data: undefined
491
+ });
492
+ expect((await resolveMediaForMessage(USER, bare)).mediaMime).toBe('application/octet-stream');
493
+ });
494
+
495
+ test('resolveMediaForMessage reports download_failed when persisting fails', async () => {
496
+ // A FILE squatting on the user dir path makes writeMedia fail.
497
+ mkdirSync(mediaRoot, { recursive: true });
498
+ writeFileSync(join(mediaRoot, 'squat'), 'not-a-dir');
499
+
500
+ expect(await resolveMediaForMessage('squat', mediaMsg()))
501
+ .toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
502
+ });
503
+
504
+ // ── verifyMediaToken ──
505
+
506
+ test('verifyMediaToken enforces only when the service has a token configured', () => {
507
+ expect(verifyMediaToken(undefined, undefined)).toBe(true); // unset → open
508
+ expect(verifyMediaToken('anything', undefined)).toBe(true);
509
+ expect(verifyMediaToken('secret', 'secret')).toBe(true);
510
+ expect(verifyMediaToken('wrong', 'secret')).toBe(false);
511
+ expect(verifyMediaToken(undefined, 'secret')).toBe(false); // missing header
512
+ expect(verifyMediaToken('', 'secret')).toBe(false);
513
+ });
514
+
515
+ // ── GET /media route contract ──
516
+
517
+ test('mediaGetResponse serves raw bytes with mime, length and disposition headers', async () => {
518
+ writeMedia(USER, MSG_ID, bytes('jpeg-bytes'), { mime: 'image/jpeg', filename: 'beach.jpg' });
519
+
520
+ const res = mediaGetResponse(USER, MSG_ID, undefined, undefined);
521
+
522
+ expect(res.status).toBe(200);
523
+ expect(res.headers.get('Content-Type')).toBe('image/jpeg');
524
+ expect(res.headers.get('Content-Length')).toBe('10');
525
+ expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="beach.jpg"');
526
+ expect(new TextDecoder().decode(await res.arrayBuffer())).toBe('jpeg-bytes');
527
+ });
528
+
529
+ test('mediaGetResponse sanitizes hostile filenames and handles missing ones', async () => {
530
+ writeMedia(USER, 'm1', bytes('x'), { mime: 'application/pdf', filename: 'a"b\r\nSet-Cookie: x.pdf' });
531
+ const evil = mediaGetResponse(USER, 'm1', undefined, undefined);
532
+ expect(evil.headers.get('Content-Disposition')).toBe('attachment; filename="a_b__Set-Cookie_ x.pdf"');
533
+
534
+ writeMedia(USER, 'm2', bytes('y'), { mime: 'audio/ogg' });
535
+ const bare = mediaGetResponse(USER, 'm2', undefined, undefined);
536
+ expect(bare.headers.get('Content-Disposition')).toBe('attachment');
537
+ });
538
+
539
+ test('mediaGetResponse answers the same 404 for unknown, swept and invalid ids', async () => {
540
+ const missing = mediaGetResponse(USER, 'never-stored', undefined, undefined);
541
+ expect(missing.status).toBe(404);
542
+ expect(await missing.json()).toEqual({ error: 'not_found' });
543
+
544
+ const invalid = mediaGetResponse('..', '..', undefined, undefined);
545
+ expect(invalid.status).toBe(404);
546
+ expect(await invalid.json()).toEqual({ error: 'not_found' });
547
+ });
548
+
549
+ test('mediaGetResponse rejects a bad token before touching the store', async () => {
550
+ writeMedia(USER, MSG_ID, bytes('secret-bytes'), { mime: 'image/jpeg' });
551
+
552
+ const res = mediaGetResponse(USER, MSG_ID, 'wrong', 'expected');
553
+ expect(res.status).toBe(401);
554
+ expect(await res.json()).toEqual({ error: 'unauthorized' });
555
+
556
+ expect(mediaGetResponse(USER, MSG_ID, 'expected', 'expected').status).toBe(200);
557
+ });
558
+
559
+ // ── DELETE /media route contract ──
560
+
561
+ test('mediaDeleteResponse deletes the pair and stays successful on repeats', async () => {
562
+ writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/jpeg' });
563
+
564
+ const first = mediaDeleteResponse(USER, MSG_ID, undefined, undefined);
565
+ expect(first.status).toBe(200);
566
+ expect(await first.json()).toEqual({ success: true });
567
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
568
+
569
+ const again = mediaDeleteResponse(USER, MSG_ID, undefined, undefined);
570
+ expect(await again.json()).toEqual({ success: true }); // idempotent
571
+
572
+ const never = mediaDeleteResponse(USER, 'never-stored', undefined, undefined);
573
+ expect(await never.json()).toEqual({ success: true });
574
+ });
575
+
576
+ test('mediaDeleteResponse enforces the token when configured', async () => {
577
+ writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/jpeg' });
578
+
579
+ const denied = mediaDeleteResponse(USER, MSG_ID, undefined, 'expected');
580
+ expect(denied.status).toBe(401);
581
+ expect(mediaExists(USER, MSG_ID)).not.toBeNull(); // nothing deleted
582
+
583
+ expect(mediaDeleteResponse(USER, MSG_ID, 'expected', 'expected').status).toBe(200);
584
+ expect(mediaExists(USER, MSG_ID)).toBeNull();
585
+ });