266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import {
|
||
createNotificationCenterProject,
|
||
listNotificationCenterProjectRecipients,
|
||
listNotificationCenterProjects,
|
||
NotificationCenterAdminError,
|
||
revealNotificationCenterProjectSecret,
|
||
rotateNotificationCenterProjectSecret,
|
||
sendNotificationCenterProjectTest,
|
||
updateNotificationCenterProject,
|
||
} from '@/lib/notification-center-admin'
|
||
|
||
describe('notification-center-admin client', () => {
|
||
const actor = {
|
||
userId: 'super-1',
|
||
username: 'wuxie',
|
||
realName: '无邪',
|
||
email: 'wuxie@example.com',
|
||
}
|
||
|
||
beforeEach(() => {
|
||
jest.restoreAllMocks()
|
||
process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL = 'https://notify-admin.example.com'
|
||
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
|
||
delete process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS
|
||
global.fetch = jest.fn()
|
||
})
|
||
|
||
test('列表查询会透传操作人身份并剥离 project/admin secret', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify({
|
||
items: [
|
||
{
|
||
code: 'material_library',
|
||
name: '素材库',
|
||
projectSecret: 'should-not-return',
|
||
env: 'SECRET=1',
|
||
adminSecret: 'server-admin-secret',
|
||
},
|
||
],
|
||
adminKey: 'server-admin-secret',
|
||
}),
|
||
})
|
||
|
||
const result = await listNotificationCenterProjects(actor)
|
||
|
||
expect(result.status).toBe(200)
|
||
expect(result.data).toEqual({
|
||
items: [
|
||
{
|
||
code: 'material_library',
|
||
name: '素材库',
|
||
},
|
||
],
|
||
})
|
||
const [, init] = (global.fetch as jest.Mock).mock.calls[0]
|
||
const headers = init.headers as Record<string, string>
|
||
expect(headers.Authorization).toBe('Bearer server-admin-secret')
|
||
expect(headers['x-yyd-admin-actor']).toBe('super-1')
|
||
expect(headers['x-yyd-admin-display-name']).toBe('无邪')
|
||
})
|
||
|
||
test('创建项目仅保留一次性 project secret/env,不回传 admin secret', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: true,
|
||
status: 201,
|
||
text: async () => JSON.stringify({
|
||
code: 'material_library',
|
||
projectSecret: 'project-secret-1',
|
||
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
|
||
adminSecret: 'server-admin-secret',
|
||
adminKey: 'server-admin-secret',
|
||
}),
|
||
})
|
||
|
||
const result = await createNotificationCenterProject(actor, { code: 'material_library' })
|
||
|
||
expect(result.status).toBe(201)
|
||
expect(result.data).toEqual({
|
||
code: 'material_library',
|
||
projectSecret: 'project-secret-1',
|
||
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
|
||
})
|
||
})
|
||
|
||
test('轮换密钥同样只保留一次性 project secret/env', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify({
|
||
projectSecret: 'next-secret',
|
||
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
|
||
adminToken: 'server-admin-secret',
|
||
}),
|
||
})
|
||
|
||
const result = await rotateNotificationCenterProjectSecret(actor, 'material_library')
|
||
|
||
expect(result.data).toEqual({
|
||
projectSecret: 'next-secret',
|
||
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
|
||
})
|
||
})
|
||
|
||
test('查看当前密钥命中专属接口,保留 project secret 且剥离 admin secret', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify({
|
||
projectCode: 'script_monitor',
|
||
secret: 'current-project-secret',
|
||
env: {
|
||
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
|
||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
|
||
YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET: 'server-admin-secret',
|
||
},
|
||
project: { recipients: [{ userId: 'ding-private-user' }] },
|
||
adminSecret: 'server-admin-secret',
|
||
}),
|
||
})
|
||
|
||
const result = await revealNotificationCenterProjectSecret(actor, 'script_monitor')
|
||
|
||
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
|
||
'https://notify-admin.example.com/projects/script_monitor/reveal-secret',
|
||
)
|
||
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
})
|
||
expect(result.data).toEqual({
|
||
projectCode: 'script_monitor',
|
||
secret: 'current-project-secret',
|
||
env: {
|
||
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
|
||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
|
||
},
|
||
})
|
||
expect(JSON.stringify(result.data)).not.toContain('server-admin-secret')
|
||
expect(JSON.stringify(result.data)).not.toContain('ding-private-user')
|
||
})
|
||
|
||
test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
|
||
;(global.fetch as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify({ code: 'material_library', name: '素材库-新版' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify([{ userId: 'ding-1' }]),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => JSON.stringify({ id: 'test-1' }),
|
||
})
|
||
|
||
await updateNotificationCenterProject(actor, 'material_library', { name: '素材库-新版' })
|
||
await listNotificationCenterProjectRecipients(
|
||
actor,
|
||
'material_library',
|
||
new URLSearchParams('page=2'),
|
||
)
|
||
await sendNotificationCenterProjectTest(actor, 'material_library', {
|
||
title: '连通性测试',
|
||
projectCode: 'material_library',
|
||
})
|
||
|
||
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
|
||
'https://notify-admin.example.com/projects/material_library',
|
||
)
|
||
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
|
||
method: 'PATCH',
|
||
})
|
||
expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(
|
||
'https://notify-admin.example.com/projects/material_library/recipients?page=2',
|
||
)
|
||
expect((global.fetch as jest.Mock).mock.calls[2][0]).toBe(
|
||
'https://notify-admin.example.com/projects/material_library/test',
|
||
)
|
||
expect((global.fetch as jest.Mock).mock.calls[2][1]).toMatchObject({
|
||
method: 'POST',
|
||
body: JSON.stringify({ title: '连通性测试' }),
|
||
})
|
||
})
|
||
|
||
test('下游 401 会转成中文配置错误且不泄漏管理员密钥', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: false,
|
||
status: 401,
|
||
text: async () => JSON.stringify({
|
||
error: 'invalid admin key',
|
||
adminKey: 'server-admin-secret',
|
||
}),
|
||
})
|
||
|
||
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
|
||
statusCode: 502,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED',
|
||
})
|
||
|
||
await listNotificationCenterProjects(actor).catch((error: NotificationCenterAdminError) => {
|
||
expect(error.message).toContain('通知中心管理员鉴权失败')
|
||
expect(error.message).not.toContain('server-admin-secret')
|
||
expect(error.details || '').not.toContain('server-admin-secret')
|
||
})
|
||
})
|
||
|
||
test('409 与 422 会保留业务状态并输出中文错误', async () => {
|
||
;(global.fetch as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 409,
|
||
text: async () => JSON.stringify({ error: 'project already exists' }),
|
||
})
|
||
.mockResolvedValueOnce({
|
||
ok: false,
|
||
status: 422,
|
||
text: async () => JSON.stringify({ message: 'code is required' }),
|
||
})
|
||
|
||
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
|
||
statusCode: 409,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_CONFLICT',
|
||
})
|
||
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
|
||
statusCode: 422,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
|
||
})
|
||
})
|
||
|
||
test('下游 500 会转成中文服务异常', async () => {
|
||
;(global.fetch as jest.Mock).mockResolvedValue({
|
||
ok: false,
|
||
status: 500,
|
||
text: async () => JSON.stringify({ error: 'internal error' }),
|
||
})
|
||
|
||
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
|
||
statusCode: 502,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_UPSTREAM',
|
||
})
|
||
})
|
||
|
||
test('离线与超时会映射为中文错误', async () => {
|
||
;(global.fetch as jest.Mock)
|
||
.mockRejectedValueOnce(new TypeError('fetch failed'))
|
||
.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
|
||
|
||
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
|
||
statusCode: 503,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_OFFLINE',
|
||
})
|
||
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
|
||
statusCode: 504,
|
||
code: 'NOTIFICATION_CENTER_ADMIN_TIMEOUT',
|
||
})
|
||
})
|
||
})
|