/** @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 { 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() }) })