Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import { GET, PUT } from '@/app/api/system-settings/route'
|
||
import { getServerSession } from 'next-auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { isSystemAdmin } from '@/lib/department-permission'
|
||
import { proxyHttpUrl } from '@/lib/url-rewrite'
|
||
|
||
jest.mock('next-auth', () => ({
|
||
getServerSession: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/auth', () => ({
|
||
authOptions: {},
|
||
}))
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
systemSettings: {
|
||
findFirst: jest.fn(),
|
||
count: jest.fn(),
|
||
create: jest.fn(),
|
||
update: jest.fn(),
|
||
},
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/department-permission', () => ({
|
||
isSystemAdmin: jest.fn((role: string | null | undefined) =>
|
||
role === 'ADMIN' || role === 'SYSTEM_ADMIN',
|
||
),
|
||
}))
|
||
|
||
jest.mock('@/lib/url-rewrite', () => ({
|
||
proxyHttpUrl: jest.fn((url: string) => url),
|
||
}))
|
||
|
||
const storedSettings = {
|
||
id: 'settings-1',
|
||
headerBgColor: '#123456',
|
||
siteName: '测试素材库',
|
||
headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/header.png',
|
||
loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/login.png',
|
||
s3Endpoint: 'https://storage.internal.example.com',
|
||
s3AccessKey: 'test-access-key',
|
||
s3SecretKey: 'test-secret-key',
|
||
s3Bucket: 'materials',
|
||
s3Region: 'us-east-1',
|
||
s3PublicUrl: 'https://cdn.example.com/materials',
|
||
maintenanceEnabled: true,
|
||
maintenanceMessage: '计划维护',
|
||
maintenanceStartTime: new Date('2026-07-30T10:00:00.000Z'),
|
||
maintenanceEndTime: new Date('2026-07-30T11:00:00.000Z'),
|
||
}
|
||
|
||
function createPutRequest(body: unknown) {
|
||
return {
|
||
json: jest.fn().mockResolvedValue(body),
|
||
} as any
|
||
}
|
||
|
||
describe('/api/system-settings 安全响应', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
;(proxyHttpUrl as jest.Mock).mockImplementation((url: string) => url)
|
||
;(isSystemAdmin as jest.Mock).mockImplementation(
|
||
(role: string | null | undefined) =>
|
||
role === 'ADMIN' || role === 'SYSTEM_ADMIN',
|
||
)
|
||
;(prisma.systemSettings.count as jest.Mock).mockResolvedValue(1)
|
||
})
|
||
|
||
test('匿名 GET 只返回公开白名单且禁止缓存', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(storedSettings)
|
||
|
||
const response = await GET()
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
expect(body).toEqual({
|
||
headerBgColor: '#123456',
|
||
siteName: '测试素材库',
|
||
headerLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/header.png',
|
||
loginLogoUrl: 'https://s3.jiansun.vip/gongxiangpan/brand/login.png',
|
||
maintenanceEnabled: true,
|
||
maintenanceMessage: '计划维护',
|
||
maintenanceStartTime: '2026-07-30T10:00:00.000Z',
|
||
maintenanceEndTime: '2026-07-30T11:00:00.000Z',
|
||
})
|
||
expect(body).not.toHaveProperty('id')
|
||
expect(body).not.toHaveProperty('s3Endpoint')
|
||
expect(body).not.toHaveProperty('s3AccessKey')
|
||
expect(body).not.toHaveProperty('s3SecretKey')
|
||
expect(body).not.toHaveProperty('s3CredentialsConfigured')
|
||
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
|
||
expect(prisma.systemSettings.count).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('系统管理员 GET 仅获得非敏感配置与 configured 状态', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'SYSTEM_ADMIN' },
|
||
})
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(storedSettings)
|
||
|
||
const response = await GET()
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(body).toEqual(expect.objectContaining({
|
||
s3Endpoint: 'https://storage.internal.example.com',
|
||
s3Bucket: 'materials',
|
||
s3Region: 'us-east-1',
|
||
s3PublicUrl: 'https://cdn.example.com/materials',
|
||
s3CredentialsConfigured: true,
|
||
}))
|
||
expect(body).not.toHaveProperty('s3AccessKey')
|
||
expect(body).not.toHaveProperty('s3SecretKey')
|
||
expect(prisma.systemSettings.count).toHaveBeenCalledWith({
|
||
where: {
|
||
id: storedSettings.id,
|
||
s3AccessKey: { not: '' },
|
||
s3SecretKey: { not: '' },
|
||
},
|
||
})
|
||
})
|
||
|
||
test('数据库遗留旧域名、第三方或代理地址时,GET 只返回本地安全品牌图', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'SYSTEM_ADMIN' },
|
||
})
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({
|
||
...storedSettings,
|
||
headerLogoUrl: 'https://s3.1080p.hk/jsxiangche/header.png',
|
||
loginLogoUrl: '/api/proxy-media?url=https%3A%2F%2Fstatic.example.com%2Flogo.png',
|
||
})
|
||
|
||
const response = await GET()
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(body.headerLogoUrl).toBe('/brand/header-logo.png')
|
||
expect(body.loginLogoUrl).toBe('/brand/login-logo.png')
|
||
expect(body.headerLogoPreviewUrl).toBe('/brand/header-logo.png')
|
||
expect(body.loginLogoPreviewUrl).toBe('/brand/login-logo.png')
|
||
expect(body.brandLogoConfigurationNeedsRepair).toBe(true)
|
||
expect(JSON.stringify(body)).not.toContain('s3.1080p.hk')
|
||
expect(JSON.stringify(body)).not.toContain('static.example.com')
|
||
})
|
||
|
||
test('普通用户不能更新系统设置', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'user-1', role: 'USER' },
|
||
})
|
||
|
||
const response = await PUT(createPutRequest({ siteName: '无权修改' }))
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(403)
|
||
expect(body).toEqual({ error: '无权限' })
|
||
expect(response.headers.get('cache-control')).toContain('no-store')
|
||
expect(prisma.systemSettings.findFirst).not.toHaveBeenCalled()
|
||
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('凭据必须成对覆盖写入', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'ADMIN' },
|
||
})
|
||
|
||
const response = await PUT(createPutRequest({
|
||
s3AccessKey: 'new-access-key',
|
||
s3SecretKey: '',
|
||
}))
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(body).toEqual({ error: 'Access Key 和 Secret Key 必须同时填写' })
|
||
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('管理员可覆盖写入新凭据,但响应不返回任何凭据原文', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'ADMIN' },
|
||
})
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({
|
||
id: storedSettings.id,
|
||
})
|
||
;(prisma.systemSettings.update as jest.Mock).mockResolvedValue({
|
||
...storedSettings,
|
||
s3AccessKey: 'new-access-key',
|
||
s3SecretKey: 'new-secret-key',
|
||
})
|
||
|
||
const response = await PUT(createPutRequest({
|
||
siteName: '更新后的素材库',
|
||
s3AccessKey: ' new-access-key ',
|
||
s3SecretKey: ' new-secret-key ',
|
||
}))
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(prisma.systemSettings.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { id: storedSettings.id },
|
||
data: expect.objectContaining({
|
||
siteName: '更新后的素材库',
|
||
s3AccessKey: 'new-access-key',
|
||
s3SecretKey: 'new-secret-key',
|
||
}),
|
||
}),
|
||
)
|
||
expect(body.s3CredentialsConfigured).toBe(true)
|
||
expect(body).not.toHaveProperty('s3AccessKey')
|
||
expect(body).not.toHaveProperty('s3SecretKey')
|
||
expect(JSON.stringify(body)).not.toContain('new-secret-key')
|
||
expect(prisma.systemSettings.count).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('超级管理员标记可以更新设置', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'super-1', role: 'USER', isSuperAdmin: true },
|
||
})
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue({
|
||
id: storedSettings.id,
|
||
})
|
||
;(prisma.systemSettings.update as jest.Mock).mockResolvedValue(storedSettings)
|
||
|
||
const response = await PUT(createPutRequest({ siteName: '超级管理员修改' }))
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(prisma.systemSettings.update).toHaveBeenCalled()
|
||
})
|
||
|
||
test('管理员 GET 的长期地址与预览地址分离,GET 后保存不会把代理地址写入数据库', async () => {
|
||
const headerLogoUrl = 'https://s3.jiansun.vip/gongxiangpan/brand/header.png'
|
||
const loginLogoUrl = 'https://s3.jiansun.vip/gongxiangpan/brand/login.png'
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'SYSTEM_ADMIN' },
|
||
})
|
||
;(proxyHttpUrl as jest.Mock).mockImplementation(
|
||
(url: string) => `/api/proxy-media?url=${encodeURIComponent(url)}`,
|
||
)
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValueOnce({
|
||
...storedSettings,
|
||
headerLogoUrl,
|
||
loginLogoUrl,
|
||
})
|
||
|
||
const getResponse = await GET()
|
||
const getBody = await getResponse.json()
|
||
|
||
expect(getBody.headerLogoUrl).toBe(headerLogoUrl)
|
||
expect(getBody.loginLogoUrl).toBe(loginLogoUrl)
|
||
expect(getBody.headerLogoPreviewUrl).toBe(
|
||
`/api/proxy-media?url=${encodeURIComponent(headerLogoUrl)}`,
|
||
)
|
||
expect(getBody.loginLogoPreviewUrl).toBe(
|
||
`/api/proxy-media?url=${encodeURIComponent(loginLogoUrl)}`,
|
||
)
|
||
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValueOnce({
|
||
id: storedSettings.id,
|
||
})
|
||
;(prisma.systemSettings.update as jest.Mock).mockResolvedValue({
|
||
...storedSettings,
|
||
headerLogoUrl,
|
||
loginLogoUrl,
|
||
})
|
||
|
||
const putResponse = await PUT(createPutRequest({
|
||
headerLogoUrl: getBody.headerLogoUrl,
|
||
loginLogoUrl: getBody.loginLogoUrl,
|
||
}))
|
||
|
||
expect(putResponse.status).toBe(200)
|
||
expect(prisma.systemSettings.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({ headerLogoUrl, loginLogoUrl }),
|
||
}),
|
||
)
|
||
expect(JSON.stringify((prisma.systemSettings.update as jest.Mock).mock.calls)).not.toContain(
|
||
'/api/proxy-media',
|
||
)
|
||
})
|
||
|
||
test.each([
|
||
'/api/proxy-media?url=https%3A%2F%2Fs3.jiansun.vip%2Fgongxiangpan%2Fbrand%2Fheader.png',
|
||
'http://s3.jiansun.vip/gongxiangpan/brand/header.png',
|
||
'https://s3.1080p.hk/jsxiangche/header.png',
|
||
'https://static.example.com/header.png',
|
||
'/uploads/header.png',
|
||
'/brand/../server.js',
|
||
'https://s3.jiansun.vip/gongxiangpan/brand/header.png?token=temporary',
|
||
])('拒绝把不受控或临时展示地址写成 LOGO 长期地址:%s', async (invalidUrl) => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue({
|
||
user: { id: 'admin-1', role: 'ADMIN' },
|
||
})
|
||
|
||
const response = await PUT(createPutRequest({ headerLogoUrl: invalidUrl }))
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(body.error).toContain('LOGO 地址无效')
|
||
expect(prisma.systemSettings.findFirst).not.toHaveBeenCalled()
|
||
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
|
||
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('无数据库设置时使用镜像内置的页头与登录品牌图', async () => {
|
||
;(getServerSession as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.systemSettings.findFirst as jest.Mock).mockResolvedValue(null)
|
||
|
||
const response = await GET()
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(body.headerLogoUrl).toBe('/brand/header-logo.png')
|
||
expect(body.loginLogoUrl).toBe('/brand/login-logo.png')
|
||
})
|
||
})
|