diff --git a/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts b/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts new file mode 100644 index 0000000..fe88b06 --- /dev/null +++ b/frontend/src/utils/http/resolveAxiosErrorMessage.spec.ts @@ -0,0 +1,82 @@ +import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; +import { describe, expect, it } from 'vitest'; +import { resolveAxiosErrorMessage } from '@/utils/http/resolveAxiosErrorMessage'; + +const fallback = 'Upload failed'; + +const axiosErrorWithResponse = (data: unknown, message = 'Request failed'): AxiosError => { + return new AxiosError( + message, + AxiosError.ERR_BAD_REQUEST, + {} as InternalAxiosRequestConfig, + {}, + { + status: 400, + statusText: 'Bad Request', + headers: {}, + config: {} as InternalAxiosRequestConfig, + data + } + ); +}; + +describe('resolveAxiosErrorMessage', () => { + it('falls back for non-Axios errors', () => { + expect(resolveAxiosErrorMessage(new Error('network'), fallback)).toBe('network'); + expect(resolveAxiosErrorMessage('oops', fallback)).toBe(fallback); + }); + + it('falls back when Axios error has no response', () => { + const error = new AxiosError('timeout', AxiosError.ETIMEDOUT); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('timeout'); + }); + + it('uses string response data', () => { + const error = axiosErrorWithResponse('Rate limit exceeded'); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('Rate limit exceeded'); + }); + + it('uses object message string (Nest-style)', () => { + const error = axiosErrorWithResponse({ message: 'Invalid ciphertext' }); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('Invalid ciphertext'); + }); + + it('uses a single string from message array', () => { + const error = axiosErrorWithResponse({ message: ['iv must be valid base64'] }); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('iv must be valid base64'); + }); + + it('joins multiple strings in message array', () => { + const error = axiosErrorWithResponse({ + message: ['expiresAt must be a date', 'maxViews must be a number'] + }); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe( + 'expiresAt must be a date, maxViews must be a number' + ); + }); + + it('ignores non-string entries in message array', () => { + const error = axiosErrorWithResponse({ + message: ['valid', 42, null, 'also valid'] + }); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('valid, also valid'); + }); + + it('falls back when response data has no usable message', () => { + const error = axiosErrorWithResponse({ message: [] }, 'HTTP 400'); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400'); + }); + + it('falls back when response data is an object without message', () => { + const error = axiosErrorWithResponse({ statusCode: 400 }, 'HTTP 400'); + + expect(resolveAxiosErrorMessage(error, fallback)).toBe('HTTP 400'); + }); +}); diff --git a/frontend/src/utils/resolveErrorMessage.spec.ts b/frontend/src/utils/resolveErrorMessage.spec.ts new file mode 100644 index 0000000..00b0ead --- /dev/null +++ b/frontend/src/utils/resolveErrorMessage.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { resolveErrorMessage } from '@/utils/resolveErrorMessage'; + +describe('resolveErrorMessage', () => { + it('returns Error.message when non-empty', () => { + expect(resolveErrorMessage(new Error('upload failed'))).toBe('upload failed'); + }); + + it('uses fallback when Error.message is empty', () => { + expect(resolveErrorMessage(new Error(''), 'custom fallback')).toBe('custom fallback'); + }); + + it('uses default fallback for non-Error values', () => { + expect(resolveErrorMessage(null)).toBe('Something went wrong'); + expect(resolveErrorMessage('plain string')).toBe('Something went wrong'); + expect(resolveErrorMessage({ message: 'not an Error' })).toBe('Something went wrong'); + }); + + it('uses custom fallback for non-Error values', () => { + expect(resolveErrorMessage(undefined, 'worker failed')).toBe('worker failed'); + }); +}); diff --git a/frontend/src/utils/share/buildHotlinkImagePath.spec.ts b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts new file mode 100644 index 0000000..3f1eda5 --- /dev/null +++ b/frontend/src/utils/share/buildHotlinkImagePath.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { buildHotlinkImagePath } from '@/utils/share/buildHotlinkImagePath'; + +describe('buildHotlinkImagePath', () => { + it('places upload id and secret in the path segments', () => { + const uploadId = 'upload-uuid'; + const secret = 'url-safe-secret'; + + expect(buildHotlinkImagePath(uploadId, secret)).toBe(`/i/${uploadId}/${secret}`); + }); +}); diff --git a/frontend/src/utils/share/buildZkViewerPath.spec.ts b/frontend/src/utils/share/buildZkViewerPath.spec.ts new file mode 100644 index 0000000..1450827 --- /dev/null +++ b/frontend/src/utils/share/buildZkViewerPath.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; +import { buildZkViewerPath } from '@/utils/share/buildZkViewerPath'; + +describe('buildZkViewerPath', () => { + it('places upload id in the path and fragment key in the hash', () => { + const uploadId = 'abc-123'; + const fragmentKey = 'dGVzdC1rZXk'; + + expect(buildZkViewerPath(uploadId, fragmentKey)).toBe(`/v/${uploadId}#${fragmentKey}`); + }); +}); diff --git a/frontend/src/utils/toArrayBuffer.spec.ts b/frontend/src/utils/toArrayBuffer.spec.ts new file mode 100644 index 0000000..e56831b --- /dev/null +++ b/frontend/src/utils/toArrayBuffer.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { AES_GCM_KEY_BYTE_LENGTH } from '@/consts/crypto/aesGcmKeyByteLength'; +import { decryptAesGcm } from '@/utils/crypto/decryptAesGcm'; +import { encryptAesGcm } from '@/utils/crypto/encryptAesGcm'; +import { generateDek } from '@/utils/crypto/generateDek'; +import { generateIv } from '@/utils/crypto/generateIv'; +import { importAesGcmKey } from '@/utils/crypto/importAesGcmKey'; +import { toArrayBuffer } from '@/utils/toArrayBuffer'; + +describe('toArrayBuffer', () => { + it('returns the same ArrayBuffer instance', () => { + const buffer = new ArrayBuffer(8); + + expect(toArrayBuffer(buffer)).toBe(buffer); + }); + + it('copies a Uint8Array view into a standalone ArrayBuffer', () => { + const pool = new Uint8Array([0, 0, 1, 2, 3, 0]); + const view = pool.subarray(2, 5); + + const copied = toArrayBuffer(view); + + expect(copied).not.toBe(pool.buffer); + expect(new Uint8Array(copied!)).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('returns null for unsupported values', () => { + expect(toArrayBuffer(null)).toBeNull(); + expect(toArrayBuffer('bytes')).toBeNull(); + }); + + it('supports Web Crypto when DEK is a subarray of a larger buffer', async () => { + const dek = generateDek(); + const pool = new Uint8Array(dek.byteLength + 4); + pool.set([9, 8, 7, 6], 0); + pool.set(dek, 4); + const dekView = pool.subarray(4, 4 + AES_GCM_KEY_BYTE_LENGTH); + + const plaintext = new TextEncoder().encode('subarray-dek'); + const iv = generateIv(); + const key = await importAesGcmKey(dekView); + const ciphertext = await encryptAesGcm(plaintext, key, iv); + const recovered = await decryptAesGcm(ciphertext, key, iv); + + expect(recovered).toEqual(plaintext); + }); +});