537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
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()
|
||
})
|
||
})
|