@studiomeyer/mcp-video 1.0.2 → 1.0.3

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -0
  3. package/dist/handlers/dispatch.test.d.ts +1 -0
  4. package/dist/handlers/dispatch.test.js +49 -0
  5. package/dist/handlers/dispatch.test.js.map +1 -0
  6. package/dist/handlers/index.js +5 -0
  7. package/dist/handlers/index.js.map +1 -1
  8. package/dist/handlers/smart-screenshot.js +3 -3
  9. package/dist/handlers/smart-screenshot.js.map +1 -1
  10. package/dist/handlers/tts.js +2 -2
  11. package/dist/handlers/tts.js.map +1 -1
  12. package/dist/handlers/video.js +4 -4
  13. package/dist/handlers/video.js.map +1 -1
  14. package/dist/lib/ffmpeg-run.test.js +44 -1
  15. package/dist/lib/ffmpeg-run.test.js.map +1 -1
  16. package/dist/lib/sanitize-tool-paths.d.ts +35 -0
  17. package/dist/lib/sanitize-tool-paths.js +130 -0
  18. package/dist/lib/sanitize-tool-paths.js.map +1 -0
  19. package/dist/lib/sanitize-tool-paths.test.d.ts +1 -0
  20. package/dist/lib/sanitize-tool-paths.test.js +134 -0
  21. package/dist/lib/sanitize-tool-paths.test.js.map +1 -0
  22. package/dist/lib/url-guard-resolve.test.d.ts +12 -0
  23. package/dist/lib/url-guard-resolve.test.js +106 -0
  24. package/dist/lib/url-guard-resolve.test.js.map +1 -0
  25. package/package.json +3 -3
  26. package/src/handlers/dispatch.test.ts +55 -0
  27. package/src/handlers/index.ts +5 -0
  28. package/src/handlers/smart-screenshot.ts +3 -3
  29. package/src/handlers/tts.ts +2 -2
  30. package/src/handlers/video.ts +4 -4
  31. package/src/lib/ffmpeg-run.test.ts +50 -1
  32. package/src/lib/sanitize-tool-paths.test.ts +185 -0
  33. package/src/lib/sanitize-tool-paths.ts +154 -0
  34. package/src/lib/url-guard-resolve.test.ts +116 -0
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sanitizeToolPaths } from './sanitize-tool-paths.js';
3
+
4
+ /**
5
+ * Each block pairs an "attack blocked" case with a "benign allowed" case, per
6
+ * the field shape the tool uses. `sanitizeToolPaths` throws on the first
7
+ * offending path and is a silent no-op otherwise.
8
+ */
9
+ describe('sanitize-tool-paths — scalar path fields', () => {
10
+ it('blocks a leading-dash outputPath (flag injection)', () => {
11
+ expect(() =>
12
+ sanitizeToolPaths('crop_video', { inputPath: 'in.mp4', outputPath: '-y' }),
13
+ ).toThrow(/outputPath.*flag/);
14
+ });
15
+
16
+ it('blocks a leading-dash inputPath', () => {
17
+ expect(() =>
18
+ sanitizeToolPaths('adjust_video_speed', { inputPath: '-i', outputPath: 'out.mp4' }),
19
+ ).toThrow(/inputPath.*flag/);
20
+ });
21
+
22
+ it('blocks a NUL byte in a path', () => {
23
+ expect(() =>
24
+ sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4\0-i', outputPath: 'b.mp3' }),
25
+ ).toThrow(/null byte/);
26
+ });
27
+
28
+ it('allows ordinary input/output paths', () => {
29
+ expect(() =>
30
+ sanitizeToolPaths('apply_color_grade', {
31
+ inputPath: '/home/user/clip.mp4',
32
+ outputPath: 'relative/out.mp4',
33
+ }),
34
+ ).not.toThrow();
35
+ });
36
+
37
+ it('skips omitted optional paths (unchanged benign behaviour)', () => {
38
+ // outputPath is optional for many tools — undefined must not throw.
39
+ expect(() => sanitizeToolPaths('extract_audio', { inputPath: 'a.mp4' })).not.toThrow();
40
+ });
41
+
42
+ it('validates every scalar field declared for a tool', () => {
43
+ expect(() =>
44
+ sanitizeToolPaths('burn_subtitles', {
45
+ inputPath: 'in.mp4',
46
+ outputPath: 'out.mp4',
47
+ subtitlePath: '-attach',
48
+ }),
49
+ ).toThrow(/subtitlePath.*flag/);
50
+ });
51
+ });
52
+
53
+ describe('sanitize-tool-paths — string-array path fields (sync_to_beat clips)', () => {
54
+ it('blocks a malicious entry inside the clips array', () => {
55
+ expect(() =>
56
+ sanitizeToolPaths('sync_to_beat', {
57
+ audioPath: 'beat.mp3',
58
+ outputPath: 'out.mp4',
59
+ clips: ['ok1.mp4', '-f', 'ok2.mp4'],
60
+ }),
61
+ ).toThrow(/clips\[1\].*flag/);
62
+ });
63
+
64
+ it('allows a clean clips array', () => {
65
+ expect(() =>
66
+ sanitizeToolPaths('sync_to_beat', {
67
+ audioPath: 'beat.mp3',
68
+ outputPath: 'out.mp4',
69
+ clips: ['a.mp4', 'b.mp4'],
70
+ }),
71
+ ).not.toThrow();
72
+ });
73
+ });
74
+
75
+ describe('sanitize-tool-paths — object-array path fields (concat clips / mixer tracks)', () => {
76
+ it('blocks a malicious clip path in concatenate_videos', () => {
77
+ expect(() =>
78
+ sanitizeToolPaths('concatenate_videos', {
79
+ outputPath: 'out.mp4',
80
+ clips: [{ path: 'good.mp4' }, { path: '-i' }],
81
+ }),
82
+ ).toThrow(/clips\[1\]\.path.*flag/);
83
+ });
84
+
85
+ it('blocks a malicious track path in mix_audio_tracks', () => {
86
+ expect(() =>
87
+ sanitizeToolPaths('mix_audio_tracks', {
88
+ outputPath: 'out.aac',
89
+ tracks: [{ path: 'voice.mp3' }, { path: '-protocol_whitelist' }],
90
+ }),
91
+ ).toThrow(/tracks\[1\]\.path.*flag/);
92
+ });
93
+
94
+ it('allows clean object-array paths', () => {
95
+ expect(() =>
96
+ sanitizeToolPaths('concatenate_videos', {
97
+ outputPath: 'out.mp4',
98
+ clips: [{ path: 'a.mp4', trimStart: 1 }, { path: 'b.mp4' }],
99
+ }),
100
+ ).not.toThrow();
101
+ });
102
+ });
103
+
104
+ describe('sanitize-tool-paths — record path fields (render_template clips)', () => {
105
+ it('blocks a malicious value in the clips record', () => {
106
+ expect(() =>
107
+ sanitizeToolPaths('render_template', {
108
+ templateId: 'social-reel',
109
+ outputPath: 'out.mp4',
110
+ clips: { intro: 'good.mp4', main: '-y' },
111
+ }),
112
+ ).toThrow(/clips\.main.*flag/);
113
+ });
114
+
115
+ it('allows a clean clips record', () => {
116
+ expect(() =>
117
+ sanitizeToolPaths('render_template', {
118
+ templateId: 'social-reel',
119
+ outputPath: 'out.mp4',
120
+ clips: { intro: 'a.mp4', main: 'b.mp4' },
121
+ }),
122
+ ).not.toThrow();
123
+ });
124
+ });
125
+
126
+ describe('sanitize-tool-paths — chroma-key background (path OR hex colour)', () => {
127
+ it('blocks a leading-dash background path', () => {
128
+ expect(() =>
129
+ sanitizeToolPaths('apply_chroma_key', {
130
+ inputPath: 'in.mp4',
131
+ outputPath: 'out.mp4',
132
+ background: '-f',
133
+ }),
134
+ ).toThrow(/background.*flag/);
135
+ });
136
+
137
+ it('allows a bare 6-digit hex colour as background', () => {
138
+ for (const hex of ['00FF00', '#0000FF', '0x000000']) {
139
+ expect(() =>
140
+ sanitizeToolPaths('apply_chroma_key', {
141
+ inputPath: 'in.mp4',
142
+ outputPath: 'out.mp4',
143
+ background: hex,
144
+ }),
145
+ ).not.toThrow();
146
+ }
147
+ });
148
+
149
+ it('allows a real background file path', () => {
150
+ expect(() =>
151
+ sanitizeToolPaths('apply_chroma_key', {
152
+ inputPath: 'in.mp4',
153
+ outputPath: 'out.mp4',
154
+ background: 'backgrounds/beach.png',
155
+ }),
156
+ ).not.toThrow();
157
+ });
158
+ });
159
+
160
+ describe('sanitize-tool-paths — registry boundaries', () => {
161
+ it('is a no-op for tools without path fields', () => {
162
+ expect(() => sanitizeToolPaths('list_voices', {})).not.toThrow();
163
+ expect(() => sanitizeToolPaths('list_video_templates', { category: 'promo' })).not.toThrow();
164
+ });
165
+
166
+ it('is a no-op for unknown tools', () => {
167
+ expect(() => sanitizeToolPaths('nonexistent_tool', { outputPath: '-y' })).not.toThrow();
168
+ });
169
+
170
+ it('tolerates non-object args', () => {
171
+ expect(() => sanitizeToolPaths('crop_video', null)).not.toThrow();
172
+ expect(() => sanitizeToolPaths('crop_video', undefined)).not.toThrow();
173
+ expect(() => sanitizeToolPaths('crop_video', 'not-an-object')).not.toThrow();
174
+ });
175
+
176
+ it('ignores malformed array entries instead of crashing', () => {
177
+ // objectArray entries that are not objects-with-path are skipped.
178
+ expect(() =>
179
+ sanitizeToolPaths('concatenate_videos', {
180
+ outputPath: 'out.mp4',
181
+ clips: [null, 'string-entry', { noPath: true }],
182
+ }),
183
+ ).not.toThrow();
184
+ });
185
+ });
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Central path-argument sanitizer for MCP tool handlers.
3
+ *
4
+ * The threat (already documented in ffmpeg-safety.ts) is real: every tool
5
+ * that shells out to ffmpeg/ffprobe passes user-supplied path strings as
6
+ * positional arguments. ffmpeg treats any argument that begins with `-` as
7
+ * a *flag*, not a filename — so a caller (or a confused LLM following an
8
+ * injected instruction) that sets `outputPath` to `-y`, `-f`, or
9
+ * `-protocol_whitelist` can rewrite the command instead of naming a file.
10
+ * Strings containing NUL bytes are equally dangerous (C-string truncation).
11
+ *
12
+ * `validateFfmpegPath` was written precisely to stop this, but until now it
13
+ * was wired into a single engine (narrated-video). The engines validate
14
+ * *input* existence (`assertExists`) which incidentally blocks some flag
15
+ * inputs, but they never check *output* paths — and several inputs flow
16
+ * through `fs.copyFileSync` / the concat demuxer without an existence check
17
+ * at all. The defense therefore had a wide bypass.
18
+ *
19
+ * This module closes the gap at the handler boundary — the one place every
20
+ * untrusted MCP argument enters — so no engine logic (filter graph building,
21
+ * arg ordering) has to change. Each tool declares which argument keys are
22
+ * path-like; nested arrays of paths (concat clips, audio-mixer tracks) are
23
+ * walked too.
24
+ */
25
+
26
+ import { validateFfmpegPath } from './ffmpeg-safety.js';
27
+
28
+ /** Describes where path-like values live in a tool's argument object. */
29
+ interface PathFieldSpec {
30
+ /** Top-level keys whose value is a single path string. */
31
+ readonly scalar?: readonly string[];
32
+ /** Keys whose value is an array of path strings (e.g. beat-sync `clips`). */
33
+ readonly stringArray?: readonly string[];
34
+ /**
35
+ * Keys whose value is an array of objects each carrying a `path` field
36
+ * (e.g. concatenate_videos `clips`, mix_audio_tracks `tracks`).
37
+ */
38
+ readonly objectArrayPath?: readonly string[];
39
+ /** Keys whose value is a record of name → path (template-renderer `clips`). */
40
+ readonly recordValues?: readonly string[];
41
+ /**
42
+ * Keys that are a path OR a benign non-path token (chroma-key `background`
43
+ * may be a 6-digit hex colour). Only validated when the value looks like a
44
+ * filesystem path rather than the allowed alternative.
45
+ */
46
+ readonly pathOrHex?: readonly string[];
47
+ }
48
+
49
+ /**
50
+ * Per-tool path-field registry. Only ffmpeg/ffprobe-shelling tools are listed
51
+ * here — tools that merely write via fs/Playwright (generate_speech,
52
+ * screenshot_element) are handled separately because a leading-`-` there is a
53
+ * file-write, not flag injection. Keep this in sync with the handler args.
54
+ */
55
+ const TOOL_PATH_FIELDS: Record<string, PathFieldSpec> = {
56
+ // post-production
57
+ add_background_music: { scalar: ['videoPath', 'musicPath', 'outputPath'] },
58
+ concatenate_videos: { scalar: ['outputPath'], objectArrayPath: ['clips'] },
59
+ generate_intro: { scalar: ['outputPath'] },
60
+ convert_social_format: { scalar: ['inputPath', 'outputPath'] },
61
+ convert_all_social_formats: { scalar: ['inputPath', 'outputDir'] },
62
+ add_text_overlay: { scalar: ['inputPath', 'outputPath'] },
63
+
64
+ // editing
65
+ adjust_video_speed: { scalar: ['inputPath', 'outputPath'] },
66
+ apply_color_grade: { scalar: ['inputPath', 'outputPath'] },
67
+ apply_video_effect: { scalar: ['inputPath', 'outputPath'] },
68
+ crop_video: { scalar: ['inputPath', 'outputPath'] },
69
+ reverse_clip: { scalar: ['inputPath', 'outputPath'] },
70
+ extract_audio: { scalar: ['inputPath', 'outputPath'] },
71
+ burn_subtitles: { scalar: ['inputPath', 'outputPath', 'subtitlePath'] },
72
+ auto_caption: { scalar: ['inputPath', 'outputPath'] },
73
+ add_keyframe_animation: { scalar: ['inputPath', 'outputPath'] },
74
+ compose_picture_in_pip: { scalar: ['mainVideo', 'overlayVideo', 'outputPath'] },
75
+ add_audio_ducking: { scalar: ['inputPath', 'outputPath'] },
76
+
77
+ // capcut-tier
78
+ apply_lut_preset: { scalar: ['inputPath', 'outputPath'] },
79
+ apply_voice_effect: { scalar: ['inputPath', 'outputPath'] },
80
+ apply_chroma_key: { scalar: ['inputPath', 'outputPath'], pathOrHex: ['background'] },
81
+ sync_to_beat: { scalar: ['audioPath', 'outputPath'], stringArray: ['clips'] },
82
+ animate_text: { scalar: ['inputPath', 'outputPath'] },
83
+ mix_audio_tracks: { scalar: ['outputPath'], objectArrayPath: ['tracks'] },
84
+ render_template: { scalar: ['outputPath', 'musicPath'], recordValues: ['clips'] },
85
+
86
+ // tts (narrated video also shells out via the recording + concat pipeline)
87
+ create_narrated_video: { scalar: ['outputPath', 'backgroundMusicPath'] },
88
+ };
89
+
90
+ const HEX_COLOR = /^(?:#|0x)?[0-9a-fA-F]{6}$/;
91
+
92
+ /** A value is "present" for validation if it is a non-empty string. */
93
+ function isPresentString(v: unknown): v is string {
94
+ return typeof v === 'string' && v.length > 0;
95
+ }
96
+
97
+ /**
98
+ * Validate every path-like argument for the named tool. Throws the same
99
+ * error class `validateFfmpegPath` throws (TypeError / Error) on the first
100
+ * offending value, which the handler's try/catch turns into a structured
101
+ * tool error. Tools not in the registry are left untouched.
102
+ *
103
+ * Only *present* values are checked — optional paths that the caller omitted
104
+ * (and the handler will default) are skipped so behaviour is unchanged for
105
+ * benign callers.
106
+ */
107
+ export function sanitizeToolPaths(toolName: string, args: unknown): void {
108
+ const spec = TOOL_PATH_FIELDS[toolName];
109
+ if (!spec) return;
110
+ if (typeof args !== 'object' || args === null) return;
111
+ const record = args as Record<string, unknown>;
112
+
113
+ for (const key of spec.scalar ?? []) {
114
+ const value = record[key];
115
+ if (isPresentString(value)) validateFfmpegPath(value, key);
116
+ }
117
+
118
+ for (const key of spec.stringArray ?? []) {
119
+ const value = record[key];
120
+ if (!Array.isArray(value)) continue;
121
+ value.forEach((entry, i) => {
122
+ if (isPresentString(entry)) validateFfmpegPath(entry, `${key}[${i}]`);
123
+ });
124
+ }
125
+
126
+ for (const key of spec.objectArrayPath ?? []) {
127
+ const value = record[key];
128
+ if (!Array.isArray(value)) continue;
129
+ value.forEach((entry, i) => {
130
+ if (entry && typeof entry === 'object' && 'path' in entry) {
131
+ const p = (entry as { path: unknown }).path;
132
+ if (isPresentString(p)) validateFfmpegPath(p, `${key}[${i}].path`);
133
+ }
134
+ });
135
+ }
136
+
137
+ for (const key of spec.recordValues ?? []) {
138
+ const value = record[key];
139
+ if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
140
+ for (const [slot, p] of Object.entries(value as Record<string, unknown>)) {
141
+ if (isPresentString(p)) validateFfmpegPath(p, `${key}.${slot}`);
142
+ }
143
+ }
144
+
145
+ for (const key of spec.pathOrHex ?? []) {
146
+ const value = record[key];
147
+ // A bare 6-digit hex colour (e.g. "00FF00") is a legitimate non-path
148
+ // value for chroma-key backgrounds — skip those. Anything else is treated
149
+ // as a path and must pass the flag-injection / NUL-byte check.
150
+ if (isPresentString(value) && !HEX_COLOR.test(value)) {
151
+ validateFfmpegPath(value, key);
152
+ }
153
+ }
154
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tests for the async, DNS-resolving URL guard `resolveAndGuardUrl`.
3
+ *
4
+ * This is the strong SSRF check now used by every URL-taking handler
5
+ * (record_website_*, create_narrated_video, screenshot_element,
6
+ * detect_page_features). Where the sync `guardUrl` only inspects the literal
7
+ * host, this one resolves the hostname and rejects when it points at a
8
+ * loopback / RFC1918 / cloud-metadata address — the classic DNS-rebinding
9
+ * bypass. `node:dns/promises.lookup` is mocked so the suite is hermetic and
10
+ * never hits the network.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
14
+
15
+ const lookupMock = vi.hoisted(() => vi.fn());
16
+
17
+ vi.mock('node:dns/promises', () => ({ lookup: lookupMock }));
18
+
19
+ // Import AFTER the mock so the binding is the mock.
20
+ import { resolveAndGuardUrl } from './url-guard.js';
21
+
22
+ const ORIGINAL_ALLOW_INTERNAL = process.env.MCP_VIDEO_ALLOW_INTERNAL;
23
+
24
+ beforeEach(() => {
25
+ lookupMock.mockReset();
26
+ delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
27
+ });
28
+ afterEach(() => {
29
+ if (ORIGINAL_ALLOW_INTERNAL === undefined) delete process.env.MCP_VIDEO_ALLOW_INTERNAL;
30
+ else process.env.MCP_VIDEO_ALLOW_INTERNAL = ORIGINAL_ALLOW_INTERNAL;
31
+ });
32
+
33
+ describe('resolveAndGuardUrl — DNS-rebinding defense', () => {
34
+ it('blocks a public hostname that resolves to a loopback address', async () => {
35
+ lookupMock.mockResolvedValueOnce([{ address: '127.0.0.1', family: 4 }]);
36
+ const res = await resolveAndGuardUrl('https://rebind.evil.example/');
37
+ expect(res.ok).toBe(false);
38
+ if (!res.ok) expect(res.reason).toMatch(/resolves to private address 127\.0\.0\.1/);
39
+ });
40
+
41
+ it('blocks a hostname resolving to the cloud-metadata IP (169.254.169.254)', async () => {
42
+ lookupMock.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }]);
43
+ const res = await resolveAndGuardUrl('https://metadata.evil.example/');
44
+ expect(res.ok).toBe(false);
45
+ if (!res.ok) expect(res.reason).toMatch(/169\.254\.169\.254/);
46
+ });
47
+
48
+ it('blocks when ANY resolved address is internal (multi-A record)', async () => {
49
+ lookupMock.mockResolvedValueOnce([
50
+ { address: '93.184.216.34', family: 4 }, // public
51
+ { address: '10.0.0.5', family: 4 }, // private — must trip the guard
52
+ ]);
53
+ const res = await resolveAndGuardUrl('https://mixed.evil.example/');
54
+ expect(res.ok).toBe(false);
55
+ if (!res.ok) expect(res.reason).toMatch(/10\.0\.0\.5/);
56
+ });
57
+
58
+ it('blocks an internal IPv6 resolution (unique-local fc00::/7)', async () => {
59
+ lookupMock.mockResolvedValueOnce([{ address: 'fc00::1', family: 6 }]);
60
+ const res = await resolveAndGuardUrl('https://v6.evil.example/');
61
+ expect(res.ok).toBe(false);
62
+ });
63
+
64
+ it('allows a hostname that resolves to a public address', async () => {
65
+ lookupMock.mockResolvedValueOnce([{ address: '93.184.216.34', family: 4 }]);
66
+ const res = await resolveAndGuardUrl('https://example.com/page');
67
+ expect(res.ok).toBe(true);
68
+ if (res.ok) expect(res.url).toContain('example.com');
69
+ });
70
+
71
+ it('returns a clean failure when DNS resolution itself fails', async () => {
72
+ lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
73
+ const res = await resolveAndGuardUrl('https://nxdomain.invalid/');
74
+ expect(res.ok).toBe(false);
75
+ if (!res.ok) expect(res.reason).toMatch(/DNS lookup failed/);
76
+ });
77
+ });
78
+
79
+ describe('resolveAndGuardUrl — short-circuits (no DNS call)', () => {
80
+ it('rejects a non-http scheme before any lookup', async () => {
81
+ const res = await resolveAndGuardUrl('file:///etc/passwd');
82
+ expect(res.ok).toBe(false);
83
+ expect(lookupMock).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('rejects a literal internal IP via the sync layer (no lookup needed)', async () => {
87
+ const res = await resolveAndGuardUrl('http://127.0.0.1/');
88
+ expect(res.ok).toBe(false);
89
+ expect(lookupMock).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it('does not resolve a literal public IPv4 (already host-checked)', async () => {
93
+ const res = await resolveAndGuardUrl('https://93.184.216.34/');
94
+ expect(res.ok).toBe(true);
95
+ expect(lookupMock).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('does not resolve a literal IPv6 host', async () => {
99
+ const res = await resolveAndGuardUrl('https://[2606:2800:220:1:248:1893:25c8:1946]/');
100
+ expect(res.ok).toBe(true);
101
+ expect(lookupMock).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('skips DNS entirely when MCP_VIDEO_ALLOW_INTERNAL=1', async () => {
105
+ process.env.MCP_VIDEO_ALLOW_INTERNAL = '1';
106
+ const res = await resolveAndGuardUrl('https://internal.dev.example/');
107
+ expect(res.ok).toBe(true);
108
+ expect(lookupMock).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('rejects a non-string input without resolving', async () => {
112
+ const res = await resolveAndGuardUrl(undefined);
113
+ expect(res.ok).toBe(false);
114
+ expect(lookupMock).not.toHaveBeenCalled();
115
+ });
116
+ });