From 394e91d6396253b7eb00b37baec47c4fc8e4c389 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 28 Oct 2025 16:31:05 -0400 Subject: [PATCH] Handle null cipher or organization with event submission (#6509) * Handle null cipher * Check for an org being null too * Add unit and integration tests * Clean up unused members --- src/Events/Controllers/CollectController.cs | 33 +- .../Controllers/CollectControllerTests.cs | 485 +++++++++++- .../Controllers/CollectControllerTests.cs | 715 ++++++++++++++++++ test/Events.Test/Events.Test.csproj | 4 + test/Events.Test/PlaceholderUnitTest.cs | 10 - 5 files changed, 1217 insertions(+), 30 deletions(-) create mode 100644 test/Events.Test/Controllers/CollectControllerTests.cs delete mode 100644 test/Events.Test/PlaceholderUnitTest.cs diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index d7fbbbc595..bae1575134 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -21,23 +21,17 @@ public class CollectController : Controller private readonly IEventService _eventService; private readonly ICipherRepository _cipherRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IFeatureService _featureService; - private readonly IApplicationCacheService _applicationCacheService; public CollectController( ICurrentContext currentContext, IEventService eventService, ICipherRepository cipherRepository, - IOrganizationRepository organizationRepository, - IFeatureService featureService, - IApplicationCacheService applicationCacheService) + IOrganizationRepository organizationRepository) { _currentContext = currentContext; _eventService = eventService; _cipherRepository = cipherRepository; _organizationRepository = organizationRepository; - _featureService = featureService; - _applicationCacheService = applicationCacheService; } [HttpPost] @@ -47,8 +41,10 @@ public class CollectController : Controller { return new BadRequestResult(); } + var cipherEvents = new List>(); var ciphersCache = new Dictionary(); + foreach (var eventModel in model) { switch (eventModel.Type) @@ -57,6 +53,7 @@ public class CollectController : Controller case EventType.User_ClientExportedVault: await _eventService.LogUserEventAsync(_currentContext.UserId.Value, eventModel.Type, eventModel.Date); break; + // Cipher events case EventType.Cipher_ClientAutofilled: case EventType.Cipher_ClientCopiedHiddenField: @@ -71,7 +68,8 @@ public class CollectController : Controller { continue; } - Cipher cipher = null; + + Cipher cipher; if (ciphersCache.TryGetValue(eventModel.CipherId.Value, out var cachedCipher)) { cipher = cachedCipher; @@ -81,6 +79,7 @@ public class CollectController : Controller cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value, _currentContext.UserId.Value); } + if (cipher == null) { // When the user cannot access the cipher directly, check if the organization allows for @@ -91,29 +90,44 @@ public class CollectController : Controller } cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value); + if (cipher == null) + { + continue; + } + var cipherBelongsToOrg = cipher.OrganizationId == eventModel.OrganizationId; var org = _currentContext.GetOrganization(eventModel.OrganizationId.Value); - if (!cipherBelongsToOrg || org == null || cipher == null) + if (!cipherBelongsToOrg || org == null) { continue; } } + ciphersCache.TryAdd(eventModel.CipherId.Value, cipher); cipherEvents.Add(new Tuple(cipher, eventModel.Type, eventModel.Date)); break; + case EventType.Organization_ClientExportedVault: if (!eventModel.OrganizationId.HasValue) { continue; } + var organization = await _organizationRepository.GetByIdAsync(eventModel.OrganizationId.Value); + if (organization == null) + { + continue; + } + await _eventService.LogOrganizationEventAsync(organization, eventModel.Type, eventModel.Date); break; + default: continue; } } + if (cipherEvents.Any()) { foreach (var eventsBatch in cipherEvents.Chunk(50)) @@ -121,6 +135,7 @@ public class CollectController : Controller await _eventService.LogCipherEventsAsync(eventsBatch); } } + return new OkResult(); } } diff --git a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs index 7f86758144..14110ff7a8 100644 --- a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs +++ b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs @@ -1,21 +1,63 @@ -using System.Net.Http.Json; +using System.Net; +using System.Net.Http.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; using Bit.Events.Models; namespace Bit.Events.IntegrationTest.Controllers; -public class CollectControllerTests +public class CollectControllerTests : IAsyncLifetime { - // This is a very simple test, and should be updated to assert more things, but for now - // it ensures that the events startup doesn't throw any errors with fairly basic configuration. - [Fact] - public async Task Post_Works() - { - var eventsApplicationFactory = new EventsApplicationFactory(); - var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount(); - var client = eventsApplicationFactory.CreateAuthedClient(accessToken); + private EventsApplicationFactory _factory = null!; + private HttpClient _client = null!; + private string _ownerEmail = null!; + private Guid _ownerId; - var response = await client.PostAsJsonAsync>("collect", + public async Task InitializeAsync() + { + _factory = new EventsApplicationFactory(); + _ownerEmail = $"integration-test+{Guid.NewGuid()}@bitwarden.com"; + var (accessToken, _) = await _factory.LoginWithNewAccount(_ownerEmail); + _client = _factory.CreateAuthedClient(accessToken); + + // Get the user ID + var userRepository = _factory.GetService(); + var user = await userRepository.GetByEmailAsync(_ownerEmail); + _ownerId = user!.Id; + } + + public Task DisposeAsync() + { + _client?.Dispose(); + _factory?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Post_NullModel_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync?>("collect", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_EmptyModel_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync("collect", Array.Empty()); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_UserClientExportedVault_Success() + { + var response = await _client.PostAsJsonAsync>("collect", [ new EventModel { @@ -26,4 +68,425 @@ public class CollectControllerTests response.EnsureSuccessStatusCode(); } + + [Fact] + public async Task Post_CipherClientAutofilled_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedPassword_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedHiddenField, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedCardCode_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedCardCode, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledCardNumberVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledCardCodeVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledHiddenFieldVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledPasswordVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientViewed_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEvent_WithoutCipherId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEvent_WithInvalidCipherId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = Guid.NewGuid(), + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithValidOrganization_Success() + { + var organization = await CreateOrganizationAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = organization.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithoutOrganizationId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithInvalidOrganizationId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = Guid.NewGuid(), + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_MultipleEvents_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + var organization = await CreateOrganizationAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = organization.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEventsBatch_MoreThan50Items_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + // Create 60 cipher events to test batching logic (should be processed in 2 batches of 50) + var events = Enumerable.Range(0, 60) + .Select(_ => new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }) + .ToList(); + + var response = await _client.PostAsJsonAsync>("collect", events); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_UnsupportedEventType_Success() + { + // Testing with an event type not explicitly handled in the switch statement + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_LoggedIn, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_MixedValidAndInvalidEvents_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = Guid.NewGuid(), // Invalid cipher ID + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, // Valid cipher ID + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherCaching_MultipleEventsForSameCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + // Multiple events for the same cipher should use caching + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + private async Task CreateCipherForUserAsync(Guid userId) + { + var cipherRepository = _factory.GetService(); + + var cipher = new Cipher + { + Type = CipherType.Login, + UserId = userId, + Data = "{\"name\":\"Test Cipher\"}", + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await cipherRepository.CreateAsync(cipher); + return cipher; + } + + private async Task CreateOrganizationAsync(Guid ownerId) + { + var organizationRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + var organization = new Organization + { + Name = "Test Organization", + BillingEmail = _ownerEmail, + Plan = "Free", + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await organizationRepository.CreateAsync(organization); + + // Add the user as an owner of the organization + var organizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }; + + await organizationUserRepository.CreateAsync(organizationUser); + + return organization; + } } diff --git a/test/Events.Test/Controllers/CollectControllerTests.cs b/test/Events.Test/Controllers/CollectControllerTests.cs new file mode 100644 index 0000000000..325442d50e --- /dev/null +++ b/test/Events.Test/Controllers/CollectControllerTests.cs @@ -0,0 +1,715 @@ +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Events.Controllers; +using Bit.Events.Models; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; + +namespace Events.Test.Controllers; + +public class CollectControllerTests +{ + private readonly CollectController _sut; + private readonly ICurrentContext _currentContext; + private readonly IEventService _eventService; + private readonly ICipherRepository _cipherRepository; + private readonly IOrganizationRepository _organizationRepository; + + public CollectControllerTests() + { + _currentContext = Substitute.For(); + _eventService = Substitute.For(); + _cipherRepository = Substitute.For(); + _organizationRepository = Substitute.For(); + + _sut = new CollectController( + _currentContext, + _eventService, + _cipherRepository, + _organizationRepository + ); + } + + [Fact] + public async Task Post_NullModel_ReturnsBadRequest() + { + var result = await _sut.Post(null); + + Assert.IsType(result); + } + + [Fact] + public async Task Post_EmptyModel_ReturnsBadRequest() + { + var result = await _sut.Post(new List()); + + Assert.IsType(result); + } + + [Theory] + [AutoData] + public async Task Post_UserClientExportedVault_LogsUserEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, eventDate); + } + + [Theory] + [AutoData] + public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.Count() == 1 && + tuples.First().Item1 == cipherDetails && + tuples.First().Item2 == EventType.Cipher_ClientAutofilled && + tuples.First().Item3 == eventDate + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedPassword_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedPassword + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedHiddenField, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedHiddenField + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedCardCode_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedCardCode, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedCardCode + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledCardNumberVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardNumberVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledCardCodeVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardCodeVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledHiddenFieldVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledHiddenFieldVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledPasswordVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledPasswordVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientViewed_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientViewed + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithoutCipherId_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default, default); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_WithoutOrgId_SkipsEvent(Guid userId, Guid cipherId) + { + _currentContext.UserId.Returns(userId); + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(cipherId); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_WithOrgId_ChecksOrgCipher( + Guid userId, Guid cipherId, Guid orgId, Cipher cipher, CurrentContextOrganization org) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + _currentContext.GetOrganization(orgId).Returns(org); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.Received(1).GetByIdAsync(cipherId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item1 == cipher + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_OrgCipherNotFound_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId) + { + _currentContext.UserId.Returns(userId); + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns((CipherDetails?)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.Received(1).GetByIdAsync(cipherId); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_CipherDoesNotBelongToOrg_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId, Guid differentOrgId, Cipher cipher) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = differentOrgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_OrgNotFound_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId, Cipher cipher) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + _currentContext.GetOrganization(orgId).Returns((CurrentContextOrganization)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_MultipleCipherEvents_WithSameCipherId_UsesCachedCipher( + Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipherId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 2) + ); + } + + [Theory] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithValidOrg_LogsOrgEvent( + Guid userId, Guid orgId, Organization organization) + { + _currentContext.UserId.Returns(userId); + organization.Id = orgId; + _organizationRepository.GetByIdAsync(orgId).Returns(organization); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.Received(1).GetByIdAsync(orgId); + await _eventService.Received(1).LogOrganizationEventAsync(organization, EventType.Organization_ClientExportedVault, eventDate); + } + + [Theory] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithoutOrgId_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default); + await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default); + } + + [Theory] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithNullOrg_SkipsEvent(Guid userId, Guid orgId) + { + _currentContext.UserId.Returns(userId); + _organizationRepository.GetByIdAsync(orgId).Returns((Organization)null); + var events = new List + { + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _organizationRepository.Received(1).GetByIdAsync(orgId); + await _eventService.DidNotReceiveWithAnyArgs().LogOrganizationEventAsync(default, default, default); + } + + [Theory] + [AutoData] + public async Task Post_UnsupportedEventType_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.User_LoggedIn, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogUserEventAsync(default, default, default); + } + + [Theory] + [AutoData] + public async Task Post_MixedEventTypes_ProcessesAllEvents( + Guid userId, Guid cipherId, Guid orgId, CipherDetails cipherDetails, Organization organization) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + organization.Id = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + _organizationRepository.GetByIdAsync(orgId).Returns(organization); + var events = new List + { + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, Arg.Any()); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 1) + ); + await _eventService.Received(1).LogOrganizationEventAsync(organization, EventType.Organization_ClientExportedVault, Arg.Any()); + } + + [Theory] + [AutoData] + public async Task Post_MoreThan50CipherEvents_LogsInBatches(Guid userId, List ciphers) + { + _currentContext.UserId.Returns(userId); + var events = new List(); + + for (int i = 0; i < 100; i++) + { + var cipher = ciphers[i % ciphers.Count]; + _cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher); + events.Add(new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow + }); + } + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(2).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 50) + ); + } + + [Theory] + [AutoData] + public async Task Post_Exactly50CipherEvents_LogsInSingleBatch(Guid userId, List ciphers) + { + _currentContext.UserId.Returns(userId); + var events = new List(); + + for (int i = 0; i < 50; i++) + { + var cipher = ciphers[i % ciphers.Count]; + _cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher); + events.Add(new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow + }); + } + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 50) + ); + } +} diff --git a/test/Events.Test/Events.Test.csproj b/test/Events.Test/Events.Test.csproj index b2efcbffc2..9d061dbf25 100644 --- a/test/Events.Test/Events.Test.csproj +++ b/test/Events.Test/Events.Test.csproj @@ -9,14 +9,18 @@ all + all runtime; build; native; contentfiles; analyzers + + + diff --git a/test/Events.Test/PlaceholderUnitTest.cs b/test/Events.Test/PlaceholderUnitTest.cs deleted file mode 100644 index 4998362f4d..0000000000 --- a/test/Events.Test/PlaceholderUnitTest.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Events.Test; - -// Delete this file once you have real tests -public class PlaceholderUnitTest -{ - [Fact] - public void Test1() - { - } -}