chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

323 lines
11 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.

/** @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')
})
})