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