work-flow/tests/Workflow.Tests/Notification/NotificationServiceTests.cs
向宁 9f878286e7 feat: form versioning, notification center, scheduler and webhooks
- 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
2026-06-14 15:03:11 +08:00

247 lines
9.0 KiB
C#
Raw Permalink 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.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);
}
}