@jgardner04/ghost-mcp-server 1.13.5 → 1.14.1

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.
@@ -1,96 +1,140 @@
1
1
  import sharp from 'sharp';
2
2
  import path from 'path';
3
- import fs from 'fs';
4
- import Joi from 'joi';
3
+ import fsp from 'fs/promises';
4
+ import { z } from 'zod';
5
5
  import { createContextLogger } from '../utils/logger.js';
6
6
 
7
- // Define processing parameters (e.g., max width)
8
7
  const MAX_WIDTH = 1200;
9
- const OUTPUT_QUALITY = 80; // JPEG quality
8
+ const JPEG_QUALITY = 80;
10
9
 
11
- /**
12
- * Processes an image: resizes if too large, ensures JPEG format (configurable).
13
- * @param {string} inputPath - Path to the original uploaded image.
14
- * @param {string} outputDir - Directory to save the processed image.
15
- * @returns {Promise<string>} Path to the processed image.
16
- */
17
- // Validation schema for processing parameters
18
- const processImageSchema = Joi.object({
19
- inputPath: Joi.string().required(),
20
- outputDir: Joi.string().required(),
10
+ // Formats we re-encode via sharp when the image is oversized.
11
+ // Everything else is passed through untouched to preserve fidelity
12
+ // (SVG vectors, GIF animation, ICO, exotic formats).
13
+ const RESIZABLE_FORMATS = new Set(['jpeg', 'png', 'webp']);
14
+
15
+ const EXT_BY_FORMAT = {
16
+ jpeg: '.jpg',
17
+ png: '.png',
18
+ webp: '.webp',
19
+ gif: '.gif',
20
+ svg: '.svg',
21
+ // ICO has no sharp `format` name; detected by extension.
22
+ };
23
+
24
+ const processImageParamsSchema = z.object({
25
+ inputPath: z.string().min(1),
26
+ outputDir: z.string().min(1),
21
27
  });
22
28
 
23
- const processImage = async (inputPath, outputDir) => {
29
+ const processImageOptsSchema = z
30
+ .object({
31
+ purpose: z.enum(['image', 'profile_image', 'icon']).optional(),
32
+ })
33
+ .optional();
34
+
35
+ /**
36
+ * Process an image for upload to Ghost.
37
+ *
38
+ * Preserves the original format. Resizes (preserving format) only for
39
+ * raster formats sharp can safely re-encode — JPEG, PNG, WEBP — when
40
+ * wider than MAX_WIDTH. SVG, GIF, ICO, and unrecognized formats are
41
+ * copied byte-for-byte so vectors, animation frames, and icon metadata
42
+ * survive the round trip.
43
+ *
44
+ * @param {string} inputPath - Absolute path to the source image.
45
+ * @param {string} outputDir - Directory to write the processed file into.
46
+ * @param {{ purpose?: 'image'|'profile_image'|'icon' }} [opts]
47
+ * @returns {Promise<string>} Absolute path to the processed (or copied) file.
48
+ */
49
+ export async function processImage(inputPath, outputDir, opts = {}) {
24
50
  const logger = createContextLogger('image-processing');
25
51
 
26
- // Validate inputs to prevent path injection
27
- const { error } = processImageSchema.validate({ inputPath, outputDir });
28
- if (error) {
29
- logger.error('Invalid processing parameters', {
30
- error: error.details[0].message,
31
- inputPath: inputPath ? path.basename(inputPath) : 'undefined',
32
- outputDir: outputDir ? path.basename(outputDir) : 'undefined',
33
- });
52
+ const params = processImageParamsSchema.safeParse({ inputPath, outputDir });
53
+ if (!params.success) {
34
54
  throw new Error('Invalid processing parameters');
35
55
  }
56
+ processImageOptsSchema.parse(opts);
36
57
 
37
- // Ensure paths are safe
38
- const resolvedInputPath = path.resolve(inputPath);
58
+ const resolvedInput = path.resolve(inputPath);
39
59
  const resolvedOutputDir = path.resolve(outputDir);
40
60
 
41
- // Verify input file exists
42
- if (!fs.existsSync(resolvedInputPath)) {
61
+ try {
62
+ await fsp.access(resolvedInput);
63
+ } catch {
43
64
  throw new Error('Input file does not exist');
44
65
  }
45
66
 
46
- const filename = path.basename(resolvedInputPath);
47
- const nameWithoutExt = filename.split('.').slice(0, -1).join('.');
48
- // Use timestamp for unique output filename
67
+ const inputExt = path.extname(resolvedInput).toLowerCase();
68
+ const baseName = path.basename(resolvedInput, path.extname(resolvedInput));
49
69
  const timestamp = Date.now();
50
- const outputFilename = `processed-${timestamp}-${nameWithoutExt}.jpg`;
51
- const outputPath = path.join(resolvedOutputDir, outputFilename);
52
70
 
53
71
  try {
54
- logger.info('Processing image', {
55
- inputFile: path.basename(inputPath),
56
- outputDir: path.basename(outputDir),
57
- });
58
- const image = sharp(inputPath);
72
+ // Non-raster passthrough: SVG and ICO are never handed to sharp.
73
+ // (sharp can read SVG but would rasterize it, destroying the vector.)
74
+ if (inputExt === '.svg' || inputExt === '.ico') {
75
+ return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
76
+ }
77
+
78
+ const image = sharp(resolvedInput);
59
79
  const metadata = await image.metadata();
80
+ const format = metadata.format; // 'jpeg' | 'png' | 'webp' | 'gif' | 'svg' | ...
60
81
 
61
- let processedImage = image;
82
+ // GIF: never re-encode — sharp would drop animation frames.
83
+ // SVG (if it slipped through by extension mismatch): also passthrough.
84
+ if (format === 'gif' || format === 'svg') {
85
+ const ext = EXT_BY_FORMAT[format] || inputExt || '';
86
+ return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, ext);
87
+ }
62
88
 
63
- // Resize if wider than MAX_WIDTH
64
- if (metadata.width && metadata.width > MAX_WIDTH) {
65
- logger.info('Resizing image', {
66
- originalWidth: metadata.width,
67
- targetWidth: MAX_WIDTH,
89
+ // Unknown / unsupported format: safest is passthrough.
90
+ if (!RESIZABLE_FORMATS.has(format)) {
91
+ logger.info('Unknown format, passing through', {
92
+ format,
68
93
  inputFile: path.basename(inputPath),
69
94
  });
70
- processedImage = processedImage.resize({ width: MAX_WIDTH });
95
+ return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
71
96
  }
72
97
 
73
- // Convert to JPEG with specified quality
74
- // You could add options for PNG/WebP etc. if needed
75
- await processedImage.jpeg({ quality: OUTPUT_QUALITY }).toFile(outputPath);
98
+ const ext = EXT_BY_FORMAT[format];
99
+ const outputPath = path.join(resolvedOutputDir, `processed-${timestamp}-${baseName}${ext}`);
76
100
 
77
- logger.info('Image processing completed', {
101
+ // Passthrough when no resize is needed — avoids generation loss.
102
+ if (!metadata.width || metadata.width <= MAX_WIDTH) {
103
+ await fsp.copyFile(resolvedInput, outputPath);
104
+ logger.info('Image within size limits, passthrough copy', {
105
+ format,
106
+ width: metadata.width,
107
+ inputFile: path.basename(inputPath),
108
+ });
109
+ return outputPath;
110
+ }
111
+
112
+ logger.info('Resizing image', {
113
+ format,
114
+ originalWidth: metadata.width,
115
+ targetWidth: MAX_WIDTH,
78
116
  inputFile: path.basename(inputPath),
79
- outputFile: path.basename(outputPath),
80
- originalSize: metadata.size,
81
- quality: OUTPUT_QUALITY,
82
117
  });
118
+
119
+ // sharp picks the encoder from outputPath extension (EXT_BY_FORMAT),
120
+ // so only JPEG needs the explicit call here to set quality. PNG/WEBP
121
+ // would be redundant.
122
+ let pipeline = image.resize({ width: MAX_WIDTH });
123
+ if (format === 'jpeg') pipeline = pipeline.jpeg({ quality: JPEG_QUALITY });
124
+
125
+ await pipeline.toFile(outputPath);
83
126
  return outputPath;
84
127
  } catch (error) {
85
128
  logger.error('Image processing failed', {
86
129
  inputFile: path.basename(inputPath),
87
130
  error: error.message,
88
- stack: error.stack,
89
131
  });
90
- // If processing fails, maybe fall back to using the original?
91
- // Or throw the error to fail the upload.
92
132
  throw new Error('Image processing failed: ' + error.message, { cause: error });
93
133
  }
94
- };
134
+ }
95
135
 
96
- export { processImage };
136
+ async function passthrough(inputPath, outputDir, baseName, timestamp, ext) {
137
+ const outputPath = path.join(outputDir, `processed-${timestamp}-${baseName}${ext}`);
138
+ await fsp.copyFile(inputPath, outputPath);
139
+ return outputPath;
140
+ }
@@ -2,19 +2,46 @@ import { GhostAPIError, ValidationError } from '../errors/index.js';
2
2
  import { handleApiRequest } from './ghostApiClient.js';
3
3
  import { validators } from './validators.js';
4
4
 
5
+ const ALLOWED_PURPOSES = new Set(['image', 'profile_image', 'icon']);
6
+ const REF_MAX_LENGTH = 200;
7
+
5
8
  /**
6
9
  * Uploads an image to Ghost CMS from a local file path.
7
- * @param {string} imagePath - Absolute path to the image file
8
- * @returns {Promise<Object>} The uploaded image object with URL
9
- * @throws {ValidationError} If the path is invalid or upload fails
10
- * @throws {NotFoundError} If the file does not exist
11
- * @throws {GhostAPIError} If the API request fails
10
+ *
11
+ * @param {string} imagePath - Absolute path to the image file.
12
+ * @param {object} [opts]
13
+ * @param {'image'|'profile_image'|'icon'} [opts.purpose] - Intended use.
14
+ * Ghost applies format/size validation per purpose (icon/profile_image
15
+ * must be square; icon also accepts ICO).
16
+ * @param {string} [opts.ref] - Caller-supplied identifier (e.g. original
17
+ * filename). Ghost echoes it back in the response. Max 200 chars.
18
+ * @returns {Promise<Object>} The uploaded image object with URL (and ref,
19
+ * if provided and the SDK forwards it).
20
+ * @throws {ValidationError} If inputs are invalid or upload fails.
21
+ * @throws {NotFoundError} If the file does not exist.
22
+ * @throws {GhostAPIError} If the API request fails.
12
23
  */
13
- export async function uploadImage(imagePath) {
14
- // Validate input
24
+ export async function uploadImage(imagePath, opts = {}) {
15
25
  await validators.validateImagePath(imagePath);
16
26
 
27
+ const { purpose, ref } = opts;
28
+ if (purpose !== undefined && !ALLOWED_PURPOSES.has(purpose)) {
29
+ throw new ValidationError(
30
+ `Invalid purpose "${purpose}". Must be one of: ${[...ALLOWED_PURPOSES].join(', ')}`
31
+ );
32
+ }
33
+ if (ref !== undefined) {
34
+ if (typeof ref !== 'string') {
35
+ throw new ValidationError('ref must be a string');
36
+ }
37
+ if (ref.length > REF_MAX_LENGTH) {
38
+ throw new ValidationError(`ref cannot exceed ${REF_MAX_LENGTH} characters`);
39
+ }
40
+ }
41
+
17
42
  const imageData = { file: imagePath };
43
+ if (purpose !== undefined) imageData.purpose = purpose;
44
+ if (ref !== undefined) imageData.ref = ref;
18
45
 
19
46
  try {
20
47
  return await handleApiRequest('images', 'upload', imageData);
@@ -41,8 +41,8 @@ const pageInputSchema = Joi.object({
41
41
  published_at: Joi.string().isoDate().optional(),
42
42
  // NO tags field - pages don't support tags
43
43
  feature_image: Joi.string().uri().optional(),
44
- feature_image_alt: Joi.string().max(255).optional(),
45
- feature_image_caption: Joi.string().max(500).optional(),
44
+ feature_image_alt: Joi.string().max(191).optional(),
45
+ feature_image_caption: Joi.string().max(5000).optional(),
46
46
  meta_title: Joi.string().max(70).optional(),
47
47
  meta_description: Joi.string().max(160).optional(),
48
48
  });
@@ -0,0 +1,158 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { formatErrorResponse } from '../formatErrorResponse.js';
3
+ import { GhostAPIError, ValidationError, NotFoundError } from '../../errors/index.js';
4
+
5
+ function parseJsonBlock(text) {
6
+ const match = text.match(/```json\n([\s\S]+?)\n```/);
7
+ expect(match, `no JSON block in: ${text}`).toBeTruthy();
8
+ return JSON.parse(match[1]);
9
+ }
10
+
11
+ describe('formatErrorResponse', () => {
12
+ const originalEnv = process.env.GHOST_ADMIN_API_KEY;
13
+
14
+ beforeEach(() => {
15
+ process.env.GHOST_ADMIN_API_KEY = '';
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (originalEnv === undefined) delete process.env.GHOST_ADMIN_API_KEY;
20
+ else process.env.GHOST_ADMIN_API_KEY = originalEnv;
21
+ });
22
+
23
+ it('returns consistent envelope with error key for generic Error (no ghost key)', () => {
24
+ const response = formatErrorResponse(new Error('boom'), 'ghost_get_posts');
25
+ expect(response.isError).toBe(true);
26
+ expect(response.content[0].type).toBe('text');
27
+ const envelope = parseJsonBlock(response.content[0].text);
28
+ expect(envelope.error).toBeDefined();
29
+ expect(envelope.error.message).toBe('boom');
30
+ expect(envelope).not.toHaveProperty('ghost');
31
+ expect(response.content[0].text).toContain('Error in ghost_get_posts: boom');
32
+ });
33
+
34
+ it('includes gated ghost sub-object for GhostAPIError', () => {
35
+ const err = new GhostAPIError('posts.edit', 'Title is required', 422);
36
+ const response = formatErrorResponse(err, 'ghost_update_post');
37
+ const envelope = parseJsonBlock(response.content[0].text);
38
+ expect(envelope.error.name).toBe('GhostAPIError');
39
+ expect(envelope.error.code).toBe('GHOST_VALIDATION_ERROR');
40
+ expect(envelope.ghost).toBeDefined();
41
+ expect(envelope.ghost.operation).toBe('posts.edit');
42
+ expect(envelope.ghost.statusCode).toBe(422);
43
+ expect(envelope.ghost.originalMessage).toBe('Title is required');
44
+ expect(response.content[0].text).toContain('422');
45
+ expect(response.content[0].text).toContain('posts.edit');
46
+ expect(response.content[0].text).toContain('Title is required');
47
+ });
48
+
49
+ it('uses raw ghostStatusCode (not remapped statusCode) in ghost envelope', () => {
50
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
51
+ // GhostAPIError remaps 422 -> 400 for .statusCode; ghost.statusCode must be 422
52
+ expect(err.statusCode).toBe(400);
53
+ expect(err.ghostStatusCode).toBe(422);
54
+ const envelope = parseJsonBlock(formatErrorResponse(err, 'ghost_update_post').content[0].text);
55
+ expect(envelope.ghost.statusCode).toBe(422);
56
+ expect(envelope.error.statusCode).toBe(400);
57
+ });
58
+
59
+ it('does not leak GHOST_ADMIN_API_KEY in surfaced response', () => {
60
+ process.env.GHOST_ADMIN_API_KEY = 'plaintext-admin-key-xyz';
61
+ const err = new GhostAPIError(
62
+ 'posts.edit',
63
+ 'Ghost complained: token plaintext-admin-key-xyz invalid',
64
+ 401
65
+ );
66
+ const response = formatErrorResponse(err, 'ghost_update_post');
67
+ expect(response.content[0].text).not.toContain('plaintext-admin-key-xyz');
68
+ expect(response.content[0].text).toContain('[REDACTED]');
69
+ });
70
+
71
+ it('does not leak Ghost-shaped admin key pattern in originalMessage', () => {
72
+ const fakeKey = `${'1'.repeat(24)}:${'2'.repeat(64)}`;
73
+ const err = new GhostAPIError('posts.edit', `failed with ${fakeKey}`, 401);
74
+ const response = formatErrorResponse(err, 'ghost_update_post');
75
+ expect(response.content[0].text).not.toContain(fakeKey);
76
+ });
77
+
78
+ it('produces envelope for ValidationError (no ghost key)', () => {
79
+ const err = new ValidationError('Validation failed', [
80
+ { field: 'title', message: 'required', type: 'invalid_type' },
81
+ ]);
82
+ const response = formatErrorResponse(err, 'ghost_update_post');
83
+ const envelope = parseJsonBlock(response.content[0].text);
84
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
85
+ expect(envelope).not.toHaveProperty('ghost');
86
+ });
87
+
88
+ it('produces envelope for NotFoundError (no ghost key)', () => {
89
+ const err = new NotFoundError('Post', 'abc');
90
+ const response = formatErrorResponse(err, 'ghost_get_post');
91
+ const envelope = parseJsonBlock(response.content[0].text);
92
+ expect(envelope.error.code).toBe('NOT_FOUND');
93
+ expect(envelope).not.toHaveProperty('ghost');
94
+ });
95
+
96
+ describe('extra context', () => {
97
+ it('includes and sanitizes extra context when provided', () => {
98
+ const extra = {
99
+ orphanedImage: { url: 'https://cdn.example/img.jpg?key=LEAK_ME_XYZ', ref: 'r1' },
100
+ };
101
+ const response = formatErrorResponse(new Error('boom'), 'ghost_set_feature_image', extra);
102
+ const envelope = parseJsonBlock(response.content[0].text);
103
+ expect(envelope.extra).toBeDefined();
104
+ expect(envelope.extra.orphanedImage.url).toContain('key=[REDACTED]');
105
+ expect(response.content[0].text).not.toContain('LEAK_ME_XYZ');
106
+ });
107
+
108
+ it('omits extra key entirely when arg is not provided', () => {
109
+ const envelope = parseJsonBlock(
110
+ formatErrorResponse(new Error('boom'), 'ghost_update_post').content[0].text
111
+ );
112
+ expect(envelope).not.toHaveProperty('extra');
113
+ });
114
+
115
+ it('omits extra key when arg is empty object (no empty-object leak)', () => {
116
+ const envelope = parseJsonBlock(
117
+ formatErrorResponse(new Error('boom'), 'ghost_update_post', {}).content[0].text
118
+ );
119
+ expect(envelope).not.toHaveProperty('extra');
120
+ });
121
+
122
+ it('combines error, ghost, and extra when all apply', () => {
123
+ const err = new GhostAPIError('posts.edit', 'bad', 422);
124
+ const envelope = parseJsonBlock(
125
+ formatErrorResponse(err, 'ghost_set_feature_image', { orphanedImage: { url: 'x' } })
126
+ .content[0].text
127
+ );
128
+ expect(envelope.error).toBeDefined();
129
+ expect(envelope.ghost).toBeDefined();
130
+ expect(envelope.extra).toBeDefined();
131
+ });
132
+ });
133
+
134
+ describe('ZodError coercion', () => {
135
+ it('coerces ZodError-shaped input to VALIDATION_ERROR / 400 envelope', () => {
136
+ const zodLike = Object.assign(new Error('zod'), {
137
+ name: 'ZodError',
138
+ issues: [{ path: ['purpose'], message: 'Invalid enum value', code: 'invalid_enum_value' }],
139
+ });
140
+ const envelope = parseJsonBlock(
141
+ formatErrorResponse(zodLike, 'ghost_upload_image').content[0].text
142
+ );
143
+ expect(envelope.error.code).toBe('VALIDATION_ERROR');
144
+ expect(envelope.error.statusCode).toBe(400);
145
+ expect(envelope.error.errors).toEqual([
146
+ { field: 'purpose', message: 'Invalid enum value', type: 'invalid_enum_value' },
147
+ ]);
148
+ });
149
+
150
+ it('does not coerce a non-ZodError error that happens to have an issues field', () => {
151
+ const notZod = Object.assign(new Error('unrelated'), { issues: [] });
152
+ const envelope = parseJsonBlock(
153
+ formatErrorResponse(notZod, 'ghost_update_post').content[0].text
154
+ );
155
+ expect(envelope.error.code).toBe('UNKNOWN');
156
+ });
157
+ });
158
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ import {
7
+ resolveLocalImagePath,
8
+ decodeBase64ToTempFile,
9
+ MAX_BASE64_BYTES,
10
+ } from '../imageInputResolver.js';
11
+
12
+ const tmpRoot = path.join(os.tmpdir(), `img-resolver-${Date.now()}`);
13
+ const allowedRoot = path.join(tmpRoot, 'allowed');
14
+ const outside = path.join(tmpRoot, 'outside');
15
+
16
+ beforeAll(async () => {
17
+ await fs.mkdir(allowedRoot, { recursive: true });
18
+ await fs.mkdir(outside, { recursive: true });
19
+ await fs.writeFile(path.join(allowedRoot, 'ok.png'), 'fake-png');
20
+ await fs.writeFile(path.join(outside, 'secret.txt'), 'top secret');
21
+ await fs.symlink(path.join(outside, 'secret.txt'), path.join(allowedRoot, 'escape.txt'));
22
+ });
23
+
24
+ afterAll(async () => {
25
+ await fs.rm(tmpRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('resolveLocalImagePath', () => {
29
+ const origRoot = process.env.GHOST_MCP_IMAGE_ROOT;
30
+
31
+ beforeEach(() => {
32
+ process.env.GHOST_MCP_IMAGE_ROOT = allowedRoot;
33
+ });
34
+
35
+ afterEach(() => {
36
+ if (origRoot === undefined) delete process.env.GHOST_MCP_IMAGE_ROOT;
37
+ else process.env.GHOST_MCP_IMAGE_ROOT = origRoot;
38
+ });
39
+
40
+ it('accepts a file inside the configured root', async () => {
41
+ const resolved = await resolveLocalImagePath(path.join(allowedRoot, 'ok.png'));
42
+ // Returns the realpath, which on macOS canonicalizes /var -> /private/var.
43
+ const canonical = await fs.realpath(path.join(allowedRoot, 'ok.png'));
44
+ expect(resolved).toBe(canonical);
45
+ });
46
+
47
+ it('rejects when GHOST_MCP_IMAGE_ROOT is unset (local-path mode disabled by default)', async () => {
48
+ delete process.env.GHOST_MCP_IMAGE_ROOT;
49
+ await expect(resolveLocalImagePath('/anywhere.png')).rejects.toThrow(
50
+ /GHOST_MCP_IMAGE_ROOT is not set/
51
+ );
52
+ });
53
+
54
+ it('rejects a path outside the configured root', async () => {
55
+ await expect(resolveLocalImagePath(path.join(outside, 'secret.txt'))).rejects.toThrow(
56
+ /outside the allowed root/
57
+ );
58
+ });
59
+
60
+ it('rejects a relative traversal attempt', async () => {
61
+ await expect(resolveLocalImagePath(`${allowedRoot}/../outside/secret.txt`)).rejects.toThrow(
62
+ /outside the allowed root/
63
+ );
64
+ });
65
+
66
+ it('rejects a symlink that escapes the allowed root', async () => {
67
+ await expect(resolveLocalImagePath(path.join(allowedRoot, 'escape.txt'))).rejects.toThrow(
68
+ /symlink escapes/
69
+ );
70
+ });
71
+
72
+ it('rejects a non-existent file', async () => {
73
+ await expect(resolveLocalImagePath(path.join(allowedRoot, 'nope.png'))).rejects.toThrow(
74
+ /does not exist/
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('decodeBase64ToTempFile', () => {
80
+ it('decodes a plain base64 string to a temp file', async () => {
81
+ const payload = Buffer.from('hello png');
82
+ const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/png');
83
+ try {
84
+ expect(path.extname(out)).toBe('.png');
85
+ const written = await fs.readFile(out);
86
+ expect(written.equals(payload)).toBe(true);
87
+ } finally {
88
+ await fs.unlink(out).catch(() => {});
89
+ }
90
+ });
91
+
92
+ it('accepts a full data: URI and ignores the prefix', async () => {
93
+ const payload = Buffer.from('svg-bytes');
94
+ const dataUri = `data:image/svg+xml;base64,${payload.toString('base64')}`;
95
+ const out = await decodeBase64ToTempFile(dataUri, 'image/svg+xml');
96
+ try {
97
+ expect(path.extname(out)).toBe('.svg');
98
+ const written = await fs.readFile(out);
99
+ expect(written.equals(payload)).toBe(true);
100
+ } finally {
101
+ await fs.unlink(out).catch(() => {});
102
+ }
103
+ });
104
+
105
+ it('rejects a payload larger than the 5MB cap', async () => {
106
+ const big = Buffer.alloc(MAX_BASE64_BYTES + 1, 0x41);
107
+ await expect(decodeBase64ToTempFile(big.toString('base64'), 'image/png')).rejects.toThrow(
108
+ /exceeds the 5MB/
109
+ );
110
+ });
111
+
112
+ it('rejects an unsupported mimeType', async () => {
113
+ const payload = Buffer.from('x');
114
+ await expect(
115
+ decodeBase64ToTempFile(payload.toString('base64'), 'application/zip')
116
+ ).rejects.toThrow(/Unsupported mimeType/);
117
+ });
118
+
119
+ it('rejects invalid base64 input', async () => {
120
+ await expect(decodeBase64ToTempFile('not base64 at all!!!', 'image/png')).rejects.toThrow(
121
+ /Invalid base64/
122
+ );
123
+ });
124
+
125
+ it('maps image/jpeg to .jpg extension', async () => {
126
+ const payload = Buffer.from([0xff, 0xd8, 0xff]);
127
+ const out = await decodeBase64ToTempFile(payload.toString('base64'), 'image/jpeg');
128
+ try {
129
+ expect(path.extname(out)).toBe('.jpg');
130
+ } finally {
131
+ await fs.unlink(out).catch(() => {});
132
+ }
133
+ });
134
+ });