diff --git a/Modules/Messages/src/Messages.Application/DomainEvents/Incoming/RelationshipStatusChanged/RelationshipStatusChangedDomainEventHandler.cs b/Modules/Messages/src/Messages.Application/DomainEvents/Incoming/RelationshipStatusChanged/RelationshipStatusChangedDomainEventHandler.cs new file mode 100644 index 0000000000..dc84a4f1a4 --- /dev/null +++ b/Modules/Messages/src/Messages.Application/DomainEvents/Incoming/RelationshipStatusChanged/RelationshipStatusChangedDomainEventHandler.cs @@ -0,0 +1,53 @@ +using System.Text; +using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.EventBus; +using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Messages.Application.Infrastructure.Persistence.Repository; +using Backbone.Modules.Messages.Domain.DomainEvents.Incoming; +using Backbone.Modules.Messages.Domain.Entities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Backbone.Modules.Messages.Application.DomainEvents.Incoming.RelationshipStatusChanged; + +public class RelationshipStatusChangedDomainEventHandler : IDomainEventHandler +{ + private const string DELETED_IDENTITY_STRING = "deleted identity"; + private readonly IMessagesRepository _messagesRepository; + private readonly ILogger _logger; + private readonly ApplicationOptions _applicationOptions; + + public RelationshipStatusChangedDomainEventHandler(IMessagesRepository messagesRepository, IOptions applicationOptions, + ILogger logger) + { + _messagesRepository = messagesRepository; + _logger = logger; + _applicationOptions = applicationOptions.Value; + } + + public async Task Handle(RelationshipStatusChangedDomainEvent @event) + { + if (@event.NewStatus != RelationshipStatus.ReadyForDeletion.ToString()) + { + _logger.LogTrace("Relationship status changed to {newStatus}. No Message anonymization required.", @event.NewStatus); + return; + } + + var anonymizedIdentityAddress = IdentityAddress.Create(Encoding.Unicode.GetBytes(DELETED_IDENTITY_STRING), _applicationOptions.DidDomainName); + var messagesExchangedBetweenRelationshipParticipants = (await _messagesRepository.Find(Message.WasExchangedBetween(@event.Initiator, @event.Peer), CancellationToken.None)).ToList(); + foreach (var message in messagesExchangedBetweenRelationshipParticipants) + { + message.SanitizeAfterRelationshipDeleted(@event.Initiator, @event.Peer, anonymizedIdentityAddress); + } + + await _messagesRepository.Update(messagesExchangedBetweenRelationshipParticipants); + } +} + +internal static partial class RelationshipStatusChangedLogs +{ + [LoggerMessage( + EventName = "Messages.RelationshipStatusChangedDomainEventHandler.RelationshipStatusChanged", + Level = LogLevel.Debug, + Message = "Relationship status changed to {newStatus}. No Message anonymization required.")] + public static partial void RelationshipStatusChanged(this ILogger logger, string newStatus); +} diff --git a/Modules/Messages/src/Messages.Application/Extensions/IEventBusExtensions.cs b/Modules/Messages/src/Messages.Application/Extensions/IEventBusExtensions.cs new file mode 100644 index 0000000000..9e83bff289 --- /dev/null +++ b/Modules/Messages/src/Messages.Application/Extensions/IEventBusExtensions.cs @@ -0,0 +1,14 @@ +using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.EventBus; +using Backbone.Modules.Messages.Application.DomainEvents.Incoming.RelationshipStatusChanged; +using Backbone.Modules.Messages.Domain.DomainEvents.Incoming; + +namespace Backbone.Modules.Messages.Application.Extensions; + +public static class IEventBusExtensions +{ + public static IEventBus AddMessagesDomainEventSubscriptions(this IEventBus eventBus) + { + eventBus.Subscribe(); + return eventBus; + } +} diff --git a/Modules/Messages/src/Messages.Application/Extensions/IServiceCollectionExtensions.cs b/Modules/Messages/src/Messages.Application/Extensions/IServiceCollectionExtensions.cs index ee1890e654..c87e9b9657 100644 --- a/Modules/Messages/src/Messages.Application/Extensions/IServiceCollectionExtensions.cs +++ b/Modules/Messages/src/Messages.Application/Extensions/IServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using System.Reflection; +using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.EventBus; using Backbone.BuildingBlocks.Application.MediatR; using Backbone.Modules.Messages.Application.AutoMapper; using Backbone.Modules.Messages.Application.Messages.Commands.SendMessage; @@ -18,5 +20,25 @@ public static void AddApplication(this IServiceCollection services) ); services.AddAutoMapper(typeof(AutoMapperProfile).Assembly); services.AddValidatorsFromAssembly(typeof(SendMessageCommandValidator).Assembly); + services.AddEventHandlers(); + } + + private static void AddEventHandlers(this IServiceCollection services) + { + foreach (var eventHandler in GetAllDomainEventHandlers()) + { + services.AddTransient(eventHandler); + } + } + + private static IEnumerable GetAllDomainEventHandlers() + { + var domainEventHandlerTypes = + from t in Assembly.GetExecutingAssembly().GetTypes() + from i in t.GetInterfaces() + where t.IsClass && !t.IsAbstract && i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDomainEventHandler<>) + select t; + + return domainEventHandlerTypes; } } diff --git a/Modules/Messages/src/Messages.Application/Infrastructure/Persistence/Repository/IMessagesRepository.cs b/Modules/Messages/src/Messages.Application/Infrastructure/Persistence/Repository/IMessagesRepository.cs index 522b662a14..87a7482483 100644 --- a/Modules/Messages/src/Messages.Application/Infrastructure/Persistence/Repository/IMessagesRepository.cs +++ b/Modules/Messages/src/Messages.Application/Infrastructure/Persistence/Repository/IMessagesRepository.cs @@ -10,7 +10,6 @@ namespace Backbone.Modules.Messages.Application.Infrastructure.Persistence.Repos public interface IMessagesRepository { Task> FindMessagesWithIds(IEnumerable ids, IdentityAddress requiredParticipant, PaginationFilter paginationFilter, CancellationToken cancellationToken, bool track = false); - Task Find(MessageId id, IdentityAddress requiredParticipant, CancellationToken cancellationToken, bool track = false, bool fillBody = true); Task Add(Message message, CancellationToken cancellationToken); Task CountUnreceivedMessagesFromSenderToRecipient(IdentityAddress sender, IdentityAddress recipient, CancellationToken cancellationToken); diff --git a/Modules/Messages/src/Messages.ConsumerApi/MessagesModule.cs b/Modules/Messages/src/Messages.ConsumerApi/MessagesModule.cs index 96c45a6cf0..7bc5f7057a 100644 --- a/Modules/Messages/src/Messages.ConsumerApi/MessagesModule.cs +++ b/Modules/Messages/src/Messages.ConsumerApi/MessagesModule.cs @@ -4,7 +4,6 @@ using Backbone.Modules.Messages.Application; using Backbone.Modules.Messages.Application.Extensions; using Backbone.Modules.Messages.Infrastructure.Persistence; -using Backbone.Tooling.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -35,5 +34,6 @@ public override void ConfigureServices(IServiceCollection services, IConfigurati public override void ConfigureEventBus(IEventBus eventBus) { + eventBus.AddMessagesDomainEventSubscriptions(); } } diff --git a/Modules/Messages/src/Messages.Domain/DomainEvents/Incoming/RelationshipStatusChangedDomainEvent.cs b/Modules/Messages/src/Messages.Domain/DomainEvents/Incoming/RelationshipStatusChangedDomainEvent.cs new file mode 100644 index 0000000000..066e36dfe9 --- /dev/null +++ b/Modules/Messages/src/Messages.Domain/DomainEvents/Incoming/RelationshipStatusChangedDomainEvent.cs @@ -0,0 +1,11 @@ +using Backbone.BuildingBlocks.Domain.Events; + +namespace Backbone.Modules.Messages.Domain.DomainEvents.Incoming; + +public class RelationshipStatusChangedDomainEvent : DomainEvent +{ + public required string RelationshipId { get; set; } + public required string NewStatus { get; set; } + public required string Initiator { get; set; } + public required string Peer { get; set; } +} diff --git a/Modules/Messages/src/Messages.Domain/Entities/Message.cs b/Modules/Messages/src/Messages.Domain/Entities/Message.cs index 866bed7d81..55d37aab29 100644 --- a/Modules/Messages/src/Messages.Domain/Entities/Message.cs +++ b/Modules/Messages/src/Messages.Domain/Entities/Message.cs @@ -59,17 +59,45 @@ public void LoadBody(byte[] bytes) public void ReplaceIdentityAddress(IdentityAddress oldIdentityAddress, IdentityAddress newIdentityAddress) { if (CreatedBy == oldIdentityAddress) - { - CreatedBy = newIdentityAddress; - } + AnonymizeSender(newIdentityAddress); - var recipient = Recipients.FirstOrDefault(r => r.Address == oldIdentityAddress); + AnonymizeRecipient(oldIdentityAddress, newIdentityAddress); + } + + public void SanitizeAfterRelationshipDeleted(string participantOne, string participantTwo, IdentityAddress anonymizedIdentityAddress) + { + AnonymizeRecipient(participantOne, anonymizedIdentityAddress); + AnonymizeRecipient(participantTwo, anonymizedIdentityAddress); - recipient?.UpdateAddress(newIdentityAddress); + if (CanAnonymizeSender(anonymizedIdentityAddress)) + AnonymizeSender(anonymizedIdentityAddress); } public static Expression> WasCreatedBy(IdentityAddress identityAddress) { return i => i.CreatedBy == identityAddress.ToString(); } + + public static Expression> WasExchangedBetween(IdentityAddress identityAddress1, IdentityAddress identityAddress2) + { + return m => + (m.CreatedBy == identityAddress1 && m.Recipients.Any(r => r.Address == identityAddress2)) || + (m.CreatedBy == identityAddress2 && m.Recipients.Any(r => r.Address == identityAddress1)); + } + + private void AnonymizeRecipient(string participantAddress, IdentityAddress anonymizedIdentityAddress) + { + var recipient = Recipients.FirstOrDefault(r => r.Address == participantAddress); + recipient?.UpdateAddress(anonymizedIdentityAddress); + } + + private bool CanAnonymizeSender(IdentityAddress anonymizedIdentityAddress) + { + return Recipients.All(r => r.Address == anonymizedIdentityAddress); + } + + private void AnonymizeSender(IdentityAddress anonymizedIdentityAddress) + { + CreatedBy = anonymizedIdentityAddress; + } } diff --git a/Modules/Messages/src/Messages.Domain/Entities/Relationship.cs b/Modules/Messages/src/Messages.Domain/Entities/Relationship.cs index 870d5732f6..8890b1bfcf 100644 --- a/Modules/Messages/src/Messages.Domain/Entities/Relationship.cs +++ b/Modules/Messages/src/Messages.Domain/Entities/Relationship.cs @@ -56,5 +56,7 @@ public enum RelationshipStatus Active = 20, Rejected = 30, Revoked = 40, - Terminated = 50 + Terminated = 50, + DeletionProposed = 60, + ReadyForDeletion = 70 } diff --git a/Modules/Messages/src/Messages.Infrastructure/Persistence/Database/Repository/RelationshipsRepository.cs b/Modules/Messages/src/Messages.Infrastructure/Persistence/Database/Repository/RelationshipsRepository.cs index 4ad8f3d899..6ee22941b7 100644 --- a/Modules/Messages/src/Messages.Infrastructure/Persistence/Database/Repository/RelationshipsRepository.cs +++ b/Modules/Messages/src/Messages.Infrastructure/Persistence/Database/Repository/RelationshipsRepository.cs @@ -9,17 +9,16 @@ namespace Backbone.Modules.Messages.Infrastructure.Persistence.Database.Reposito public class RelationshipsRepository : IRelationshipsRepository { - private readonly MessagesDbContext _dbContext; + private readonly IQueryable _readOnlyRelationships; public RelationshipsRepository(MessagesDbContext dbContext) { - _dbContext = dbContext; + _readOnlyRelationships = dbContext.Relationships.AsNoTracking(); } public Task GetIdOfRelationshipBetweenSenderAndRecipient(IdentityAddress sender, IdentityAddress recipient) { - return _dbContext.Relationships - .AsNoTracking() + return _readOnlyRelationships .WithParticipants(sender, recipient) .Select(r => r.Id) .FirstOrDefaultAsync(); @@ -27,8 +26,7 @@ public RelationshipsRepository(MessagesDbContext dbContext) public Task FindYoungestRelationship(IdentityAddress sender, IdentityAddress recipient, CancellationToken cancellationToken) { - return _dbContext.Relationships - .AsNoTracking() + return _readOnlyRelationships .WithParticipants(sender, recipient) .OrderByDescending(r => r.CreatedAt) .FirstOrDefaultAsync(cancellationToken); diff --git a/Modules/Messages/test/Messages.Domain.Tests/Messages.Domain.Tests.csproj b/Modules/Messages/test/Messages.Domain.Tests/Messages.Domain.Tests.csproj index 503d7bea6b..3f87b4e8fc 100644 --- a/Modules/Messages/test/Messages.Domain.Tests/Messages.Domain.Tests.csproj +++ b/Modules/Messages/test/Messages.Domain.Tests/Messages.Domain.Tests.csproj @@ -7,8 +7,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Modules/Messages/test/Messages.Domain.Tests/Messages/ExpressionTests.cs b/Modules/Messages/test/Messages.Domain.Tests/Messages/ExpressionTests.cs new file mode 100644 index 0000000000..8f7bdcf8a6 --- /dev/null +++ b/Modules/Messages/test/Messages.Domain.Tests/Messages/ExpressionTests.cs @@ -0,0 +1,57 @@ +using Backbone.Modules.Messages.Domain.Entities; +using Backbone.UnitTestTools.BaseClasses; +using Backbone.UnitTestTools.Data; +using FluentAssertions; +using Xunit; + +namespace Backbone.Modules.Messages.Domain.Tests.Messages; + +public class ExpressionTests : AbstractTestsBase +{ + #region WasExchangedBetween + + [Fact] + public void WasExchangedBetween_with_true_assertion() + { + // Arrange + var senderAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var recipientAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var recipient = new RecipientInformation(recipientAddress, []); + var message = new Message(senderAddress, TestDataGenerator.CreateRandomDeviceId(), [], [], [recipient]); + + // Act + var resultOne = message.EvaluateWasExchangedBetweenExpression(senderAddress, recipientAddress); + var resultTwo = message.EvaluateWasExchangedBetweenExpression(recipientAddress, senderAddress); + + // Assert + resultOne.Should().BeTrue(); + resultTwo.Should().BeTrue(); + } + + [Fact] + public void WasExchangedBetween_with_false_assertion() + { + // Arrange + var senderAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var recipientAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var recipient = new RecipientInformation(recipientAddress, []); + var message = new Message(senderAddress, TestDataGenerator.CreateRandomDeviceId(), [], [], [recipient]); + + // Act + var result = message.EvaluateWasExchangedBetweenExpression(senderAddress, TestDataGenerator.CreateRandomIdentityAddress()); + + // Assert + result.Should().BeFalse(); + } + + #endregion +} + +file static class MessageExtensions +{ + public static bool EvaluateWasExchangedBetweenExpression(this Message message, string identityAddressOne, string identityAddressTwo) + { + var expression = Message.WasExchangedBetween(identityAddressOne, identityAddressTwo); + return expression.Compile()(message); + } +} diff --git a/Modules/Messages/test/Messages.Domain.Tests/Messages/MessageTests.cs b/Modules/Messages/test/Messages.Domain.Tests/Messages/MessageTests.cs index 7a235d945e..5f7227cf9e 100644 --- a/Modules/Messages/test/Messages.Domain.Tests/Messages/MessageTests.cs +++ b/Modules/Messages/test/Messages.Domain.Tests/Messages/MessageTests.cs @@ -1,8 +1,9 @@ using Backbone.Modules.Messages.Domain.DomainEvents.Outgoing; using Backbone.Modules.Messages.Domain.Entities; -using Backbone.Modules.Messages.Domain.Ids; +using Backbone.Modules.Messages.Domain.Tests.TestHelpers; using Backbone.UnitTestTools.BaseClasses; using Backbone.UnitTestTools.Data; +using Backbone.UnitTestTools.Extensions; using Backbone.UnitTestTools.FluentAssertions.Extensions; using FluentAssertions; using Xunit; @@ -34,4 +35,56 @@ public void Raises_MessageCreatedDomainEvent_when_created() domainEvent.Recipients.Should().HaveCount(1); domainEvent.Recipients.First().Should().Be(recipient.Address); } + + [Fact] + public void SanitizeAfterRelationshipDeleted_anonymizes_recipient_and_sender_when_there_is_only_one_recipient() + { + // Arrange + var anonymizedAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var message = TestData.CreateMessageWithOneRecipient(); + + // Act + message.SanitizeAfterRelationshipDeleted(message.CreatedBy, message.Recipients.First().Address, anonymizedAddress); + + // Assert + message.CreatedBy.Should().Be(anonymizedAddress); + message.Recipients.First().Address.Should().Be(anonymizedAddress); + } + + [Fact] + public void SanitizeAfterRelationshipDeleted_does_not_anonymize_sender_as_long_as_there_are_still_unanonymized_recipients() + { + // Arrange + var senderAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var anonymizedAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var message = TestData.CreateMessageWithTwoRecipients(senderAddress); + + // Act + message.SanitizeAfterRelationshipDeleted(senderAddress, message.Recipients.First().Address, anonymizedAddress); + + // Assert + message.CreatedBy.Should().Be(senderAddress); + + message.Recipients.First().Address.Should().Be(anonymizedAddress); + } + + [Fact] + public void SanitizeAfterRelationshipDeleted_anonymizes_sender_when_there_are_no_more_unanonymized_recipients() + { + // Arrange + var senderAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var anonymizedAddress = TestDataGenerator.CreateRandomIdentityAddress(); + var message = TestData.CreateMessageWithTwoRecipients(senderAddress); + + message.SanitizeAfterRelationshipDeleted(senderAddress, message.Recipients.First().Address, anonymizedAddress); + + // Act + message.SanitizeAfterRelationshipDeleted(senderAddress, message.Recipients.Second().Address, anonymizedAddress); + + // Assert + message.CreatedBy.Should().Be(anonymizedAddress); + + message.Recipients.First().Address.Should().Be(anonymizedAddress); + message.Recipients.Second().Address.Should().Be(anonymizedAddress); + } } diff --git a/Modules/Messages/test/Messages.Domain.Tests/TestHelpers/TestData.cs b/Modules/Messages/test/Messages.Domain.Tests/TestHelpers/TestData.cs new file mode 100644 index 0000000000..1aa04e3bcd --- /dev/null +++ b/Modules/Messages/test/Messages.Domain.Tests/TestHelpers/TestData.cs @@ -0,0 +1,26 @@ +using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Messages.Domain.Entities; +using Backbone.UnitTestTools.Data; + +namespace Backbone.Modules.Messages.Domain.Tests.TestHelpers; + +public static class TestData +{ + public static Message CreateMessageWithOneRecipient(IdentityAddress? senderAddress = null, IdentityAddress? recipientAddress = null) + { + senderAddress ??= TestDataGenerator.CreateRandomIdentityAddress(); + recipientAddress ??= TestDataGenerator.CreateRandomIdentityAddress(); + + var recipient = new RecipientInformation(recipientAddress, []); + return new Message(senderAddress, TestDataGenerator.CreateRandomDeviceId(), [], [], [recipient]); + } + + public static Message CreateMessageWithTwoRecipients(IdentityAddress? senderAddress = null, IdentityAddress? recipient1Address = null, IdentityAddress? recipient2Address = null) + { + senderAddress ??= TestDataGenerator.CreateRandomIdentityAddress(); + recipient1Address ??= TestDataGenerator.CreateRandomIdentityAddress(); + recipient2Address ??= TestDataGenerator.CreateRandomIdentityAddress(); + + return new Message(senderAddress, TestDataGenerator.CreateRandomDeviceId(), [], [], [new RecipientInformation(recipient1Address, []), new RecipientInformation(recipient2Address, [])]); + } +} diff --git a/Modules/Relationships/test/Relationships.Domain.Tests/Tests/Aggregates/Relationships/ExpressionTests.cs b/Modules/Relationships/test/Relationships.Domain.Tests/Tests/Aggregates/Relationships/ExpressionTests.cs index b47ad9674c..6628166980 100644 --- a/Modules/Relationships/test/Relationships.Domain.Tests/Tests/Aggregates/Relationships/ExpressionTests.cs +++ b/Modules/Relationships/test/Relationships.Domain.Tests/Tests/Aggregates/Relationships/ExpressionTests.cs @@ -13,41 +13,53 @@ public class ExpressionTests : AbstractTestsBase [Fact] public void CountsAsActive_with_status_Pending() { + // Arrange var pendingRelationship = TestData.CreatePendingRelationship(); + // Act var result = pendingRelationship.EvaluateCountsAsActiveExpression(); - Assert.True(result); + // Assert + result.Should().BeTrue(); } [Fact] public void CountsAsActive_with_status_Active() { + // Arrange var activeRelationship = TestData.CreateActiveRelationship(); + // Act var result = activeRelationship.EvaluateCountsAsActiveExpression(); - Assert.True(result); + // Assert + result.Should().BeTrue(); } [Fact] public void CountsAsActive_with_status_Rejected() { + // Arrange var rejectedRelationship = TestData.CreateRejectedRelationship(); + // Act var result = rejectedRelationship.EvaluateCountsAsActiveExpression(); - Assert.False(result); + // Assert + result.Should().BeFalse(); } [Fact] public void CountsAsActive_with_status_Revoked() { + // Arrange var revokedRelationship = TestData.CreateRevokedRelationship(); + // Act var result = revokedRelationship.EvaluateCountsAsActiveExpression(); - Assert.False(result); + // Assert + result.Should().BeFalse(); } #endregion @@ -57,30 +69,39 @@ public void CountsAsActive_with_status_Revoked() [Fact] public void HasParticipant_recognizes_from_address() { + // Arrange var relationship = TestData.CreateActiveRelationship(); + // Act var result = relationship.EvaluateHasParticipantExpression(relationship.From); + // Assert result.Should().BeTrue(); } [Fact] public void HasParticipant_recognizes_to_address() { + // Arrange var relationship = TestData.CreateActiveRelationship(); + // Act var result = relationship.EvaluateHasParticipantExpression(relationship.To); + // Assert result.Should().BeTrue(); } [Fact] public void HasParticipant_recognizes_foreign_addresses() { + // Arrange var relationship = TestData.CreateActiveRelationship(); + // Act var result = relationship.EvaluateHasParticipantExpression("did:e:localhost:dids:1111111111111111111111"); + // Assert result.Should().BeFalse(); }