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

188 lines
6.1 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 { POST } from '@/app/api/system-settings/upload-logo/route'
import { getServerSession } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { uploadToS3, verifyUploadedS3Object } from '@/lib/s3'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
jest.mock('@/lib/prisma', () => ({
prisma: {
systemSettings: {
findFirst: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
},
}))
jest.mock('@/lib/s3', () => ({
uploadToS3: jest.fn(),
verifyUploadedS3Object: jest.fn(),
}))
jest.mock('@/lib/department-permission', () => ({
hasSystemAdminPermission: jest.fn(),
}))
function pngBytes(): Buffer {
return readFileSync(join(process.cwd(), 'public', 'brand', 'favicon.png'))
}
function createLogo(bytes: Buffer, overrides: Partial<File> = {}): File {
return {
name: 'logo.png',
type: 'image/png',
size: bytes.length,
arrayBuffer: jest.fn().mockResolvedValue(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
),
...overrides,
} as unknown as File
}
function createRequest(logo: File, type = 'header') {
return {
headers: new Headers({
'content-type': 'multipart/form-data; boundary=test-boundary',
'content-length': String(logo.size + 512),
}),
formData: jest.fn().mockResolvedValue({
get: (key: string) => key === 'logo' ? logo : key === 'type' ? type : null,
entries: function* () {
yield ['logo', logo]
yield ['type', type]
},
}),
} as any
}
describe('/api/system-settings/upload-logo', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: { id: 'system-admin', role: 'SYSTEM_ADMIN' },
})
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(uploadToS3 as jest.Mock).mockResolvedValue(
'https://s3.jiansun.vip/gongxiangpan/由壹点AI/header-logo.png',
)
;(verifyUploadedS3Object as jest.Mock).mockResolvedValue(undefined)
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
;(prisma.systemSettings.create as jest.Mock).mockResolvedValue({ id: 'settings-1' })
})
test('系统管理员可上传,按真实签名决定格式,并在写库前 HEAD 校验', async () => {
const bytes = pngBytes()
const response = await POST(createRequest(createLogo(bytes)))
expect(response.status).toBe(200)
expect(uploadToS3).toHaveBeenCalledWith(
bytes,
expect.stringMatching(/^header-logo-\d+\.png$/),
'image/png',
)
expect(verifyUploadedS3Object).toHaveBeenCalledWith(
'https://s3.jiansun.vip/gongxiangpan/由壹点AI/header-logo.png',
bytes.length,
'image/png',
)
expect(prisma.systemSettings.findFirst).toHaveBeenCalledWith({
select: { id: true },
})
expect(prisma.systemSettings.create).toHaveBeenCalledWith({
data: {
headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/由壹点AI/header-logo.png',
loginLogoUrl: '/brand/login-logo.png',
},
select: { id: true },
})
})
test('已有设置行只读取 id,更新结果也不把存储凭据读入进程', async () => {
const bytes = pngBytes()
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({ id: 'settings-1' })
;(prisma.systemSettings.update as jest.Mock).mockResolvedValue({ id: 'settings-1' })
const response = await POST(createRequest(createLogo(bytes), 'login'))
expect(response.status).toBe(200)
expect(prisma.systemSettings.findFirst).toHaveBeenCalledWith({
select: { id: true },
})
expect(prisma.systemSettings.update).toHaveBeenCalledWith({
where: { id: 'settings-1' },
data: {
loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/由壹点AI/header-logo.png',
},
select: { id: true },
})
})
test('伪造 image/png 的非图片内容会被拒绝', async () => {
const bytes = Buffer.from('not-a-png')
const response = await POST(createRequest(createLogo(bytes)))
expect(response.status).toBe(400)
expect(uploadToS3).not.toHaveBeenCalled()
})
test('只有 PNG 文件头的截断图片也会被拒绝', async () => {
const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
const response = await POST(createRequest(createLogo(bytes)))
expect(response.status).toBe(400)
expect(uploadToS3).not.toHaveBeenCalled()
})
test('超过 5MB 时在读取文件内容前拒绝', async () => {
const logo = createLogo(pngBytes(), { size: 5 * 1024 * 1024 + 1 })
const response = await POST(createRequest(logo))
expect(response.status).toBe(413)
expect(logo.arrayBuffer).not.toHaveBeenCalled()
})
test('对象 HEAD 校验失败时保持原数据库值', async () => {
;(verifyUploadedS3Object as jest.Mock).mockRejectedValue(new Error('HEAD failed'))
const response = await POST(createRequest(createLogo(pngBytes())))
expect(response.status).toBe(500)
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
test('对象存储返回非受控地址时不做 HEAD 也不写数据库', async () => {
;(uploadToS3 as jest.Mock).mockResolvedValue('http://storage.internal/logo.png')
const response = await POST(createRequest(createLogo(pngBytes())))
expect(response.status).toBe(500)
expect(verifyUploadedS3Object).not.toHaveBeenCalled()
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
test('普通用户无权上传系统 Logo', async () => {
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false)
const response = await POST(createRequest(createLogo(pngBytes())))
expect(response.status).toBe(403)
expect(uploadToS3).not.toHaveBeenCalled()
})
})