chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

187 lines
6.5 KiB
TypeScript

/** @jest-environment node */
import sharp from 'sharp'
import { getServerSession } from 'next-auth'
import { GET } from '@/app/api/taobao-detail-plus/image-proxy/route'
import { assertProcessableMetadata } from '@/lib/image-processor'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/image-processing-limiter', () => ({
withImageProcessingSlot: jest.fn((task: () => Promise<unknown>) => task()),
}))
jest.mock('@/lib/image-processor', () => ({
assertProcessableMetadata: jest.fn(),
}))
jest.mock('sharp', () => jest.fn())
describe('taobao detail image proxy', () => {
const originalFetch = global.fetch
const pngBuffer = Buffer.from('safe-png')
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
const pipeline = {
metadata: jest.fn().mockResolvedValue({ width: 800, height: 1200, pages: 1 }),
flatten: jest.fn(),
png: jest.fn(),
toBuffer: jest.fn().mockResolvedValue(pngBuffer),
}
pipeline.flatten.mockReturnValue(pipeline)
pipeline.png.mockReturnValue(pipeline)
;(sharp as unknown as jest.Mock).mockReturnValue(pipeline)
})
afterEach(() => {
global.fetch = originalFetch
jest.useRealTimers()
})
function request(rawUrl: string) {
return {
nextUrl: new URL(`https://materials.example/api/proxy?url=${encodeURIComponent(rawUrl)}`),
} as any
}
function responseWithStream(
chunks: Uint8Array[],
options: { status?: number; headers?: Record<string, string>; cancel?: jest.Mock } = {},
) {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
chunks.forEach((chunk) => controller.enqueue(chunk))
controller.close()
},
cancel: options.cancel,
})
return new Response(stream, {
status: options.status ?? 200,
headers: options.headers,
})
}
test('只接受白名单域名的 HTTPS 默认端口', async () => {
global.fetch = jest.fn() as any
const httpResponse = await GET(request('http://img.alicdn.com/a.jpg'))
const portResponse = await GET(request('https://img.alicdn.com:8443/a.jpg'))
const suffixTrickResponse = await GET(request('https://alicdn.com.attacker.example/a.jpg'))
expect(httpResponse.status).toBe(400)
expect(portResponse.status).toBe(400)
expect(suffixTrickResponse.status).toBe(400)
expect(global.fetch).not.toHaveBeenCalled()
})
test('fetch 必须关闭自动重定向,并拒绝上游重定向且取消响应体', async () => {
const cancel = jest.fn().mockResolvedValue(undefined)
global.fetch = jest.fn().mockResolvedValue(responseWithStream(
[new Uint8Array([1])],
{ status: 302, headers: { location: 'https://attacker.example/a.jpg' }, cancel },
)) as any
const response = await GET(request('https://img.alicdn.com/a.jpg'))
const body = await response.json()
expect(response.status).toBe(502)
expect(body.error.code).toBe('UPSTREAM_REDIRECT_REJECTED')
expect(global.fetch).toHaveBeenCalledWith(
'https://img.alicdn.com/a.jpg',
expect.objectContaining({ redirect: 'manual', signal: expect.any(AbortSignal) }),
)
expect(cancel).toHaveBeenCalled()
})
test('Content-Length 超过 20MB 时在读取前拒绝并取消响应体', async () => {
const cancel = jest.fn()
global.fetch = jest.fn().mockResolvedValue(responseWithStream(
[new Uint8Array([1])],
{
headers: {
'content-type': 'image/jpeg',
'content-length': String(20 * 1024 * 1024 + 1),
},
cancel,
},
)) as any
const response = await GET(request('https://img.alicdn.com/a.jpg'))
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.code).toBe('UPSTREAM_IMAGE_TOO_LARGE')
expect(cancel).toHaveBeenCalled()
expect(sharp).not.toHaveBeenCalled()
})
test('缺少 Content-Length 仍以实际流量硬上限终止读取并 cancel', async () => {
const cancel = jest.fn().mockResolvedValue(undefined)
const releaseLock = jest.fn()
const read = jest.fn()
.mockResolvedValueOnce({ done: false, value: new Uint8Array(12 * 1024 * 1024) })
.mockResolvedValueOnce({ done: false, value: new Uint8Array(9 * 1024 * 1024) })
global.fetch = jest.fn().mockResolvedValue({
status: 200,
ok: true,
headers: new Headers({ 'content-type': 'image/jpeg' }),
body: {
locked: false,
getReader: () => ({ read, cancel, releaseLock }),
},
}) as any
const response = await GET(request('https://img.alicdn.com/a.jpg'))
const body = await response.json()
expect(response.status).toBe(413)
expect(body.error.code).toBe('UPSTREAM_IMAGE_TOO_LARGE')
expect(cancel).toHaveBeenCalled()
expect(sharp).not.toHaveBeenCalled()
})
test('成功响应先按声明长度完整读取,再在全局图片锁内校验像素并转换', async () => {
const source = new Uint8Array([1, 2, 3])
global.fetch = jest.fn().mockResolvedValue(responseWithStream([source], {
headers: {
'content-type': 'image/jpeg; charset=binary',
'content-length': String(source.byteLength),
},
})) as any
const response = await GET(request('https://img.alicdn.com/a.jpg'))
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(Buffer.from(await response.arrayBuffer())).toEqual(pngBuffer)
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
expect(assertProcessableMetadata).toHaveBeenCalledWith(
{ width: 800, height: 1200, pages: 1 },
{
maxInputWidth: 12_000,
maxInputHeight: 20_000,
maxInputPixels: 24_000_000,
},
)
})
test('上游超过十秒未响应时中止请求并返回 504', async () => {
jest.useFakeTimers()
global.fetch = jest.fn((_url, init: RequestInit) => new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }))
})
})) as any
const responsePromise = GET(request('https://img.alicdn.com/a.jpg'))
await jest.advanceTimersByTimeAsync(10_001)
const response = await responsePromise
const body = await response.json()
expect(response.status).toBe(504)
expect(body.error.code).toBe('UPSTREAM_IMAGE_TIMEOUT')
})
})