using FluentAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Workflow.Application.Notifications; using Workflow.Domain.Entities; using Workflow.Domain.Enums; using Workflow.Infrastructure.Persistence; using Xunit; using TaskStatus = Workflow.Domain.Enums.TaskStatus; namespace Workflow.Tests.Notifications; public class NotificationServiceTests { private static WorkflowDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new WorkflowDbContext(options); } 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())); 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())); 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())); 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())); 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())); 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())); 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)); 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)); 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)); await svc.NotifyTaskArrivedAsync(task); await db.SaveChangesAsync(); // 站内信仍创建(必达),但 Webhook 因 SSRF 防护被跳过 (await db.Notifications.CountAsync()).Should().Be(1); (await db.WebhookDeliveries.CountAsync()).Should().Be(0); } }