diff --git a/Projects/SesNotifications.App/Controllers/MonitorRuleController.cs b/Projects/SesNotifications.App/Controllers/MonitorRuleController.cs index 59a961a..382b383 100644 --- a/Projects/SesNotifications.App/Controllers/MonitorRuleController.cs +++ b/Projects/SesNotifications.App/Controllers/MonitorRuleController.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Mvc; using SesNotifications.App.Factories; using SesNotifications.App.Models; diff --git a/Projects/SesNotifications.App/Helpers/MatchingHelpers.cs b/Projects/SesNotifications.App/Helpers/MatchingHelpers.cs new file mode 100644 index 0000000..df29894 --- /dev/null +++ b/Projects/SesNotifications.App/Helpers/MatchingHelpers.cs @@ -0,0 +1,23 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; + +namespace SesNotifications.App.Helpers +{ + public static class MatchingHelpers + { + public static JToken TokenizeJson(this string json) + { + return JToken.Parse(json); + } + + public static JToken FindToken(this JToken token, string jsonMatcher) + { + return token.SelectToken(jsonMatcher); + } + + public static bool IsMatch(this string value, string regex) + { + return new Regex(regex).IsMatch(value); + } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.App/Models/MonitorRuleType.cs b/Projects/SesNotifications.App/Models/MonitorRuleType.cs new file mode 100644 index 0000000..2d04a51 --- /dev/null +++ b/Projects/SesNotifications.App/Models/MonitorRuleType.cs @@ -0,0 +1,13 @@ +namespace SesNotifications.App.Models +{ + public enum MonitorRuleType + { + BounceEvent = 0, + ComplaintNotification, + ComplaintEvent, + DeliveryNotification, + DeliveryEvent, + OpenEvent, + SendEvent + } +} diff --git a/Projects/SesNotifications.App/Models/SqsConfiguration.cs b/Projects/SesNotifications.App/Models/SqsConfiguration.cs new file mode 100644 index 0000000..22bdb9e --- /dev/null +++ b/Projects/SesNotifications.App/Models/SqsConfiguration.cs @@ -0,0 +1,10 @@ +namespace SesNotifications.App.Models +{ + public class SqsConfiguration + { + public virtual string QueueUrl { get; set; } + public virtual string Region { get; set; } + public virtual string AccessKey { get; set; } + public virtual string SecretKey { get; set; } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.App/Services/Interfaces/IRuleService.cs b/Projects/SesNotifications.App/Services/Interfaces/IRuleService.cs new file mode 100644 index 0000000..f8801bf --- /dev/null +++ b/Projects/SesNotifications.App/Services/Interfaces/IRuleService.cs @@ -0,0 +1,9 @@ +using SesNotifications.App.Models; + +namespace SesNotifications.App.Services.Interfaces +{ + public interface IRuleService + { + void ProcessMessage(string json, MonitorRuleType ruleType); + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.App/Services/Interfaces/ISqsNotifier.cs b/Projects/SesNotifications.App/Services/Interfaces/ISqsNotifier.cs new file mode 100644 index 0000000..983bfe3 --- /dev/null +++ b/Projects/SesNotifications.App/Services/Interfaces/ISqsNotifier.cs @@ -0,0 +1,9 @@ +using SesNotifications.App.Models; + +namespace SesNotifications.App.Services.Interfaces +{ + public interface ISqsNotifier + { + void Notify(string header, string message, SqsConfiguration configuration); + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.App/Services/NotificationService.cs b/Projects/SesNotifications.App/Services/NotificationService.cs index 738d10d..99ea801 100644 --- a/Projects/SesNotifications.App/Services/NotificationService.cs +++ b/Projects/SesNotifications.App/Services/NotificationService.cs @@ -1,5 +1,4 @@ using System; -using Microsoft.Extensions.Logging; using Newtonsoft.Json; using NLog; using SesNotifications.App.Factories; @@ -29,6 +28,7 @@ public class NotificationService : INotificationService private readonly ISesDeliveryEventsRepository _sesDeliveryEventsRepository; private readonly ISesBounceEventsRepository _sesBounceEventsRepository; private readonly ISesComplaintEventsRepository _sesComplaintEventsRepository; + private readonly IRuleService _ruleService; public NotificationService(INotificationsRepository notificationsRepository, ISesBouncesRepository sesBouncesRepository, @@ -38,8 +38,9 @@ public NotificationService(INotificationsRepository notificationsRepository, ISesSendEventsRepository sesSendEventsRepository, ISesDeliveryEventsRepository sesDeliveryEventsRepository, ISesBounceEventsRepository sesBounceEventsRepository, - ISesComplaintEventsRepository sesComplaintEventsRepository - ) + ISesComplaintEventsRepository sesComplaintEventsRepository, + IRuleService ruleService + ) { _notificationsRepository = notificationsRepository; _sesBouncesRepository = sesBouncesRepository; @@ -51,6 +52,7 @@ ISesComplaintEventsRepository sesComplaintEventsRepository _sesDeliveryEventsRepository = sesDeliveryEventsRepository; _sesBounceEventsRepository = sesBounceEventsRepository; _sesComplaintEventsRepository = sesComplaintEventsRepository; + _ruleService = ruleService; } public void HandleNotification(string content) @@ -127,6 +129,8 @@ private void HandleComplaintEvent(string content) var notification = SaveNotification(complaintEvent.Mail, content); _sesComplaintEventsRepository.Save(complaintEvent.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.ComplaintEvent); } private void HandleBounceEvent(string content) @@ -136,6 +140,8 @@ private void HandleBounceEvent(string content) var notification = SaveNotification(bounceEvent.Mail, content); _sesBounceEventsRepository.Save(bounceEvent.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.BounceEvent); } private void HandleDeliveryEvent(string content) @@ -145,6 +151,8 @@ private void HandleDeliveryEvent(string content) var notification = SaveNotification(delivery.Mail, content); _sesDeliveryEventsRepository.Save(delivery.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.DeliveryEvent); } private void HandleDelivery(string content) @@ -154,6 +162,8 @@ private void HandleDelivery(string content) var notification = SaveNotification(delivery.Mail, content); _sesDeliveriesRepository.Save(delivery.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.DeliveryNotification); } private void HandleComplaint(string content) @@ -163,6 +173,8 @@ private void HandleComplaint(string content) var notification = SaveNotification(complaint.Mail, content); _sesComplaintsRepository.Save(complaint.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.ComplaintNotification); } private void HandleBounce(string content) @@ -172,6 +184,8 @@ private void HandleBounce(string content) var notification = SaveNotification(bounce.Mail, content); _sesBouncesRepository.Save(bounce.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.BounceEvent); } private void HandleOpenEvent(string content) @@ -181,6 +195,8 @@ private void HandleOpenEvent(string content) var notification = SaveNotification(open.Mail, content); _sesOpensEventsRepository.Save(open.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.OpenEvent); } private void HandleSendEvent(string content) @@ -190,6 +206,8 @@ private void HandleSendEvent(string content) var notification = SaveNotification(send.Mail, content); _sesSendEventsRepository.Save(send.Create(notification.Id)); + + _ruleService.ProcessMessage(content, MonitorRuleType.SendEvent); } private SesNotification SaveNotification(SesMail mail, string content) diff --git a/Projects/SesNotifications.App/Services/RuleService.cs b/Projects/SesNotifications.App/Services/RuleService.cs new file mode 100644 index 0000000..bf670fc --- /dev/null +++ b/Projects/SesNotifications.App/Services/RuleService.cs @@ -0,0 +1,79 @@ +using System; +using System.Linq; +using Newtonsoft.Json; +using NLog; +using SesNotifications.App.Helpers; +using SesNotifications.App.Models; +using SesNotifications.App.Services.Interfaces; +using SesNotifications.DataAccess.Repositories.Interfaces; + +namespace SesNotifications.App.Services +{ + public class RuleService : IRuleService + { + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + private readonly IMonitorRuleRepository _monitorRuleRepository; + private readonly ISqsNotifier _sqsNotifier; + private readonly SqsConfiguration _sqsConfiguration; + + public RuleService(IConfigurationRepository configurationRepository, + IMonitorRuleRepository monitorRuleRepository, + ISqsNotifier sqsNotifier) + { + _monitorRuleRepository = monitorRuleRepository; + _sqsNotifier = sqsNotifier; + + var sqsConfig = configurationRepository.GetByKey("sqs_notification_config"); + if (sqsConfig == null) + { + Logger.Debug("No SQS notification configuration found"); + return; + } + + try + { + _sqsConfiguration = JsonConvert.DeserializeObject(sqsConfig.Value); + } + catch (Exception e) + { + Logger.Error(e, "SQS notification configuration is invalid"); + _sqsConfiguration = null; + } + } + + public void ProcessMessage(string json, MonitorRuleType ruleType) + { + if (_sqsConfiguration == null) + { + return; + } + + var rules = _monitorRuleRepository.GetAll(); + var typeRules = rules.Where(x => x.SesMessage.ToLower() == ruleType.ToString().ToLower()).ToList(); + + if (typeRules.Count == 0) + { + return; + } + + var o = json.TokenizeJson(); + + foreach (var rule in typeRules) + { + var extracted = o.FindToken(rule.JsonMatcher); + if (extracted == null) + { + break; + } + + var isMatch = extracted.ToString().IsMatch(rule.Regex); + + if (isMatch) + { + _sqsNotifier.Notify($"Rule {rule.Name} match", extracted.ToString(), _sqsConfiguration); + } + } + } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.App/Services/SqsNotifier.cs b/Projects/SesNotifications.App/Services/SqsNotifier.cs new file mode 100644 index 0000000..3ad7080 --- /dev/null +++ b/Projects/SesNotifications.App/Services/SqsNotifier.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using Amazon; +using Amazon.Runtime; +using Amazon.SQS; +using Amazon.SQS.Model; +using NLog; +using SesNotifications.App.Models; +using SesNotifications.App.Services.Interfaces; + +namespace SesNotifications.App.Services +{ + public class SqsNotifier : ISqsNotifier, IDisposable + { + private static readonly ILogger Logger = LogManager.GetCurrentClassLogger(); + + private AmazonSQSClient _sqsClient; + + public void Notify(string header, string message, SqsConfiguration configuration) + { + var client = CreateClient(configuration); + + try + { + var response = client.SendMessageAsync(new SendMessageRequest + { + QueueUrl = configuration.QueueUrl, + MessageBody = message, + MessageAttributes = new Dictionary + { + ["Title"] = new MessageAttributeValue { DataType = "String", StringValue = header } + } + }, CancellationToken.None).Result; + + if (response.HttpStatusCode != HttpStatusCode.OK) + { + Logger.Error($"Error while sending SNS notification, status code {response.HttpStatusCode}"); + } + } + catch (Exception e) + { + Logger.Error(e, "Unexpected error while sending SQS notification"); + } + } + + private AmazonSQSClient CreateClient(SqsConfiguration configuration) + { + if (_sqsClient == null) + { + _sqsClient = string.IsNullOrEmpty(configuration.AccessKey) + ? new AmazonSQSClient(new AmazonSQSConfig + { + RegionEndpoint = RegionEndpoint.GetBySystemName(configuration.Region) + }) + : new AmazonSQSClient(new BasicAWSCredentials(configuration.AccessKey, configuration.SecretKey), + RegionEndpoint.GetBySystemName(configuration.Region)); + } + + return _sqsClient; + } + + public void Dispose() + { + _sqsClient?.Dispose(); + } + } +} diff --git a/Projects/SesNotifications.App/SesNotifications.App.csproj b/Projects/SesNotifications.App/SesNotifications.App.csproj index 401a7dd..706bf5a 100644 --- a/Projects/SesNotifications.App/SesNotifications.App.csproj +++ b/Projects/SesNotifications.App/SesNotifications.App.csproj @@ -5,6 +5,7 @@ + diff --git a/Projects/SesNotifications.App/Startup.cs b/Projects/SesNotifications.App/Startup.cs index 8130129..ef81fa2 100644 --- a/Projects/SesNotifications.App/Startup.cs +++ b/Projects/SesNotifications.App/Startup.cs @@ -67,6 +67,8 @@ public void ConfigureScopedRepositories(IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) diff --git a/Projects/SesNotifications.DataAccess/Entities/ConfigurationItem.cs b/Projects/SesNotifications.DataAccess/Entities/ConfigurationItem.cs new file mode 100644 index 0000000..dc24fad --- /dev/null +++ b/Projects/SesNotifications.DataAccess/Entities/ConfigurationItem.cs @@ -0,0 +1,9 @@ +namespace SesNotifications.DataAccess.Entities +{ + public class ConfigurationItem + { + public virtual int Id { get; set; } + public virtual string Key { get; set; } + public virtual string Value { get; set; } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.DataAccess/Mappings/ConfigurationMap.cs b/Projects/SesNotifications.DataAccess/Mappings/ConfigurationMap.cs new file mode 100644 index 0000000..8e7d840 --- /dev/null +++ b/Projects/SesNotifications.DataAccess/Mappings/ConfigurationMap.cs @@ -0,0 +1,17 @@ +using FluentNHibernate.Mapping; +using SesNotifications.DataAccess.Entities; + +namespace SesNotifications.DataAccess.Mappings +{ + public class ConfigurationMap : ClassMap + { + public ConfigurationMap() + { + Table("ses_notifications.configuration"); + Id(x => x.Id).Column("id").GeneratedBy.Identity(); + Map(x => x.Key).Column("key"); + Map(x => x.Value).Column("value"); + Cache.ReadOnly(); + } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.DataAccess/Repositories/ConfigurationRepository.cs b/Projects/SesNotifications.DataAccess/Repositories/ConfigurationRepository.cs new file mode 100644 index 0000000..797b7b0 --- /dev/null +++ b/Projects/SesNotifications.DataAccess/Repositories/ConfigurationRepository.cs @@ -0,0 +1,26 @@ +using NHibernate; +using NHibernate.Criterion; +using SesNotifications.DataAccess.Entities; +using SesNotifications.DataAccess.Repositories.Interfaces; + +namespace SesNotifications.DataAccess.Repositories +{ + public class ConfigurationRepository : Repository, IConfigurationRepository + { + public ConfigurationRepository() + { + } + + public ConfigurationRepository(ISession session) : base(session) + { + } + + public ConfigurationItem GetByKey(string key) + { + return Session.CreateCriteria() + .Add(Restrictions.Eq(nameof(ConfigurationItem.Key), key)) + .SetCacheable(true) + .UniqueResult(); + } + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.DataAccess/Repositories/Interfaces/IConfigurationRepository.cs b/Projects/SesNotifications.DataAccess/Repositories/Interfaces/IConfigurationRepository.cs new file mode 100644 index 0000000..6d6e9a3 --- /dev/null +++ b/Projects/SesNotifications.DataAccess/Repositories/Interfaces/IConfigurationRepository.cs @@ -0,0 +1,9 @@ +using SesNotifications.DataAccess.Entities; + +namespace SesNotifications.DataAccess.Repositories.Interfaces +{ + public interface IConfigurationRepository + { + ConfigurationItem GetByKey(string key); + } +} \ No newline at end of file diff --git a/Projects/SesNotifications.DataAccess/StartupExtensions.cs b/Projects/SesNotifications.DataAccess/StartupExtensions.cs index 13470b5..80fa5ba 100644 --- a/Projects/SesNotifications.DataAccess/StartupExtensions.cs +++ b/Projects/SesNotifications.DataAccess/StartupExtensions.cs @@ -28,6 +28,7 @@ public static IServiceCollection AddNHibernate(this IServiceCollection services, .Add() .Add() .Add() + .Add() ) .BuildSessionFactory(); @@ -57,6 +58,7 @@ public static IServiceCollection AddScopedRepositories(this IServiceCollection s services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/Sql/ses_notifications_init.sql b/Sql/ses_notifications_init.sql index a333f3c..2a4b5a9 100644 --- a/Sql/ses_notifications_init.sql +++ b/Sql/ses_notifications_init.sql @@ -220,6 +220,14 @@ CREATE TABLE ses_notifications.monitorrules ( ); CREATE UNIQUE INDEX monitorrules_id_idx ON ses_notifications.monitorrules (id); +CREATE TABLE ses_notifications."configuration" ( + id int NOT NULL GENERATED ALWAYS AS IDENTITY, + "key" varchar(100) NOT NULL, + value varchar(4000) NOT NULL +); +CREATE UNIQUE INDEX configuration_id_idx ON ses_notifications."configuration" (id); +CREATE INDEX configuration_key_idx ON ses_notifications."configuration" ("key"); + CREATE OR REPLACE VIEW ses_notifications.operational AS SELECT bounceevents.notification_id, bounceevents.notification_type, diff --git a/Tests/SesNotifications.App.Tests/Factories/DbMonitorRuleFactoryTests.cs b/Tests/SesNotifications.App.Tests/Factories/DbMonitorRuleFactoryTests.cs new file mode 100644 index 0000000..24acc1f --- /dev/null +++ b/Tests/SesNotifications.App.Tests/Factories/DbMonitorRuleFactoryTests.cs @@ -0,0 +1,51 @@ +using SesNotifications.App.Factories; +using SesNotifications.App.Models; +using Xunit; + +namespace SesNotifications.App.Tests.Factories +{ + public class DbMonitorRuleFactoryTests + { + [Fact] + public void VerifyOneWay() + { + var rule = new MonitorRule + { + JsonMatcher = "json", + SesMessage = "ses", + Regex = "regex", + Name = "name", + Id = 1 + }; + + var created = rule.Create(); + + Assert.Equal(created.JsonMatcher, rule.JsonMatcher); + Assert.Equal(created.Regex, rule.Regex); + Assert.Equal(created.SesMessage, rule.SesMessage); + Assert.Equal(created.Name, rule.Name); + Assert.Equal(created.Id, rule.Id); + } + + [Fact] + public void VerifyOtherWay() + { + var rule = new DataAccess.Entities.MonitorRule() + { + JsonMatcher = "json", + SesMessage = "ses", + Regex = "regex", + Name = "name", + Id = 1 + }; + + var created = rule.Create(); + + Assert.Equal(created.JsonMatcher, rule.JsonMatcher); + Assert.Equal(created.Regex, rule.Regex); + Assert.Equal(created.SesMessage, rule.SesMessage); + Assert.Equal(created.Name, rule.Name); + Assert.Equal(created.Id, rule.Id); + } + } +} diff --git a/Tests/SesNotifications.App.Tests/Helpers/MatchingHelperTests.cs b/Tests/SesNotifications.App.Tests/Helpers/MatchingHelperTests.cs new file mode 100644 index 0000000..a5a863f --- /dev/null +++ b/Tests/SesNotifications.App.Tests/Helpers/MatchingHelperTests.cs @@ -0,0 +1,45 @@ +using SesNotifications.App.Helpers; +using Xunit; + +namespace SesNotifications.App.Tests.Helpers +{ + public class MatchingHelperTests + { + [Fact] + public void FindMatch() + { + var t = Delivery.TokenizeJson(); + + var found = t.FindToken("$.notificationType"); + + Assert.NotNull(found); + Assert.Equal("Delivery", found.ToString()); + } + + [Fact] + public void DoNotFindMatch() + { + var t = Delivery.TokenizeJson(); + + var found = t.FindToken("$.nothing"); + + Assert.Null(found); + } + + [Theory] + [InlineData("john@example.com", true)] + [InlineData("other@example.com", false)] + public void Regex(string toFind, bool expected) + { + var t = Delivery.TokenizeJson(); + + var token = t.FindToken("$.mail.source"); + + var found = token.ToString().IsMatch(toFind); + + Assert.Equal(expected, found); + } + + private const string Delivery = "{ \"notificationType\":\"Delivery\", \"mail\":{ \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"messageId\":\"0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000000\", \"source\":\"john@example.com\", \"sourceArn\": \"arn:aws:ses:us-west-2:888888888888:identity/example.com\", \"sourceIp\": \"127.0.3.0\", \"sendingAccountId\":\"123456789012\", \"destination\":[ \"jane@example.com\" ], \"headersTruncated\":false, \"headers\":[ { \"name\":\"From\", \"value\":\"\\\"John Doe\\\" \" }, { \"name\":\"To\", \"value\":\"\\\"Jane Doe\\\" \" }, { \"name\":\"Message-ID\", \"value\":\"custom-message-ID\" }, { \"name\":\"Subject\", \"value\":\"Hello\" }, { \"name\":\"Content-Type\", \"value\":\"text/plain; charset=\\\"UTF-8\\\"\" }, { \"name\":\"Content-Transfer-Encoding\", \"value\":\"base64\" }, { \"name\":\"Date\", \"value\":\"Wed, 27 Jan 2016 14:58:45 +0000\" } ], \"commonHeaders\":{ \"from\":[ \"John Doe \" ], \"date\":\"Wed, 27 Jan 2016 14:58:45 +0000\", \"to\":[ \"Jane Doe \" ], \"messageId\":\"custom-message-ID\", \"subject\":\"Hello\" } }, \"delivery\":{ \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"recipients\":[\"jane@example.com\"], \"processingTimeMillis\":546, \"reportingMTA\":\"a8-70.smtp-out.amazonses.com\", \"smtpResponse\":\"250 ok: Message 64111812 accepted\", \"remoteMtaIp\":\"127.0.2.0\" } }"; + } +} diff --git a/Tests/SesNotifications.App.Tests/Services/NotificationServiceTests.cs b/Tests/SesNotifications.App.Tests/Services/NotificationServiceTests.cs index 4a24090..835957b 100644 --- a/Tests/SesNotifications.App.Tests/Services/NotificationServiceTests.cs +++ b/Tests/SesNotifications.App.Tests/Services/NotificationServiceTests.cs @@ -1,10 +1,20 @@ using System; using Moq; using Newtonsoft.Json; +using SesNotifications.App.Models; using SesNotifications.App.Services; +using SesNotifications.App.Services.Interfaces; using SesNotifications.DataAccess.Entities; using SesNotifications.DataAccess.Repositories.Interfaces; using Xunit; +using SesBounce = SesNotifications.DataAccess.Entities.SesBounce; +using SesBounceEvent = SesNotifications.DataAccess.Entities.SesBounceEvent; +using SesComplaint = SesNotifications.DataAccess.Entities.SesComplaint; +using SesComplaintEvent = SesNotifications.DataAccess.Entities.SesComplaintEvent; +using SesDelivery = SesNotifications.DataAccess.Entities.SesDelivery; +using SesDeliveryEvent = SesNotifications.DataAccess.Entities.SesDeliveryEvent; +using SesOpenEvent = SesNotifications.DataAccess.Entities.SesOpenEvent; +using SesSendEvent = SesNotifications.DataAccess.Entities.SesSendEvent; namespace SesNotifications.App.Tests.Services { @@ -15,17 +25,19 @@ public void VerifyDelivery() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesDeliveries = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesDeliveries.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, mockSesDeliveries.Object, - null, null, null, null, null); + null, null, null, null, null, mockRuleService.Object); service.HandleNotification(Delivery); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesDeliveries.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -33,17 +45,19 @@ public void VerifyBounce() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesBounces = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesBounces.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, mockSesBounces.Object, null, null, - null, null, null, null, null); + null, null, null, null, null, mockRuleService.Object); service.HandleNotification(Bounce); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesBounces.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -51,17 +65,19 @@ public void VerifyComplaints() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesComplaints = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesComplaints.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, mockSesComplaints.Object, null, - null, null, null, null, null); + null, null, null, null, null, mockRuleService.Object); service.HandleNotification(Complaint); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesComplaints.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -69,17 +85,19 @@ public void VerifyOpenEvents() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesOpens = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesOpens.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, null, - mockSesOpens.Object, null, null, null, null); + mockSesOpens.Object, null, null, null, null, mockRuleService.Object); service.HandleNotification(OpenEvent); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesOpens.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -87,17 +105,19 @@ public void VerifySendEvents() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesSends = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesSends.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, null, - null, mockSesSends.Object, null, null, null); + null, mockSesSends.Object, null, null, null, mockRuleService.Object); service.HandleNotification(SendEvent); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesSends.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -105,17 +125,19 @@ public void VerifyDeliveryEvents() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesDeliveries = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesDeliveries.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, null, - null, null, mockSesDeliveries.Object, null, null); + null, null, mockSesDeliveries.Object, null, null, mockRuleService.Object); service.HandleNotification(DeliveryEvent); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesDeliveries.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -123,17 +145,19 @@ public void VerifyBounceEvents() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesBounceEvents = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesBounceEvents.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, null, - null, null, null, mockSesBounceEvents.Object, null); + null, null, null, mockSesBounceEvents.Object, null, mockRuleService.Object); service.HandleNotification(BounceEvent); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesBounceEvents.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -141,24 +165,26 @@ public void VerifyComplaintEvents() { var mockNotifications = new Mock(MockBehavior.Strict); var mockSesComplaintEvents = new Mock(MockBehavior.Strict); + var mockRuleService = CreateRuleMock(); mockNotifications.Setup(x => x.Save(It.IsAny())); mockSesComplaintEvents.Setup(x => x.Save(It.IsAny())); var service = new NotificationService(mockNotifications.Object, null, null, null, - null, null, null, null, mockSesComplaintEvents.Object); + null, null, null, null, mockSesComplaintEvents.Object, mockRuleService.Object); service.HandleNotification(ComplaintEvent); mockNotifications.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); mockSesComplaintEvents.Verify(x => x.Save(It.IsAny()), Times.Exactly(1)); + mockRuleService.Verify(x => x.ProcessMessage(It.IsAny(), It.IsAny()), Times.Once); } [Fact] public void VerifyInvalidException() { var service = - new NotificationService(null, null, null, null, null, null, null, null, null); + new NotificationService(null, null, null, null, null, null, null, null, null, null); Assert.Throws(() => service.HandleNotification(NotJson)); } @@ -167,11 +193,18 @@ public void VerifyInvalidException() public void VerifyUnsupportedException() { var service = - new NotificationService(null, null, null, null, null, null, null, null, null); + new NotificationService(null, null, null, null, null, null, null, null, null, null); Assert.Throws(() => service.HandleNotification(Invalid)); } + private Mock CreateRuleMock() + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(x => x.ProcessMessage(It.IsAny(), It.IsAny())); + return mock; + } + // Some of the examples taken from https://docs.aws.amazon.com/ses/latest/DeveloperGuide/notification-examples.html. private const string Delivery = "{ \"notificationType\":\"Delivery\", \"mail\":{ \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"messageId\":\"0000014644fe5ef6-9a483358-9170-4cb4-a269-f5dcdf415321-000000\", \"source\":\"john@example.com\", \"sourceArn\": \"arn:aws:ses:us-west-2:888888888888:identity/example.com\", \"sourceIp\": \"127.0.3.0\", \"sendingAccountId\":\"123456789012\", \"destination\":[ \"jane@example.com\" ], \"headersTruncated\":false, \"headers\":[ { \"name\":\"From\", \"value\":\"\\\"John Doe\\\" \" }, { \"name\":\"To\", \"value\":\"\\\"Jane Doe\\\" \" }, { \"name\":\"Message-ID\", \"value\":\"custom-message-ID\" }, { \"name\":\"Subject\", \"value\":\"Hello\" }, { \"name\":\"Content-Type\", \"value\":\"text/plain; charset=\\\"UTF-8\\\"\" }, { \"name\":\"Content-Transfer-Encoding\", \"value\":\"base64\" }, { \"name\":\"Date\", \"value\":\"Wed, 27 Jan 2016 14:58:45 +0000\" } ], \"commonHeaders\":{ \"from\":[ \"John Doe \" ], \"date\":\"Wed, 27 Jan 2016 14:58:45 +0000\", \"to\":[ \"Jane Doe \" ], \"messageId\":\"custom-message-ID\", \"subject\":\"Hello\" } }, \"delivery\":{ \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"recipients\":[\"jane@example.com\"], \"processingTimeMillis\":546, \"reportingMTA\":\"a8-70.smtp-out.amazonses.com\", \"smtpResponse\":\"250 ok: Message 64111812 accepted\", \"remoteMtaIp\":\"127.0.2.0\" } }"; private const string Complaint ="{ \"notificationType\":\"Complaint\", \"complaint\":{ \"userAgent\":\"AnyCompany Feedback Loop (V0.01)\", \"complainedRecipients\":[ { \"emailAddress\":\"richard@example.com\" } ], \"complaintFeedbackType\":\"abuse\", \"arrivalDate\":\"2016-01-27T14:59:38.237Z\", \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"feedbackId\":\"000001378603177f-18c07c78-fa81-4a58-9dd1-fedc3cb8f49a-000000\" }, \"mail\":{ \"timestamp\":\"2016-01-27T14:59:38.237Z\", \"messageId\":\"000001378603177f-7a5433e7-8edb-42ae-af10-f0181f34d6ee-000000\", \"source\":\"john@example.com\", \"sourceArn\": \"arn:aws:ses:us-west-2:888888888888:identity/example.com\", \"sourceIp\": \"127.0.3.0\", \"sendingAccountId\":\"123456789012\", \"destination\":[ \"jane@example.com\", \"mary@example.com\", \"richard@example.com\" ], \"headersTruncated\":false, \"headers\":[ { \"name\":\"From\", \"value\":\"\\\"John Doe\\\" \" }, { \"name\":\"To\", \"value\":\"\\\"Jane Doe\\\" , \\\"Mary Doe\\\" , \\\"Richard Doe\\\" \" }, { \"name\":\"Message-ID\", \"value\":\"custom-message-ID\" }, { \"name\":\"Subject\", \"value\":\"Hello\" }, { \"name\":\"Content-Type\", \"value\":\"text/plain; charset=\\\"UTF-8\\\"\" }, { \"name\":\"Content-Transfer-Encoding\", \"value\":\"base64\" }, { \"name\":\"Date\", \"value\":\"Wed, 27 Jan 2016 14:05:45 +0000\" } ], \"commonHeaders\":{ \"from\":[ \"John Doe \" ], \"date\":\"Wed, 27 Jan 2016 14:05:45 +0000\", \"to\":[ \"Jane Doe , Mary Doe , Richard Doe \" ], \"messageId\":\"custom-message-ID\", \"subject\":\"Hello\" } } }"; diff --git a/Tests/SesNotifications.App.Tests/Services/RuleServiceTests.cs b/Tests/SesNotifications.App.Tests/Services/RuleServiceTests.cs new file mode 100644 index 0000000..7c1cb17 --- /dev/null +++ b/Tests/SesNotifications.App.Tests/Services/RuleServiceTests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using Moq; +using Newtonsoft.Json; +using SesNotifications.App.Models; +using SesNotifications.App.Services; +using SesNotifications.App.Services.Interfaces; +using SesNotifications.DataAccess.Entities; +using SesNotifications.DataAccess.Repositories.Interfaces; +using Xunit; +using MonitorRule = SesNotifications.DataAccess.Entities.MonitorRule; + +namespace SesNotifications.App.Tests.Services +{ + public class RuleServiceTests + { + [Fact] + public void NothingHappensWithoutConfiguration() + { + var mockConfigRepo = new Mock(MockBehavior.Strict); + mockConfigRepo.Setup(x => x.GetByKey("sqs_notification_config")).Returns((ConfigurationItem) null); + + var service = new RuleService(mockConfigRepo.Object, null, null); + + service.ProcessMessage("json", MonitorRuleType.BounceEvent); + + mockConfigRepo.Verify(x => x.GetByKey("sqs_notification_config"), Times.Once); + } + + [Fact] + public void NothingHappensWithoutConfiguredRule() + { + var mockConfigRepo = GetConfigRepository(); + var mockRulesRepo = GetRuleRepository(new List { new MonitorRule { JsonMatcher = "matcher", SesMessage = SesMessageTypes.SendEvent.ToString()}}); + + var service = new RuleService(mockConfigRepo.Object, mockRulesRepo.Object, null); + + service.ProcessMessage("invalid json", MonitorRuleType.BounceEvent); + + mockConfigRepo.Verify(x => x.GetByKey("sqs_notification_config"), Times.Once); + mockRulesRepo.Verify(x => x.GetAll(), Times.Once()); + } + + [Fact] + public void NothingHappensWithoutMatch() + { + var mockConfigRepo = GetConfigRepository(); + var mockRulesRepo = GetRuleRepository(new List { new MonitorRule { JsonMatcher = "$.field", Regex = "non_existent", SesMessage = SesMessageTypes.BounceEvent.ToString() } }); + + var service = new RuleService(mockConfigRepo.Object, mockRulesRepo.Object, null); + + service.ProcessMessage(Json, MonitorRuleType.BounceEvent); + + mockConfigRepo.Verify(x => x.GetByKey("sqs_notification_config"), Times.Once); + mockRulesRepo.Verify(x => x.GetAll(), Times.Once()); + } + + [Fact] + public void NotificationSend() + { + var mockConfigRepo = GetConfigRepository(); + var mockRulesRepo = GetRuleRepository(new List { new MonitorRule { JsonMatcher = "$.field", Regex = "value", SesMessage = SesMessageTypes.BounceEvent.ToString() } }); + var mockNotifier = new Mock(MockBehavior.Strict); + mockNotifier.Setup(x => x.Notify(It.IsAny(), It.IsAny(), It.IsAny())); + + var service = new RuleService(mockConfigRepo.Object, mockRulesRepo.Object, mockNotifier.Object); + + service.ProcessMessage(Json, MonitorRuleType.BounceEvent); + + mockConfigRepo.Verify(x => x.GetByKey("sqs_notification_config"), Times.Once); + mockRulesRepo.Verify(x => x.GetAll(), Times.Once()); + mockNotifier.Verify(x => x.Notify(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + private Mock GetRuleRepository(IList rules) + { + var repo = new Mock(MockBehavior.Strict); + repo.Setup(x => x.GetAll()).Returns(rules); + return repo; + } + + private Mock GetConfigRepository() + { + var mockConfigRepo = new Mock(MockBehavior.Strict); + mockConfigRepo.Setup(x => x.GetByKey("sqs_notification_config")).Returns(new ConfigurationItem { Value = JsonConvert.SerializeObject(new SqsConfiguration())}); + return mockConfigRepo; + } + + private const string Json = "{\"field\": \"value\"}"; + } +}