Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
187 lines
6.5 KiB
TypeScript
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')
|
|
})
|
|
})
|