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((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((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((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((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) await prisma.$disconnect() } })