gongxiangp2/lib/__tests__/notification-center-admin-routes.test.ts
2026-08-29 21:39:17 +08:00

353 lines
14 KiB
TypeScript

/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
createNotificationCenterProject,
deleteNotificationCenterProject,
getNotificationCenterProject,
listNotificationCenterAudits,
listNotificationCenterDeliveries,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
revealNotificationCenterProjectSecret,
resolveNotificationCenterRecipientAlias,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
NotificationCenterAdminError,
} from '@/lib/notification-center-admin'
import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route'
import { DELETE as deleteProjectRoute, GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route'
import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route'
import { POST as revealProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/reveal-secret/route'
import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route'
import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route'
import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route'
import { GET as getAuditsRoute } from '@/app/api/admin/notification-center/audits/route'
import { POST as postRecipientResolveRoute } from '@/app/api/internal/notification-center/recipients/resolve/route'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/notification-center-admin', () => ({
NotificationCenterAdminError: class NotificationCenterAdminError extends Error {
statusCode: number
code: string
details?: string | null
constructor(message: string, options: { statusCode?: number; code?: string; details?: string | null } = {}) {
super(message)
this.name = 'NotificationCenterAdminError'
this.statusCode = options.statusCode ?? 500
this.code = options.code ?? 'NOTIFICATION_CENTER_ADMIN_ERROR'
this.details = options.details ?? null
}
},
listNotificationCenterProjects: jest.fn(),
revealNotificationCenterProjectSecret: jest.fn(),
createNotificationCenterProject: jest.fn(),
getNotificationCenterProject: jest.fn(),
updateNotificationCenterProject: jest.fn(),
deleteNotificationCenterProject: jest.fn(),
rotateNotificationCenterProjectSecret: jest.fn(),
listNotificationCenterProjectRecipients: jest.fn(),
sendNotificationCenterProjectTest: jest.fn(),
listNotificationCenterDeliveries: jest.fn(),
listNotificationCenterAudits: jest.fn(),
resolveNotificationCenterRecipientAlias: jest.fn(),
}))
describe('notification-center admin routes', () => {
beforeEach(() => {
jest.clearAllMocks()
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
role: 'SYSTEM_ADMIN',
isSuperAdmin: true,
username: 'wuxie',
realName: '无邪',
email: 'wuxie@example.com',
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
})
test('非真正超管即使 session 带 SYSTEM_ADMIN / isSuperAdmin 也返回 403', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toContain('仅超级管理员可访问')
expect(listNotificationCenterProjects).not.toHaveBeenCalled()
})
test('GET /projects 调用后端代理层并传操作人身份', async () => {
;(listNotificationCenterProjects as jest.Mock).mockResolvedValue({
status: 200,
data: [{ code: 'material_library' }],
})
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects?page=1') as any)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(listNotificationCenterProjects).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'admin-1',
username: 'wuxie',
realName: '无邪',
}),
expect.any(URLSearchParams),
)
})
test('POST /projects 透传一次性 project secret/env', async () => {
;(createNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 201,
data: {
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
},
})
const request = {
json: jest.fn().mockResolvedValue({ code: 'material_library' }),
} as any
const response = await postProjectsRoute(request)
const body = await response.json()
expect(response.status).toBe(201)
expect(body.data.projectSecret).toBe('project-secret-1')
expect(body.data.env).toContain('YYD_NOTIFICATION_CENTER_PROJECT_SECRET')
})
test('GET /projects/[code]、PATCH /projects/[code] 与 DELETE /projects/[code] 可用', async () => {
;(getNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 200,
data: { code: 'material_library', name: '素材库' },
})
;(updateNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 200,
data: { code: 'material_library', name: '素材库-新版' },
})
;(deleteNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 200,
data: { code: 'material_library', deleted: true },
})
const getResponse = await getProjectRoute(new Request('http://localhost') as any, { params: { code: 'material_library' } } as any)
const patchResponse = await patchProjectRoute(
{ json: jest.fn().mockResolvedValue({ name: '素材库-新版' }) } as any,
{ params: { code: 'material_library' } } as any,
)
const deleteResponse = await deleteProjectRoute(
new Request('http://localhost') as any,
{ params: { code: 'material_library' } } as any,
)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).data.code).toBe('material_library')
expect(patchResponse.status).toBe(200)
expect((await patchResponse.json()).data.name).toBe('素材库-新版')
expect(deleteResponse.status).toBe(200)
expect((await deleteResponse.json()).data.deleted).toBe(true)
expect(deleteNotificationCenterProject).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'material_library',
)
})
test('POST /projects/[code]/rotate-secret 透传一次性 secret/env', async () => {
;(rotateNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({
status: 200,
data: {
projectSecret: 'rotated-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=rotated-secret',
},
})
const response = await rotateProjectSecretRoute(
{ json: jest.fn().mockResolvedValue({ reason: 'rotate' }) } as any,
{ params: { code: 'material_library' } } as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.projectSecret).toBe('rotated-secret')
})
test('POST /projects/[code]/reveal-secret 仅超管可读取当前密钥且禁止缓存', async () => {
;(revealNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({
status: 200,
data: {
projectCode: 'script_monitor',
secret: 'current-project-secret',
},
})
const response = await revealProjectSecretRoute(
{} as any,
{ params: { code: 'script_monitor' } } as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('no-store')
expect(response.headers.get('pragma')).toBe('no-cache')
expect(body.data.secret).toBe('current-project-secret')
expect(revealNotificationCenterProjectSecret).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'script_monitor',
)
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const forbidden = await revealProjectSecretRoute(
{} as any,
{ params: { code: 'script_monitor' } } as any,
)
expect(forbidden.status).toBe(403)
expect(forbidden.headers.get('cache-control')).toContain('no-store')
expect(revealNotificationCenterProjectSecret).toHaveBeenCalledTimes(1)
})
test('GET /projects/[code]/recipients、GET /deliveries、GET /audits、POST /projects/[code]/test 可用', async () => {
;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] })
;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] })
;(listNotificationCenterAudits as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'audit-1' }] })
;(sendNotificationCenterProjectTest as jest.Mock).mockResolvedValue({ status: 200, data: { id: 'test-1' } })
const recipients = await getProjectRecipientsRoute(
new Request('http://localhost/api/admin/notification-center/projects/material_library/recipients?page=2') as any,
{ params: { code: 'material_library' } } as any,
)
const deliveries = await getDeliveriesRoute(new Request('http://localhost/api/admin/notification-center/deliveries?projectCode=material_library') as any)
const audits = await getAuditsRoute(new Request('http://localhost/api/admin/notification-center/audits?projectCode=material_library') as any)
const testResponse = await postProjectTestRoute(
{ json: jest.fn().mockResolvedValue({ title: '连通性测试' }) } as any,
{ params: { code: 'material_library' } } as any,
)
expect(recipients.status).toBe(200)
expect(deliveries.status).toBe(200)
expect(audits.status).toBe(200)
expect(testResponse.status).toBe(200)
expect(listNotificationCenterProjectRecipients).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'material_library',
expect.any(URLSearchParams),
)
expect(sendNotificationCenterProjectTest).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'material_library',
{ title: '连通性测试' },
)
})
test('POST /internal/notification-center/recipients/resolve 仅允许服务端 Bearer 调用并返回最小结果', async () => {
;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue({
recipientUserId: 'ding-user-1',
})
const response = await postRecipientResolveRoute(
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
method: 'POST',
headers: {
Authorization: 'Bearer server-admin-secret',
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipientAlias: '测试员工' }),
}) as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('cache-control')).toContain('no-store')
expect(body).toEqual({ recipientUserId: 'ding-user-1' })
expect(resolveNotificationCenterRecipientAlias).toHaveBeenCalledWith('测试员工')
})
test('POST /internal/notification-center/recipients/resolve 对未授权和未命中返回明确定义错误', async () => {
const unauthorized = await postRecipientResolveRoute(
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
method: 'POST',
headers: {
Authorization: 'Bearer wrong-secret',
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipientAlias: '测试员工' }),
}) as any,
)
const unauthorizedBody = await unauthorized.json()
expect(unauthorized.status).toBe(401)
expect(unauthorizedBody).toEqual({
error: { code: 'UNAUTHORIZED', message: '未授权' },
})
expect(resolveNotificationCenterRecipientAlias).not.toHaveBeenCalled()
;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue(null)
const notFound = await postRecipientResolveRoute(
new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
method: 'POST',
headers: {
Authorization: 'Bearer server-admin-secret',
'Content-Type': 'application/json',
},
body: JSON.stringify({ recipientAlias: '查无此人' }),
}) as any,
)
const notFoundBody = await notFound.json()
expect(notFound.status).toBe(404)
expect(notFoundBody).toEqual({
error: { code: 'RECIPIENT_ALIAS_NOT_FOUND', message: '花名未找到对应钉钉 userId' },
})
})
test('下游错误会返回中文错误且不泄漏管理员密钥', async () => {
const error = new NotificationCenterAdminError('通知中心管理员鉴权失败,请检查服务端密钥配置', {
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED',
details: 'invalid admin key',
})
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(listNotificationCenterProjects as jest.Mock).mockRejectedValue(error)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(502)
expect(body.error).toContain('通知中心管理员鉴权失败')
expect(JSON.stringify(body)).not.toContain('server-admin-secret')
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('server-admin-secret'))
})
test('未登录返回 401', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(401)
expect(body.error).toBe('未登录')
})
})