From ddcf3e181014015f2fda32a31f8b25e762071ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Vetni=C4=87?= <62119280+NikolaVetnic@users.noreply.github.com> Date: Wed, 16 Oct 2024 07:34:20 +0200 Subject: [PATCH] Consumer API: Password-protected Tokens (#909) * feat: implement password protected tokens * chore: remove unused methods * chore: remove empty line * test: add tests for anonymous user fetching tokens * fix: add check for existing allocations to relationship template CanBeCollectedWithPassword expression * chore: add explaining comments to password checks in Token.cs * refactor: don't use queryable extensions; instead use expressions in entity * refactor: remove redundant "when not null" check from validator * refactor: rename things * fix: re-add check for "password is not null" to validators * test: add can be collected with password tests * test: check for status code in get multiple templates feature file * test: check for status code in get multiple tokens feature file * fix: don't throw ArgumentNullException in ReplaceExpressionVisitor * test: fix naming * fix: use correct error message in validator tests * fix: use correct error message in CreateToken validator tests * test: add tests for anonymous user --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Timo Notheisen --- .../RelationshipTemplates/GET.feature | 5 +- .../RelationshipTemplates/POST.feature | 2 +- .../RelationshipTemplates/{id}/GET.feature | 2 +- .../Features/Tokens/GET.feature | 73 +++++---- .../Features/Tokens/POST.feature | 12 ++ .../Features/Tokens/{id}/GET.feature | 54 ++++--- .../RelationshipTemplatesStepDefinitions.cs | 12 +- .../StepDefinitions/TokensStepDefinitions.cs | 142 +++++++++++------- .../Extensions/ExpressionExtensions.cs | 10 +- .../IRelationshipTemplatesRepository.cs | 2 +- .../Validator.Tests.cs | 2 +- .../CreateRelationshipTemplate/Validator.cs | 3 +- .../GetRelationshipTemplate/Validator.cs | 2 +- .../ListRelationshipTemplates/Handler.cs | 2 +- .../ListRelationshipTemplatesQuery.cs | 6 +- .../Validator.Tests.cs | 7 +- .../ListRelationshipTemplates/Validator.cs | 2 +- .../RelationshipTemplatesController.cs | 6 +- ...mplate.CanBeCollectedByExpressionTests.cs} | 1 + ...plate.CanBeCollectedUsingPasswordTests.cs} | 2 +- .../RelationshipTemplate.cs | 14 +- .../RelationshipTemplatesRepository.cs | 2 +- .../Repository/ITokensRepository.cs | 8 +- .../CreateToken/CreateTokenCommand.cs | 1 + .../Tokens/Commands/CreateToken/Handler.cs | 2 +- .../Tokens/Commands/CreateToken/Validator.cs | 3 + .../Tokens/Queries/GetToken/GetTokenQuery.cs | 1 + .../Tokens/Queries/GetToken/Handler.cs | 13 +- .../Tokens/Queries/GetToken/Validator.cs | 2 + .../Tokens/Queries/ListTokens/Handler.cs | 3 +- .../Queries/ListTokens/ListTokensQuery.cs | 12 +- .../Tokens/Queries/ListTokens/Validator.cs | 20 ++- .../Controllers/TokensController.cs | 33 +++- .../src/Tokens.Domain/Entities/Token.cs | 32 +++- ...41011123029_AddPasswordToToken.Designer.cs | 77 ++++++++++ .../20241011123029_AddPasswordToToken.cs | 31 ++++ .../TokensDbContextModelSnapshot.cs | 6 +- ...41011123022_AddPasswordToToken.Designer.cs | 77 ++++++++++ .../20241011123022_AddPasswordToToken.cs | 31 ++++ .../TokensDbContextModelSnapshot.cs | 6 +- .../TokenEntityTypeConfiguration.cs | 2 + .../Repository/TokensRepository.cs | 30 +++- .../Tokens/CreateToken/ValidatorTests.cs | 39 ++++- .../TestHelpers/TestData.cs | 4 +- .../TokenCanBeCollectedUsingPasswordTests.cs | 102 +++++++++++++ .../RelationshipTemplatesEndpoint.cs | 4 +- ... => ListRelationshipTemplatesQueryItem.cs} | 2 +- .../src/Endpoints/Tokens/TokensEndpoint.cs | 14 +- .../Types/Requests/CreateTokenRequest.cs | 1 + .../Types/Requests/ListTokensQueryItem.cs | 7 + 50 files changed, 748 insertions(+), 178 deletions(-) rename Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/{RelationshipTemplate.CanBeCollectedByTests.cs => RelationshipTemplate.CanBeCollectedByExpressionTests.cs} (99%) rename Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/{RelationshipTemplate.CanBeCollectedWithPasswordTests.cs => RelationshipTemplate.CanBeCollectedUsingPasswordTests.cs} (96%) create mode 100644 Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.Designer.cs create mode 100644 Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.cs create mode 100644 Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.Designer.cs create mode 100644 Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.cs create mode 100644 Modules/Tokens/test/Tokens.Domain.Tests/Tests/TokenCanBeCollectedUsingPasswordTests.cs rename Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/{RelationshipTemplateQueryItem.cs => ListRelationshipTemplatesQueryItem.cs} (78%) create mode 100644 Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/ListTokensQueryItem.cs diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/GET.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/GET.feature index 6f825a614b..8fa82d97bd 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/GET.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/GET.feature @@ -21,7 +21,7 @@ User requests Relationship Templates | rt12 | i2 | i3 | - | | rt13 | i2 | i3 | password | | rt14 | i2 | i3 | password | - When sends a GET request to the /RelationshipTemplate endpoint with the following payloads + When sends a GET request to the /RelationshipTemplates endpoint with the following payloads | templateName | passwordOnGet | | rt1 | - | | rt2 | - | @@ -37,7 +37,8 @@ User requests Relationship Templates | rt12 | - | | rt13 | password | | rt14 | wordpass | - Then the response contains Relationship Template(s) + Then the response status code is 200 (OK) + And the response contains Relationship Template(s) Examples: | activeIdentity | retreivedTemplates | diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/POST.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/POST.feature index 02b8277a45..c45599a507 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/POST.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/POST.feature @@ -17,5 +17,5 @@ User creates a Relationship Template Scenario: Create a personalized Relationship Template with a password Given Identities i1 and i2 - When i1 sends a POST request to the /RelationshipTemplate endpoint with password "my-password" and forIdentity i2 + When i1 sends a POST request to the /RelationshipTemplates endpoint with password "my-password" and forIdentity i2 Then the response status code is 201 (Created) diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/{id}/GET.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/{id}/GET.feature index 4bfdea34f0..f01f8d595e 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/{id}/GET.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/RelationshipTemplates/{id}/GET.feature @@ -6,7 +6,7 @@ User requests a Relationship Template Scenario Outline: Requesting a Relationship Template in a variety of scenarios Given Identities And Relationship Template rt created by with password "" and forIdentity - When sends a GET request to the /RelationshipTemplate/rt.Id endpoint with password "" + When sends a GET request to the /RelationshipTemplates/rt.Id endpoint with password "" Then the response status code is Examples: 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 c912abd56d..7beb1f1302 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/GET.feature @@ -3,37 +3,46 @@ Feature: GET /Tokens User requests multiple Tokens - Scenario: Requesting a list of own Tokens - Given Identity i - And Tokens t1 and t2 belonging to i - When i sends a GET request to the /Tokens endpoint with the ids of t1 and t2 + 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 | + 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 | Then the response status code is 200 (OK) - And the response contains the Tokens t1 and t2 + And the response contains Token(s) - Scenario: Requesting an own Token and a Token belonging to another identity - Given Identities i1 and i2 - And Token t1 belonging to i1 - And Token t2 belonging to i2 - When i1 sends a GET request to the /Tokens endpoint with the ids of t1 and t2 - Then the response status code is 200 (OK) - And the response contains the Tokens t1 and t2 - - Scenario: Requesting a list of Tokens contains tokens with ForIdentity which were created by me - Given Identities i1 and i2 - And Token t belonging to i1 where ForIdentity is the address of i2 - When i1 sends a GET request to the /Tokens endpoint with the ids of t - Then the response status code is 200 (Ok) - And the response contains the Token t - - Scenario: Requesting a list of Tokens contains tokens with ForIdentity which were created for me - Given Identities i1 and i2 - And Token t belonging to i1 where ForIdentity is the address of i2 - When i2 sends a GET request to the /Tokens endpoint with the ids of t - Then the response status code is 200 (Ok) - And the response contains the Token t - - Scenario: Requesting a list of Tokens does not contain tokens with ForIdentity which were created for someone else - Given Identities i1, i2 and i3 - And Token t belonging to i1 where ForIdentity is the address of i2 - When i3 sends a GET request to the /Tokens endpoint with the ids of t - Then the response does not contain the Token t + Examples: + | activeIdentity | retreivedTokens | + | i1 | rt1, rt2, rt3, rt4, rt5, rt6, rt7, rt9 | + | i2 | rt1, rt2, rt3, rt4, rt5, rt7, rt8, rt10, rt11, rt12, rt13, rt14 | + | i3 | rt1, rt2, rt3, rt4, rt5, rt7, rt10, rt12, rt13 | + | i4 | rt1, rt2, rt3, rt4, rt5, rt7 | diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/POST.feature b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/POST.feature index ef9811d5b1..fdaa5c46f0 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/POST.feature +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/Features/Tokens/POST.feature @@ -12,3 +12,15 @@ User creates a Token Scenario: Creating a Token as an anonymous user When an anonymous user sends a POST request to the /Tokens endpoint Then the response status code is 401 (Unauthorized) + + Scenario: Creating a Token with a password + Given Identity i + When i sends a POST request to the /Tokens endpoint with the password "password" + Then the response status code is 201 (Created) + And the response contains a CreateTokenResponse + + Scenario: Create a personalized Token with a password + Given Identities i1 and i2 + When i1 sends a POST request to the /Tokens endpoint with password "password" and forIdentity i2 + Then the response status code is 201 (Created) + And the response contains a CreateTokenResponse 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 a7976eb875..1b1c5d0885 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,29 +3,33 @@ Feature: GET /Tokens/{id} User requests a Token - Scenario: Requesting an own Token as an authenticated user - Given Identity i - And Token t belonging to i - When i sends a GET request to the /Tokens/{id} endpoint with t.Id - Then the response status code is 200 (OK) - And the response contains 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: Requesting an own Token as an anonymous user - Given Identity i - And Token t belonging to i - When an anonymous user sends a GET request to the /Tokens/{id} endpoint with t.Id - Then the response status code is 200 (OK) - And the response contains a Token - - Scenario: Requesting a Token of another Identity as an authenticated user - Given Identities i1 and i2 - And Token t belonging to i2 - When i1 sends a GET request to the /Tokens/{id} endpoint with t.Id - Then the response status code is 200 (OK) - And the response contains a Token - - Scenario: Requesting a nonexistent Token - Given Identity i - When i sends a GET request to the /Tokens/{id} endpoint with "TOKthisisnonexisting" - Then the response status code is 404 (Not Found) - And the response content contains an error with the error code "error.platform.recordNotFound" + 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 | diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/RelationshipTemplatesStepDefinitions.cs b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/RelationshipTemplatesStepDefinitions.cs index a1f37d92f7..010880e18f 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/RelationshipTemplatesStepDefinitions.cs +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/RelationshipTemplatesStepDefinitions.cs @@ -45,7 +45,7 @@ public async Task GivenRelationshipTemplateCreatedByTokenOwnerWithPasswordAndFor } [Given(@"the following Relationship Templates")] - public async Task GivenRelationshipTemplatesWithTheFollowingProperties(Table table) + public async Task GivenTheFollowingRelationshipTemplates(Table table) { var relationshipTemplatePropertiesSet = table.CreateSet(); @@ -85,7 +85,7 @@ public async Task WhenIdentitySendsAPostRequestToTheRelationshipTemplatesEndpoin new CreateRelationshipTemplateRequest { Content = TestData.SOME_BYTES, Password = password }); } - [When($@"{RegexFor.SINGLE_THING} sends a POST request to the /RelationshipTemplate endpoint with password ""(.*)"" and forIdentity {RegexFor.SINGLE_THING}")] + [When($@"{RegexFor.SINGLE_THING} sends a POST request to the /RelationshipTemplates endpoint with password ""(.*)"" and forIdentity {RegexFor.SINGLE_THING}")] public async Task WhenIdentitySendsAPostRequestToTheRelationshipTemplatesEndpointWithPasswordAndForIdentity(string identityName, string passwordString, string forIdentityName) { var client = _clientPool.FirstForIdentityName(identityName); @@ -96,7 +96,7 @@ public async Task WhenIdentitySendsAPostRequestToTheRelationshipTemplatesEndpoin new CreateRelationshipTemplateRequest { Content = TestData.SOME_BYTES, ForIdentity = forClient.IdentityData!.Address, Password = password }); } - [When($@"{RegexFor.SINGLE_THING} sends a GET request to the /RelationshipTemplate/{RegexFor.SINGLE_THING}.Id endpoint with password ""([^""]*)""")] + [When($@"{RegexFor.SINGLE_THING} sends a GET request to the /RelationshipTemplates/{RegexFor.SINGLE_THING}.Id endpoint with password ""([^""]*)""")] public async Task WhenIdentitySendsAGetRequestToTheRelationshipTemplatesIdEndpointWithPassword(string identityName, string relationshipTemplateName, string password) { var client = _clientPool.FirstForIdentityName(identityName); @@ -108,8 +108,8 @@ public async Task WhenIdentitySendsAGetRequestToTheRelationshipTemplatesIdEndpoi : await client.RelationshipTemplates.GetTemplate(relationshipTemplateId); } - [When($@"{RegexFor.SINGLE_THING} sends a GET request to the /RelationshipTemplate endpoint with the following payloads")] - public async Task WhenISendsAGETRequestToTheRelationshipTemplateEndpointWithTheFollowingPayloads(string identityName, Table table) + [When($@"{RegexFor.SINGLE_THING} sends a GET request to the /RelationshipTemplates endpoint with the following payloads")] + public async Task WhenISendsAGETRequestToTheRelationshipTemplatesEndpointWithTheFollowingPayloads(string identityName, Table table) { var client = _clientPool.FirstForIdentityName(identityName); @@ -120,7 +120,7 @@ public async Task WhenISendsAGETRequestToTheRelationshipTemplateEndpointWithTheF var relationshipTemplateId = _relationshipTemplatesContext.CreateRelationshipTemplatesResponses[payload.TemplateName].Id; var password = payload.PasswordOnGet == "-" ? null : Convert.FromBase64String(payload.PasswordOnGet.Trim()); - return new RelationshipTemplateQueryItem { Id = relationshipTemplateId, Password = password }; + return new ListRelationshipTemplatesQueryItem { Id = relationshipTemplateId, Password = password }; }).ToList(); _responseContext.WhenResponse = _listRelationshipTemplatesResponse = await client.RelationshipTemplates.ListTemplates(queryItems); diff --git a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs index 92e4063e58..3b847b36ef 100644 --- a/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs +++ b/Applications/ConsumerApi/test/ConsumerApi.Tests.Integration/StepDefinitions/TokensStepDefinitions.cs @@ -1,9 +1,11 @@ -using Backbone.BuildingBlocks.SDK.Endpoints.Common.Types; +using System.Diagnostics.CodeAnalysis; +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.Extensions; using Backbone.ConsumerApi.Tests.Integration.Helpers; +using TechTalk.SpecFlow.Assist; namespace Backbone.ConsumerApi.Tests.Integration.StepDefinitions; @@ -31,47 +33,41 @@ public TokensStepDefinitions(ResponseContext responseContext, TokensContext toke #region Given - [Given($"Tokens? {RegexFor.LIST_OF_THINGS} belonging to {RegexFor.SINGLE_THING}")] - public async Task GivenTheIdentityCreatedMultipleTokens(string tokenNames, string identityName) + [Given($@"Token {RegexFor.SINGLE_THING} created by {RegexFor.SINGLE_THING} with password ""([^""]*)"" and forIdentity {RegexFor.OPTIONAL_SINGLE_THING}")] + public async Task GivenRelationshipTemplateCreatedByTokenOwnerWithPasswordAndForIdentity(string relationshipTemplateName, string identityName, string passwordString, string forIdentityName) { - foreach (var tokenName in Utils.SplitNames(tokenNames)) - { - var client = _clientPool.FirstForIdentityName(identityName); + var client = _clientPool.FirstForIdentityName(identityName); + var forClient = forIdentityName != "-" ? _clientPool.FirstForIdentityName(forIdentityName).IdentityData!.Address : null; + var password = passwordString.Trim() != "-" ? Convert.FromBase64String(passwordString.Trim()) : null; - var response = await client.Tokens.CreateToken(new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW }); - response.Should().BeASuccess(); + var response = await client.Tokens.CreateToken( + new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = forClient, Password = password }); - _tokensContext.CreateTokenResponses[tokenName] = response.Result!; - } + _tokensContext.CreateTokenResponses[relationshipTemplateName] = response.Result!; } - [Given($"Token {RegexFor.SINGLE_THING} belonging to {RegexFor.SINGLE_THING} where ForIdentity is the address of {RegexFor.SINGLE_THING}")] - public async Task GivenTokenTBelongingToIWhereForIdentityIsTheAddressOfI(string tokenName, string identityName, string forIdentityName) + [Given(@"the following Tokens")] + public async Task GivenTheFollowingTokens(Table table) { - var client = _clientPool.FirstForIdentityName(identityName); - var forClient = _clientPool.FirstForIdentityName(forIdentityName); + var tokenPropertiesSet = table.CreateSet(); - var response = await client.Tokens.CreateToken(new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = forClient.IdentityData!.Address }); - response.Should().BeASuccess(); + foreach (var tokenProperties in tokenPropertiesSet) + { + 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 response = await client.Tokens + .CreateToken(new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = forClient, Password = password }); - _tokensContext.CreateTokenResponses[tokenName] = response.Result!; + _tokensContext.CreateTokenResponses[tokenProperties.TokenName] = response.Result!; + } } #endregion #region When - [When($"{RegexFor.SINGLE_THING} sends a GET request to the /Tokens endpoint with the ids of {RegexFor.LIST_OF_THINGS}")] - public async Task WhenIdentitySendsAGetRequestToTheTokensEndpointWithAListOfIdsOfOwnTokens(string identityName, string tokenNames) - { - var client = _clientPool.FirstForIdentityName(identityName); - - var tokenIds = Utils.SplitNames(tokenNames).Select(tokenName => _tokensContext.CreateTokenResponses[tokenName].Id).ToArray(); - - _responseContext.WhenResponse = _listTokensResponse = await client.Tokens.ListTokens(tokenIds); - _responseContext.WhenResponse.Should().NotBeNull(); - } - [When($"{RegexFor.SINGLE_THING} sends a POST request to the /Tokens endpoint")] public async Task WhenIdentitySendsAPostRequestToTheTokensEndpoint(string identityName) { @@ -85,50 +81,92 @@ public async Task WhenAnAnonymousUserSendsAPOSTRequestIsSentToTheTokensEndpoint( _responseContext.WhenResponse = await _clientPool.Anonymous.Tokens.CreateTokenUnauthenticated(new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW }); } - [When($"{RegexFor.SINGLE_THING} sends a GET request to the /Tokens/{{id}} endpoint with {RegexFor.SINGLE_THING}.Id")] - public async Task WhenIdentitySendsAGetRequestToTheTokensIdEndpointWithTokenId(string identityName, string tokenName) + [When($@"{RegexFor.SINGLE_THING} sends a POST request to the /Tokens endpoint with the password ""([^""]*)""")] + public async Task WhenISendsAPOSTRequestToTheTokensEndpointWithThePassword(string identityName, string passwordString) { var client = _clientPool.FirstForIdentityName(identityName); - var tokenId = _tokensContext.CreateTokenResponses[tokenName].Id; + var password = Encoding.UTF8.GetBytes(passwordString); + + _responseContext.WhenResponse = await client.Tokens.CreateToken( + new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, Password = password }); + } + + [When($@"{RegexFor.SINGLE_THING} sends a POST request to the /Tokens endpoint with password ""([^""]*)"" and forIdentity {RegexFor.OPTIONAL_SINGLE_THING}")] + public async Task WhenISendsAPOSTRequestToTheTokensEndpointWithPasswordAndForIdentityI(string identityName, string passwordString, string forIdentityName) + { + var client = _clientPool.FirstForIdentityName(identityName); + var forClient = _clientPool.FirstForIdentityName(forIdentityName); + var password = Encoding.UTF8.GetBytes(passwordString); - _responseContext.WhenResponse = await client.Tokens.GetToken(tokenId); + _responseContext.WhenResponse = await client.Tokens.CreateToken( + new CreateTokenRequest { Content = TestData.SOME_BYTES, ExpiresAt = TOMORROW, ForIdentity = forClient.IdentityData!.Address, Password = password }); } - [When($"an anonymous user sends a GET request to the /Tokens/{{id}} endpoint with {RegexFor.SINGLE_THING}.Id")] - public async Task WhenAnAnonymousUserSendsAGetRequestToTheTokensIdEndpointWithTokenId(string tokenName) + [When($@"{RegexFor.OPTIONAL_SINGLE_THING} sends a GET request to the /Tokens/{RegexFor.SINGLE_THING}.Id endpoint with password ""([^""]*)""")] + public async Task WhenIdentitySendsAGetRequestToTheTokensIdEndpointWithPassword(string identityName, string tokenName, string password) { + var isAuthenticated = identityName != "-"; + var isPasswordProvided = password != "-"; + + var client = isAuthenticated ? _clientPool.FirstForIdentityName(identityName) : _clientPool.Anonymous; var tokenId = _tokensContext.CreateTokenResponses[tokenName].Id; - _responseContext.WhenResponse = await _clientPool.Anonymous.Tokens.GetTokenUnauthenticated(tokenId); + + if (isAuthenticated) + _responseContext.WhenResponse = isPasswordProvided + ? await client.Tokens.GetToken(tokenId, Convert.FromBase64String(password.Trim())) + : await client.Tokens.GetToken(tokenId); + else + _responseContext.WhenResponse = isPasswordProvided + ? await client.Tokens.GetTokenUnauthenticated(tokenId, Convert.FromBase64String(password.Trim())) + : await client.Tokens.GetTokenUnauthenticated(tokenId); } - [When($"{RegexFor.SINGLE_THING} sends a GET request to the /Tokens/{{id}} endpoint with \"([^\"]*)\"")] - public async Task WhenIdentitySendsAGetRequestToTheTokensIdEndpointWithNonExistingTokenId(string identityName, string nonExistingTokenId) + [When($@"{RegexFor.SINGLE_THING} sends a GET request to the /Tokens endpoint with the following payloads")] + public async Task WhenISendsAGETRequestToTheTokensEndpointWithTheFollowingPayloads(string identityName, Table table) { var client = _clientPool.FirstForIdentityName(identityName); - _responseContext.WhenResponse = await client.Tokens.GetToken(nonExistingTokenId); + + var getRequestPayloadSet = table.CreateSet(); + + 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 }; + }).ToList(); + + _responseContext.WhenResponse = _listTokensResponse = await client.Tokens.ListTokens(queryItems); } #endregion #region Then - [Then($"the response contains the Tokens? {RegexFor.LIST_OF_THINGS}")] + [Then($@"the response contains Token\(s\) {RegexFor.LIST_OF_THINGS}")] public void ThenTheResponseContainsTokens(string tokenNames) { - foreach (var tokenId in Utils.SplitNames(tokenNames).Select(tokenName => _tokensContext.CreateTokenResponses[tokenName].Id)) - { - _listTokensResponse!.Result.Should().Contain(token => token.Id == tokenId); - } + var tokens = tokenNames.Split(',').Select(item => _tokensContext.CreateTokenResponses[item.Trim()]).ToList(); + _listTokensResponse!.Result!.Should().BeEquivalentTo(tokens, options => options.WithStrictOrdering()); } - [Then($"the response does not contain the Token {RegexFor.SINGLE_THING}")] - public void ThenTheResponseDoesNotContainTheTokenT(string tokenName) - { - var tokenId = _tokensContext.CreateTokenResponses[tokenName].Id; - - _listTokensResponse!.Result.Should().NotContain(token => token.Id == tokenId); - } + #endregion +} +// ReSharper disable once ClassNeverInstantiated.Local +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] +file class TokenProperties +{ + public required string TokenName { get; set; } + public required string TokenOwner { get; set; } + public required string ForIdentity { get; set; } + public required string Password { get; set; } +} - #endregion +// ReSharper disable once ClassNeverInstantiated.Local +[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] +file class GetRequestPayload +{ + public required string TokenName { get; set; } + public required string PasswordOnGet { get; set; } } diff --git a/BuildingBlocks/src/BuildingBlocks.Application/Extensions/ExpressionExtensions.cs b/BuildingBlocks/src/BuildingBlocks.Application/Extensions/ExpressionExtensions.cs index 25c8e793d5..325db0b6e4 100644 --- a/BuildingBlocks/src/BuildingBlocks.Application/Extensions/ExpressionExtensions.cs +++ b/BuildingBlocks/src/BuildingBlocks.Application/Extensions/ExpressionExtensions.cs @@ -14,7 +14,8 @@ public static Expression> Or(this Expression> exp var rightVisitor = new ReplaceExpressionVisitor(expression2.Parameters[0], parameter); var right = rightVisitor.Visit(expression2.Body); - return Expression.Lambda>(Expression.OrElse(left, right), parameter); + // CAUTION: the null suppression operator is used here without being sure if it's safe; so if there's a NullReferenceException, this is the first place to check + return Expression.Lambda>(Expression.OrElse(left!, right!), parameter); } public static Expression> And(this Expression> expression1, Expression> expression2) @@ -27,7 +28,8 @@ public static Expression> And(this Expression> ex var rightVisitor = new ReplaceExpressionVisitor(expression2.Parameters[0], parameter); var right = rightVisitor.Visit(expression2.Body); - return Expression.Lambda>(Expression.AndAlso(left, right), parameter); + // CAUTION: the null suppression operator is used here without being sure if it's safe; so if there's a NullReferenceException, this is the first place to check + return Expression.Lambda>(Expression.AndAlso(left!, right!), parameter); } private class ReplaceExpressionVisitor : ExpressionVisitor @@ -41,10 +43,8 @@ public ReplaceExpressionVisitor(Expression oldValue, Expression newValue) _newValue = newValue; } - public override Expression Visit(Expression? node) + public override Expression? Visit(Expression? node) { - ArgumentNullException.ThrowIfNull(node); - return node == _oldValue ? _newValue : base.Visit(node); } } diff --git a/Modules/Relationships/src/Relationships.Application/Infrastructure/Persistence/Repository/IRelationshipTemplatesRepository.cs b/Modules/Relationships/src/Relationships.Application/Infrastructure/Persistence/Repository/IRelationshipTemplatesRepository.cs index 2628ab72bd..194e507eed 100644 --- a/Modules/Relationships/src/Relationships.Application/Infrastructure/Persistence/Repository/IRelationshipTemplatesRepository.cs +++ b/Modules/Relationships/src/Relationships.Application/Infrastructure/Persistence/Repository/IRelationshipTemplatesRepository.cs @@ -9,7 +9,7 @@ namespace Backbone.Modules.Relationships.Application.Infrastructure.Persistence. public interface IRelationshipTemplatesRepository { - Task> FindTemplatesWithIds(IEnumerable queryItems, IdentityAddress activeIdentity, PaginationFilter paginationFilter, + Task> FindTemplates(IEnumerable queryItems, IdentityAddress activeIdentity, PaginationFilter paginationFilter, CancellationToken cancellationToken, bool track = false); Task Find(RelationshipTemplateId id, IdentityAddress identityAddress, CancellationToken cancellationToken, bool track = false); diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.Tests.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.Tests.cs index e95c925910..274cbdcebd 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.Tests.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.Tests.cs @@ -119,6 +119,6 @@ public void Fails_when_Password_is_too_long() // Assert validationResult.ShouldHaveValidationErrorForItem(nameof(CreateRelationshipTemplateCommand.Password), "error.platform.validation.invalidPropertyValue", - "'Password' must be between 0 and 200 bytes long. You entered 250 bytes."); + "'Password' must be between 1 and 200 bytes long. You entered 250 bytes."); } } diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.cs index c8b3bb896c..e5faf6e9b9 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Commands/CreateRelationshipTemplate/Validator.cs @@ -2,6 +2,7 @@ using Backbone.BuildingBlocks.Application.Extensions; using Backbone.BuildingBlocks.Application.FluentValidation; using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Relationships.Domain.Aggregates.RelationshipTemplates; using Backbone.Tooling; using Backbone.Tooling.Extensions; using FluentValidation; @@ -25,6 +26,6 @@ public Validator() .ValidId() .When(c => c.ForIdentity != null); - RuleFor(c => c.Password).NumberOfBytes(0, 200); + RuleFor(c => c.Password).NumberOfBytes(1, RelationshipTemplate.MAX_PASSWORD_LENGTH).When(t => t.Password != null); } } diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/GetRelationshipTemplate/Validator.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/GetRelationshipTemplate/Validator.cs index f4a572c5b0..d64dc56198 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/GetRelationshipTemplate/Validator.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/GetRelationshipTemplate/Validator.cs @@ -11,6 +11,6 @@ public Validator() { RuleFor(x => x.Id).ValidId(); - RuleFor(x => x.Password).NumberOfBytes(1, RelationshipTemplate.MAX_PASSWORD_LENGTH).When(x => x.Password != null); + RuleFor(x => x.Password).NumberOfBytes(1, RelationshipTemplate.MAX_PASSWORD_LENGTH).When(t => t.Password != null); } } diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Handler.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Handler.cs index dfe1db7947..6f710fafc0 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Handler.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Handler.cs @@ -17,7 +17,7 @@ public Handler(IUserContext userContext, IRelationshipTemplatesRepository relati public async Task Handle(ListRelationshipTemplatesQuery request, CancellationToken cancellationToken) { - var dbPaginationResult = await _relationshipTemplatesRepository.FindTemplatesWithIds(request.QueryItems, _userContext.GetAddress(), request.PaginationFilter, + var dbPaginationResult = await _relationshipTemplatesRepository.FindTemplates(request.QueryItems, _userContext.GetAddress(), request.PaginationFilter, cancellationToken, track: false); return new ListRelationshipTemplatesResponse(dbPaginationResult, request.PaginationFilter); diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/ListRelationshipTemplatesQuery.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/ListRelationshipTemplatesQuery.cs index da2db692e4..2d684c3d36 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/ListRelationshipTemplatesQuery.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/ListRelationshipTemplatesQuery.cs @@ -5,17 +5,17 @@ namespace Backbone.Modules.Relationships.Application.RelationshipTemplates.Queri public class ListRelationshipTemplatesQuery : IRequest { - public ListRelationshipTemplatesQuery(PaginationFilter paginationFilter, IEnumerable? queries) + public ListRelationshipTemplatesQuery(PaginationFilter paginationFilter, IEnumerable? queries) { PaginationFilter = paginationFilter; QueryItems = queries == null ? [] : queries.ToList(); } public PaginationFilter PaginationFilter { get; set; } - public List QueryItems { get; set; } + public List QueryItems { get; set; } } -public class RelationshipTemplateQueryItem +public class ListRelationshipTemplatesQueryItem { public required string Id { get; set; } public byte[]? Password { get; set; } diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.Tests.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.Tests.cs index c20521b2fa..bf177ce565 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.Tests.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.Tests.cs @@ -3,7 +3,6 @@ using Backbone.UnitTestTools.BaseClasses; using Backbone.UnitTestTools.FluentValidation; using FluentValidation.TestHelper; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Xunit; namespace Backbone.Modules.Relationships.Application.RelationshipTemplates.Queries.ListRelationshipTemplates; @@ -17,7 +16,8 @@ public void Happy_path_with_password() var validator = new Validator(); // Act - var validationResult = validator.TestValidate(new ListRelationshipTemplatesQuery(new PaginationFilter(), new[] { new RelationshipTemplateQueryItem() { Id = RelationshipTemplateId.New(), Password = [1, 2, 3] } })); + var validationResult = validator.TestValidate(new ListRelationshipTemplatesQuery(new PaginationFilter(), + new[] { new ListRelationshipTemplatesQueryItem() { Id = RelationshipTemplateId.New(), Password = [1, 2, 3] } })); // Assert validationResult.ShouldNotHaveAnyValidationErrors(); @@ -30,7 +30,8 @@ public void Happy_path_without_password() var validator = new Validator(); // Act - var validationResult = validator.TestValidate(new ListRelationshipTemplatesQuery(new PaginationFilter(), new[] { new RelationshipTemplateQueryItem() { Id = RelationshipTemplateId.New() } })); + var validationResult = + validator.TestValidate(new ListRelationshipTemplatesQuery(new PaginationFilter(), new[] { new ListRelationshipTemplatesQueryItem() { Id = RelationshipTemplateId.New() } })); // Assert validationResult.ShouldNotHaveAnyValidationErrors(); diff --git a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.cs b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.cs index 3ac120caed..1ec7c21e2c 100644 --- a/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.cs +++ b/Modules/Relationships/src/Relationships.Application/RelationshipTemplates/Queries/ListRelationshipTemplates/Validator.cs @@ -20,7 +20,7 @@ public Validator() { queryItems .RuleFor(query => query.Id) - .ValidId(); + .ValidId(); queryItems .RuleFor(query => query.Password) diff --git a/Modules/Relationships/src/Relationships.ConsumerApi/Controllers/RelationshipTemplatesController.cs b/Modules/Relationships/src/Relationships.ConsumerApi/Controllers/RelationshipTemplatesController.cs index 1b1d304fbd..2ff771ab3a 100644 --- a/Modules/Relationships/src/Relationships.ConsumerApi/Controllers/RelationshipTemplatesController.cs +++ b/Modules/Relationships/src/Relationships.ConsumerApi/Controllers/RelationshipTemplatesController.cs @@ -45,13 +45,13 @@ public async Task GetById(string id, [FromQuery] byte[]? password StatusCodes.Status200OK)] public async Task GetAll([FromQuery] PaginationFilter paginationFilter, [FromQuery] string? templates, [FromQuery] IEnumerable ids, CancellationToken cancellationToken) { - List? relationshipTemplateQueryItems; + List? relationshipTemplateQueryItems; if (templates != null) { try { - relationshipTemplateQueryItems = JsonSerializer.Deserialize>(templates, _jsonSerializerOptions); + relationshipTemplateQueryItems = JsonSerializer.Deserialize>(templates, _jsonSerializerOptions); } catch (JsonException ex) { @@ -60,7 +60,7 @@ public async Task GetAll([FromQuery] PaginationFilter paginationF } else { - relationshipTemplateQueryItems = ids.Select(id => new RelationshipTemplateQueryItem { Id = id }).ToList(); + relationshipTemplateQueryItems = ids.Select(id => new ListRelationshipTemplatesQueryItem { Id = id }).ToList(); } var request = new ListRelationshipTemplatesQuery(paginationFilter, relationshipTemplateQueryItems); diff --git a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByTests.cs b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByExpressionTests.cs similarity index 99% rename from Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByTests.cs rename to Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByExpressionTests.cs index 2860b78182..248c2dbc6c 100644 --- a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByTests.cs +++ b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedByExpressionTests.cs @@ -5,6 +5,7 @@ using Xunit; namespace Backbone.Modules.Relationships.Domain.Aggregates.RelationshipTemplates; + public class RelationshipTemplateCanBeCollectedBy : AbstractTestsBase { private const string I1 = "did:e:prod.enmeshed.eu:dids:70cf4f3e6edf6bca33d35f"; diff --git a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedWithPasswordTests.cs b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedUsingPasswordTests.cs similarity index 96% rename from Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedWithPasswordTests.cs rename to Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedUsingPasswordTests.cs index e183151289..08eaea4893 100644 --- a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedWithPasswordTests.cs +++ b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.CanBeCollectedUsingPasswordTests.cs @@ -6,7 +6,7 @@ namespace Backbone.Modules.Relationships.Domain.Aggregates.RelationshipTemplates; -public class RelationshipTemplateCanBeCollectedWithPasswordTests : AbstractTestsBase +public class RelationshipTemplateCanBeCollectedUsingPasswordExpressionTests : AbstractTestsBase { [Fact] public void Can_collect_without_a_password_when_no_password_is_defined() diff --git a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.cs b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.cs index 7c2e3d3306..3e9811b80c 100644 --- a/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.cs +++ b/Modules/Relationships/src/Relationships.Domain/Aggregates/RelationshipTemplates/RelationshipTemplate.cs @@ -95,12 +95,20 @@ public static Expression> WasCreatedBy(Identity public static Expression> CanBeCollectedBy(IdentityAddress address) { - return relationshipTemplate => relationshipTemplate.ForIdentity == null || relationshipTemplate.ForIdentity == address || relationshipTemplate.CreatedBy == address; + return relationshipTemplate => + relationshipTemplate.ForIdentity == null || + relationshipTemplate.ForIdentity == address || + relationshipTemplate.CreatedBy == address; } - public static Expression> CanBeCollectedWithPassword(IdentityAddress address, byte[]? password) + public static Expression> CanBeCollectedWithPassword(IdentityAddress activeIdentity, byte[]? password) { - return relationshipTemplate => relationshipTemplate.Password == null || relationshipTemplate.Password == password || relationshipTemplate.CreatedBy == address; + return relationshipTemplate => + relationshipTemplate.Password == null || + relationshipTemplate.Password == password || + relationshipTemplate.CreatedBy == activeIdentity || // The owner shouldn't need a password to get the template + relationshipTemplate.Allocations.Any(a => + a.AllocatedBy == activeIdentity); // if the template has already been allocated by the active identity, it doesn't need to pass the password again; } #endregion diff --git a/Modules/Relationships/src/Relationships.Infrastructure/Persistence/Database/Repository/RelationshipTemplatesRepository.cs b/Modules/Relationships/src/Relationships.Infrastructure/Persistence/Database/Repository/RelationshipTemplatesRepository.cs index 3b730a76f7..e62a94e513 100644 --- a/Modules/Relationships/src/Relationships.Infrastructure/Persistence/Database/Repository/RelationshipTemplatesRepository.cs +++ b/Modules/Relationships/src/Relationships.Infrastructure/Persistence/Database/Repository/RelationshipTemplatesRepository.cs @@ -48,7 +48,7 @@ public async Task Delete(Expression> filter, Ca return template; } - public async Task> FindTemplatesWithIds(IEnumerable queryItems, IdentityAddress activeIdentity, + public async Task> FindTemplates(IEnumerable queryItems, IdentityAddress activeIdentity, PaginationFilter paginationFilter, CancellationToken cancellationToken, bool track = false) { diff --git a/Modules/Tokens/src/Tokens.Application/Infrastructure/Persistence/Repository/ITokensRepository.cs b/Modules/Tokens/src/Tokens.Application/Infrastructure/Persistence/Repository/ITokensRepository.cs index 1e5c397865..ad9fdbead2 100644 --- a/Modules/Tokens/src/Tokens.Application/Infrastructure/Persistence/Repository/ITokensRepository.cs +++ b/Modules/Tokens/src/Tokens.Application/Infrastructure/Persistence/Repository/ITokensRepository.cs @@ -2,6 +2,7 @@ using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.Persistence.Database; using Backbone.BuildingBlocks.Application.Pagination; using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Tokens.Application.Tokens.Queries.ListTokens; using Backbone.Modules.Tokens.Domain.Entities; namespace Backbone.Modules.Tokens.Application.Infrastructure.Persistence.Repository; @@ -9,7 +10,10 @@ namespace Backbone.Modules.Tokens.Application.Infrastructure.Persistence.Reposit public interface ITokensRepository { Task Add(Token token); - Task Find(TokenId tokenId, IdentityAddress? activeIdentity); - Task> FindAllWithIds(IdentityAddress activeIdentity, IEnumerable ids, PaginationFilter paginationFilter, CancellationToken cancellationToken); + + Task> FindTokens(IEnumerable queryItems, IdentityAddress activeIdentity, PaginationFilter paginationFilter, + CancellationToken cancellationToken, bool track = false); + + Task Find(TokenId tokenId, IdentityAddress? activeIdentity, CancellationToken cancellationToken, bool track = false); Task DeleteTokens(Expression> filter, CancellationToken cancellationToken); } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/CreateTokenCommand.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/CreateTokenCommand.cs index a63c3c487e..ae57c8757e 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/CreateTokenCommand.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/CreateTokenCommand.cs @@ -9,4 +9,5 @@ public class CreateTokenCommand : IRequest public required byte[] Content { get; set; } public required DateTime ExpiresAt { get; set; } public string? ForIdentity { get; set; } + public byte[]? Password { get; set; } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Handler.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Handler.cs index cb0d3eb4b5..7f4da228da 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Handler.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Handler.cs @@ -20,7 +20,7 @@ public Handler(IUserContext userContext, ITokensRepository tokensRepository) public async Task Handle(CreateTokenCommand request, CancellationToken cancellationToken) { var forIdentity = request.ForIdentity == null ? null : IdentityAddress.Parse(request.ForIdentity); - var newToken = new Token(_userContext.GetAddress(), _userContext.GetDeviceId(), request.Content, request.ExpiresAt, forIdentity); + var newToken = new Token(_userContext.GetAddress(), _userContext.GetDeviceId(), request.Content, request.ExpiresAt, forIdentity, request.Password); await _tokensRepository.Add(newToken); diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Validator.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Validator.cs index d1b4e17095..79c5ec96bb 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Validator.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Commands/CreateToken/Validator.cs @@ -2,6 +2,7 @@ using Backbone.BuildingBlocks.Application.Extensions; using Backbone.BuildingBlocks.Application.FluentValidation; using Backbone.DevelopmentKit.Identity.ValueObjects; +using Backbone.Modules.Tokens.Domain.Entities; using Backbone.Tooling; using Backbone.Tooling.Extensions; using FluentValidation; @@ -24,5 +25,7 @@ public Validator() RuleFor(t => t.ForIdentity) .ValidId() .When(t => t.ForIdentity != null); + + RuleFor(c => c.Password).NumberOfBytes(1, Token.MAX_PASSWORD_LENGTH).When(c => c.Password != null); } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/GetTokenQuery.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/GetTokenQuery.cs index fb4ed2cb77..d907b88075 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/GetTokenQuery.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/GetTokenQuery.cs @@ -6,4 +6,5 @@ namespace Backbone.Modules.Tokens.Application.Tokens.Queries.GetToken; public class GetTokenQuery : IRequest { public required string Id { get; set; } + public byte[]? Password { get; set; } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Handler.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Handler.cs index 41a9966567..05f42a9547 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Handler.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Handler.cs @@ -20,7 +20,18 @@ public Handler(ITokensRepository tokensRepository, IUserContext userContext) public async Task Handle(GetTokenQuery request, CancellationToken cancellationToken) { - var token = await _tokensRepository.Find(TokenId.Parse(request.Id), _userContext.GetAddressOrNull()) ?? throw new NotFoundException(); + var token = await GetToken(request.Id, request.Password, cancellationToken); return new TokenDTO(token); } + + private async Task GetToken(string tokenId, byte[]? password, CancellationToken cancellationToken) + { + var token = await _tokensRepository.Find(TokenId.Parse(tokenId), _userContext.GetAddressOrNull(), cancellationToken, true) ?? + throw new NotFoundException(nameof(Token)); + + if (!token.CanBeCollectedUsingPassword(_userContext.GetAddressOrNull(), password)) + throw new NotFoundException(nameof(Token)); + + return token; + } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Validator.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Validator.cs index 107c81664b..1791ce93f3 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Validator.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/GetToken/Validator.cs @@ -1,4 +1,5 @@ using Backbone.BuildingBlocks.Application.Extensions; +using Backbone.BuildingBlocks.Application.FluentValidation; using Backbone.Modules.Tokens.Domain.Entities; using FluentValidation; @@ -9,5 +10,6 @@ public class Validator : AbstractValidator public Validator() { RuleFor(x => x.Id).ValidId(); + RuleFor(x => x.Password).NumberOfBytes(1, Token.MAX_PASSWORD_LENGTH).When(t => t.Password != null); } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Handler.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Handler.cs index 0d9c7b8a75..df925b2cff 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Handler.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Handler.cs @@ -1,7 +1,6 @@ using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.UserContext; using Backbone.DevelopmentKit.Identity.ValueObjects; using Backbone.Modules.Tokens.Application.Infrastructure.Persistence.Repository; -using Backbone.Modules.Tokens.Domain.Entities; using MediatR; namespace Backbone.Modules.Tokens.Application.Tokens.Queries.ListTokens; @@ -19,7 +18,7 @@ public Handler(ITokensRepository tokensRepository, IUserContext userContext) public async Task Handle(ListTokensQuery request, CancellationToken cancellationToken) { - var dbPaginationResult = await _tokensRepository.FindAllWithIds(_activeIdentity, request.Ids.Select(TokenId.Parse), request.PaginationFilter, cancellationToken); + var dbPaginationResult = await _tokensRepository.FindTokens(request.QueryItems, _activeIdentity, request.PaginationFilter, cancellationToken, track: false); return new ListTokensResponse(dbPaginationResult, request.PaginationFilter); } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/ListTokensQuery.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/ListTokensQuery.cs index df912053b4..0de15059d7 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/ListTokensQuery.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/ListTokensQuery.cs @@ -5,12 +5,18 @@ namespace Backbone.Modules.Tokens.Application.Tokens.Queries.ListTokens; public class ListTokensQuery : IRequest { - public ListTokensQuery(PaginationFilter paginationFilter, IEnumerable ids) + public ListTokensQuery(PaginationFilter paginationFilter, IEnumerable? queries) { PaginationFilter = paginationFilter; - Ids = ids; + QueryItems = queries == null ? [] : queries.ToList(); } public PaginationFilter PaginationFilter { get; set; } - public IEnumerable Ids { get; set; } + public List QueryItems { get; set; } +} + +public class ListTokensQueryItem +{ + public required string Id { get; set; } + public byte[]? Password { get; set; } } diff --git a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Validator.cs b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Validator.cs index eb222c7408..50ef87652f 100644 --- a/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Validator.cs +++ b/Modules/Tokens/src/Tokens.Application/Tokens/Queries/ListTokens/Validator.cs @@ -1,5 +1,6 @@ using Backbone.BuildingBlocks.Application.Abstractions.Exceptions; using Backbone.BuildingBlocks.Application.Extensions; +using Backbone.BuildingBlocks.Application.FluentValidation; using Backbone.BuildingBlocks.Application.Pagination; using Backbone.Modules.Tokens.Domain.Entities; using FluentValidation; @@ -12,7 +13,24 @@ public class Validator : AbstractValidator public Validator() { RuleFor(t => t.PaginationFilter).SetValidator(new PaginationFilterValidator()).When(t => t != null); - RuleForEach(x => x.Ids).ValidId(); + + RuleFor(q => q.QueryItems) + .Cascade(CascadeMode.Stop) + .DetailedNotEmpty(); + + RuleForEach(x => x.QueryItems) + .Cascade(CascadeMode.Stop) + .ChildRules(queryItems => + { + queryItems + .RuleFor(query => query.Id) + .ValidId(); + + queryItems + .RuleFor(query => query.Password) + .NumberOfBytes(1, Token.MAX_PASSWORD_LENGTH) + .When(query => query.Password != null); + }); } } diff --git a/Modules/Tokens/src/Tokens.ConsumerApi/Controllers/TokensController.cs b/Modules/Tokens/src/Tokens.ConsumerApi/Controllers/TokensController.cs index 88f9654b8c..8caa366b79 100644 --- a/Modules/Tokens/src/Tokens.ConsumerApi/Controllers/TokensController.cs +++ b/Modules/Tokens/src/Tokens.ConsumerApi/Controllers/TokensController.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Backbone.BuildingBlocks.API; using Backbone.BuildingBlocks.API.Mvc; using Backbone.BuildingBlocks.API.Mvc.ControllerAttributes; @@ -22,10 +23,12 @@ namespace Backbone.Modules.Tokens.ConsumerApi.Controllers; public class TokensController : ApiControllerBase { private readonly ApplicationOptions _options; + private readonly JsonSerializerOptions _jsonSerializerOptions; - public TokensController(IMediator mediator, IOptions options) : base(mediator) + public TokensController(IMediator mediator, IOptions options, IOptions jsonOptions) : base(mediator) { _options = options.Value; + _jsonSerializerOptions = jsonOptions.Value.JsonSerializerOptions; } [HttpPost] @@ -40,24 +43,44 @@ public async Task CreateToken(CreateTokenCommand request, Cancell [ProducesResponseType(typeof(HttpResponseEnvelopeResult), StatusCodes.Status200OK)] [ProducesError(StatusCodes.Status404NotFound)] [AllowAnonymous] - public async Task GetToken([FromRoute] string id, CancellationToken cancellationToken) + public async Task GetToken([FromRoute] string id, [FromQuery] byte[]? password, CancellationToken cancellationToken) { - var response = await _mediator.Send(new GetTokenQuery { Id = id }, cancellationToken); + var response = await _mediator.Send(new GetTokenQuery { Id = id, Password = password }, cancellationToken); return Ok(response); } [HttpGet] [ProducesResponseType(typeof(PagedHttpResponseEnvelope), StatusCodes.Status200OK)] - public async Task ListTokens([FromQuery] PaginationFilter paginationFilter, + public async Task ListTokens([FromQuery] PaginationFilter paginationFilter, [FromQuery] string? tokens, [FromQuery] IEnumerable ids, CancellationToken cancellationToken) { + List? tokenQueryItems; + + if (tokens != null) + { + try + { + tokenQueryItems = JsonSerializer.Deserialize>(tokens, _jsonSerializerOptions); + } + catch (JsonException ex) + { + throw new ApplicationException(GenericApplicationErrors.Validation.InputCannotBeParsed(ex.Message)); + } + } + else + { + tokenQueryItems = ids.Select(id => new ListTokensQueryItem { Id = id }).ToList(); + } + + var request = new ListTokensQuery(paginationFilter, tokenQueryItems); + paginationFilter.PageSize ??= _options.Pagination.DefaultPageSize; if (paginationFilter.PageSize > _options.Pagination.MaxPageSize) throw new ApplicationException( GenericApplicationErrors.Validation.InvalidPageSize(_options.Pagination.MaxPageSize)); - var response = await _mediator.Send(new ListTokensQuery(paginationFilter, ids), cancellationToken); + var response = await _mediator.Send(request, cancellationToken); return Paged(response); } diff --git a/Modules/Tokens/src/Tokens.Domain/Entities/Token.cs b/Modules/Tokens/src/Tokens.Domain/Entities/Token.cs index a9cc09c437..8f6510ac69 100644 --- a/Modules/Tokens/src/Tokens.Domain/Entities/Token.cs +++ b/Modules/Tokens/src/Tokens.Domain/Entities/Token.cs @@ -8,6 +8,8 @@ namespace Backbone.Modules.Tokens.Domain.Entities; public class Token : Entity { + public const int MAX_PASSWORD_LENGTH = 200; + // ReSharper disable once UnusedMember.Local private Token() { @@ -18,7 +20,7 @@ private Token() Content = null!; } - public Token(IdentityAddress createdBy, DeviceId createdByDevice, byte[] content, DateTime expiresAt, IdentityAddress? forIdentity = null) + public Token(IdentityAddress createdBy, DeviceId createdByDevice, byte[] content, DateTime expiresAt, IdentityAddress? forIdentity = null, byte[]? password = null) { Id = TokenId.New(); @@ -30,6 +32,7 @@ public Token(IdentityAddress createdBy, DeviceId createdByDevice, byte[] content Content = content; ForIdentity = forIdentity; + Password = password; RaiseDomainEvent(new TokenCreatedDomainEvent(this)); } @@ -40,13 +43,21 @@ public Token(IdentityAddress createdBy, DeviceId createdByDevice, byte[] content public DeviceId CreatedByDevice { get; set; } public IdentityAddress? ForIdentity { get; set; } + public byte[]? Password { get; set; } public byte[] Content { get; private set; } public DateTime CreatedAt { get; set; } public DateTime ExpiresAt { get; set; } - public static Expression> IsExpired => - challenge => challenge.ExpiresAt <= SystemTime.UtcNow; + public bool CanBeCollectedUsingPassword(IdentityAddress? address, byte[]? password) + { + return + Password == null || + password != null && Password.SequenceEqual(password) || + CreatedBy == address; // The owner shouldn't need a password to get the template + } + + #region Expressions public static Expression> IsNotExpired => challenge => challenge.ExpiresAt > SystemTime.UtcNow; @@ -60,4 +71,19 @@ public static Expression> WasCreatedBy(IdentityAddress identit { return t => t.CreatedBy == identityAddress.ToString(); } + + public static Expression> HasId(TokenId id) + { + return r => r.Id == id; + } + + public static Expression> CanBeCollectedWithPassword(IdentityAddress address, byte[]? password) + { + return token => + token.Password == null || + token.Password == password || + token.CreatedBy == address; // The owner shouldn't need a password to get the template + } + + #endregion } diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.Designer.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.Designer.cs new file mode 100644 index 0000000000..3380426013 --- /dev/null +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.Designer.cs @@ -0,0 +1,77 @@ +// +using System; +using Backbone.Modules.Tokens.Infrastructure.Persistence.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Backbone.Modules.Tokens.Infrastructure.Database.Postgres.Migrations +{ + [DbContext(typeof(TokensDbContext))] + [Migration("20241011123029_AddPasswordToToken")] + partial class AddPasswordToToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Tokens") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Backbone.Modules.Tokens.Domain.Entities.Token", b => + { + b.Property("Id") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character(20)") + .IsFixedLength(); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(80) + .IsUnicode(false) + .HasColumnType("character varying(80)") + .IsFixedLength(false); + + b.Property("CreatedByDevice") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("character(20)") + .IsFixedLength(); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ForIdentity") + .HasMaxLength(80) + .IsUnicode(false) + .HasColumnType("character varying(80)") + .IsFixedLength(false); + + b.Property("Password") + .HasMaxLength(200) + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("Tokens", "Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.cs new file mode 100644 index 0000000000..fd4787e984 --- /dev/null +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/20241011123029_AddPasswordToToken.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backbone.Modules.Tokens.Infrastructure.Database.Postgres.Migrations +{ + /// + public partial class AddPasswordToToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Password", + schema: "Tokens", + table: "Tokens", + type: "bytea", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Password", + schema: "Tokens", + table: "Tokens"); + } + } +} diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/TokensDbContextModelSnapshot.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/TokensDbContextModelSnapshot.cs index bd02e2d9b9..5a55a5d951 100644 --- a/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/TokensDbContextModelSnapshot.cs +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.Postgres/Migrations/TokensDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Tokens") - .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -60,6 +60,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(80)") .IsFixedLength(false); + b.Property("Password") + .HasMaxLength(200) + .HasColumnType("bytea"); + b.HasKey("Id"); b.ToTable("Tokens", "Tokens"); diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.Designer.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.Designer.cs new file mode 100644 index 0000000000..53d8ae7ac1 --- /dev/null +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.Designer.cs @@ -0,0 +1,77 @@ +// +using System; +using Backbone.Modules.Tokens.Infrastructure.Persistence.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Backbone.Modules.Tokens.Infrastructure.Database.SqlServer.Migrations +{ + [DbContext(typeof(TokensDbContext))] + [Migration("20241011123022_AddPasswordToToken")] + partial class AddPasswordToToken + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("Tokens") + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Backbone.Modules.Tokens.Domain.Entities.Token", b => + { + b.Property("Id") + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("char(20)") + .IsFixedLength(); + + b.Property("Content") + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(80) + .IsUnicode(false) + .HasColumnType("varchar(80)") + .IsFixedLength(false); + + b.Property("CreatedByDevice") + .IsRequired() + .HasMaxLength(20) + .IsUnicode(false) + .HasColumnType("char(20)") + .IsFixedLength(); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("ForIdentity") + .HasMaxLength(80) + .IsUnicode(false) + .HasColumnType("varchar(80)") + .IsFixedLength(false); + + b.Property("Password") + .HasMaxLength(200) + .HasColumnType("varbinary(200)"); + + b.HasKey("Id"); + + b.ToTable("Tokens", "Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.cs new file mode 100644 index 0000000000..149f8e7ffc --- /dev/null +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/20241011123022_AddPasswordToToken.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Backbone.Modules.Tokens.Infrastructure.Database.SqlServer.Migrations +{ + /// + public partial class AddPasswordToToken : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Password", + schema: "Tokens", + table: "Tokens", + type: "varbinary(200)", + maxLength: 200, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Password", + schema: "Tokens", + table: "Tokens"); + } + } +} diff --git a/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/TokensDbContextModelSnapshot.cs b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/TokensDbContextModelSnapshot.cs index 3e5621dfe4..8d6b40d9b2 100644 --- a/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/TokensDbContextModelSnapshot.cs +++ b/Modules/Tokens/src/Tokens.Infrastructure.Database.SqlServer/Migrations/TokensDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("Tokens") - .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("ProductVersion", "8.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -60,6 +60,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("varchar(80)") .IsFixedLength(false); + b.Property("Password") + .HasMaxLength(200) + .HasColumnType("varbinary(200)"); + b.HasKey("Id"); b.ToTable("Tokens", "Tokens"); diff --git a/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Database/EntityConfigurations/TokenEntityTypeConfiguration.cs b/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Database/EntityConfigurations/TokenEntityTypeConfiguration.cs index 58ec0d0e26..a8a6b95f4a 100644 --- a/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Database/EntityConfigurations/TokenEntityTypeConfiguration.cs +++ b/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Database/EntityConfigurations/TokenEntityTypeConfiguration.cs @@ -11,5 +11,7 @@ public override void Configure(EntityTypeBuilder builder) base.Configure(builder); builder.Property(r => r.Content).IsRequired(false); + + builder.Property(x => x.Password).HasMaxLength(Token.MAX_PASSWORD_LENGTH); } } diff --git a/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Repository/TokensRepository.cs b/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Repository/TokensRepository.cs index 147b2c27fd..5693d75373 100644 --- a/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Repository/TokensRepository.cs +++ b/Modules/Tokens/src/Tokens.Infrastructure/Persistence/Repository/TokensRepository.cs @@ -4,6 +4,7 @@ using Backbone.BuildingBlocks.Application.Pagination; using Backbone.DevelopmentKit.Identity.ValueObjects; using Backbone.Modules.Tokens.Application.Infrastructure.Persistence.Repository; +using Backbone.Modules.Tokens.Application.Tokens.Queries.ListTokens; using Backbone.Modules.Tokens.Domain.Entities; using Backbone.Modules.Tokens.Infrastructure.Persistence.Database; using Microsoft.EntityFrameworkCore; @@ -23,12 +24,37 @@ public TokensRepository(TokensDbContext dbContext) _readonlyTokensDbSet = dbContext.Tokens.AsNoTracking(); } - public async Task Find(TokenId id, IdentityAddress? activeIdentity) + public async Task> FindTokens(IEnumerable queryItems, IdentityAddress activeIdentity, + PaginationFilter paginationFilter, CancellationToken cancellationToken, bool track = false) + { + var queryItemsList = queryItems.ToList(); + + Expression> idAndPasswordFilter = template => false; + + foreach (var inputQuery in queryItemsList) + { + idAndPasswordFilter = idAndPasswordFilter + .Or(Token.HasId(TokenId.Parse(inputQuery.Id)) + .And(Token.CanBeCollectedWithPassword(activeIdentity, inputQuery.Password))); + } + + var query = (track ? _tokensDbSet : _readonlyTokensDbSet) + .Where(Token.IsNotExpired) + .Where(Token.CanBeCollectedBy(activeIdentity)) + .Where(idAndPasswordFilter); + + var templates = await query.OrderAndPaginate(d => d.CreatedAt, paginationFilter, cancellationToken); + + return templates; + } + + public async Task Find(TokenId id, IdentityAddress? activeIdentity, CancellationToken cancellationToken, bool track = false) { var token = await _readonlyTokensDbSet .Where(Token.IsNotExpired) .Where(Token.CanBeCollectedBy(activeIdentity)) - .FirstOrDefaultAsync(t => t.Id == id); + .Where(Token.HasId(id)) + .FirstOrDefaultAsync(cancellationToken); return token; } diff --git a/Modules/Tokens/test/Tokens.Application.Tests/Tests/Tokens/CreateToken/ValidatorTests.cs b/Modules/Tokens/test/Tokens.Application.Tests/Tests/Tokens/CreateToken/ValidatorTests.cs index df5f0c3687..dfb86d96cc 100644 --- a/Modules/Tokens/test/Tokens.Application.Tests/Tests/Tokens/CreateToken/ValidatorTests.cs +++ b/Modules/Tokens/test/Tokens.Application.Tests/Tests/Tokens/CreateToken/ValidatorTests.cs @@ -10,17 +10,29 @@ namespace Backbone.Modules.Tokens.Application.Tests.Tests.Tokens.CreateToken; public class ValidatorTests : AbstractTestsBase { - [Theory] - [InlineData("did:e:prod.enmeshed.eu:dids:70cf4f3e6edf6bca33d35f")] - [InlineData(null)] - public void Happy_Path(string? forIdentity) + [Fact] + public void Happy_Path_with_optional_parameters() + { + // Arrange + var validator = new Validator(); + + // Act + var validationResult = validator.TestValidate( + new CreateTokenCommand { Content = [1], ExpiresAt = DateTime.UtcNow.AddDays(1), ForIdentity = "did:e:prod.enmeshed.eu:dids:70cf4f3e6edf6bca33d35f", Password = [1, 2, 3] }); + + // Assert + validationResult.ShouldNotHaveAnyValidationErrors(); + } + + [Fact] + public void Happy_Path_without_optional_parameters() { // Arrange var validator = new Validator(); // Act var validationResult = validator.TestValidate( - new CreateTokenCommand { Content = [1], ExpiresAt = DateTime.UtcNow.AddDays(1), ForIdentity = forIdentity }); + new CreateTokenCommand { Content = [1], ExpiresAt = DateTime.UtcNow.AddDays(1) }); // Assert validationResult.ShouldNotHaveAnyValidationErrors(); @@ -69,4 +81,21 @@ public void Fails_when_ForIdentity_is_invalid() // Assert validationResult.ShouldHaveValidationErrorForId(nameof(Token.ForIdentity)); } + + [Fact] + public void Fails_when_Password_is_too_long() + { + // Arrange + var validator = new Validator(); + + var password = new byte[250]; + new Random().NextBytes(password); + + // Act + var validationResult = validator.TestValidate(new CreateTokenCommand { Content = [1], ExpiresAt = DateTime.UtcNow.AddDays(1), Password = password }); + + // Assert + validationResult.ShouldHaveValidationErrorForItem(nameof(CreateTokenCommand.Password), "error.platform.validation.invalidPropertyValue", + "'Password' must be between 1 and 200 bytes long. You entered 250 bytes."); + } } diff --git a/Modules/Tokens/test/Tokens.Domain.Tests/TestHelpers/TestData.cs b/Modules/Tokens/test/Tokens.Domain.Tests/TestHelpers/TestData.cs index c6217d994c..0bd10f22aa 100644 --- a/Modules/Tokens/test/Tokens.Domain.Tests/TestHelpers/TestData.cs +++ b/Modules/Tokens/test/Tokens.Domain.Tests/TestHelpers/TestData.cs @@ -4,8 +4,8 @@ namespace Backbone.Modules.Tokens.Domain.Tests.TestHelpers; public class TestData { - public static Token CreateToken(IdentityAddress createdBy, IdentityAddress? forIdentity) + public static Token CreateToken(IdentityAddress createdBy, IdentityAddress? forIdentity, byte[]? password = null) { - return new Token(createdBy, DeviceId.Parse("DVC1"), [], DateTime.Now.AddDays(1), forIdentity); + return new Token(createdBy, DeviceId.Parse("DVC1"), [], DateTime.Now.AddDays(1), forIdentity, password); } } diff --git a/Modules/Tokens/test/Tokens.Domain.Tests/Tests/TokenCanBeCollectedUsingPasswordTests.cs b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/TokenCanBeCollectedUsingPasswordTests.cs new file mode 100644 index 0000000000..4724ac39df --- /dev/null +++ b/Modules/Tokens/test/Tokens.Domain.Tests/Tests/TokenCanBeCollectedUsingPasswordTests.cs @@ -0,0 +1,102 @@ +using Backbone.Modules.Tokens.Domain.Tests.TestHelpers; +using Backbone.UnitTestTools.Data; +using FluentAssertions; +using Xunit; + +namespace Backbone.Modules.Tokens.Domain.Tests.Tests; + +public class TokenCanBeCollectedUsingPasswordTests +{ + [Fact] + public void Can_collect_without_a_password_when_no_password_is_defined() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + var collector = TestDataGenerator.CreateRandomIdentityAddress(); + + var template = TestData.CreateToken(creator, null); + + // Act + var result = template.CanBeCollectedUsingPassword(collector, null); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Can_collect_with_a_password_when_no_password_is_defined() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + var collector = TestDataGenerator.CreateRandomIdentityAddress(); + + var template = TestData.CreateToken(creator, null); + + // Act + var result = template.CanBeCollectedUsingPassword(collector, [1]); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Can_collect_with_correct_password() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + var collector = TestDataGenerator.CreateRandomIdentityAddress(); + + var template = TestData.CreateToken(creator, null, password: [1]); + + // Act + var result = template.CanBeCollectedUsingPassword(collector, [1]); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Cannot_collect_with_incorrect_password() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + var collector = TestDataGenerator.CreateRandomIdentityAddress(); + + var template = TestData.CreateToken(creator, null, password: [1]); + + // Act + var result = template.CanBeCollectedUsingPassword(collector, [2]); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void Can_collect_as_owner_without_a_password() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + + var template = TestData.CreateToken(creator, null, password: [1]); + + // Act + var result = template.CanBeCollectedUsingPassword(creator, null); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void Can_collect_as_anonymous_user_with_correct_password() + { + // Arrange + var creator = TestDataGenerator.CreateRandomIdentityAddress(); + var template = TestData.CreateToken(creator, null, password: [1]); + + // Act + var result = template.CanBeCollectedUsingPassword(null, [1]); + + // Assert + result.Should().BeTrue(); + } +} diff --git a/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/RelationshipTemplatesEndpoint.cs b/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/RelationshipTemplatesEndpoint.cs index 1ba0415e10..88111406e7 100644 --- a/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/RelationshipTemplatesEndpoint.cs +++ b/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/RelationshipTemplatesEndpoint.cs @@ -9,7 +9,7 @@ namespace Backbone.ConsumerApi.Sdk.Endpoints.RelationshipTemplates; public class RelationshipTemplatesEndpoint(EndpointClient client) : ConsumerApiEndpoint(client) { - public async Task> ListTemplates(IEnumerable queryItems, PaginationFilter? pagination = null) + public async Task> ListTemplates(IEnumerable queryItems, PaginationFilter? pagination = null) { return await _client .Request(HttpMethod.Get, $"api/{API_VERSION}/RelationshipTemplates") @@ -37,7 +37,7 @@ public async Task> CreateTemplat file static class RelationshipTemplateQueryExtensions { - public static string ToJson(this IEnumerable queryItems) + public static string ToJson(this IEnumerable queryItems) { return JsonConvert.SerializeObject(queryItems, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, Formatting = Formatting.None }); } diff --git a/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/RelationshipTemplateQueryItem.cs b/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/ListRelationshipTemplatesQueryItem.cs similarity index 78% rename from Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/RelationshipTemplateQueryItem.cs rename to Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/ListRelationshipTemplatesQueryItem.cs index 3f4482136c..caf23774fa 100644 --- a/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/RelationshipTemplateQueryItem.cs +++ b/Sdks/ConsumerApi.Sdk/src/Endpoints/RelationshipTemplates/Types/Requests/ListRelationshipTemplatesQueryItem.cs @@ -1,6 +1,6 @@ namespace Backbone.ConsumerApi.Sdk.Endpoints.RelationshipTemplates.Types.Requests; -public class RelationshipTemplateQueryItem +public class ListRelationshipTemplatesQueryItem { public required string Id { get; set; } public byte[]? Password { get; set; } diff --git a/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/TokensEndpoint.cs b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/TokensEndpoint.cs index eb15a858bb..5e072cfc70 100644 --- a/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/TokensEndpoint.cs +++ b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/TokensEndpoint.cs @@ -23,13 +23,13 @@ public async Task> ListTokens(PaginationFilter? return await _client.Get($"api/{API_VERSION}/Tokens", null, pagination); } - public async Task> ListTokens(IEnumerable ids, PaginationFilter? pagination = null) + public async Task> ListTokens(IEnumerable queryItems, PaginationFilter? pagination = null) { return await _client .Request(HttpMethod.Get, $"api/{API_VERSION}/Tokens") .Authenticate() .WithPagination(pagination) - .AddQueryParameter("ids", ids) + .AddQueryParameter("tokens", queryItems) .Execute(); } @@ -38,8 +38,18 @@ public async Task> GetTokenUnauthenticated(string id) return await _client.GetUnauthenticated($"api/{API_VERSION}/Tokens/{id}"); } + public async Task> GetTokenUnauthenticated(string id, byte[] password) + { + return await _client.GetUnauthenticated($"api/{API_VERSION}/Tokens/{id}?password={Convert.ToBase64String(password)}"); + } + public async Task> GetToken(string id) { return await _client.Get($"api/{API_VERSION}/Tokens/{id}"); } + + public async Task> GetToken(string id, byte[] password) + { + return await _client.Get($"api/{API_VERSION}/Tokens/{id}?password={Convert.ToBase64String(password)}"); + } } diff --git a/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/CreateTokenRequest.cs b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/CreateTokenRequest.cs index 53b00f472f..a9c8495094 100644 --- a/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/CreateTokenRequest.cs +++ b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/CreateTokenRequest.cs @@ -5,4 +5,5 @@ public class CreateTokenRequest public required byte[] Content { get; set; } public required DateTime ExpiresAt { get; set; } public string? ForIdentity { get; set; } + public byte[]? Password { get; set; } } diff --git a/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/ListTokensQueryItem.cs b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/ListTokensQueryItem.cs new file mode 100644 index 0000000000..6e2dd95bb5 --- /dev/null +++ b/Sdks/ConsumerApi.Sdk/src/Endpoints/Tokens/Types/Requests/ListTokensQueryItem.cs @@ -0,0 +1,7 @@ +namespace Backbone.ConsumerApi.Sdk.Endpoints.Tokens.Types.Requests; + +public class ListTokensQueryItem +{ + public required string Id { get; set; } + public byte[]? Password { get; set; } +}