353 lines
14 KiB
TypeScript
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('未登录')
|
|
})
|
|
})
|