admin/lib/fileValidation.test.ts
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

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()
})
})