xiangning 81fbf63504 feat(workflow): 集成平台消息中心实现工作流通知推送
- 移除文件端点的权限控制以支持无权限访问
- 添加 IPlatformNotificationPublisher 接口定义跨模块通知发布能力
- 实现 WorkflowNotificationPublisher 将工作流通知转发到平台消息中心
- 在 NotificationService 中集成平台通知发布功能
- 添加 NoOpPlatformNotificationPublisher 用于单元测试
- 配置服务注册将工作流通知连接到平台消息系统
- 支持按用户ID批量发布工作流通知到平台通知表
- 复用平台的 fanout 落库和 SignalR 铃铛推送机制
- 实现容错机制确保通知失败不影响工作流主链路
2026-07-03 16:33:46 +08:00

260 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Workflow.Application.Notifications;
using Workflow.Domain.Common;
using Workflow.Domain.Entities;
using Workflow.Domain.Enums;
using Xunit;
using TaskStatus = global::Workflow.Domain.Enums.TaskStatus;
namespace BuildFlow.Workflow.Tests.Notification;
public class NotificationServiceTests
{
private static WorkflowDbContext CreateDbContext()
{
var options = new DbContextOptionsBuilder<WorkflowDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new WorkflowDbContext(options);
db.SavingChanges += AuditStub.OnSave;
return db; }
private static NotificationOptions DefaultOptions() => new()
{
Webhook = new NotificationOptions.WebhookSection
{
AllowedHosts = [], // 默认禁用 Webhook,专注测站内信
DefaultUrl = ""
}
};
private static WorkflowTask NewTask(Guid? assigneeId = null, string? role = null)
{
var instanceId = Guid.NewGuid();
return new WorkflowTask
{
Id = Guid.NewGuid(),
InstanceId = instanceId,
TokenId = Guid.NewGuid(),
NodeId = Guid.NewGuid(),
Title = "测试任务",
AssigneeId = assigneeId,
AssigneeRole = role,
Type = TaskType.Approval,
Status = TaskStatus.Pending
};
}
private static WorkflowInstance NewInstance(Guid instanceId, Guid initiatorId)
=> new()
{
Id = instanceId,
DefinitionId = Guid.NewGuid(),
Title = "测试流程",
InitiatorId = initiatorId,
Status = InstanceStatus.Running
};
[Fact]
public async Task NotifyTaskArrived_ByUserId_CreatesNotificationForAssignee()
{
var db = CreateDbContext();
var assigneeId = Guid.NewGuid();
var task = NewTask(assigneeId: assigneeId);
db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid()));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskArrivedAsync(task);
await db.SaveChangesAsync();
var notifications = await db.Notifications.ToListAsync();
notifications.Should().HaveCount(1);
notifications[0].RecipientUserId.Should().Be(assigneeId);
notifications[0].RecipientRole.Should().BeNull();
notifications[0].Category.Should().Be("task-arrived");
notifications[0].IsRead.Should().BeFalse();
notifications[0].RelatedTaskId.Should().Be(task.Id);
}
[Fact]
public async Task NotifyTaskArrived_ByRole_CreatesRoleScopedNotification()
{
var db = CreateDbContext();
var task = NewTask(role: "manager");
db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid()));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskArrivedAsync(task);
await db.SaveChangesAsync();
var n = await db.Notifications.SingleAsync();
n.RecipientRole.Should().Be("manager");
n.RecipientUserId.Should().BeNull();
}
[Fact]
public async Task NotifyTaskApproved_NotifiesBothAssigneeAndInitiator()
{
var db = CreateDbContext();
var assigneeId = Guid.NewGuid();
var initiatorId = Guid.NewGuid();
var task = NewTask(assigneeId: assigneeId);
db.WorkflowInstances.Add(NewInstance(task.InstanceId, initiatorId));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskApprovedAsync(task);
await db.SaveChangesAsync();
var notifications = await db.Notifications.ToListAsync();
notifications.Should().HaveCount(2);
notifications.Should().Contain(n => n.RecipientUserId == assigneeId);
notifications.Should().Contain(n => n.RecipientUserId == initiatorId);
notifications.Should().OnlyContain(n => n.Category == "approved");
}
[Fact]
public async Task NotifyTaskApproved_AssigneeIsInitiator_AvoidsDuplicate()
{
var db = CreateDbContext();
var userId = Guid.NewGuid();
var task = NewTask(assigneeId: userId);
db.WorkflowInstances.Add(NewInstance(task.InstanceId, userId));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskApprovedAsync(task);
await db.SaveChangesAsync();
var notifications = await db.Notifications.ToListAsync();
notifications.Should().HaveCount(1, "受理人与发起人相同时不应重复通知");
}
[Fact]
public async Task NotifyTaskRejected_NotifiesInitiator()
{
var db = CreateDbContext();
var initiatorId = Guid.NewGuid();
var task = NewTask(role: "manager");
db.WorkflowInstances.Add(NewInstance(task.InstanceId, initiatorId));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskRejectedAsync(task);
await db.SaveChangesAsync();
var n = await db.Notifications.SingleAsync(x => x.RecipientUserId == initiatorId);
n.Category.Should().Be("rejected");
}
[Fact]
public async Task NotifyTaskTimeout_AutoApproved_UsesCorrectCategoryAndText()
{
var db = CreateDbContext();
var task = NewTask(assigneeId: Guid.NewGuid());
db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid()));
await db.SaveChangesAsync();
var svc = new NotificationService(db, Options.Create(DefaultOptions()), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskTimeoutAsync(task, autoApproved: true);
await db.SaveChangesAsync();
var notifications = await db.Notifications.ToListAsync();
notifications.Should().HaveCountGreaterThanOrEqualTo(1);
notifications.Should().OnlyContain(n => n.Category == "timeout-approved");
notifications.Should().OnlyContain(n => n.Title.Contains("自动通过"));
}
[Fact]
public async Task Notify_WithWebhookConfigured_CreatesWebhookDeliveryPending()
{
var db = CreateDbContext();
var task = NewTask(assigneeId: Guid.NewGuid());
db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid()));
await db.SaveChangesAsync();
var options = new NotificationOptions
{
Webhook = new NotificationOptions.WebhookSection
{
AllowedHosts = ["example.com"],
DefaultUrl = "https://example.com/hook"
}
};
var svc = new NotificationService(db, Options.Create(options), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskArrivedAsync(task);
await db.SaveChangesAsync();
var delivery = await db.WebhookDeliveries.SingleAsync();
delivery.Status.Should().Be("pending");
delivery.Url.Should().Be("https://example.com/hook");
delivery.Attempts.Should().Be(0);
delivery.Payload.Should().Contain("task-arrived");
}
[Theory]
[InlineData("https://example.com/hook", true)]
[InlineData("http://localhost:8080/hook", false)] // localhost 拒绝
[InlineData("http://127.0.0.1/hook", false)] // 回环 IP 拒绝
[InlineData("http://10.0.0.5/hook", false)] // 私有网段拒绝
[InlineData("http://192.168.1.1/hook", false)] // 私有网段拒绝
[InlineData("not-a-url", false)] // 非法 URL 拒绝
public void IsHostAllowed_EnforcesSsrfProtection(string url, bool expected)
{
var db = CreateDbContext();
var options = new NotificationOptions
{
Webhook = new NotificationOptions.WebhookSection
{
AllowedHosts = ["example.com"]
}
};
var svc = new NotificationService(db, Options.Create(options), new NoOpPlatformNotificationPublisher());
svc.IsHostAllowed(url).Should().Be(expected);
}
[Fact]
public async Task Notify_WebhookHostNotInAllowlist_SkipsWebhookDelivery()
{
var db = CreateDbContext();
var task = NewTask(assigneeId: Guid.NewGuid());
db.WorkflowInstances.Add(NewInstance(task.InstanceId, Guid.NewGuid()));
await db.SaveChangesAsync();
// DefaultUrl 指向不在白名单的域
var options = new NotificationOptions
{
Webhook = new NotificationOptions.WebhookSection
{
AllowedHosts = ["allowed.com"],
DefaultUrl = "https://evil.com/hook"
}
};
var svc = new NotificationService(db, Options.Create(options), new NoOpPlatformNotificationPublisher());
await svc.NotifyTaskArrivedAsync(task);
await db.SaveChangesAsync();
// 站内信仍创建(必达),但 Webhook 因 SSRF 防护被跳过
(await db.Notifications.CountAsync()).Should().Be(1);
(await db.WebhookDeliveries.CountAsync()).Should().Be(0);
}
}
/// <summary>
/// 测试用 no-op 平台通知发布器:转发逻辑由 Host 层实现,单元测试无需真实推送,
/// 此实现空跑返回 0,避免 NotificationService 构造依赖阻断。
/// </summary>
file sealed class NoOpPlatformNotificationPublisher : IPlatformNotificationPublisher
{
public Task<int> PublishAsync(
string type, string title, string content, string source,
string? relatedId, string? relatedType,
IEnumerable<Guid> recipientUserIds, CancellationToken ct = default)
=> Task.FromResult(0);
}