gongxiangp2/app/api/favicon/route.test.ts
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

239 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jest-environment node */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { NextRequest } from 'next/server'
import { GET } from '@/app/api/favicon/route'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/url-rewrite', () => ({
rewriteStorageUrlForClient: jest.fn((url: string) => url),
}))
const request = new NextRequest('https://sc.jiansun.vip/api/favicon')
let remoteLogoSequence = 0
function mockFetchResponse({
ok,
contentType,
body = Buffer.alloc(0),
chunks = [body],
contentLength = String(body.length),
cancel = jest.fn(),
}: {
ok: boolean
contentType: string
body?: Buffer
chunks?: Buffer[]
contentLength?: string | null
cancel?: jest.Mock
}) {
let index = 0
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (index >= chunks.length) {
controller.close()
return
}
controller.enqueue(chunks[index])
index += 1
},
cancel,
})
const arrayBuffer = jest.fn().mockRejectedValue(
new Error('favicon route must not buffer the unbounded response'),
)
;(global.fetch as jest.Mock).mockResolvedValue({
ok,
headers: {
get: jest.fn((name: string) => {
if (name.toLowerCase() === 'content-type') return contentType
if (name.toLowerCase() === 'content-length') return contentLength
return null
}),
},
body: stream,
arrayBuffer,
})
return { arrayBuffer, cancel }
}
describe('/api/favicon', () => {
let localFavicon: Buffer
beforeAll(async () => {
localFavicon = await readFile(join(process.cwd(), 'public', 'brand', 'favicon.png'))
})
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn()
remoteLogoSequence += 1
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({
headerLogoUrl: `https://s3.jiansun.vip/gongxiangpan/brand/header-${remoteLogoSequence}.png`,
})
})
test.each([
{ name: '404', ok: false, contentType: 'text/html' },
{ name: '返回 HTML', ok: true, contentType: 'text/html; charset=utf-8' },
])('上游 $name 时返回真实本地图标', async ({ ok, contentType }) => {
const upstream = mockFetchResponse({
ok,
contentType,
body: Buffer.from('<html>bad gateway</html>'),
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(upstream.cancel).toHaveBeenCalledTimes(1)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(bytes.equals(localFavicon)).toBe(true)
})
test('Content-Length 声明超过 favicon 的 1MB 上限时不读取正文并立即取消', async () => {
const upstream = mockFetchResponse({
ok: true,
contentType: 'image/png',
body: localFavicon,
contentLength: String(1024 * 1024 + 1),
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(upstream.arrayBuffer).not.toHaveBeenCalled()
expect(upstream.cancel).toHaveBeenCalledTimes(1)
expect(bytes.equals(localFavicon)).toBe(true)
})
test('无 Content-Length 的流超过 favicon 的 1MB 上限时停止读取、取消上游并回退', async () => {
const upstream = mockFetchResponse({
ok: true,
contentType: 'image/png',
// 第三个分片让流保持打开,可确认超限时确实主动 cancel,而不是自然结束。
chunks: [
Buffer.alloc(768 * 1024),
Buffer.alloc(512 * 1024),
Buffer.alloc(1),
],
contentLength: null,
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(upstream.arrayBuffer).not.toHaveBeenCalled()
expect(upstream.cancel).toHaveBeenCalledTimes(1)
expect(bytes.equals(localFavicon)).toBe(true)
})
test('Content-Length 撒谎时仍由流式硬上限拦截', async () => {
const upstream = mockFetchResponse({
ok: true,
contentType: 'image/png',
chunks: [
Buffer.alloc(768 * 1024),
Buffer.alloc(512 * 1024),
Buffer.alloc(1),
],
contentLength: '1024',
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(upstream.cancel).toHaveBeenCalledTimes(1)
expect(bytes.equals(localFavicon)).toBe(true)
})
test('无 Content-Length 的正常分片图片可以完成解码并返回', async () => {
const splitAt = Math.floor(localFavicon.length / 2)
const upstream = mockFetchResponse({
ok: true,
contentType: 'image/png',
chunks: [localFavicon.subarray(0, splitAt), localFavicon.subarray(splitAt)],
contentLength: null,
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(upstream.arrayBuffer).not.toHaveBeenCalled()
expect(upstream.cancel).not.toHaveBeenCalled()
expect(response.headers.get('content-type')).toBe('image/png')
expect(bytes.equals(localFavicon)).toBe(true)
})
test('上游请求超时或网络失败时返回真实本地图标', async () => {
;(global.fetch as jest.Mock).mockRejectedValue(new Error('request timeout'))
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(response.status).toBe(200)
expect(bytes.equals(localFavicon)).toBe(true)
})
test('数据库是本地品牌地址时不发起网络请求', async () => {
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({
headerLogoUrl: '/brand/header-logo.png',
})
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(global.fetch).not.toHaveBeenCalled()
expect(bytes.equals(localFavicon)).toBe(true)
})
test('只透传真正能够完整解码的受控上游图片', async () => {
mockFetchResponse({ ok: true, contentType: 'image/png', body: localFavicon })
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(response.headers.get('content-type')).toBe('image/png')
expect(bytes.equals(localFavicon)).toBe(true)
})
test('同一 Logo 的并发和后续请求只下载并解码一次', async () => {
mockFetchResponse({ ok: true, contentType: 'image/png', body: localFavicon })
const [first, second] = await Promise.all([GET(request), GET(request)])
const third = await GET(request)
const bodies = await Promise.all(
[first, second, third].map(async response => Buffer.from(await response.arrayBuffer())),
)
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(bodies.every(bytes => bytes.equals(localFavicon))).toBe(true)
})
test('只有图片文件头的截断响应会回退本地图标', async () => {
const truncatedPng = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3,
])
mockFetchResponse({ ok: true, contentType: 'image/png', body: truncatedPng })
const response = await GET(request)
const bytes = Buffer.from(await response.arrayBuffer())
expect(response.headers.get('content-type')).toBe('image/png')
expect(bytes.equals(localFavicon)).toBe(true)
expect(bytes.equals(truncatedPng)).toBe(false)
})
})