@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.
Files changed (69) hide show
  1. package/README.md +854 -0
  2. package/dist/client-next.d.mts +61 -0
  3. package/dist/client-next.d.mts.map +1 -0
  4. package/dist/client-next.mjs +111 -0
  5. package/dist/client-next.mjs.map +1 -0
  6. package/dist/client-utils-Dx6W25iz.d.mts +43 -0
  7. package/dist/client-utils-Dx6W25iz.d.mts.map +1 -0
  8. package/dist/client.d.mts +28 -0
  9. package/dist/client.d.mts.map +1 -0
  10. package/dist/client.mjs +183 -0
  11. package/dist/client.mjs.map +1 -0
  12. package/dist/env-BVHLmQdh.mjs +128 -0
  13. package/dist/env-BVHLmQdh.mjs.map +1 -0
  14. package/dist/env.mjs +3 -0
  15. package/dist/health-check-D7LnnDec.mjs +746 -0
  16. package/dist/health-check-D7LnnDec.mjs.map +1 -0
  17. package/dist/health-check-im_huJ59.d.mts +116 -0
  18. package/dist/health-check-im_huJ59.d.mts.map +1 -0
  19. package/dist/index.d.mts +60 -0
  20. package/dist/index.d.mts.map +1 -0
  21. package/dist/index.mjs +3 -0
  22. package/dist/keys.d.mts +37 -0
  23. package/dist/keys.d.mts.map +1 -0
  24. package/dist/keys.mjs +253 -0
  25. package/dist/keys.mjs.map +1 -0
  26. package/dist/server-edge.d.mts +28 -0
  27. package/dist/server-edge.d.mts.map +1 -0
  28. package/dist/server-edge.mjs +88 -0
  29. package/dist/server-edge.mjs.map +1 -0
  30. package/dist/server-next.d.mts +183 -0
  31. package/dist/server-next.d.mts.map +1 -0
  32. package/dist/server-next.mjs +1353 -0
  33. package/dist/server-next.mjs.map +1 -0
  34. package/dist/server.d.mts +70 -0
  35. package/dist/server.d.mts.map +1 -0
  36. package/dist/server.mjs +384 -0
  37. package/dist/server.mjs.map +1 -0
  38. package/dist/types.d.mts +321 -0
  39. package/dist/types.d.mts.map +1 -0
  40. package/dist/types.mjs +3 -0
  41. package/dist/validation.d.mts +101 -0
  42. package/dist/validation.d.mts.map +1 -0
  43. package/dist/validation.mjs +590 -0
  44. package/dist/validation.mjs.map +1 -0
  45. package/dist/vercel-blob-07Sx0Akn.d.mts +31 -0
  46. package/dist/vercel-blob-07Sx0Akn.d.mts.map +1 -0
  47. package/dist/vercel-blob-DA8HaYuw.mjs +158 -0
  48. package/dist/vercel-blob-DA8HaYuw.mjs.map +1 -0
  49. package/package.json +111 -0
  50. package/src/actions/blob-upload.ts +171 -0
  51. package/src/actions/index.ts +23 -0
  52. package/src/actions/mediaActions.ts +1071 -0
  53. package/src/actions/productMediaActions.ts +538 -0
  54. package/src/auth-helpers.ts +386 -0
  55. package/src/capabilities.ts +225 -0
  56. package/src/client-next.ts +184 -0
  57. package/src/client-utils.ts +292 -0
  58. package/src/client.ts +102 -0
  59. package/src/constants.ts +88 -0
  60. package/src/health-check.ts +81 -0
  61. package/src/multi-storage.ts +230 -0
  62. package/src/multipart.ts +497 -0
  63. package/src/retry-utils.test.ts +118 -0
  64. package/src/retry-utils.ts +59 -0
  65. package/src/server-edge.ts +129 -0
  66. package/src/server-next.ts +14 -0
  67. package/src/server.ts +666 -0
  68. package/src/validation.test.ts +312 -0
  69. 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
+ });