whatsapp_notifier 0.6.0 → 0.8.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.
- checksums.yaml +4 -4
- data/README.md +20 -3
- data/lib/generators/whatsapp_notifier/install_service_generator.rb +3 -0
- data/lib/whatsapp_notifier/client.rb +20 -0
- data/lib/whatsapp_notifier/providers/web_automation.rb +54 -0
- data/lib/whatsapp_notifier/services/web_automation/history.test.ts +695 -0
- data/lib/whatsapp_notifier/services/web_automation/history.ts +323 -0
- data/lib/whatsapp_notifier/services/web_automation/inbound.test.ts +357 -2
- data/lib/whatsapp_notifier/services/web_automation/inbound.ts +228 -18
- data/lib/whatsapp_notifier/services/web_automation/index.ts +123 -41
- data/lib/whatsapp_notifier/services/web_automation/media.test.ts +751 -0
- data/lib/whatsapp_notifier/services/web_automation/media.ts +548 -0
- data/lib/whatsapp_notifier/services/web_automation/send.test.ts +20 -0
- data/lib/whatsapp_notifier/services/web_automation/send.ts +17 -0
- data/lib/whatsapp_notifier/version.rb +1 -1
- data/lib/whatsapp_notifier/web_adapter.rb +199 -13
- data/lib/whatsapp_notifier.rb +20 -0
- data/spec/client_spec.rb +48 -0
- data/spec/generators/install_service_generator_spec.rb +12 -1
- data/spec/providers/web_automation_spec.rb +97 -0
- data/spec/web_adapter_spec.rb +407 -0
- data/spec/whatsapp_notifier_spec.rb +33 -0
- metadata +7 -1
|
@@ -0,0 +1,751 @@
|
|
|
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
|
+
maxUserBytes,
|
|
20
|
+
userDirBytes,
|
|
21
|
+
enforceUserCap,
|
|
22
|
+
downloadPolicy,
|
|
23
|
+
sweepExpired,
|
|
24
|
+
resolveMediaForMessage,
|
|
25
|
+
verifyMediaToken,
|
|
26
|
+
mediaGetResponse,
|
|
27
|
+
mediaDeleteResponse,
|
|
28
|
+
resetMediaState
|
|
29
|
+
} from './media';
|
|
30
|
+
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), 'wa-media-'));
|
|
32
|
+
let mediaRoot: string;
|
|
33
|
+
let caseId = 0;
|
|
34
|
+
|
|
35
|
+
const ENV_KEYS = ['WHATSAPP_MEDIA_TTL_MS', 'WHATSAPP_MEDIA_MAX_BYTES', 'WHATSAPP_MEDIA_MAX_DISK_BYTES', 'WHATSAPP_MEDIA_MAX_USER_BYTES'];
|
|
36
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
for (const key of ENV_KEYS) { savedEnv[key] = process.env[key]; delete process.env[key]; }
|
|
40
|
+
mediaRoot = join(root, `media-${caseId++}`);
|
|
41
|
+
configureMedia(() => mediaRoot);
|
|
42
|
+
resetMediaState();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
for (const key of ENV_KEYS) {
|
|
47
|
+
if (savedEnv[key] === undefined) delete process.env[key];
|
|
48
|
+
else process.env[key] = savedEnv[key];
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
rmSync(root, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const USER = '42';
|
|
57
|
+
const MSG_ID = 'true_919999000001@c.us_ABC';
|
|
58
|
+
|
|
59
|
+
function bytes(text: string) {
|
|
60
|
+
return new TextEncoder().encode(text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── sanitizeId ──
|
|
64
|
+
|
|
65
|
+
test('sanitizeId passes real ids through and strips hostile characters', () => {
|
|
66
|
+
expect(sanitizeId(MSG_ID)).toBe(MSG_ID);
|
|
67
|
+
expect(sanitizeId('42')).toBe('42');
|
|
68
|
+
expect(sanitizeId('a/b\\c d#e?f')).toBe('abcdef'); // path + query chars stripped
|
|
69
|
+
expect(sanitizeId('../../etc/passwd')).toBe('....etcpasswd'); // traversal neutered
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('sanitizeId rejects empty, dot-only, oversized and non-string ids', () => {
|
|
73
|
+
expect(sanitizeId('')).toBeNull();
|
|
74
|
+
expect(sanitizeId('///')).toBeNull(); // strips to empty
|
|
75
|
+
expect(sanitizeId('.')).toBeNull();
|
|
76
|
+
expect(sanitizeId('..')).toBeNull();
|
|
77
|
+
expect(sanitizeId('...')).toBeNull();
|
|
78
|
+
expect(sanitizeId('x'.repeat(201))).toBeNull();
|
|
79
|
+
expect(sanitizeId(null)).toBeNull();
|
|
80
|
+
expect(sanitizeId(undefined)).toBeNull();
|
|
81
|
+
expect(sanitizeId(42 as any)).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── mediaPaths ──
|
|
85
|
+
|
|
86
|
+
test('mediaPaths lays files out under <root>/<user>/<messageId> and never escapes the root', () => {
|
|
87
|
+
const paths = mediaPaths(USER, MSG_ID)!;
|
|
88
|
+
expect(paths.dir).toBe(resolve(mediaRoot, USER));
|
|
89
|
+
expect(paths.dataPath).toBe(resolve(mediaRoot, USER, MSG_ID));
|
|
90
|
+
// '~' is outside the sanitize charset, so no message id can ever name a sidecar.
|
|
91
|
+
expect(paths.metaPath).toBe(`${paths.dataPath}~meta.json`);
|
|
92
|
+
expect(paths.dataPath.startsWith(resolve(mediaRoot) + sep)).toBe(true);
|
|
93
|
+
|
|
94
|
+
const hostile = mediaPaths('../../outside', '../../../etc/passwd')!;
|
|
95
|
+
expect(hostile.dataPath.startsWith(resolve(mediaRoot) + sep)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Regression: a sanitized message id ending in ".json" used to land in the
|
|
99
|
+
// sidecar namespace — skipped by accounting, deletable as an orphan, and able
|
|
100
|
+
// to overwrite another message's sidecar.
|
|
101
|
+
test('a message id ending in .json cannot collide with a sidecar', () => {
|
|
102
|
+
writeMedia(USER, 'm1', bytes('real-payload'), { mime: 'image/png' });
|
|
103
|
+
// Hostile/unlucky id: exactly the OLD sidecar name of message m1.
|
|
104
|
+
writeMedia(USER, 'm1.json', bytes('12345'), { mime: 'application/json' });
|
|
105
|
+
|
|
106
|
+
// m1's sidecar survives intact — the second write touched different files.
|
|
107
|
+
expect(mediaExists(USER, 'm1')!.mime).toBe('image/png');
|
|
108
|
+
expect(mediaExists(USER, 'm1.json')!.size).toBe(5);
|
|
109
|
+
|
|
110
|
+
// Accounting counts BOTH payloads (the .json one is data, not a sidecar).
|
|
111
|
+
expect(mediaDiskBytes()).toBe(17);
|
|
112
|
+
|
|
113
|
+
// The sweep treats it as data too: fresh → kept, not reaped as an orphan.
|
|
114
|
+
expect(sweepExpired()).toBe(0);
|
|
115
|
+
expect(mediaExists(USER, 'm1.json')).not.toBeNull();
|
|
116
|
+
expect(mediaDiskBytes()).toBe(17);
|
|
117
|
+
|
|
118
|
+
// And deleting one message never touches the other's files.
|
|
119
|
+
expect(deleteMedia(USER, 'm1')).toBe(true);
|
|
120
|
+
expect(mediaExists(USER, 'm1')).toBeNull();
|
|
121
|
+
expect(readMedia(USER, 'm1.json')!.data.toString()).toBe('12345');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('mediaPaths returns null when either id fails sanitization', () => {
|
|
125
|
+
expect(mediaPaths('..', MSG_ID)).toBeNull();
|
|
126
|
+
expect(mediaPaths(USER, '//')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ── write / read / delete / exists ──
|
|
130
|
+
|
|
131
|
+
test('writeMedia + mediaExists + readMedia roundtrip bytes and sidecar metadata', () => {
|
|
132
|
+
const data = bytes('jpeg-bytes');
|
|
133
|
+
expect(writeMedia(USER, MSG_ID, data, { mime: 'image/jpeg', filename: 'beach.jpg' })).toBe(true);
|
|
134
|
+
|
|
135
|
+
const meta = mediaExists(USER, MSG_ID)!;
|
|
136
|
+
expect(meta.mime).toBe('image/jpeg');
|
|
137
|
+
expect(meta.filename).toBe('beach.jpg');
|
|
138
|
+
expect(meta.size).toBe(data.byteLength);
|
|
139
|
+
expect(meta.capturedAt).toBeGreaterThan(0);
|
|
140
|
+
|
|
141
|
+
const found = readMedia(USER, MSG_ID)!;
|
|
142
|
+
expect(new Uint8Array(found.data)).toEqual(data);
|
|
143
|
+
expect(found.meta.mime).toBe('image/jpeg');
|
|
144
|
+
|
|
145
|
+
// Sidecar is real JSON on disk next to the payload.
|
|
146
|
+
const sidecar = JSON.parse(readFileSync(mediaPaths(USER, MSG_ID)!.metaPath, 'utf8'));
|
|
147
|
+
expect(sidecar.size).toBe(data.byteLength);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('writeMedia stores a null filename and refuses invalid ids and fs failures', () => {
|
|
151
|
+
expect(writeMedia(USER, MSG_ID, bytes('x'), { mime: 'audio/ogg' })).toBe(true);
|
|
152
|
+
expect(mediaExists(USER, MSG_ID)!.filename).toBeNull();
|
|
153
|
+
|
|
154
|
+
expect(writeMedia('..', MSG_ID, bytes('x'), { mime: 'audio/ogg' })).toBe(false);
|
|
155
|
+
|
|
156
|
+
// A FILE squatting on the user dir path makes mkdir/write explode → false.
|
|
157
|
+
mkdirSync(mediaRoot, { recursive: true });
|
|
158
|
+
writeFileSync(join(mediaRoot, 'blocked'), 'not-a-dir');
|
|
159
|
+
expect(writeMedia('blocked', MSG_ID, bytes('x'), { mime: 'image/png' })).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('mediaExists is null for missing pairs, half-written pairs and corrupt sidecars', () => {
|
|
163
|
+
expect(mediaExists(USER, 'never-written')).toBeNull();
|
|
164
|
+
expect(mediaExists('..', MSG_ID)).toBeNull();
|
|
165
|
+
|
|
166
|
+
const paths = mediaPaths(USER, MSG_ID)!;
|
|
167
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
168
|
+
writeFileSync(paths.dataPath, 'payload-without-sidecar');
|
|
169
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
170
|
+
|
|
171
|
+
writeFileSync(paths.metaPath, 'not json');
|
|
172
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('mediaExists tolerates a sidecar with junk field types', () => {
|
|
176
|
+
const paths = mediaPaths(USER, MSG_ID)!;
|
|
177
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
178
|
+
writeFileSync(paths.dataPath, 'x');
|
|
179
|
+
writeFileSync(paths.metaPath, JSON.stringify({ mime: 5, filename: 7, size: 'big', capturedAt: 'now' }));
|
|
180
|
+
|
|
181
|
+
const meta = mediaExists(USER, MSG_ID)!;
|
|
182
|
+
expect(meta.mime).toBe('application/octet-stream');
|
|
183
|
+
expect(meta.filename).toBeNull();
|
|
184
|
+
expect(meta.size).toBe(0);
|
|
185
|
+
expect(meta.capturedAt).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('readMedia is null when the payload vanished after the sidecar check', () => {
|
|
189
|
+
writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/png' });
|
|
190
|
+
rmSync(mediaPaths(USER, MSG_ID)!.dataPath);
|
|
191
|
+
// Sidecar alone → mediaExists already says no.
|
|
192
|
+
expect(readMedia(USER, MSG_ID)).toBeNull();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('deleteMedia removes the pair, is idempotent, and rejects invalid ids', () => {
|
|
196
|
+
writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/png' });
|
|
197
|
+
expect(deleteMedia(USER, MSG_ID)).toBe(true);
|
|
198
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
199
|
+
expect(existsSync(mediaPaths(USER, MSG_ID)!.dataPath)).toBe(false);
|
|
200
|
+
|
|
201
|
+
expect(deleteMedia(USER, MSG_ID)).toBe(true); // second delete: still fine
|
|
202
|
+
expect(deleteMedia('..', MSG_ID)).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── clearUserMedia (logout privacy contract) ──
|
|
206
|
+
|
|
207
|
+
test('clearUserMedia removes every file for the user and fixes the accounting', () => {
|
|
208
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
209
|
+
writeMedia(USER, 'm2', bytes('1234567890'), { mime: 'application/pdf', filename: 'doc.pdf' });
|
|
210
|
+
writeMedia('7', 'other', bytes('123'), { mime: 'image/png' });
|
|
211
|
+
expect(mediaDiskBytes()).toBe(18);
|
|
212
|
+
|
|
213
|
+
expect(clearUserMedia(USER)).toBe(true);
|
|
214
|
+
|
|
215
|
+
expect(mediaExists(USER, 'm1')).toBeNull();
|
|
216
|
+
expect(mediaExists(USER, 'm2')).toBeNull();
|
|
217
|
+
expect(existsSync(join(mediaRoot, USER))).toBe(false); // dir itself gone
|
|
218
|
+
expect(mediaExists('7', 'other')).not.toBeNull(); // scoped per user
|
|
219
|
+
expect(mediaDiskBytes()).toBe(3); // cap accounting refreshed
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('clearUserMedia is idempotent and safe before any media was stored', () => {
|
|
223
|
+
expect(clearUserMedia(USER)).toBe(true); // nothing on disk yet
|
|
224
|
+
writeMedia(USER, 'm1', bytes('x'), { mime: 'image/png' });
|
|
225
|
+
expect(clearUserMedia(USER)).toBe(true);
|
|
226
|
+
expect(clearUserMedia(USER)).toBe(true); // repeat is fine
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('clearUserMedia refuses traversal-hostile user ids without touching the root', () => {
|
|
230
|
+
writeMedia(USER, 'm1', bytes('payload'), { mime: 'image/png' });
|
|
231
|
+
|
|
232
|
+
expect(clearUserMedia('..')).toBe(false);
|
|
233
|
+
expect(clearUserMedia('../..')).toBe(false);
|
|
234
|
+
expect(clearUserMedia('')).toBe(false);
|
|
235
|
+
expect(clearUserMedia(null as any)).toBe(false);
|
|
236
|
+
|
|
237
|
+
expect(mediaExists(USER, 'm1')).not.toBeNull(); // nothing collateral
|
|
238
|
+
expect(existsSync(mediaRoot)).toBe(true); // root untouched
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ── disk accounting ──
|
|
242
|
+
|
|
243
|
+
test('mediaDiskBytes counts payload bytes only and tracks writes and deletes', () => {
|
|
244
|
+
expect(mediaDiskBytes()).toBe(0);
|
|
245
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
246
|
+
writeMedia('7', 'm2', bytes('1234567890'), { mime: 'image/png' });
|
|
247
|
+
expect(mediaDiskBytes()).toBe(15); // sidecar JSON not counted
|
|
248
|
+
|
|
249
|
+
deleteMedia(USER, 'm1');
|
|
250
|
+
expect(mediaDiskBytes()).toBe(10);
|
|
251
|
+
|
|
252
|
+
// Fresh process (cache reset) recomputes the same truth from disk.
|
|
253
|
+
resetMediaState();
|
|
254
|
+
expect(mediaDiskBytes()).toBe(10);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ── downloadPolicy ──
|
|
258
|
+
|
|
259
|
+
test('downloadPolicy allows image/audio/ptt up to 16MB and rejects above', () => {
|
|
260
|
+
for (const type of ['image', 'audio', 'ptt']) {
|
|
261
|
+
expect(downloadPolicy(type, INLINE_MEDIA_MAX_BYTES)).toEqual({ download: true });
|
|
262
|
+
expect(downloadPolicy(type, INLINE_MEDIA_MAX_BYTES + 1)).toEqual({ download: false, reason: 'too_large' });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('downloadPolicy caps documents at WHATSAPP_MEDIA_MAX_BYTES (default 25MB)', () => {
|
|
267
|
+
expect(maxDocumentBytes()).toBe(25 * 1024 * 1024);
|
|
268
|
+
expect(downloadPolicy('document', 25 * 1024 * 1024)).toEqual({ download: true });
|
|
269
|
+
expect(downloadPolicy('document', 25 * 1024 * 1024 + 1)).toEqual({ download: false, reason: 'too_large' });
|
|
270
|
+
|
|
271
|
+
process.env.WHATSAPP_MEDIA_MAX_BYTES = '10';
|
|
272
|
+
expect(downloadPolicy('document', 10)).toEqual({ download: true });
|
|
273
|
+
expect(downloadPolicy('document', 11)).toEqual({ download: false, reason: 'too_large' });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('downloadPolicy skips stickers, videos, unknown types and view-once media', () => {
|
|
277
|
+
expect(downloadPolicy('sticker', 10)).toEqual({ download: false, reason: 'unsupported_type' });
|
|
278
|
+
expect(downloadPolicy('video', 10)).toEqual({ download: false, reason: 'unsupported_type' });
|
|
279
|
+
expect(downloadPolicy('chat', 10)).toEqual({ download: false, reason: 'unsupported_type' });
|
|
280
|
+
expect(downloadPolicy('image', 10, true)).toEqual({ download: false, reason: 'unsupported_type' });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Malformed env → NaN → every comparison false → sweep + cap silently off.
|
|
284
|
+
test('malformed limit envs fall back to the defaults instead of NaN', () => {
|
|
285
|
+
process.env.WHATSAPP_MEDIA_TTL_MS = '2 days';
|
|
286
|
+
process.env.WHATSAPP_MEDIA_MAX_BYTES = 'garbage';
|
|
287
|
+
process.env.WHATSAPP_MEDIA_MAX_DISK_BYTES = '50GB';
|
|
288
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '1 gig';
|
|
289
|
+
|
|
290
|
+
expect(mediaTtlMs()).toBe(48 * 60 * 60 * 1000);
|
|
291
|
+
expect(maxDocumentBytes()).toBe(25 * 1024 * 1024);
|
|
292
|
+
expect(maxDiskBytes()).toBe(5 * 1024 * 1024 * 1024);
|
|
293
|
+
expect(maxUserBytes()).toBe(1024 * 1024 * 1024);
|
|
294
|
+
|
|
295
|
+
// The guards stay live: the document cap still rejects oversize media...
|
|
296
|
+
expect(downloadPolicy('document', 25 * 1024 * 1024 + 1))
|
|
297
|
+
.toEqual({ download: false, reason: 'too_large' });
|
|
298
|
+
|
|
299
|
+
// ...and the TTL sweep still evicts media older than the default 48h.
|
|
300
|
+
writeMedia(USER, 'old', bytes('x'), { mime: 'image/png' });
|
|
301
|
+
expect(sweepExpired(Date.now() + 48 * 60 * 60 * 1000 + 1000)).toBe(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The per-user cap is now enforced post-write by eviction, so downloadPolicy
|
|
305
|
+
// NEVER skips a download on disk grounds — a user is never starved. Even with
|
|
306
|
+
// a tiny disk cap and a near-full disk, the policy still says download (the
|
|
307
|
+
// post-write enforceUserCap rolls the oldest off instead).
|
|
308
|
+
test('downloadPolicy never refuses on disk grounds (per-user eviction replaces starvation)', () => {
|
|
309
|
+
process.env.WHATSAPP_MEDIA_MAX_DISK_BYTES = '12';
|
|
310
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
311
|
+
writeMedia(USER, 'taken', bytes('1234567890'), { mime: 'image/png' }); // 10 bytes used
|
|
312
|
+
|
|
313
|
+
expect(downloadPolicy('image', 2)).toEqual({ download: true });
|
|
314
|
+
expect(downloadPolicy('image', 3)).toEqual({ download: true }); // would-blow-disk still allowed
|
|
315
|
+
expect(downloadPolicy('image', INLINE_MEDIA_MAX_BYTES)).toEqual({ download: true }); // up to the per-message cap
|
|
316
|
+
|
|
317
|
+
// ...but the per-MESSAGE size gate still rejects oversize media.
|
|
318
|
+
expect(downloadPolicy('image', INLINE_MEDIA_MAX_BYTES + 1)).toEqual({ download: false, reason: 'too_large' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// ── maxUserBytes / userDirBytes / enforceUserCap (per-user rolling cap) ──
|
|
322
|
+
|
|
323
|
+
// Backdate a stored item's capturedAt so the oldest-first eviction order is
|
|
324
|
+
// deterministic regardless of write timing (sub-ms writes share a clock).
|
|
325
|
+
function backdate(userId: string, messageId: string, capturedAt: number) {
|
|
326
|
+
const p = mediaPaths(userId, messageId)!;
|
|
327
|
+
const sidecar = JSON.parse(readFileSync(p.metaPath, 'utf8'));
|
|
328
|
+
sidecar.capturedAt = capturedAt;
|
|
329
|
+
writeFileSync(p.metaPath, JSON.stringify(sidecar));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
test('maxUserBytes defaults to 1GB', () => {
|
|
333
|
+
expect(maxUserBytes()).toBe(1024 * 1024 * 1024);
|
|
334
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '2048';
|
|
335
|
+
expect(maxUserBytes()).toBe(2048);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('userDirBytes counts only that user payloads, sidecars excluded', () => {
|
|
339
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
340
|
+
writeMedia(USER, 'm2', bytes('1234567890'), { mime: 'image/png' });
|
|
341
|
+
writeMedia('7', 'other', bytes('123'), { mime: 'image/png' });
|
|
342
|
+
|
|
343
|
+
expect(userDirBytes(USER)).toBe(15); // 5 + 10, sidecar JSON not counted
|
|
344
|
+
expect(userDirBytes('7')).toBe(3); // scoped per user
|
|
345
|
+
expect(userDirBytes('never')).toBe(0); // no dir yet
|
|
346
|
+
expect(userDirBytes('..')).toBe(0); // unsanitizable id
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Writing past 1GB (here: a tiny cap) evicts THAT user's OLDEST media, oldest
|
|
350
|
+
// first, until they fit — the new media survives. Stored under a generous cap
|
|
351
|
+
// and backdated to fix the age order, then enforced under the tight cap so the
|
|
352
|
+
// eviction is deterministic (not at the mercy of the inline write hook).
|
|
353
|
+
test('writing past the user cap evicts the user oldest media first', () => {
|
|
354
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '1000'; // generous: no inline eviction
|
|
355
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' }); backdate(USER, 'm1', 1); // 5
|
|
356
|
+
writeMedia(USER, 'm2', bytes('12345'), { mime: 'image/png' }); backdate(USER, 'm2', 2); // 5
|
|
357
|
+
writeMedia(USER, 'm3', bytes('12345'), { mime: 'image/png' }); backdate(USER, 'm3', 3); // 5
|
|
358
|
+
expect(mediaDiskBytes()).toBe(15);
|
|
359
|
+
|
|
360
|
+
// Tighten to 12 and enforce: 15 > 12 → evict the oldest (m1, 5) → 10 ≤ 12.
|
|
361
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
362
|
+
expect(enforceUserCap(USER)).toBe(1);
|
|
363
|
+
|
|
364
|
+
expect(mediaExists(USER, 'm1')).toBeNull(); // oldest gone
|
|
365
|
+
expect(mediaExists(USER, 'm2')).not.toBeNull();
|
|
366
|
+
expect(mediaExists(USER, 'm3')).not.toBeNull();
|
|
367
|
+
expect(userDirBytes(USER)).toBe(10);
|
|
368
|
+
expect(mediaDiskBytes()).toBe(10); // global total kept honest
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('enforceUserCap evicts multiple oldest items when the dir blows well past the cap', () => {
|
|
372
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '1000'; // generous during the writes
|
|
373
|
+
writeMedia(USER, 'a', bytes('111'), { mime: 'image/png' }); backdate(USER, 'a', 1); // 3
|
|
374
|
+
writeMedia(USER, 'b', bytes('222'), { mime: 'image/png' }); backdate(USER, 'b', 2); // 3
|
|
375
|
+
writeMedia(USER, 'c', bytes('3333333333'), { mime: 'image/png' }); backdate(USER, 'c', 3); // 10
|
|
376
|
+
|
|
377
|
+
// 16 bytes, cap 6 → evict a (3) → 13, evict b (3) → 10, evict c (10) → 0.
|
|
378
|
+
// Even the single 10-byte file exceeds the cap, so the loop empties the dir.
|
|
379
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '6';
|
|
380
|
+
expect(enforceUserCap(USER)).toBe(3);
|
|
381
|
+
expect(userDirBytes(USER)).toBe(0);
|
|
382
|
+
expect(mediaDiskBytes()).toBe(0);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('enforceUserCap evicts one user without touching another (independent caps)', () => {
|
|
386
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '1000'; // generous during the writes
|
|
387
|
+
writeMedia(USER, 'a', bytes('111'), { mime: 'image/png' }); backdate(USER, 'a', 1); // 3
|
|
388
|
+
writeMedia(USER, 'b', bytes('222'), { mime: 'image/png' }); backdate(USER, 'b', 2); // 3
|
|
389
|
+
writeMedia('7', 'x', bytes('12345'), { mime: 'image/png' }); backdate('7', 'x', 1); // 5
|
|
390
|
+
writeMedia('7', 'y', bytes('12345'), { mime: 'image/png' }); backdate('7', 'y', 2); // +5 = 10
|
|
391
|
+
|
|
392
|
+
// Cap 6: USER (6 bytes) is exactly at the cap → no eviction; user 7 (10) is over.
|
|
393
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '6';
|
|
394
|
+
expect(enforceUserCap(USER)).toBe(0);
|
|
395
|
+
expect(mediaExists(USER, 'a')).not.toBeNull();
|
|
396
|
+
expect(mediaExists(USER, 'b')).not.toBeNull();
|
|
397
|
+
|
|
398
|
+
// User 7 over cap evicts only user 7 oldest; USER untouched.
|
|
399
|
+
expect(enforceUserCap('7')).toBe(1);
|
|
400
|
+
expect(mediaExists('7', 'x')).toBeNull(); // user 7 oldest gone
|
|
401
|
+
expect(mediaExists('7', 'y')).not.toBeNull();
|
|
402
|
+
expect(userDirBytes(USER)).toBe(6); // the other user is intact
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('enforceUserCap is a no-op for a user under the cap, an empty dir and bad ids', () => {
|
|
406
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '100';
|
|
407
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
408
|
+
|
|
409
|
+
expect(enforceUserCap(USER)).toBe(0); // under cap
|
|
410
|
+
expect(mediaExists(USER, 'm1')).not.toBeNull();
|
|
411
|
+
expect(enforceUserCap('never-stored')).toBe(0); // no dir
|
|
412
|
+
expect(enforceUserCap('..')).toBe(0); // unsanitizable id
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// NaN cap → enforceUserCap must still use the 1GB default, never treat NaN as
|
|
416
|
+
// "0, evict everything" or "Infinity, never evict via a broken comparison".
|
|
417
|
+
test('enforceUserCap falls back to the 1GB default on a malformed cap env', () => {
|
|
418
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = 'one gigabyte';
|
|
419
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
420
|
+
|
|
421
|
+
expect(maxUserBytes()).toBe(1024 * 1024 * 1024);
|
|
422
|
+
expect(enforceUserCap(USER)).toBe(0); // 5 bytes ≪ 1GB → nothing evicted
|
|
423
|
+
expect(mediaExists(USER, 'm1')).not.toBeNull();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// The post-write hook: writeMedia itself enforces the cap, so a caller that
|
|
427
|
+
// just writes (no explicit enforceUserCap) still rolls the oldest off. Here
|
|
428
|
+
// capturedAt ties on the shared clock, so the eviction falls back to file
|
|
429
|
+
// mtime ordering — the regression that proves the mtime path works.
|
|
430
|
+
test('writeMedia enforces the cap inline, falling back to mtime order on a clock tie', () => {
|
|
431
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
432
|
+
const first = mediaPaths(USER, 'm1')!;
|
|
433
|
+
writeMedia(USER, 'm1', bytes('12345'), { mime: 'image/png' });
|
|
434
|
+
// Make m1 unambiguously older by mtime AND drop its sidecar capturedAt so
|
|
435
|
+
// the age source falls back to mtime.
|
|
436
|
+
const sidecar = JSON.parse(readFileSync(first.metaPath, 'utf8'));
|
|
437
|
+
delete sidecar.capturedAt;
|
|
438
|
+
writeFileSync(first.metaPath, JSON.stringify(sidecar));
|
|
439
|
+
const past = (Date.now() - 60000) / 1000;
|
|
440
|
+
utimesSync(first.dataPath, past, past);
|
|
441
|
+
|
|
442
|
+
writeMedia(USER, 'm2', bytes('12345'), { mime: 'image/png' }); // 10 ≤ 12 → no eviction
|
|
443
|
+
writeMedia(USER, 'm3', bytes('12345'), { mime: 'image/png' }); // 15 > 12 → evict oldest (m1)
|
|
444
|
+
|
|
445
|
+
expect(mediaExists(USER, 'm1')).toBeNull();
|
|
446
|
+
expect(mediaExists(USER, 'm2')).not.toBeNull();
|
|
447
|
+
expect(mediaExists(USER, 'm3')).not.toBeNull();
|
|
448
|
+
expect(userDirBytes(USER)).toBe(10);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// ── sweepExpired ──
|
|
452
|
+
|
|
453
|
+
test('sweepExpired removes media past the TTL, keeps fresh media, refreshes the cap total', () => {
|
|
454
|
+
expect(mediaTtlMs()).toBe(48 * 60 * 60 * 1000);
|
|
455
|
+
writeMedia(USER, 'old', bytes('old-bytes'), { mime: 'image/png' });
|
|
456
|
+
writeMedia(USER, 'fresh', bytes('fresh'), { mime: 'image/png' });
|
|
457
|
+
|
|
458
|
+
// Backdate the sidecar's capturedAt beyond the TTL.
|
|
459
|
+
const oldPaths = mediaPaths(USER, 'old')!;
|
|
460
|
+
const sidecar = JSON.parse(readFileSync(oldPaths.metaPath, 'utf8'));
|
|
461
|
+
sidecar.capturedAt = Date.now() - mediaTtlMs() - 1000;
|
|
462
|
+
writeFileSync(oldPaths.metaPath, JSON.stringify(sidecar));
|
|
463
|
+
|
|
464
|
+
expect(sweepExpired()).toBe(1);
|
|
465
|
+
expect(mediaExists(USER, 'old')).toBeNull();
|
|
466
|
+
expect(existsSync(oldPaths.metaPath)).toBe(false);
|
|
467
|
+
expect(mediaExists(USER, 'fresh')).not.toBeNull();
|
|
468
|
+
expect(mediaDiskBytes()).toBe(5); // 'fresh' only
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('sweepExpired honours the WHATSAPP_MEDIA_TTL_MS override', () => {
|
|
472
|
+
writeMedia(USER, 'm1', bytes('x'), { mime: 'image/png' });
|
|
473
|
+
process.env.WHATSAPP_MEDIA_TTL_MS = '50';
|
|
474
|
+
expect(sweepExpired(Date.now() + 200)).toBe(1);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('sweepExpired falls back to file mtime for payloads with no sidecar', () => {
|
|
478
|
+
const paths = mediaPaths(USER, 'orphan')!;
|
|
479
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
480
|
+
writeFileSync(paths.dataPath, 'orphan-bytes');
|
|
481
|
+
const past = (Date.now() - mediaTtlMs() - 60000) / 1000;
|
|
482
|
+
utimesSync(paths.dataPath, past, past);
|
|
483
|
+
|
|
484
|
+
expect(sweepExpired()).toBe(1);
|
|
485
|
+
expect(existsSync(paths.dataPath)).toBe(false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('sweepExpired removes orphaned sidecars and survives a missing media root', () => {
|
|
489
|
+
const paths = mediaPaths(USER, 'gone')!;
|
|
490
|
+
mkdirSync(paths.dir, { recursive: true });
|
|
491
|
+
writeFileSync(paths.metaPath, JSON.stringify({ mime: 'image/png', size: 1, capturedAt: Date.now() }));
|
|
492
|
+
|
|
493
|
+
expect(sweepExpired()).toBe(0); // orphans don't count as expired media
|
|
494
|
+
expect(existsSync(paths.metaPath)).toBe(false);
|
|
495
|
+
|
|
496
|
+
configureMedia(() => join(root, 'never-created'));
|
|
497
|
+
expect(sweepExpired()).toBe(0);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ── resolveMediaForMessage ──
|
|
501
|
+
|
|
502
|
+
function mediaMsg(overrides: any = {}, dataOverrides: any = {}) {
|
|
503
|
+
return {
|
|
504
|
+
from: '919999000001@c.us',
|
|
505
|
+
id: { _serialized: MSG_ID },
|
|
506
|
+
timestamp: 1717000000,
|
|
507
|
+
type: 'image',
|
|
508
|
+
hasMedia: true,
|
|
509
|
+
_data: { size: 1024, mimetype: 'image/jpeg', ...dataOverrides },
|
|
510
|
+
downloadMedia: async () => ({
|
|
511
|
+
data: Buffer.from('jpeg-bytes').toString('base64'),
|
|
512
|
+
mimetype: 'image/jpeg',
|
|
513
|
+
filename: 'beach.jpg'
|
|
514
|
+
}),
|
|
515
|
+
...overrides
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
test('resolveMediaForMessage downloads, persists and reports an available verdict', async () => {
|
|
520
|
+
const verdict = await resolveMediaForMessage(USER, mediaMsg());
|
|
521
|
+
|
|
522
|
+
expect(verdict).toEqual({
|
|
523
|
+
mediaStatus: 'available',
|
|
524
|
+
mediaMime: 'image/jpeg',
|
|
525
|
+
mediaFilename: 'beach.jpg',
|
|
526
|
+
mediaSize: 10
|
|
527
|
+
});
|
|
528
|
+
const stored = readMedia(USER, MSG_ID)!;
|
|
529
|
+
expect(stored.data.toString()).toBe('jpeg-bytes');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('resolveMediaForMessage short-circuits when the media is already on disk (backfill)', async () => {
|
|
533
|
+
writeMedia(USER, MSG_ID, bytes('cached'), { mime: 'image/png', filename: 'c.png' });
|
|
534
|
+
|
|
535
|
+
let downloads = 0;
|
|
536
|
+
const msg = mediaMsg({ downloadMedia: async () => { downloads += 1; return null; } });
|
|
537
|
+
const verdict = await resolveMediaForMessage(USER, msg);
|
|
538
|
+
|
|
539
|
+
expect(downloads).toBe(0);
|
|
540
|
+
expect(verdict).toEqual({
|
|
541
|
+
mediaStatus: 'available', mediaMime: 'image/png', mediaFilename: 'c.png', mediaSize: 6
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('resolveMediaForMessage skips unsupported types and view-once without downloading', async () => {
|
|
546
|
+
let downloads = 0;
|
|
547
|
+
const dl = async () => { downloads += 1; return null; };
|
|
548
|
+
|
|
549
|
+
expect(await resolveMediaForMessage(USER, mediaMsg({ type: 'video', downloadMedia: dl })))
|
|
550
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'unsupported_type' });
|
|
551
|
+
expect(await resolveMediaForMessage(USER, mediaMsg({ downloadMedia: dl }, { isViewOnce: true })))
|
|
552
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'unsupported_type' });
|
|
553
|
+
expect(downloads).toBe(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('resolveMediaForMessage rejects on the declared size before downloading', async () => {
|
|
557
|
+
const msg = mediaMsg({}, { size: INLINE_MEDIA_MAX_BYTES + 1 });
|
|
558
|
+
expect(await resolveMediaForMessage(USER, msg))
|
|
559
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'too_large' });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('resolveMediaForMessage re-checks the real byte size after download', async () => {
|
|
563
|
+
process.env.WHATSAPP_MEDIA_MAX_BYTES = '5';
|
|
564
|
+
// Declared size lies (says 3), actual payload is 10 bytes.
|
|
565
|
+
const msg = mediaMsg({ type: 'document' }, { size: 3, mimetype: 'application/pdf' });
|
|
566
|
+
expect(await resolveMediaForMessage(USER, msg))
|
|
567
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'too_large' });
|
|
568
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// The download always succeeds now; the per-user cap is honoured AFTER the
|
|
572
|
+
// write by evicting the user's oldest, so the new media is always available.
|
|
573
|
+
test('resolveMediaForMessage stores the new media and evicts the oldest under the cap', async () => {
|
|
574
|
+
process.env.WHATSAPP_MEDIA_MAX_USER_BYTES = '12';
|
|
575
|
+
// An older payload (backdated so it is unambiguously the oldest).
|
|
576
|
+
writeMedia(USER, 'oldest', bytes('12345678'), { mime: 'image/png' }); // 8 used
|
|
577
|
+
const oldPaths = mediaPaths(USER, 'oldest')!;
|
|
578
|
+
const sidecar = JSON.parse(readFileSync(oldPaths.metaPath, 'utf8'));
|
|
579
|
+
sidecar.capturedAt = 1; // ancient
|
|
580
|
+
writeFileSync(oldPaths.metaPath, JSON.stringify(sidecar));
|
|
581
|
+
|
|
582
|
+
// 10 real bytes arrive → 18 > 12 → the oldest (8) is rolled off, leaving 10.
|
|
583
|
+
const msg = mediaMsg({}, { size: undefined });
|
|
584
|
+
expect(await resolveMediaForMessage(USER, msg))
|
|
585
|
+
.toEqual({ mediaStatus: 'available', mediaMime: 'image/jpeg', mediaFilename: 'beach.jpg', mediaSize: 10 });
|
|
586
|
+
expect(mediaExists(USER, MSG_ID)).not.toBeNull(); // new media kept
|
|
587
|
+
expect(mediaExists(USER, 'oldest')).toBeNull(); // oldest evicted
|
|
588
|
+
expect(mediaDiskBytes()).toBe(10);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('resolveMediaForMessage maps undefined download results to expired', async () => {
|
|
592
|
+
const noMedia = mediaMsg({ downloadMedia: async () => undefined });
|
|
593
|
+
expect(await resolveMediaForMessage(USER, noMedia))
|
|
594
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'expired' });
|
|
595
|
+
|
|
596
|
+
const dataless = mediaMsg({ downloadMedia: async () => ({ mimetype: 'image/jpeg' }) });
|
|
597
|
+
expect(await resolveMediaForMessage(USER, dataless))
|
|
598
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'expired' });
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('resolveMediaForMessage maps a throwing or hanging download to download_failed', async () => {
|
|
602
|
+
const throwing = mediaMsg({ downloadMedia: async () => { throw new Error('boom'); } });
|
|
603
|
+
expect(await resolveMediaForMessage(USER, throwing))
|
|
604
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
|
|
605
|
+
|
|
606
|
+
const syncThrow = mediaMsg({ downloadMedia: () => { throw new Error('sync boom'); } });
|
|
607
|
+
expect(await resolveMediaForMessage(USER, syncThrow))
|
|
608
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
|
|
609
|
+
|
|
610
|
+
const hanging = mediaMsg({ downloadMedia: () => new Promise(() => {}) });
|
|
611
|
+
expect(await resolveMediaForMessage(USER, hanging, { timeoutMs: 20 }))
|
|
612
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('resolveMediaForMessage reports invalid_id for unsanitizable ids', async () => {
|
|
616
|
+
expect(await resolveMediaForMessage('..', mediaMsg()))
|
|
617
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'invalid_id' });
|
|
618
|
+
expect(await resolveMediaForMessage(USER, mediaMsg({ id: { _serialized: '//' } })))
|
|
619
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'invalid_id' });
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('resolveMediaForMessage falls back to the from-timestamp message id', async () => {
|
|
623
|
+
const msg = mediaMsg({ id: undefined });
|
|
624
|
+
const verdict = await resolveMediaForMessage(USER, msg);
|
|
625
|
+
expect(verdict.mediaStatus).toBe('available');
|
|
626
|
+
// Same fallback normalizeInbound uses → host can address the file.
|
|
627
|
+
expect(mediaExists(USER, '919999000001@c.us-1717000000')).not.toBeNull();
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// The fallback must mirror normalizeInbound's COUNTERPARTY keying: on fromMe
|
|
631
|
+
// the `from` is the operator's own jid, shared by every chat — keying on it
|
|
632
|
+
// stores the bytes under an id the wire never advertised (host GET 404s) and
|
|
633
|
+
// collides two same-second sends to different customers on the same path.
|
|
634
|
+
test('resolveMediaForMessage keys the fromMe fallback id on the counterparty', async () => {
|
|
635
|
+
const msg = mediaMsg({ id: undefined, fromMe: true, from: '919000000001@c.us', to: '919999000002@c.us' });
|
|
636
|
+
const verdict = await resolveMediaForMessage(USER, msg);
|
|
637
|
+
expect(verdict.mediaStatus).toBe('available');
|
|
638
|
+
expect(mediaExists(USER, '919999000002@c.us-1717000000')).not.toBeNull(); // keyed on `to`…
|
|
639
|
+
expect(mediaExists(USER, '919000000001@c.us-1717000000')).toBeNull(); // …never the operator
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test('resolveMediaForMessage fills mime/filename gaps and omits a missing filename', async () => {
|
|
643
|
+
const msg = mediaMsg(
|
|
644
|
+
{ downloadMedia: async () => ({ data: Buffer.from('x').toString('base64') }) },
|
|
645
|
+
{ mimetype: 'image/webp' }
|
|
646
|
+
);
|
|
647
|
+
const verdict = await resolveMediaForMessage(USER, msg);
|
|
648
|
+
|
|
649
|
+
expect(verdict.mediaMime).toBe('image/webp'); // from _data when payload lacks it
|
|
650
|
+
expect(verdict.mediaSize).toBe(1);
|
|
651
|
+
expect('mediaFilename' in verdict).toBe(false); // no filename anywhere → omitted
|
|
652
|
+
|
|
653
|
+
const bare = mediaMsg({
|
|
654
|
+
id: { _serialized: 'bare-1' },
|
|
655
|
+
downloadMedia: async () => ({ data: Buffer.from('y').toString('base64') }),
|
|
656
|
+
_data: undefined
|
|
657
|
+
});
|
|
658
|
+
expect((await resolveMediaForMessage(USER, bare)).mediaMime).toBe('application/octet-stream');
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test('resolveMediaForMessage reports download_failed when persisting fails', async () => {
|
|
662
|
+
// A FILE squatting on the user dir path makes writeMedia fail.
|
|
663
|
+
mkdirSync(mediaRoot, { recursive: true });
|
|
664
|
+
writeFileSync(join(mediaRoot, 'squat'), 'not-a-dir');
|
|
665
|
+
|
|
666
|
+
expect(await resolveMediaForMessage('squat', mediaMsg()))
|
|
667
|
+
.toEqual({ mediaStatus: 'unavailable', mediaError: 'download_failed' });
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// ── verifyMediaToken ──
|
|
671
|
+
|
|
672
|
+
test('verifyMediaToken enforces only when the service has a token configured', () => {
|
|
673
|
+
expect(verifyMediaToken(undefined, undefined)).toBe(true); // unset → open
|
|
674
|
+
expect(verifyMediaToken('anything', undefined)).toBe(true);
|
|
675
|
+
expect(verifyMediaToken('secret', 'secret')).toBe(true);
|
|
676
|
+
expect(verifyMediaToken('wrong', 'secret')).toBe(false);
|
|
677
|
+
expect(verifyMediaToken(undefined, 'secret')).toBe(false); // missing header
|
|
678
|
+
expect(verifyMediaToken('', 'secret')).toBe(false);
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ── GET /media route contract ──
|
|
682
|
+
|
|
683
|
+
test('mediaGetResponse serves raw bytes with mime, length and disposition headers', async () => {
|
|
684
|
+
writeMedia(USER, MSG_ID, bytes('jpeg-bytes'), { mime: 'image/jpeg', filename: 'beach.jpg' });
|
|
685
|
+
|
|
686
|
+
const res = mediaGetResponse(USER, MSG_ID, undefined, undefined);
|
|
687
|
+
|
|
688
|
+
expect(res.status).toBe(200);
|
|
689
|
+
expect(res.headers.get('Content-Type')).toBe('image/jpeg');
|
|
690
|
+
expect(res.headers.get('Content-Length')).toBe('10');
|
|
691
|
+
expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="beach.jpg"');
|
|
692
|
+
expect(new TextDecoder().decode(await res.arrayBuffer())).toBe('jpeg-bytes');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('mediaGetResponse sanitizes hostile filenames and handles missing ones', async () => {
|
|
696
|
+
writeMedia(USER, 'm1', bytes('x'), { mime: 'application/pdf', filename: 'a"b\r\nSet-Cookie: x.pdf' });
|
|
697
|
+
const evil = mediaGetResponse(USER, 'm1', undefined, undefined);
|
|
698
|
+
expect(evil.headers.get('Content-Disposition')).toBe('attachment; filename="a_b__Set-Cookie_ x.pdf"');
|
|
699
|
+
|
|
700
|
+
writeMedia(USER, 'm2', bytes('y'), { mime: 'audio/ogg' });
|
|
701
|
+
const bare = mediaGetResponse(USER, 'm2', undefined, undefined);
|
|
702
|
+
expect(bare.headers.get('Content-Disposition')).toBe('attachment');
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test('mediaGetResponse answers the same 404 for unknown, swept and invalid ids', async () => {
|
|
706
|
+
const missing = mediaGetResponse(USER, 'never-stored', undefined, undefined);
|
|
707
|
+
expect(missing.status).toBe(404);
|
|
708
|
+
expect(await missing.json()).toEqual({ error: 'not_found' });
|
|
709
|
+
|
|
710
|
+
const invalid = mediaGetResponse('..', '..', undefined, undefined);
|
|
711
|
+
expect(invalid.status).toBe(404);
|
|
712
|
+
expect(await invalid.json()).toEqual({ error: 'not_found' });
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test('mediaGetResponse rejects a bad token before touching the store', async () => {
|
|
716
|
+
writeMedia(USER, MSG_ID, bytes('secret-bytes'), { mime: 'image/jpeg' });
|
|
717
|
+
|
|
718
|
+
const res = mediaGetResponse(USER, MSG_ID, 'wrong', 'expected');
|
|
719
|
+
expect(res.status).toBe(401);
|
|
720
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' });
|
|
721
|
+
|
|
722
|
+
expect(mediaGetResponse(USER, MSG_ID, 'expected', 'expected').status).toBe(200);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ── DELETE /media route contract ──
|
|
726
|
+
|
|
727
|
+
test('mediaDeleteResponse deletes the pair and stays successful on repeats', async () => {
|
|
728
|
+
writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/jpeg' });
|
|
729
|
+
|
|
730
|
+
const first = mediaDeleteResponse(USER, MSG_ID, undefined, undefined);
|
|
731
|
+
expect(first.status).toBe(200);
|
|
732
|
+
expect(await first.json()).toEqual({ success: true });
|
|
733
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
734
|
+
|
|
735
|
+
const again = mediaDeleteResponse(USER, MSG_ID, undefined, undefined);
|
|
736
|
+
expect(await again.json()).toEqual({ success: true }); // idempotent
|
|
737
|
+
|
|
738
|
+
const never = mediaDeleteResponse(USER, 'never-stored', undefined, undefined);
|
|
739
|
+
expect(await never.json()).toEqual({ success: true });
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('mediaDeleteResponse enforces the token when configured', async () => {
|
|
743
|
+
writeMedia(USER, MSG_ID, bytes('x'), { mime: 'image/jpeg' });
|
|
744
|
+
|
|
745
|
+
const denied = mediaDeleteResponse(USER, MSG_ID, undefined, 'expected');
|
|
746
|
+
expect(denied.status).toBe(401);
|
|
747
|
+
expect(mediaExists(USER, MSG_ID)).not.toBeNull(); // nothing deleted
|
|
748
|
+
|
|
749
|
+
expect(mediaDeleteResponse(USER, MSG_ID, 'expected', 'expected').status).toBe(200);
|
|
750
|
+
expect(mediaExists(USER, MSG_ID)).toBeNull();
|
|
751
|
+
});
|