- Add FormDefinitionVersion with compare/versions endpoints and schema differ - Add Notification entity, endpoints and application features - Add Scheduler (timeout) and WebhookDispatcher services - Add FormDataValidator/FieldPermissionEvaluator/ReactionEvaluator - Add workflow task mark-read, CC support and SystemUserContext - Add EF migrations for form versions and notifications - Add unit tests for form schema, notifications, scheduler and serialization
247 lines
9.0 KiB
C#
247 lines
9.0 KiB
C#
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<WorkflowDbContext>()
|
||
.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);
|
||
}
|
||
}
|