补强后台权限和旧素材更新边界
This commit is contained in:
parent
e3cc3a428d
commit
7068732479
@ -13,9 +13,10 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isAdmin = (session?.user as any)?.role === 'ADMIN'
|
const isAdmin = (session?.user as any)?.role === 'ADMIN'
|
||||||
|
const isSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
|
||||||
if (status === 'unauthenticated') {
|
if (status === 'unauthenticated') {
|
||||||
router.replace('/login')
|
router.replace('/login')
|
||||||
} else if (status === 'authenticated' && !isAdmin) {
|
} else if (status === 'authenticated' && (!isAdmin || !isSuperAdmin)) {
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
}, [status, session, router])
|
}, [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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ describe('admin destructive actions require super admin', () => {
|
|||||||
expect(data.error).toContain('最高权限')
|
expect(data.error).toContain('最高权限')
|
||||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: 'admin-1' },
|
where: { id: 'admin-1' },
|
||||||
select: { isSuperAdmin: true },
|
select: { isSuperAdmin: true, isActive: true },
|
||||||
})
|
})
|
||||||
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
|
expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled()
|
||||||
expect(prisma.$queryRawUnsafe).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 requesterId = (session.user as any)?.id
|
||||||
const requester = await prisma.user.findUnique({ where: { id: requesterId } }) as any
|
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 })
|
return NextResponse.json({ error: '仅最高权限账号可转移最高权限' }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +52,14 @@ export async function POST(req: NextRequest) {
|
|||||||
// 事务:转移最高权限
|
// 事务:转移最高权限
|
||||||
await prisma.$transaction([
|
await prisma.$transaction([
|
||||||
prisma.user.update({ where: { id: requesterId }, data: { isSuperAdmin: false } as any }),
|
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: '最高权限已转移' })
|
return NextResponse.json({ message: '最高权限已转移' })
|
||||||
|
|||||||
@ -585,8 +585,35 @@ describe('素材模板字段链路测试', () => {
|
|||||||
expect(prisma.material.update).not.toHaveBeenCalled()
|
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 () => {
|
test('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
user: {
|
user: {
|
||||||
@ -649,7 +676,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
getServerSession.mockResolvedValueOnce({
|
getServerSession.mockResolvedValueOnce({
|
||||||
user: { id: 'owner-1', role: 'USER' },
|
user: { id: 'owner-1', role: 'USER' },
|
||||||
})
|
})
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
previewUrl: 'https://example.com/preview.webp',
|
previewUrl: 'https://example.com/preview.webp',
|
||||||
@ -701,7 +728,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
|
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
user: {
|
user: {
|
||||||
@ -760,7 +787,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
getServerSession.mockResolvedValueOnce({
|
getServerSession.mockResolvedValueOnce({
|
||||||
user: { id: 'user-2', role: 'USER' },
|
user: { id: 'user-2', role: 'USER' },
|
||||||
})
|
})
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
previewUrl: 'https://example.com/preview.webp',
|
previewUrl: 'https://example.com/preview.webp',
|
||||||
@ -801,7 +828,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('编辑时缺失模板必填字段应拒绝更新', async () => {
|
test('编辑时缺失模板必填字段应拒绝更新', async () => {
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
user: {
|
user: {
|
||||||
@ -850,7 +877,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
|
|
||||||
test('编辑时条件不满足的必填字段缺失应允许通过且保留历史隐藏字段', async () => {
|
test('编辑时条件不满足的必填字段缺失应允许通过且保留历史隐藏字段', async () => {
|
||||||
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
|
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
user: {
|
user: {
|
||||||
@ -899,7 +926,7 @@ describe('素材模板字段链路测试', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
|
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
|
||||||
prisma.material.findUnique.mockResolvedValueOnce({
|
prisma.material.findFirst.mockResolvedValueOnce({
|
||||||
id: 'material-1',
|
id: 'material-1',
|
||||||
userId: 'owner-1',
|
userId: 'owner-1',
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@ -84,8 +84,11 @@ export async function PUT(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get existing material
|
// Get existing material
|
||||||
const existingMaterial = await prisma.material.findUnique({
|
const existingMaterial = await prisma.material.findFirst({
|
||||||
where: { id },
|
where: {
|
||||||
|
id,
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
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> {
|
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({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: userId },
|
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