/** @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({ 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('bad gateway'), }) 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) }) })