Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
139 lines
4.8 KiB
TypeScript
139 lines
4.8 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
|
|
import { detectImageMime, prepareImageForUpload, validateImageFile } from '@/lib/fileValidation'
|
|
|
|
const PNG_HEADER = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
const JPEG_HEADER = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46])
|
|
|
|
function blobPart(bytes: Uint8Array): ArrayBuffer {
|
|
const buffer = new ArrayBuffer(bytes.byteLength)
|
|
|
|
new Uint8Array(buffer).set(bytes)
|
|
|
|
return buffer
|
|
}
|
|
|
|
/** Minimal HEIC-like ftyp box: size(4) + 'ftyp' + 'heic' */
|
|
function heicHeader(): Uint8Array {
|
|
const bytes = new Uint8Array(16)
|
|
|
|
bytes.set([0x00, 0x00, 0x00, 0x18], 0)
|
|
bytes.set(new TextEncoder().encode('ftypheic'), 4)
|
|
|
|
return bytes
|
|
}
|
|
|
|
describe('detectImageMime', () => {
|
|
it('detects JPEG/PNG from magic bytes even when declared type is empty', () => {
|
|
expect(detectImageMime(JPEG_HEADER, '')).toBe('image/jpeg')
|
|
expect(detectImageMime(PNG_HEADER, '')).toBe('image/png')
|
|
})
|
|
|
|
it('detects HEIC from ftyp brand or declared mime', () => {
|
|
expect(detectImageMime(heicHeader(), '')).toBe('image/heic')
|
|
expect(detectImageMime(new Uint8Array(16), 'image/heic')).toBe('image/heic')
|
|
})
|
|
})
|
|
|
|
describe('image upload validation', () => {
|
|
it('accepts a PNG with a valid binary signature', async () => {
|
|
const file = new File([blobPart(PNG_HEADER)], 'safe.png', { type: 'image/png' })
|
|
|
|
await expect(validateImageFile(file)).resolves.toBeNull()
|
|
})
|
|
|
|
it('accepts JPEG when file.type is empty (common on iOS Safari)', async () => {
|
|
const file = new File([blobPart(JPEG_HEADER)], 'photo.jpg', { type: '' })
|
|
const prepared = await prepareImageForUpload(file)
|
|
|
|
expect(typeof prepared === 'string' ? prepared : prepared.type).toBe('image/jpeg')
|
|
await expect(validateImageFile(file)).resolves.toBeNull()
|
|
})
|
|
|
|
it('rejects spoofed MIME, SVG and oversized images', async () => {
|
|
const spoofed = new File(['<script>alert(1)</script>'], 'attack.png', { type: 'image/png' })
|
|
const svg = new File(['<svg onload=alert(1)>'], 'attack.svg', { type: 'image/svg+xml' })
|
|
const oversized = new File([new Uint8Array(17)], 'large.jpg', { type: 'image/jpeg' })
|
|
|
|
await expect(validateImageFile(spoofed)).resolves.toMatch(/مجاز هستند/)
|
|
await expect(validateImageFile(svg)).resolves.toMatch(/مجاز هستند/)
|
|
await expect(validateImageFile(oversized, 0.000001)).resolves.toMatch(/حجم تصویر/)
|
|
})
|
|
|
|
it('rejects GIF, since the backend only accepts JPEG/PNG/WebP (and HEIC, which it converts server-side)', async () => {
|
|
const gif = new File([new TextEncoder().encode('GIF89a')], 'attack.gif', { type: 'image/gif' })
|
|
|
|
await expect(validateImageFile(gif)).resolves.toMatch(/مجاز هستند/)
|
|
})
|
|
|
|
it('converts HEIC to JPEG when createImageBitmap succeeds', async () => {
|
|
const close = vi.fn()
|
|
const drawImage = vi.fn()
|
|
|
|
vi.stubGlobal(
|
|
'createImageBitmap',
|
|
vi.fn(async () => ({
|
|
width: 2,
|
|
height: 2,
|
|
close,
|
|
}))
|
|
)
|
|
|
|
const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
|
drawImage,
|
|
} as unknown as CanvasRenderingContext2D)
|
|
|
|
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(function toBlob(callback) {
|
|
callback?.(new Blob([JPEG_HEADER], { type: 'image/jpeg' }))
|
|
})
|
|
|
|
const file = new File([blobPart(heicHeader())], 'id.heic', { type: 'image/heic' })
|
|
const prepared = await prepareImageForUpload(file)
|
|
|
|
expect(prepared).toBeInstanceOf(File)
|
|
expect((prepared as File).type).toBe('image/jpeg')
|
|
expect((prepared as File).name).toBe('id.jpg')
|
|
expect(close).toHaveBeenCalled()
|
|
|
|
getContext.mockRestore()
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('falls back to the original HEIC file when the browser cannot decode it (e.g. Chrome/Edge on Windows), letting the backend convert it', async () => {
|
|
vi.stubGlobal(
|
|
'createImageBitmap',
|
|
vi.fn(async () => {
|
|
throw new Error('unsupported')
|
|
})
|
|
)
|
|
|
|
const file = new File([blobPart(heicHeader())], 'id.heic', { type: 'image/heic' })
|
|
const prepared = await prepareImageForUpload(file)
|
|
|
|
expect(prepared).toBeInstanceOf(File)
|
|
expect((prepared as File).type).toBe('image/heic')
|
|
expect((prepared as File).name).toBe('id.heic')
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('relabels a HEIC file with an empty declared type without corrupting its extension', async () => {
|
|
vi.stubGlobal(
|
|
'createImageBitmap',
|
|
vi.fn(async () => {
|
|
throw new Error('unsupported')
|
|
})
|
|
)
|
|
|
|
const file = new File([blobPart(heicHeader())], 'id.heic', { type: '' })
|
|
const prepared = await prepareImageForUpload(file)
|
|
|
|
expect(prepared).toBeInstanceOf(File)
|
|
expect((prepared as File).type).toBe('image/heic')
|
|
expect((prepared as File).name).toBe('id.heic')
|
|
|
|
vi.unstubAllGlobals()
|
|
})
|
|
})
|