@od-oneapp/storage 2026.1.1301
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.
- package/README.md +854 -0
- package/dist/client-next.d.mts +61 -0
- package/dist/client-next.d.mts.map +1 -0
- package/dist/client-next.mjs +111 -0
- package/dist/client-next.mjs.map +1 -0
- package/dist/client-utils-Dx6W25iz.d.mts +43 -0
- package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
- package/dist/client.d.mts +28 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +183 -0
- package/dist/client.mjs.map +1 -0
- package/dist/env-BVHLmQdh.mjs +128 -0
- package/dist/env-BVHLmQdh.mjs.map +1 -0
- package/dist/env.mjs +3 -0
- package/dist/health-check-D7LnnDec.mjs +746 -0
- package/dist/health-check-D7LnnDec.mjs.map +1 -0
- package/dist/health-check-im_huJ59.d.mts +116 -0
- package/dist/health-check-im_huJ59.d.mts.map +1 -0
- package/dist/index.d.mts +60 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/keys.d.mts +37 -0
- package/dist/keys.d.mts.map +1 -0
- package/dist/keys.mjs +253 -0
- package/dist/keys.mjs.map +1 -0
- package/dist/server-edge.d.mts +28 -0
- package/dist/server-edge.d.mts.map +1 -0
- package/dist/server-edge.mjs +88 -0
- package/dist/server-edge.mjs.map +1 -0
- package/dist/server-next.d.mts +183 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +1353 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +70 -0
- package/dist/server.d.mts.map +1 -0
- package/dist/server.mjs +384 -0
- package/dist/server.mjs.map +1 -0
- package/dist/types.d.mts +321 -0
- package/dist/types.d.mts.map +1 -0
- package/dist/types.mjs +3 -0
- package/dist/validation.d.mts +101 -0
- package/dist/validation.d.mts.map +1 -0
- package/dist/validation.mjs +590 -0
- package/dist/validation.mjs.map +1 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
- package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
- package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
- package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
- package/package.json +111 -0
- package/src/actions/blob-upload.ts +171 -0
- package/src/actions/index.ts +23 -0
- package/src/actions/mediaActions.ts +1071 -0
- package/src/actions/productMediaActions.ts +538 -0
- package/src/auth-helpers.ts +386 -0
- package/src/capabilities.ts +225 -0
- package/src/client-next.ts +184 -0
- package/src/client-utils.ts +292 -0
- package/src/client.ts +102 -0
- package/src/constants.ts +88 -0
- package/src/health-check.ts +81 -0
- package/src/multi-storage.ts +230 -0
- package/src/multipart.ts +497 -0
- package/src/retry-utils.test.ts +118 -0
- package/src/retry-utils.ts +59 -0
- package/src/server-edge.ts +129 -0
- package/src/server-next.ts +14 -0
- package/src/server.ts +666 -0
- package/src/validation.test.ts +312 -0
- package/src/validation.ts +827 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview validation.test.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ConfigError,
|
|
9
|
+
DownloadError,
|
|
10
|
+
NetworkError,
|
|
11
|
+
ProviderError,
|
|
12
|
+
StorageError,
|
|
13
|
+
StorageErrorCode,
|
|
14
|
+
UploadError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
createStorageError,
|
|
17
|
+
formatFileSize,
|
|
18
|
+
getErrorCode,
|
|
19
|
+
getQuotaInfo,
|
|
20
|
+
isQuotaExceeded,
|
|
21
|
+
isRetryableError,
|
|
22
|
+
parseFileSize,
|
|
23
|
+
validateFileExtension,
|
|
24
|
+
validateFileSize,
|
|
25
|
+
validateMimeType,
|
|
26
|
+
validateStorageKey,
|
|
27
|
+
validateUploadOptions,
|
|
28
|
+
validateUrlForSSRF,
|
|
29
|
+
} from './validation';
|
|
30
|
+
|
|
31
|
+
describe('validation utilities', () => {
|
|
32
|
+
it('validates file size boundaries', () => {
|
|
33
|
+
expect(validateFileSize(512, 1024)).toStrictEqual({ valid: true });
|
|
34
|
+
expect(validateFileSize(-1, 1024)).toStrictEqual({
|
|
35
|
+
valid: false,
|
|
36
|
+
error: 'File size cannot be negative',
|
|
37
|
+
});
|
|
38
|
+
expect(validateFileSize(2048, 1024)).toStrictEqual({
|
|
39
|
+
valid: false,
|
|
40
|
+
error: 'File size 2048 bytes exceeds maximum 1024 bytes',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('validates mime types with wildcards and dangerous types', () => {
|
|
45
|
+
expect(validateMimeType('image/png', ['image/png', 'image/jpeg']).valid).toBe(true);
|
|
46
|
+
expect(validateMimeType('image/webp', ['image/*']).valid).toBe(true);
|
|
47
|
+
expect(validateMimeType('text/plain', ['image/*'])).toStrictEqual({
|
|
48
|
+
valid: false,
|
|
49
|
+
error: "MIME type 'text/plain' is not allowed. Allowed types: image/*",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Required check
|
|
53
|
+
expect(validateMimeType('')).toStrictEqual({
|
|
54
|
+
valid: false,
|
|
55
|
+
error: 'MIME type is required',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Dangerous types
|
|
59
|
+
expect(validateMimeType('application/x-msdownload')).toStrictEqual({
|
|
60
|
+
valid: false,
|
|
61
|
+
error:
|
|
62
|
+
"Content type 'application/x-msdownload' is not allowed for security reasons (executable or script content)",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Empty allowed list (defaults to allowing safe types)
|
|
66
|
+
expect(validateMimeType('image/png', [])).toStrictEqual({ valid: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('validates file extensions against content type', () => {
|
|
70
|
+
expect(validateFileExtension('image.jpg', 'image/jpeg')).toStrictEqual({ valid: true });
|
|
71
|
+
expect(validateFileExtension('image.png', 'image/png')).toStrictEqual({ valid: true });
|
|
72
|
+
|
|
73
|
+
// Missing requirements
|
|
74
|
+
expect(validateFileExtension('', 'image/jpeg').valid).toBe(false);
|
|
75
|
+
expect(validateFileExtension('image.jpg', '').valid).toBe(false);
|
|
76
|
+
|
|
77
|
+
// Missing extension
|
|
78
|
+
expect(validateFileExtension('no-extension', 'image/jpeg')).toStrictEqual({
|
|
79
|
+
valid: false,
|
|
80
|
+
error: 'File must have an extension',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Mismatch
|
|
84
|
+
expect(validateFileExtension('virus.exe', 'image/jpeg')).toStrictEqual({
|
|
85
|
+
valid: false,
|
|
86
|
+
error:
|
|
87
|
+
"File extension '.exe' does not match content type 'image/jpeg'. Expected: .jpg, .jpeg",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Unknown MIME type (allowed)
|
|
91
|
+
expect(validateFileExtension('file.custom', 'application/x-custom')).toStrictEqual({
|
|
92
|
+
valid: true,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('validates storage keys with optional rules', () => {
|
|
97
|
+
// Valid key returns { valid: true, errors: [] }
|
|
98
|
+
const validResult = validateStorageKey('folder/file.txt');
|
|
99
|
+
expect(validResult.valid).toBe(true);
|
|
100
|
+
expect(validResult.errors).toHaveLength(0);
|
|
101
|
+
|
|
102
|
+
// Key exceeds max length
|
|
103
|
+
const tooLong = validateStorageKey('folder/file.txt', { maxLength: 5 });
|
|
104
|
+
expect(tooLong.valid).toBe(false);
|
|
105
|
+
expect(tooLong.errors).toContain('Key exceeds maximum length of 5 characters');
|
|
106
|
+
|
|
107
|
+
// Forbidden pattern match
|
|
108
|
+
const forbidden = validateStorageKey('secret/file.txt', {
|
|
109
|
+
forbiddenPatterns: [/secret/],
|
|
110
|
+
});
|
|
111
|
+
expect(forbidden.valid).toBe(false);
|
|
112
|
+
expect(forbidden.errors.some(e => e.includes('forbidden pattern'))).toBe(true);
|
|
113
|
+
|
|
114
|
+
// Empty key with allowEmpty: true
|
|
115
|
+
const emptyAllowed = validateStorageKey('', { allowEmpty: true });
|
|
116
|
+
expect(emptyAllowed.valid).toBe(true);
|
|
117
|
+
|
|
118
|
+
// Empty key with allowEmpty: false
|
|
119
|
+
const emptyNotAllowed = validateStorageKey('', { allowEmpty: false });
|
|
120
|
+
expect(emptyNotAllowed.valid).toBe(false);
|
|
121
|
+
expect(emptyNotAllowed.errors.some(e => e.includes('empty'))).toBe(true);
|
|
122
|
+
|
|
123
|
+
// Invalid characters
|
|
124
|
+
const badChars = validateStorageKey('bad|name');
|
|
125
|
+
expect(badChars.valid).toBe(false);
|
|
126
|
+
expect(badChars.errors.some(e => e.includes('invalid characters'))).toBe(true);
|
|
127
|
+
|
|
128
|
+
// Double slashes
|
|
129
|
+
const doubleSlash = validateStorageKey('double//slash');
|
|
130
|
+
expect(doubleSlash.valid).toBe(false);
|
|
131
|
+
expect(doubleSlash.errors.some(e => e.includes('double slashes'))).toBe(true);
|
|
132
|
+
|
|
133
|
+
// Relative path traversal
|
|
134
|
+
const traversal = validateStorageKey('../relative/path');
|
|
135
|
+
expect(traversal.valid).toBe(false);
|
|
136
|
+
expect(traversal.errors.some(e => e.includes('relative path'))).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('validates upload options for multiple rules', () => {
|
|
140
|
+
const result = validateUploadOptions(
|
|
141
|
+
{
|
|
142
|
+
fileSize: 2048,
|
|
143
|
+
contentType: 'image/png',
|
|
144
|
+
key: 'safe/path.png',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
maxFileSize: 1024,
|
|
148
|
+
allowedMimeTypes: ['image/*'],
|
|
149
|
+
maxKeyLength: 100,
|
|
150
|
+
forbiddenKeyPatterns: [/safe\/path/],
|
|
151
|
+
requireContentType: true,
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(result.valid).toBe(false);
|
|
156
|
+
expect(result.errors).toContain('File size 2048 bytes exceeds maximum 1024 bytes');
|
|
157
|
+
expect(result.errors).toContain('Key matches forbidden pattern: safe\\/path');
|
|
158
|
+
expect(result.errors.some(error => error.includes('MIME type'))).toBe(false);
|
|
159
|
+
|
|
160
|
+
const missingType = validateUploadOptions({ key: 'file.txt' }, { requireContentType: true });
|
|
161
|
+
expect(missingType).toStrictEqual({ valid: false, errors: ['Content type is required'] });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('detects retryable errors', () => {
|
|
165
|
+
const retryable = new NetworkError('temporary network issue');
|
|
166
|
+
const nonRetryable = new ConfigError('invalid configuration');
|
|
167
|
+
|
|
168
|
+
expect(isRetryableError(retryable)).toBe(true);
|
|
169
|
+
expect(isRetryableError(nonRetryable)).toBe(false);
|
|
170
|
+
expect(isRetryableError(new Error('permanent failure'))).toBe(false);
|
|
171
|
+
expect(isRetryableError(new Error('connection timeout'))).toBe(true);
|
|
172
|
+
expect(isRetryableError(new Error('rate limit reached'))).toBe(true);
|
|
173
|
+
expect(isRetryableError('not an error object')).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('extracts error codes from various error shapes', () => {
|
|
177
|
+
const custom = new ProviderError('provider down', 'vercel', undefined, false);
|
|
178
|
+
const timeout = new Error('Request timeout occurred');
|
|
179
|
+
const notFound = new Error('resource not found on server');
|
|
180
|
+
const accessDenied = new Error('access denied to resource');
|
|
181
|
+
const network = new Error('network failure');
|
|
182
|
+
|
|
183
|
+
expect(getErrorCode(custom)).toBe(StorageErrorCode.PROVIDER_ERROR);
|
|
184
|
+
expect(getErrorCode(timeout)).toBe(StorageErrorCode.TIMEOUT);
|
|
185
|
+
expect(getErrorCode(notFound)).toBe(StorageErrorCode.FILE_NOT_FOUND);
|
|
186
|
+
expect(getErrorCode(accessDenied)).toBe(StorageErrorCode.ACCESS_DENIED);
|
|
187
|
+
expect(getErrorCode(network)).toBe(StorageErrorCode.NETWORK_ERROR);
|
|
188
|
+
expect(getErrorCode({})).toBe(StorageErrorCode.PROVIDER_ERROR);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('creates consistent storage errors', () => {
|
|
192
|
+
const underlying = new UploadError('upload failed', { key: 'file.txt' }, true);
|
|
193
|
+
const wrapped = createStorageError(underlying, { operation: 'upload', provider: 'mock' });
|
|
194
|
+
expect(wrapped).toBe(underlying);
|
|
195
|
+
|
|
196
|
+
const genericError = new Error('Something went wrong');
|
|
197
|
+
const standardized = createStorageError(genericError, { key: 'file.txt' });
|
|
198
|
+
|
|
199
|
+
expect(standardized).toBeInstanceOf(StorageError);
|
|
200
|
+
expect(standardized.code).toBe(StorageErrorCode.PROVIDER_ERROR);
|
|
201
|
+
expect(standardized.details).toMatchObject({ key: 'file.txt', originalError: genericError });
|
|
202
|
+
expect(standardized.retryable).toBe(false);
|
|
203
|
+
|
|
204
|
+
const stringError = createStorageError('error string');
|
|
205
|
+
expect(stringError.message).toBe('error string');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('provides quota helper utilities', () => {
|
|
209
|
+
expect(isQuotaExceeded(100, 100)).toBe(true);
|
|
210
|
+
expect(isQuotaExceeded(101, 100)).toBe(true);
|
|
211
|
+
expect(isQuotaExceeded(50, 100)).toBe(false);
|
|
212
|
+
|
|
213
|
+
const quota = getQuotaInfo(25, 100, 'mock');
|
|
214
|
+
expect(quota).toStrictEqual({
|
|
215
|
+
used: 25,
|
|
216
|
+
limit: 100,
|
|
217
|
+
remaining: 75,
|
|
218
|
+
provider: 'mock',
|
|
219
|
+
resetAt: undefined,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('formats and parses file sizes', () => {
|
|
224
|
+
expect(formatFileSize(0)).toBe('0 B');
|
|
225
|
+
expect(formatFileSize(1536)).toBe('1.5 KB');
|
|
226
|
+
expect(formatFileSize(5 * 1024 * 1024)).toBe('5 MB');
|
|
227
|
+
|
|
228
|
+
expect(parseFileSize('10 MB')).toBe(10 * 1024 * 1024);
|
|
229
|
+
expect(parseFileSize('1.5GB')).toBeCloseTo(1.5 * 1024 * 1024 * 1024);
|
|
230
|
+
expect(() => parseFileSize('unknown')).toThrowError(ValidationError);
|
|
231
|
+
expect(() => parseFileSize('100')).toThrowError(ValidationError);
|
|
232
|
+
expect(() => parseFileSize('10 ZZ')).toThrowError(ValidationError);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('validates URLs for SSRF protection', () => {
|
|
236
|
+
// Valid
|
|
237
|
+
expect(validateUrlForSSRF('https://example.com/image.jpg')).toStrictEqual({ valid: true });
|
|
238
|
+
|
|
239
|
+
// Non-HTTPS
|
|
240
|
+
expect(validateUrlForSSRF('http://example.com')).toStrictEqual({
|
|
241
|
+
valid: false,
|
|
242
|
+
error: 'Only HTTPS URLs are allowed',
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Localhost / Loopback
|
|
246
|
+
expect(validateUrlForSSRF('https://localhost/admin')).toStrictEqual({
|
|
247
|
+
valid: false,
|
|
248
|
+
error:
|
|
249
|
+
'URL hostname "localhost" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
250
|
+
});
|
|
251
|
+
expect(validateUrlForSSRF('https://127.0.0.1')).toStrictEqual({
|
|
252
|
+
valid: false,
|
|
253
|
+
error:
|
|
254
|
+
'URL hostname "127.0.0.1" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Private Ranges
|
|
258
|
+
expect(validateUrlForSSRF('https://10.0.0.1')).toStrictEqual({
|
|
259
|
+
valid: false,
|
|
260
|
+
error:
|
|
261
|
+
'URL hostname "10.0.0.1" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
262
|
+
});
|
|
263
|
+
expect(validateUrlForSSRF('https://192.168.1.1')).toStrictEqual({
|
|
264
|
+
valid: false,
|
|
265
|
+
error:
|
|
266
|
+
'URL hostname "192.168.1.1" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Cloud Metadata
|
|
270
|
+
expect(validateUrlForSSRF('https://169.254.169.254')).toStrictEqual({
|
|
271
|
+
valid: false,
|
|
272
|
+
error:
|
|
273
|
+
'URL hostname "169.254.169.254" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// IPv6 Loopback
|
|
277
|
+
expect(validateUrlForSSRF('https://[::1]')).toStrictEqual({
|
|
278
|
+
valid: false,
|
|
279
|
+
error:
|
|
280
|
+
'URL hostname "[::1]" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Local domains
|
|
284
|
+
expect(validateUrlForSSRF('https://internal.local')).toStrictEqual({
|
|
285
|
+
valid: false,
|
|
286
|
+
error:
|
|
287
|
+
'URL hostname "internal.local" is blocked for security reasons (private IP, localhost, or reserved address)',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Invalid format
|
|
291
|
+
expect(validateUrlForSSRF('not-a-url')).toStrictEqual({
|
|
292
|
+
valid: false,
|
|
293
|
+
error: 'Invalid URL format',
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('exposes concrete error subclasses for completeness', () => {
|
|
298
|
+
const storageError = new StorageError('base', StorageErrorCode.CONFIG_ERROR);
|
|
299
|
+
const providerError = new ProviderError('provider', 'mock');
|
|
300
|
+
const networkError = new NetworkError('network');
|
|
301
|
+
const uploadError = new UploadError('upload');
|
|
302
|
+
const downloadError = new DownloadError('download');
|
|
303
|
+
const configError = new ConfigError('config');
|
|
304
|
+
|
|
305
|
+
expect(storageError).toBeInstanceOf(StorageError);
|
|
306
|
+
expect(providerError.details?.provider).toBe('mock');
|
|
307
|
+
expect(networkError.retryable).toBe(true);
|
|
308
|
+
expect(uploadError.code).toBe(StorageErrorCode.UPLOAD_FAILED);
|
|
309
|
+
expect(downloadError.code).toBe(StorageErrorCode.DOWNLOAD_FAILED);
|
|
310
|
+
expect(configError.retryable).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
});
|