215 lines
9.9 KiB
TypeScript
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()
|
|
}
|
|
})
|