diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature index 7beb1f1302..8584aa509b 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature @@ -6,37 +6,37 @@ User requests multiple Tokens Scenario Outline: Requesting a list of Tokens in a variety of scenarios Given Identities i1, i2, i3 and i4 And the following Tokens - | tokenName | tokenOwner | forIdentity | password | - | rt1 | i1 | - | - | - | rt2 | i2 | - | - | - | rt3 | i1 | - | - | - | rt4 | i2 | - | - | - | rt5 | i1 | - | password | - | rt6 | i1 | - | password | - | rt7 | i2 | - | password | - | rt8 | i2 | - | password | - | rt9 | i1 | i1 | - | - | rt10 | i2 | i3 | - | - | rt11 | i2 | i2 | - | - | rt12 | i2 | i3 | - | - | rt13 | i2 | i3 | password | - | rt14 | i2 | i3 | password | + | tokenName | tokenOwner | forIdentity | password | allocatedBy | + | rt1 | i1 | - | - | i2, i3, i4 | + | rt2 | i2 | - | - | i1, i3, i4 | + | rt3 | i1 | - | - | i2, i3, i4 | + | rt4 | i2 | - | - | i1, i3, i4 | + | rt5 | i1 | - | password | i2, i3, i4 | + | rt6 | i1 | - | password | - | + | rt7 | i2 | - | password | i1, i3, i4 | + | rt8 | i2 | - | password | - | + | rt9 | i1 | i1 | - | - | + | rt10 | i2 | i3 | - | i3 | + | rt11 | i2 | i2 | - | - | + | rt12 | i2 | i3 | - | i3 | + | rt13 | i2 | i3 | password | i3 | + | rt14 | i2 | i3 | password | - | When sends a GET request to the /Tokens endpoint with the following payloads - | tokenName | passwordOnGet | - | rt1 | - | - | rt2 | - | - | rt3 | password | - | rt4 | password | - | rt5 | password | - | rt6 | - | - | rt7 | password | - | rt8 | - | - | rt9 | - | - | rt10 | - | - | rt11 | - | - | rt12 | - | - | rt13 | password | - | rt14 | wordpass | + | tokenName | + | rt1 | + | rt2 | + | rt3 | + | rt4 | + | rt5 | + | rt6 | + | rt7 | + | rt8 | + | rt9 | + | rt10 | + | rt11 | + | rt12 | + | rt13 | + | rt14 | Then the response status code is 200 (OK) And the response contains Token(s) diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/{id}/GET.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/{id}/GET.feature index 1b1c5d0885..a8ebef71ed 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/{id}/GET.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/{id}/GET.feature @@ -3,33 +3,46 @@ Feature: GET /Tokens/{id} User requests a Token - Scenario Outline: Requesting a Token in a variety of scenarios - Given Identities - And Token t created by with password "" and forIdentity - When sends a GET request to the /Tokens/t.Id endpoint with password "" - Then the response status code is + Scenario Outline: Requesting a Token in a variety of scenarios + Given Identities + And Token t created by with password "" and forIdentity + When sends a GET request to the /Tokens/t.Id endpoint with password "" + Then the response status code is - Examples: - | givenIdentities | tokenOwner | forIdentity | password | activeIdentity | passwordOnGet | responseStatusCode | description | - | i | i | - | - | i | - | 200 (OK) | owner tries to get | - | i1 and i2 | i1 | - | - | i2 | - | 200 (OK) | non-owner tries to get | - | i | i | - | - | - | - | 200 (OK) | anonymous user tries to get | - | i | i | - | - | i | password | 200 (OK) | owner passes password even though none is set | - | i1 and i2 | i1 | - | - | i2 | password | 200 (OK) | non-owner identity passes password even though none is set | - | i | i | - | - | - | password | 200 (OK) | anonymous user passes password even though none is set | - | i | i | - | password | i | password | 200 (OK) | owner passes correct password | - | i | i | - | password | i | - | 200 (OK) | owner doesn't pass password, even though one is set | - | i1 and i2 | i1 | - | password | i2 | password | 200 (OK) | non-owner identity passes correct password | - | i1 and i2 | i1 | - | password | i2 | - | 404 (Not Found) | non-owner identity passes no password even though one is set | - | i | i | - | password | - | password | 200 (OK) | anonymous user passes correct password | - | i | i | - | password | - | - | 404 (Not Found) | anonymous user doesn't pass password, even though one is set | - | i | i | i | - | i | - | 200 (OK) | owner is forIdentity and tries to get | - | i1 and i2 | i1 | i2 | - | i1 | - | 200 (OK) | non-owner is forIdentity, creator tries to get | - | i1 and i2 | i1 | i1 | - | i2 | - | 404 (Not Found) | owner is forIdentity and non-owner tries to get | - | i | i | i | - | - | - | 404 (Not Found) | owner is forIdentity and anonymous user tries to get | - | i1 and i2 | i1 | i2 | - | i2 | - | 200 (OK) | non-owner is forIdentity and tries to get | - | i | i | i | - | - | - | 404 (Not Found) | forIdentity is set and anonymous user tries to get | - | i1 and i2 | i1 | i2 | password | i2 | password | 200 (OK) | non-owner is forIdentity and tries to get with correct password | - | i1 and i2 | i1 | i2 | password | i2 | wordpass | 404 (Not Found) | non-owner is forIdentity and tries to get with incorrect password | - | i1, i2 and i3 | i1 | i2 | password | i3 | password | 404 (Not Found) | non-owner is forIdentity, and thirdParty tries to get | - | i1 and i2 | i1 | i2 | password | - | password | 404 (Not Found) | non-owner is forIdentity, and anonymous user tries to get | + Examples: + | givenIdentities | tokenOwner | forIdentity | password | activeIdentity | passwordOnGet | responseStatusCode | description | + | i | i | - | - | i | - | 200 (OK) | owner tries to get | + | i1 and i2 | i1 | - | - | i2 | - | 200 (OK) | non-owner tries to get | + | i | i | - | - | - | - | 200 (OK) | anonymous user tries to get | + | i | i | - | - | i | password | 200 (OK) | owner passes password even though none is set | + | i1 and i2 | i1 | - | - | i2 | password | 200 (OK) | non-owner identity passes password even though none is set | + | i | i | - | - | - | password | 200 (OK) | anonymous user passes password even though none is set | + | i | i | - | password | i | password | 200 (OK) | owner passes correct password | + | i | i | - | password | i | - | 200 (OK) | owner doesn't pass password, even though one is set | + | i1 and i2 | i1 | - | password | i2 | password | 200 (OK) | non-owner identity passes correct password | + | i1 and i2 | i1 | - | password | i2 | - | 404 (Not Found) | non-owner identity passes no password even though one is set | + | i | i | - | password | - | password | 200 (OK) | anonymous user passes correct password | + | i | i | - | password | - | - | 404 (Not Found) | anonymous user doesn't pass password, even though one is set | + | i | i | i | - | i | - | 200 (OK) | owner is forIdentity and tries to get | + | i1 and i2 | i1 | i2 | - | i1 | - | 200 (OK) | non-owner is forIdentity, creator tries to get | + | i1 and i2 | i1 | i1 | - | i2 | - | 404 (Not Found) | owner is forIdentity and non-owner tries to get | + | i | i | i | - | - | - | 404 (Not Found) | owner is forIdentity and anonymous user tries to get | + | i1 and i2 | i1 | i2 | - | i2 | - | 200 (OK) | non-owner is forIdentity and tries to get | + | i | i | i | - | - | - | 404 (Not Found) | forIdentity is set and anonymous user tries to get | + | i1 and i2 | i1 | i2 | password | i2 | password | 200 (OK) | non-owner is forIdentity and tries to get with correct password | + | i1 and i2 | i1 | i2 | password | i2 | wordpass | 404 (Not Found) | non-owner is forIdentity and tries to get with incorrect password | + | i1, i2 and i3 | i1 | i2 | password | i3 | password | 404 (Not Found) | non-owner is forIdentity, and thirdParty tries to get | + | i1 and i2 | i1 | i2 | password | - | password | 404 (Not Found) | non-owner is forIdentity, and anonymous user tries to get | + + Scenario: A Token owner can access a locked token + Given Identity i + And locked Token t created by i with password password + When i sends a GET request to the /Tokens/t.Id endpoint + Then the response status code is 200 (OK) + + Scenario: A Non-owner identity can access a locked token, when it has an allocation + Given Identities i1, i2 + And almost locked Token t created by i1 with password password and allocated by i2 + When t gets locked + And i2 sends a GET request to the /Tokens/t.Id endpoint + Then the response status code is 200 (OK) diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs index 3eb3aa8643..7b0d136075 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs @@ -1,10 +1,12 @@ using System.Diagnostics.CodeAnalysis; +using System.Net; using System.Text; using Backbone.BuildingBlocks.SDK.Endpoints.Common.Types; using Backbone.ConsumerApi.Sdk.Endpoints.Tokens.Types.Requests; using Backbone.ConsumerApi.Sdk.Endpoints.Tokens.Types.Responses; using Backbone.ConsumerApi.Tests.Integration.Contexts; using Backbone.ConsumerApi.Tests.Integration.Helpers; +using Microsoft.AspNetCore.Http.HttpResults; using TechTalk.SpecFlow.Assist; namespace Backbone.ConsumerApi.Tests.Integration.StepDefinitions; @@ -67,14 +69,63 @@ public async Task GivenTheFollowingTokens(Table table) var client = _clientPool.FirstForIdentityName(tokenProperties.TokenOwner); var forClient = tokenProperties.ForIdentity != "-" ? _clientPool.FirstForIdentityName(tokenProperties.ForIdentity).IdentityData!.Address : null; var password = tokenProperties.Password.Trim() != "-" ? Convert.FromBase64String(tokenProperties.Password.Trim()) : null; + var allocatedBy = tokenProperties.AllocatedBy.Trim() != "-" ? tokenProperties.AllocatedBy.Split(",").Select(s => s.Trim()).ToList() : []; var response = await client.Tokens .CreateToken(new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = forClient, Password = password }); _tokensContext.CreateTokenResponses[tokenProperties.TokenName] = response.Result!; + + foreach (var allocatedIdentityName in allocatedBy) + { + var allocatedClient = _clientPool.FirstForIdentityName(allocatedIdentityName); + var allocatedResponse = password != null ? await allocatedClient.Tokens.GetToken(response.Result!.Id, password) : await allocatedClient.Tokens.GetToken(response.Result!.Id); + allocatedResponse.Status.Should().Be(HttpStatusCode.OK); + } } } + [Given($@"locked Token {RegexFor.SINGLE_THING} created by {RegexFor.SINGLE_THING} with password {RegexFor.SINGLE_THING}")] + public async Task GivenALockedTokenCreatedByIdentityWithPassword(string tokenName, string identityName, string password) + { + var client = _clientPool.FirstForIdentityName(identityName); + var passwordData = Convert.FromBase64String(password.Trim()); + + var response = await client.Tokens.CreateToken( + new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = null, Password = passwordData }); + + _tokensContext.CreateTokenResponses[tokenName] = response.Result!; + + await SendInvalidPasswordsToToken(tokenName, 100); + } + + [Given($@"almost locked Token {RegexFor.SINGLE_THING} created by {RegexFor.SINGLE_THING} with password {RegexFor.SINGLE_THING} and allocated by {RegexFor.SINGLE_THING}")] + public async Task GivenAnAlmostLockedTokenCreatedByIdentityWithPassword(string tokenName, string identityName, string password, string allocatedIdentityName) + { + var client = _clientPool.FirstForIdentityName(identityName); + var passwordData = Convert.FromBase64String(password.Trim()); + + var response = await client.Tokens.CreateToken( + new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = null, Password = passwordData }); + + _tokensContext.CreateTokenResponses[tokenName] = response.Result!; + + var allocatorClient = _clientPool.FirstForIdentityName(allocatedIdentityName); + var allocatorResponse = await allocatorClient.Tokens.GetToken(response.Result!.Id, passwordData); + allocatorResponse.Status.Should().Be(HttpStatusCode.OK); + + await SendInvalidPasswordsToToken(tokenName, 99); + } + + private async Task SendInvalidPasswordsToToken(string tokenName, int numberOfInvalidRequests) + { + var client = _clientPool.Anonymous; + var token = _tokensContext.CreateTokenResponses[tokenName]; + + for (var i = 0; i < numberOfInvalidRequests; i++) + await client.Tokens.GetTokenUnauthenticated(token.Id); + } + #endregion #region When @@ -151,9 +202,8 @@ public async Task WhenISendsAGETRequestToTheTokensEndpointWithTheFollowingPayloa var queryItems = getRequestPayloadSet.Select(payload => { var tokenId = _tokensContext.CreateTokenResponses[payload.TokenName].Id; - var password = payload.PasswordOnGet == "-" ? null : Convert.FromBase64String(payload.PasswordOnGet.Trim()); - return new ListTokensQueryItem() { Id = tokenId, Password = password }; + return new ListTokensQueryItem { Id = tokenId }; }).ToList(); _responseContext.WhenResponse = _listTokensResponse = await client.Tokens.ListTokens(queryItems); @@ -168,6 +218,12 @@ public async Task WhenISendsADeleteRequestToTheTokensIdEndpoint(string identityN _responseContext.WhenResponse = await client.Tokens.DeleteToken(tokenId); } + [When($@"{RegexFor.SINGLE_THING} gets locked")] + public async Task WhenTokenGetsLocked(string tokenName) + { + await SendInvalidPasswordsToToken(tokenName, 1); + } + #endregion #region Then @@ -190,6 +246,7 @@ file class TokenProperties public required string TokenOwner { get; set; } public required string ForIdentity { get; set; } public required string Password { get; set; } + public required string AllocatedBy { get; set; } } // ReSharper disable once ClassNeverInstantiated.Local @@ -197,5 +254,4 @@ file class TokenProperties file class GetRequestPayload { public required string TokenName { get; set; } - public required string PasswordOnGet { get; set; } } diff --git a/Modules/Devices/test/Devices.Application.Tests/Tests/DomainEvents/Incoming/TokenLockedDomainEventHandlerTests.cs b/Modules/Devices/test/Devices.Application.Tests/Tests/DomainEvents/Incoming/TokenLockedDomainEventHandlerTests.cs new file mode 100644 index 0000000000..fab5d139a3 --- /dev/null +++ b/Modules/Devices/test/Devices.Application.Tests/Tests/DomainEvents/Incoming/TokenLockedDomainEventHandlerTests.cs @@ -0,0 +1,32 @@ +using Backbone.BuildingBlocks.Application.PushNotifications; +using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Devices.Application.DomainEvents.Incoming.TokenLocked; +using Backbone.Modules.Devices.Application.Infrastructure.Persistence.Repository; +using Backbone.Modules.Devices.Application.Infrastructure.PushNotifications.Tokens; +using Backbone.Modules.Devices.Domain.DomainEvents.Incoming.TokenLocked; +using FakeItEasy; + +namespace Backbone.Modules.Devices.Application.Tests.Tests.DomainEvents.Incoming; + +public class TokenLockedDomainEventHandlerTests : AbstractTestsBase +{ + [Fact] + public async Task Sends_a_push_notification() + { + // Arrange + var mockPushSender = A.Fake(); + var fakeRepository = A.Fake(); + var identity = TestDataGenerator.CreateIdentity(); + + A.CallTo(() => fakeRepository.FindByAddress(A._, A._, A._)).Returns(identity); + + var handler = new TokenLockedDomainEventHandler(mockPushSender, fakeRepository); + var domainEvent = new TokenLockedDomainEvent { TokenId = "TOK00000000000000001", CreatedBy = identity.Address }; + + // Act + await handler.Handle(domainEvent); + + // Assert + A.CallTo(() => mockPushSender.SendNotification(A._, A._, A._)).MustHaveHappenedOnceExactly(); + } +} diff --git a/Modules/Synchronization/test/Synchronization.Application.Tests/Tests/DomainEvents/TokenLockedDomainEventHandlerTests.cs b/Modules/Synchronization/test/Synchronization.Application.Tests/Tests/DomainEvents/TokenLockedDomainEventHandlerTests.cs new file mode 100644 index 0000000000..1b20410836 --- /dev/null +++ b/Modules/Synchronization/test/Synchronization.Application.Tests/Tests/DomainEvents/TokenLockedDomainEventHandlerTests.cs @@ -0,0 +1,26 @@ +using Backbone.Modules.Synchronization.Application.DomainEvents.Incoming.TokenLocked; +using Backbone.Modules.Synchronization.Application.Infrastructure; +using Backbone.Modules.Synchronization.Domain.DomainEvents.Incoming.TokenLocked; +using Backbone.Modules.Synchronization.Domain.Entities.Sync; +using FakeItEasy; + +namespace Backbone.Modules.Synchronization.Application.Tests.Tests.DomainEvents; + +public class TokenLockedDomainEventHandlerTests : AbstractTestsBase +{ + [Fact] + public async Task Sends_a_push_notification() + { + // Arrange + var fakeDbContext = A.Fake(); + var identityAddress = CreateRandomIdentityAddress(); + var handler = new TokenLockedDomainEventHandler(fakeDbContext); + var domainEvent = new TokenLockedDomainEvent { TokenId = "TOK00000000000000001", CreatedBy = identityAddress }; + + // Act + await handler.Handle(domainEvent); + + // Assert + A.CallTo(() => fakeDbContext.CreateExternalEvent(A._)).MustHaveHappenedOnceExactly(); + } +} diff --git a/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.AllocationTests.cs b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.AllocationTests.cs new file mode 100644 index 0000000000..18346b8643 --- /dev/null +++ b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.AllocationTests.cs @@ -0,0 +1,58 @@ +using Backbone.BuildingBlocks.Domain.Exceptions; +using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Tokens.Domain.Entities; +using Backbone.Modules.Tokens.Domain.Tests.TestHelpers; +using Backbone.UnitTestTools.Extensions; + +namespace Backbone.Modules.Tokens.Domain.Tests.Tests; + +public class TokenAllocationTests : AbstractTestsBase +{ + [Fact] + public void An_identity_can_be_allocated() + { + // Arrange + var token = TestData.CreateToken(CreateRandomIdentityAddress(), null); + var identityAddress = CreateRandomIdentityAddress(); + var deviceId = CreateRandomDeviceId(); + + // Act + token.AddAllocationFor(identityAddress, deviceId); + + // Assert + token.Allocations.Should().HaveCount(1); + token.Allocations[0].AllocatedBy.Should().Be(identityAddress); + token.Allocations[0].AllocatedByDevice.Should().Be(deviceId); + } + + [Fact] + public void An_identity_can_not_be_allocated_twice() + { + // Arrange + var token = TestData.CreateToken(CreateRandomIdentityAddress(), null); + var identityAddress = CreateRandomIdentityAddress(); + var deviceId = CreateRandomDeviceId(); + token.AddAllocationFor(identityAddress, deviceId); + + // Act + var acting = () => token.AddAllocationFor(identityAddress, deviceId); + + // Assert + acting.Should().Throw().WithError("error.platform.validation.token.alreadyAllocated"); + } + + [Fact] + public void The_owner_can_not_be_allocated() + { + // Arrange + var identityAddress = CreateRandomIdentityAddress(); + var deviceId = CreateRandomDeviceId(); + var token = TestData.CreateToken(identityAddress, null); + + // Act + var acting = () => token.AddAllocationFor(identityAddress, deviceId); + + // Assert + acting.Should().Throw().WithError("error.platform.validation.token.noAllocationForOwner"); + } +} diff --git a/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.LockTests.cs b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.LockTests.cs new file mode 100644 index 0000000000..d33cf4d36f --- /dev/null +++ b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/Token.LockTests.cs @@ -0,0 +1,26 @@ +using Backbone.Modules.Tokens.Domain.DomainEvents; +using Backbone.Modules.Tokens.Domain.Tests.TestHelpers; + +namespace Backbone.Modules.Tokens.Domain.Tests.Tests; + +public class TokenLockTests : AbstractTestsBase +{ + [Fact] + public void Raises_domain_event() + { + // Arrange + var token = TestData.CreateToken(CreateRandomIdentityAddress(), null, [1, 1, 1, 1, 1, 1, 1, 1]); + token.ClearDomainEvents(); + + for (var i = 0; i < 99; i++) + token.IncrementAccessFailedCount(); + + // Act + token.IncrementAccessFailedCount(); + + // Assert + token.IsLocked.Should().BeTrue(); + token.DomainEvents.Should().HaveCount(1); + token.DomainEvents[0].Should().BeOfType(); + } +}