2026-06-02 10:16:28 +08:00

537 lines
17 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.

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({ role: 'ADMIN', isSuperAdmin: false, isActive: true })
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: 'admin-id', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: false, isActive: false })
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.s3Bucket).toBeUndefined()
expect(data.enableRegistration).toBeUndefined()
expect(data.headerBgColor).toBe('#1f2937')
})
it('最高权限账号登录时才返回对象存储密钥', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, isActive: 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 没有系统设置记录时后台兜底也默认关闭手动注册', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: false, isActive: true })
prisma.systemSettings.findFirst.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.enableRegistration).toBe(false)
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).not.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',
)
})
it('首次创建系统设置时默认关闭手动注册', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ role: 'ADMIN', isSuperAdmin: true, isActive: true })
prisma.systemSettings.findFirst.mockResolvedValue(null)
prisma.systemSettings.create.mockResolvedValue({
...mockSettings,
id: 'settings-new',
siteName: '首次配置',
enableRegistration: false,
})
const response = await PUT(new NextRequest('http://localhost/api/system-settings', {
method: 'PUT',
body: JSON.stringify({
siteName: '首次配置',
}),
}))
expect(response.status).toBe(200)
expect(prisma.systemSettings.create).toHaveBeenCalledWith({
data: expect.objectContaining({
siteName: '首次配置',
enableRegistration: false,
}),
})
})
})
describe('POST /api/system-settings/upload-logo 权限边界', () => {
beforeEach(() => {
jest.resetAllMocks()
canUseSystemSettings.mockResolvedValue(true)
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('免费版即使是最高权限账号也不能上传系统 LOGO', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
canUseSystemSettings.mockResolvedValue(false)
const response = await uploadLogo(createLogoRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('当前许可证未包含系统设置功能')
expect(processImage).not.toHaveBeenCalled()
expect(uploadToS3).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
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('<svg />')], '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()
})
})