xiangning 5332ff3867 refactor(monitoring): 重构监控模块数据库表名和查询处理
- 将监控模块表前缀从 mon_ 改为 b_
- 为工作流审批任务处理器添加流程引擎图加载逻辑
- 移除外部系统相关DTO和查询类的直接引用改为查询对象模式
- 为脚本执行服务添加模块程序集诊断日志
- 为分享表单提交功能增加密码验证支持
- 集成脚本模块数据库迁移支持
- 更新通知服务测试以兼容IOptionsMonitor接口变化
2026-07-19 13:09:43 +08:00

271 lines
11 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 = ""
}
};
/// <summary>包装 IOptionsMonitor(NotificationService 现用 IOptionsMonitor 而非 IOptions)。</summary>
private static IOptionsMonitor<NotificationOptions> OptionsMonitorOf(NotificationOptions options) =>
new ConstantOptionsMonitor(options);
private sealed class ConstantOptionsMonitor(NotificationOptions options) : IOptionsMonitor<NotificationOptions>
{
public NotificationOptions CurrentValue => options;
public NotificationOptions Get(string? name) => options;
public IDisposable? OnChange(Action<NotificationOptions, string?> listener) => null!;
}
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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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, OptionsMonitorOf(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);
}