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