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