215 lines
9.9 KiB
TypeScript

import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import test from 'node:test'
import { AddressInfo } from 'node:net'
import { loadProjectConfigs } from '../config'
import prisma from '../prisma'
import { createServer, renderNotificationContent, renderNotificationTitle } from '../server'
import { createCanonicalSignature, sha256 } from '../signature'
const projectSecret = '123456789012345678901234'
const projects = loadProjectConfigs(JSON.stringify([
{ code: 'fixed_asset', name: '固定资产', secret: projectSecret, allowedHosts: ['asset.example.com'] },
]))
const headers = (method: string, path: string, body: string, nonce = crypto.randomBytes(18).toString('base64url')) => {
const timestamp = String(Math.floor(Date.now() / 1000))
return {
'Content-Type': 'application/json',
'x-yyd-project-code': 'fixed_asset',
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': createCanonicalSignature({ secret: projectSecret, timestamp, nonce, method, path, body }),
}
}
test('通知中心验证签名、项目域名和幂等键', async () => {
await prisma.requestNonce.deleteMany()
await prisma.notificationDelivery.deleteMany()
const server = createServer(projects).listen(0)
await new Promise<void>((resolve) => server.once('listening', resolve))
const port = (server.address() as AddressInfo).port
const path = '/v1/notifications'
const body = JSON.stringify({
idempotencyKey: 'disposal_applied:53:1',
recipientUserId: 'ding-user-1',
title: '待审批报废',
content: '### 待你审批\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102',
actionTitle: '查看详情',
actionUrl: 'https://asset.example.com/workflows/disposals/53',
})
try {
const first = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
assert.equal(first.status, 202)
const firstPayload = await first.json() as any
assert.equal(firstPayload.title, '【固定资产】待审批报废')
const stored = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: firstPayload.id } })
assert.equal(stored.maxAttempts, 5)
assert.equal(
stored.content,
'### 【固定资产】待审批报废\n\n**申请时间:** 2026年7月16日 14:30\n\n**资产:** 摄影灯 A-102',
)
assert.doesNotMatch(stored.content, /待你审批/)
const second = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, body), body })
assert.equal(second.status, 200)
const secondPayload = await second.json() as any
assert.equal(secondPayload.id, firstPayload.id)
const badBody = JSON.stringify({ ...JSON.parse(body), actionUrl: 'https://evil.example.com/path' })
const rejected = await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: headers('POST', path, badBody), body: badBody })
assert.equal(rejected.status, 422)
// 按状态列出本项目投递记录,接入方对账失败用(签名按纯路径,与现有协议一致)
await prisma.notificationDelivery.update({
where: { id: firstPayload.id },
data: { status: 'failed', lastErrorCode: 'DELIVERY_OUTCOME_UNKNOWN', lastErrorMessage: '人工核验' },
})
const listResponse = await fetch(`http://127.0.0.1:${port}/v1/notifications?status=failed&limit=5`, {
method: 'GET',
headers: headers('GET', '/v1/notifications', ''),
})
assert.equal(listResponse.status, 200)
const listPayload = await listResponse.json() as any
assert.equal(listPayload.items.length, 1)
assert.equal(listPayload.items[0].id, firstPayload.id)
const healthResponse = await fetch(`http://127.0.0.1:${port}/health`)
const healthPayload = await healthResponse.json() as any
assert.equal(healthPayload.ok, true)
assert.equal(healthPayload.deliveries.counts.failed, 1)
assert.equal(healthPayload.deliveries.outcomeUnknown, 1)
const reservedTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'reserved-title', title: '【固定资产】待审批报废' })
const reservedTitle = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST', headers: headers('POST', path, reservedTitleBody), body: reservedTitleBody,
})
assert.equal(reservedTitle.status, 422)
assert.deepEqual(await reservedTitle.json(), { error: 'TITLE_BRAND_RESERVED' })
const urgentTitleBody = JSON.stringify({ ...JSON.parse(body), idempotencyKey: 'urgent-title', title: '【紧急】资产报废待审批' })
const urgentTitle = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST', headers: headers('POST', path, urgentTitleBody), body: urgentTitleBody,
})
assert.equal(urgentTitle.status, 202)
assert.equal((await urgentTitle.json() as any).title, '【固定资产】【紧急】资产报废待审批')
const tooLongBody = JSON.stringify({
...JSON.parse(body),
idempotencyKey: 'rendered-content-too-long',
content: '内'.repeat(4800),
})
const tooLong = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST', headers: headers('POST', path, tooLongBody), body: tooLongBody,
})
assert.equal(tooLong.status, 422)
assert.deepEqual(await tooLong.json(), { error: 'MESSAGE_TOO_LONG' })
const legacyRequest = {
idempotencyKey: 'legacy-existing',
recipientUserId: 'ding-user-1',
title: '【固定资产】旧版消息',
content: '旧'.repeat(4800),
actionTitle: '查看详情',
actionUrl: 'https://asset.example.com/workflows/disposals/53',
}
const legacyRequestHash = sha256(JSON.stringify({
recipientUserId: legacyRequest.recipientUserId,
title: legacyRequest.title,
content: legacyRequest.content,
actionTitle: legacyRequest.actionTitle,
actionUrl: legacyRequest.actionUrl,
}))
await prisma.notificationDelivery.create({
data: {
id: 'legacy-existing-delivery',
projectCode: 'fixed_asset',
idempotencyKey: legacyRequest.idempotencyKey,
requestHash: legacyRequestHash,
recipientUserId: legacyRequest.recipientUserId,
businessTitle: legacyRequest.title,
renderedTitle: `【由壹点】固定资产·${legacyRequest.title}`,
content: legacyRequest.content,
actionTitle: legacyRequest.actionTitle,
actionUrl: legacyRequest.actionUrl,
},
})
const legacyBody = JSON.stringify(legacyRequest)
const legacyResponse = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST', headers: headers('POST', path, legacyBody), body: legacyBody,
})
assert.equal(legacyResponse.status, 200)
const legacyPayload = await legacyResponse.json() as any
assert.equal(legacyPayload.id, 'legacy-existing-delivery')
assert.equal(legacyPayload.title, `【由壹点】固定资产·${legacyRequest.title}`)
const unchangedLegacy = await prisma.notificationDelivery.findUniqueOrThrow({ where: { id: 'legacy-existing-delivery' } })
assert.equal(unchangedLegacy.content, legacyRequest.content)
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await prisma.$disconnect()
}
})
test('通知标题突出项目,正文首行由通知中心统一生成', () => {
assert.equal(renderNotificationTitle('固定资产', '资产报废待审批'), '【固定资产】资产报废待审批')
assert.equal(
renderNotificationContent('固定资产', '资产报废待审批', '**申请时间:** 2026年7月16日 14:30'),
'### 【固定资产】资产报废待审批\n\n**申请时间:** 2026年7月16日 14:30',
)
assert.equal(
renderNotificationContent('固定资产', '资产报废待审批', '### 待你审批\n\n正文'),
'### 【固定资产】资产报废待审批\n\n正文',
)
assert.equal(
renderNotificationContent('固定资产', '资产报废待审批', '### 【固定资产】资产报废待审批\n\n正文'),
'### 【固定资产】资产报废待审批\n\n正文',
)
for (const heading of ['# 一级标题', '## 二级标题', '#### 四级标题']) {
assert.equal(
renderNotificationContent('固定资产', '资产报废待审批', `${heading}\n\n正文`),
`### 【固定资产】资产报废待审批\n\n${heading}\n\n正文`,
)
}
})
test('渲染后标题长度边界由通知入口校验', async () => {
const longName = '项'.repeat(20)
const longSecret = 'abcdefghijklmnopqrstuvwxyz123456'
const longProjects = loadProjectConfigs(JSON.stringify([
{ code: 'long_project', name: longName, secret: longSecret, allowedHosts: ['long.example.com'] },
]))
const server = createServer(longProjects).listen(0)
await new Promise<void>((resolve) => server.once('listening', resolve))
const port = (server.address() as AddressInfo).port
const path = '/v1/notifications'
const body = JSON.stringify({
idempotencyKey: 'rendered-title-too-long',
recipientUserId: 'ding-user-1',
title: '题'.repeat(100),
content: '正文',
actionTitle: '查看详情',
actionUrl: 'https://long.example.com/detail',
})
const timestamp = String(Math.floor(Date.now() / 1000))
const nonce = crypto.randomBytes(18).toString('base64url')
try {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-yyd-project-code': 'long_project',
'x-yyd-timestamp': timestamp,
'x-yyd-nonce': nonce,
'x-yyd-signature': createCanonicalSignature({ secret: longSecret, timestamp, nonce, method: 'POST', path, body }),
},
body,
})
assert.equal(response.status, 422)
assert.deepEqual(await response.json(), { error: 'MESSAGE_TOO_LONG' })
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
await prisma.$disconnect()
}
})