/** @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('未登录') }) })