diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index bf5f045..a433823 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -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 } diff --git a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts index 8925c7d..3a64a97 100644 --- a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts +++ b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts @@ -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() diff --git a/app/api/admin/transfer-superadmin/__tests__/route.test.ts b/app/api/admin/transfer-superadmin/__tests__/route.test.ts new file mode 100644 index 0000000..b627cae --- /dev/null +++ b/app/api/admin/transfer-superadmin/__tests__/route.test.ts @@ -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) { + 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, + }, + }) + }) +}) diff --git a/app/api/admin/transfer-superadmin/route.ts b/app/api/admin/transfer-superadmin/route.ts index 951647e..b63dc74 100644 --- a/app/api/admin/transfer-superadmin/route.ts +++ b/app/api/admin/transfer-superadmin/route.ts @@ -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: '最高权限已转移' }) diff --git a/app/api/materials/__tests__/template-fields-routes.test.ts b/app/api/materials/__tests__/template-fields-routes.test.ts index bd60457..62f53f6 100644 --- a/app/api/materials/__tests__/template-fields-routes.test.ts +++ b/app/api/materials/__tests__/template-fields-routes.test.ts @@ -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: { diff --git a/app/api/materials/update/route.ts b/app/api/materials/update/route.ts index 4c9d5e3..20f22c4 100644 --- a/app/api/materials/update/route.ts +++ b/app/api/materials/update/route.ts @@ -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: { diff --git a/lib/__tests__/admin-permissions.test.ts b/lib/__tests__/admin-permissions.test.ts new file mode 100644 index 0000000..3c4cf8c --- /dev/null +++ b/lib/__tests__/admin-permissions.test.ts @@ -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) + }) +}) diff --git a/lib/admin-permissions.ts b/lib/admin-permissions.ts index af2133f..e343bde 100644 --- a/lib/admin-permissions.ts +++ b/lib/admin-permissions.ts @@ -8,10 +8,14 @@ export async function isCurrentAdmin(session: unknown): Promise { 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 { @@ -22,8 +26,8 @@ export async function isCurrentSuperAdmin(session: unknown): Promise { 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) }