import { NextRequest } from 'next/server' import { GET, PUT } from '../route' import { POST as uploadLogo } from '../upload-logo/route' jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/prisma', () => ({ prisma: { systemSettings: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, user: { findUnique: jest.fn(), }, $queryRaw: jest.fn(), $executeRawUnsafe: jest.fn(), }, })) jest.mock('@/lib/cache', () => ({ cache: { get: jest.fn(), set: jest.fn(), delete: jest.fn(), }, })) jest.mock('@/lib/system-settings-columns', () => ({ ensureSystemSettingsOptionalColumns: jest.fn(), })) jest.mock('@/lib/config', () => ({ defaultLogos: { header: '/logo/header.png', login: '/logo/login.png', }, featureFlags: { aiEditEnabled: false, tunnelEnabled: false, }, })) jest.mock('@/lib/license', () => ({ canUseSystemSettings: jest.fn(), })) jest.mock('@/lib/s3', () => ({ uploadToS3: jest.fn(), })) jest.mock('@/lib/image-processor', () => ({ processImage: jest.fn(), })) const { getServerSession } = require('next-auth') const { prisma } = require('@/lib/prisma') const { cache } = require('@/lib/cache') const { canUseSystemSettings } = require('@/lib/license') const { uploadToS3 } = require('@/lib/s3') const { processImage } = require('@/lib/image-processor') const { ensureSystemSettingsOptionalColumns } = require('@/lib/system-settings-columns') const mockSettings = { id: 'settings-1', headerBgColor: '#1f2937', siteName: 'FLNXI素材库', premiumRatio: 10, updatedAt: new Date('2026-05-24T00:00:00.000Z'), createdAt: new Date('2026-05-23T00:00:00.000Z'), headerLogoUrl: '/logo/header.png', loginLogoUrl: '/logo/login.png', s3AccessKey: 'access-key', s3Bucket: 'private-bucket', s3Endpoint: 'http://minio:9000', s3PublicUrl: 'http://minio.local/materials', s3Region: 'us-east-1', s3SecretKey: 'secret-key', enableRegistration: true, enableUserActivation: false, enableAiImageEdit: true, enableTunnelService: true, enablePublicShare: true, } describe('GET /api/system-settings', () => { const originalEnv = process.env beforeEach(() => { jest.resetAllMocks() process.env = { ...originalEnv } cache.get.mockReturnValue(null) prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) prisma.$queryRaw.mockResolvedValue([ { enabledBuiltinTemplates: '["3dmax-material"]', homeDeleteButtonMode: 'ADMIN_ONLY', premiumRatio: 10, }, ]) }) afterAll(() => { process.env = originalEnv }) it('未登录时只返回公开展示字段,不返回对象存储和功能开关', async () => { getServerSession.mockResolvedValue(null) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data).toMatchObject({ headerBgColor: '#1f2937', siteName: 'FLNXI素材库', headerLogoUrl: '/logo/header.png', loginLogoUrl: '/logo/login.png', }) expect(data.s3AccessKey).toBeUndefined() expect(data.s3SecretKey).toBeUndefined() expect(data.s3Bucket).toBeUndefined() expect(data.s3Endpoint).toBeUndefined() expect(data.enableRegistration).toBeUndefined() expect(data.enableUserActivation).toBeUndefined() expect(data.enableAiImageEdit).toBeUndefined() expect(data.enableTunnelService).toBeUndefined() expect(data.enablePublicShare).toBeUndefined() expect(data.enabledBuiltinTemplates).toBeUndefined() expect(data.homeDeleteButtonMode).toBeUndefined() expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled() expect(prisma.systemSettings.create).not.toHaveBeenCalled() expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('普通管理员登录时返回后台设置但不返回对象存储密钥', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data.s3AccessKey).toBeUndefined() expect(data.s3SecretKey).toBeUndefined() expect(data.s3Bucket).toBe('private-bucket') expect(data.enableRegistration).toBe(true) expect(data.enableAiImageEdit).toBe(true) expect(data.enablePublicShare).toBe(true) expect(data.enabledBuiltinTemplates).toBe('["3dmax-material"]') expect(data.homeDeleteButtonMode).toBe('ADMIN_ONLY') }) it('最高权限账号登录时才返回对象存储密钥', async () => { getServerSession.mockResolvedValue({ user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data.s3AccessKey).toBe('access-key') expect(data.s3SecretKey).toBe('secret-key') expect(data.s3Bucket).toBe('private-bucket') }) it('GET 不会用环境变量补写对象存储密钥', async () => { getServerSession.mockResolvedValue(null) prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings, s3AccessKey: null, s3SecretKey: null, s3Bucket: null, s3Endpoint: null, s3PublicUrl: null, }) process.env.S3_ACCESS_KEY_ID = 'env-access-key' process.env.S3_SECRET_ACCESS_KEY = 'env-secret-key' process.env.S3_BUCKET = 'env-bucket' process.env.S3_ENDPOINT = 'http://env-minio:9000' await GET() expect(prisma.systemSettings.update).not.toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ s3AccessKey: expect.any(String), }), }), ) expect(prisma.systemSettings.update).not.toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ s3SecretKey: expect.any(String), }), }), ) }) it('GET 没有系统设置记录时只返回内存默认值,不创建数据库记录', async () => { getServerSession.mockResolvedValue(null) prisma.systemSettings.findFirst.mockResolvedValue(null) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data).toMatchObject({ headerBgColor: '#1f2937', siteName: 'FLNXI素材库', headerLogoUrl: '', loginLogoUrl: '', }) expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled() expect(prisma.systemSettings.create).not.toHaveBeenCalled() expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('GET 只在响应中整理旧 Logo,不写回数据库', async () => { getServerSession.mockResolvedValue(null) prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings, headerLogoUrl: '/logo/logo1.png', loginLogoUrl: '', }) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data.headerLogoUrl).toBe('/logo/header.png') expect(data.loginLogoUrl).toBe('/logo/login.png') expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('数据库异常时,未登录读取会返回公共兜底设置而不是 500', async () => { getServerSession.mockResolvedValue(null) prisma.systemSettings.findFirst.mockRejectedValue(new Error('database offline')) const response = await GET() const data = await response.json() expect(response.status).toBe(200) expect(data).toMatchObject({ headerBgColor: '#1f2937', siteName: 'FLNXI素材库', headerLogoUrl: '', loginLogoUrl: '', }) }) }) describe('PUT /api/system-settings 权限边界', () => { beforeEach(() => { jest.resetAllMocks() cache.get.mockReturnValue(null) canUseSystemSettings.mockResolvedValue(true) prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) prisma.systemSettings.update.mockResolvedValue({ ...mockSettings, siteName: '新系统名', }) }) it('普通管理员不能修改任何系统设置', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) const response = await PUT(new NextRequest('http://localhost/api/system-settings', { method: 'PUT', body: JSON.stringify({ siteName: '普通管理员不应能改', s3SecretKey: 'new-secret', enableRegistration: false, enablePublicShare: false, }), })) const data = await response.json() expect(response.status).toBe(403) expect(data.error).toContain('最高权限') expect(ensureSystemSettingsOptionalColumns).toHaveBeenCalled() expect(canUseSystemSettings).not.toHaveBeenCalled() expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('旧会话仍带最高权限标记但数据库已撤销时不能修改系统设置', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) const response = await PUT(new NextRequest('http://localhost/api/system-settings', { method: 'PUT', body: JSON.stringify({ siteName: '不应写入' }), })) expect(response.status).toBe(403) expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('最高权限账号可以修改系统设置', async () => { getServerSession.mockResolvedValue({ user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) const response = await PUT(new NextRequest('http://localhost/api/system-settings', { method: 'PUT', body: JSON.stringify({ siteName: '新系统名', s3SecretKey: 'new-secret', enableRegistration: false, enablePublicShare: false, }), })) const data = await response.json() expect(response.status).toBe(200) expect(data.siteName).toBe('新系统名') expect(prisma.systemSettings.update).toHaveBeenCalledWith({ where: { id: 'settings-1' }, data: expect.objectContaining({ siteName: '新系统名', s3SecretKey: 'new-secret', enableRegistration: false, }), }) expect(prisma.$executeRawUnsafe).toHaveBeenCalledWith( `UPDATE "system_settings" SET "enablePublicShare" = $1 WHERE "id" = $2`, false, 'settings-1', ) }) }) describe('POST /api/system-settings/upload-logo 权限边界', () => { beforeEach(() => { jest.resetAllMocks() processImage.mockResolvedValue(Buffer.from('processed-logo')) uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png') prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) prisma.systemSettings.update.mockResolvedValue({ ...mockSettings, headerLogoUrl: 'https://cdn.example.com/header-logo.png', }) }) function createLogoRequest() { const formData = new FormData() formData.set('type', 'header') formData.set('logo', new File([Buffer.from('logo')], 'logo.png', { type: 'image/png' })) return new NextRequest('http://localhost/api/system-settings/upload-logo', { method: 'POST', body: formData, }) } it('普通管理员不能上传系统 LOGO', async () => { getServerSession.mockResolvedValue({ user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) const response = await uploadLogo(createLogoRequest()) const data = await response.json() expect(response.status).toBe(403) expect(data.error).toContain('最高权限') expect(uploadToS3).not.toHaveBeenCalled() expect(prisma.systemSettings.update).not.toHaveBeenCalled() }) it('最高权限账号可以上传系统 LOGO', async () => { getServerSession.mockResolvedValue({ user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) const response = await uploadLogo(createLogoRequest()) const data = await response.json() expect(response.status).toBe(200) expect(data.logoUrl).toBe('https://cdn.example.com/header-logo.png') expect(processImage).toHaveBeenCalledWith(expect.any(Buffer), { maxSizeKB: 300, maxWidth: 1200, maxHeight: 400, format: 'webp', }) expect(uploadToS3).toHaveBeenCalledWith( Buffer.from('processed-logo'), expect.stringMatching(/^header-logo-\d+\.webp$/), 'image/webp' ) expect(prisma.systemSettings.update).toHaveBeenCalledWith({ where: { id: 'settings-1' }, data: { headerLogoUrl: 'https://cdn.example.com/header-logo.png' }, }) }) it('拒绝 SVG 或非白名单图片格式', async () => { getServerSession.mockResolvedValue({ user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) const formData = new FormData() formData.set('type', 'header') formData.set('logo', new File([Buffer.from('')], 'logo.svg', { type: 'image/svg+xml' })) const response = await uploadLogo(new NextRequest('http://localhost/api/system-settings/upload-logo', { method: 'POST', body: formData, })) const data = await response.json() expect(response.status).toBe(400) expect(data.error).toContain('JPG、PNG 或 WebP') expect(processImage).not.toHaveBeenCalled() expect(uploadToS3).not.toHaveBeenCalled() }) it('拒绝超过 5MB 的 LOGO 图片', async () => { getServerSession.mockResolvedValue({ user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, }) prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) const formData = new FormData() formData.set('type', 'header') formData.set('logo', new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.png', { type: 'image/png' })) const response = await uploadLogo(new NextRequest('http://localhost/api/system-settings/upload-logo', { method: 'POST', body: formData, })) const data = await response.json() expect(response.status).toBe(400) expect(data.error).toContain('小于5MB') expect(processImage).not.toHaveBeenCalled() expect(uploadToS3).not.toHaveBeenCalled() }) })