399 lines
12 KiB
TypeScript
399 lines
12 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(),
|
||
}))
|
||
|
||
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 { 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()
|
||
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(uploadToS3).toHaveBeenCalled()
|
||
expect(prisma.systemSettings.update).toHaveBeenCalledWith({
|
||
where: { id: 'settings-1' },
|
||
data: { headerLogoUrl: 'https://cdn.example.com/header-logo.png' },
|
||
})
|
||
})
|
||
})
|