补强后台权限和旧素材更新边界
This commit is contained in:
parent
e3cc3a428d
commit
7068732479
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
101
app/api/admin/transfer-superadmin/__tests__/route.test.ts
Normal file
101
app/api/admin/transfer-superadmin/__tests__/route.test.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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: '最高权限已转移' })
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
47
lib/__tests__/admin-permissions.test.ts
Normal file
47
lib/__tests__/admin-permissions.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user