补强后台权限和旧素材更新边界

This commit is contained in:
WuYongJie 2026-06-01 11:40:02 +08:00
parent e3cc3a428d
commit 7068732479
8 changed files with 208 additions and 18 deletions

View File

@ -13,9 +13,10 @@ export default function SystemSettingsPage() {
useEffect(() => {
const isAdmin = (session?.user as any)?.role === 'ADMIN'
const isSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
if (status === 'unauthenticated') {
router.replace('/login')
} else if (status === 'authenticated' && !isAdmin) {
} else if (status === 'authenticated' && (!isAdmin || !isSuperAdmin)) {
router.replace('/')
}
}, [status, session, router])
@ -28,7 +29,7 @@ export default function SystemSettingsPage() {
)
}
if (!session || (session.user as any)?.role !== 'ADMIN') {
if (!session || (session.user as any)?.role !== 'ADMIN' || !(session.user as any)?.isSuperAdmin) {
return null
}

View File

@ -68,7 +68,7 @@ describe('admin destructive actions require super admin', () => {
expect(data.error).toContain('最高权限')
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 'admin-1' },
select: { isSuperAdmin: true },
select: { isSuperAdmin: true, isActive: true },
})
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()

View File

@ -0,0 +1,101 @@
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('bcryptjs', () => ({
compare: jest.fn(),
}))
import { NextRequest } from 'next/server'
import { POST } from '../route'
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const bcrypt = require('bcryptjs')
function createTransferRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/admin/transfer-superadmin', {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' },
})
}
describe('POST /api/admin/transfer-superadmin', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
bcrypt.compare.mockResolvedValue(true)
prisma.$transaction.mockResolvedValue([])
})
it('禁用的最高权限账号不能转移最高权限', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
id: 'super-1',
isSuperAdmin: true,
isActive: false,
password: 'hashed-password',
})
const response = await POST(createTransferRequest({
targetUserId: 'user-2',
password: 'right-password',
}))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可转移最高权限')
expect(bcrypt.compare).not.toHaveBeenCalled()
expect(prisma.user.update).not.toHaveBeenCalled()
})
it('转移最高权限时会把目标账号修正为可登录管理员', async () => {
prisma.user.findUnique
.mockResolvedValueOnce({
id: 'super-1',
isSuperAdmin: true,
isActive: true,
password: 'hashed-password',
})
.mockResolvedValueOnce({
id: 'user-2',
email: 'user2@example.com',
role: 'USER',
isActive: false,
})
prisma.user.update.mockResolvedValue({})
const response = await POST(createTransferRequest({
targetUserId: 'user-2',
password: 'right-password',
}))
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('最高权限已转移')
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: {
isSuperAdmin: true,
role: 'ADMIN',
isActive: true,
},
})
})
})

View File

@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
const requesterId = (session.user as any)?.id
const requester = await prisma.user.findUnique({ where: { id: requesterId } }) as any
if (!requester || !requester.isSuperAdmin) {
if (!requester || !requester.isSuperAdmin || requester.isActive === false) {
return NextResponse.json({ error: '仅最高权限账号可转移最高权限' }, { status: 403 })
}
@ -52,7 +52,14 @@ export async function POST(req: NextRequest) {
// 事务:转移最高权限
await prisma.$transaction([
prisma.user.update({ where: { id: requesterId }, data: { isSuperAdmin: false } as any }),
prisma.user.update({ where: { id: targetUser.id }, data: { isSuperAdmin: true } as any }),
prisma.user.update({
where: { id: targetUser.id },
data: {
isSuperAdmin: true,
role: 'ADMIN',
isActive: true,
} as any,
}),
])
return NextResponse.json({ message: '最高权限已转移' })

View File

@ -585,8 +585,35 @@ describe('素材模板字段链路测试', () => {
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('软删除素材不能通过旧表单更新入口继续编辑', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await updateMaterial(
buildUpdateRequest({
id: 'deleted-material',
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'NOPE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
@ -649,7 +676,7 @@ describe('素材模板字段链路测试', () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'owner-1', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
@ -701,7 +728,7 @@ describe('素材模板字段链路测试', () => {
})
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
@ -760,7 +787,7 @@ describe('素材模板字段链路测试', () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },
})
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
@ -801,7 +828,7 @@ describe('素材模板字段链路测试', () => {
})
test('编辑时缺失模板必填字段应拒绝更新', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
@ -850,7 +877,7 @@ describe('素材模板字段链路测试', () => {
test('编辑时条件不满足的必填字段缺失应允许通过且保留历史隐藏字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
@ -899,7 +926,7 @@ describe('素材模板字段链路测试', () => {
})
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {

View File

@ -84,8 +84,11 @@ export async function PUT(req: NextRequest) {
}
// Get existing material
const existingMaterial = await prisma.material.findUnique({
where: { id },
const existingMaterial = await prisma.material.findFirst({
where: {
id,
deletedAt: null,
},
include: {
user: {
select: {

View File

@ -0,0 +1,47 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
},
}))
const { prisma } = require('@/lib/prisma')
import { isCurrentAdmin, isCurrentSuperAdmin } from '../admin-permissions'
describe('admin permission helpers', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('禁用的普通管理员不再通过后台权限 helper', async () => {
prisma.user.findUnique.mockResolvedValue({
role: 'ADMIN',
isSuperAdmin: false,
isActive: false,
})
await expect(isCurrentAdmin({ user: { id: 'admin-1' } })).resolves.toBe(false)
})
it('禁用的最高权限账号不再通过最高权限 helper', async () => {
prisma.user.findUnique.mockResolvedValue({
isSuperAdmin: true,
isActive: false,
})
await expect(isCurrentSuperAdmin({ user: { id: 'super-1' } })).resolves.toBe(false)
})
it('可用的最高权限账号仍然通过管理员和最高权限判断', async () => {
prisma.user.findUnique.mockResolvedValue({
role: 'USER',
isSuperAdmin: true,
isActive: true,
})
await expect(isCurrentAdmin({ user: { id: 'super-1' } })).resolves.toBe(true)
await expect(isCurrentSuperAdmin({ user: { id: 'super-1' } })).resolves.toBe(true)
})
})

View File

@ -8,10 +8,14 @@ export async function isCurrentAdmin(session: unknown): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true, isSuperAdmin: true },
select: { role: true, isSuperAdmin: true, isActive: true },
})
return Boolean(user && (user.role === 'ADMIN' || user.isSuperAdmin))
return Boolean(
user
&& (user.isSuperAdmin || user.isActive !== false)
&& (user.role === 'ADMIN' || user.isSuperAdmin)
)
}
export async function isCurrentSuperAdmin(session: unknown): Promise<boolean> {
@ -22,8 +26,8 @@ export async function isCurrentSuperAdmin(session: unknown): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { isSuperAdmin: true },
select: { isSuperAdmin: true, isActive: true },
})
return Boolean(user?.isSuperAdmin)
return Boolean(user?.isSuperAdmin && user.isActive !== false)
}