From 7858a9da52ff663369195e163361d83b6ea8edbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 16 Jan 2025 10:42:48 +0100 Subject: [PATCH] Download signDocuments and synchronize with SigneeContexts. Enables checking signature status. --- .../Controllers/SigningController.cs | 48 +++-- .../Signing/Interfaces/ISigningService.cs | 5 +- .../Features/Signing/Models/SigneeContext.cs | 8 + .../Features/Signing/SigningService.cs | 167 ++++++++++++++---- .../Features/Signing/SigningServiceTests.cs | 142 +++++++++++++++ 5 files changed, 320 insertions(+), 50 deletions(-) create mode 100644 test/Altinn.App.Core.Tests/Features/Signing/SigningServiceTests.cs diff --git a/src/Altinn.App.Api/Controllers/SigningController.cs b/src/Altinn.App.Api/Controllers/SigningController.cs index 6caff11e4..718acd657 100644 --- a/src/Altinn.App.Api/Controllers/SigningController.cs +++ b/src/Altinn.App.Api/Controllers/SigningController.cs @@ -3,10 +3,13 @@ using Altinn.App.Core.Features.Signing.Interfaces; using Altinn.App.Core.Features.Signing.Models; using Altinn.App.Core.Helpers; +using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Mvc; using SigneeState = Altinn.App.Api.Models.SigneeState; @@ -23,6 +26,9 @@ namespace Altinn.App.Api.Controllers; public class SigningController : ControllerBase { private readonly IInstanceClient _instanceClient; + private readonly IAppMetadata _appMetadata; + private readonly IDataClient _dataClient; + private readonly ModelSerializationService _modelSerialization; private readonly IProcessReader _processReader; private readonly ISigningService _signingService; @@ -32,10 +38,16 @@ public class SigningController : ControllerBase public SigningController( IServiceProvider serviceProvider, IInstanceClient instanceClient, + IAppMetadata appMetadata, + IDataClient dataClient, + ModelSerializationService modelSerialization, IProcessReader processReader ) { _instanceClient = instanceClient; + _appMetadata = appMetadata; + _dataClient = dataClient; + _modelSerialization = modelSerialization; _processReader = processReader; _signingService = serviceProvider.GetRequiredService(); } @@ -61,6 +73,15 @@ public async Task GetSigneesState( ) { Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); + ApplicationMetadata appMetadata = await _appMetadata.GetApplicationMetadata(); + + var cachedDataMutator = new InstanceDataUnitOfWork( + instance, + _dataClient, + _instanceClient, + appMetadata, + _modelSerialization + ); if (instance.Process.CurrentTask.AltinnTaskType != "signing") { @@ -76,25 +97,24 @@ public async Task GetSigneesState( throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension"); } - List signeeContexts = await _signingService.GetSigneeContexts(instance, signingConfiguration); + List signeeContexts = await _signingService.GetSigneeContexts( + cachedDataMutator, + signingConfiguration + ); - Random rnd = new Random(); var response = new SingingStateResponse { SigneeStates = signeeContexts - .Select(signeeContext => + .Select(signeeContext => new SigneeState { - return new SigneeState - { - Name = signeeContext.PersonSignee?.DisplayName ?? signeeContext.OrganisationSignee?.DisplayName, - Organisation = signeeContext.OrganisationSignee?.DisplayName, - HasSigned = rnd.Next(1, 10) > 5, //TODO: When and where to check if signee has signed? - DelegationSuccessful = signeeContext.SigneeState.IsAccessDelegated is false, - NotificationSuccessful = - signeeContext.SigneeState - is { SignatureRequestEmailSent: false, SignatureRequestSmsSent: false }, - PartyId = signeeContext.Party.PartyId, - }; + Name = signeeContext.PersonSignee?.DisplayName ?? signeeContext.OrganisationSignee?.DisplayName, + Organisation = signeeContext.OrganisationSignee?.DisplayName, + HasSigned = signeeContext.SignDocument is not null, + DelegationSuccessful = signeeContext.SigneeState.IsAccessDelegated, + NotificationSuccessful = + signeeContext.SigneeState + is { SignatureRequestEmailSent: false, SignatureRequestSmsSent: false }, + PartyId = signeeContext.Party.PartyId, }) .ToList(), }; diff --git a/src/Altinn.App.Core/Features/Signing/Interfaces/ISigningService.cs b/src/Altinn.App.Core/Features/Signing/Interfaces/ISigningService.cs index e27f5f584..2b9615762 100644 --- a/src/Altinn.App.Core/Features/Signing/Interfaces/ISigningService.cs +++ b/src/Altinn.App.Core/Features/Signing/Interfaces/ISigningService.cs @@ -24,5 +24,8 @@ Task> ProcessSignees( CancellationToken ct ); - Task> GetSigneeContexts(Instance instance, AltinnSignatureConfiguration signatureConfiguration); + Task> GetSigneeContexts( + IInstanceDataMutator instanceMutator, + AltinnSignatureConfiguration signatureConfiguration + ); } diff --git a/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs b/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs index 3aa48b668..b5533d03a 100644 --- a/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs +++ b/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs @@ -1,5 +1,6 @@ using System.Text.Json.Serialization; using Altinn.Platform.Register.Models; +using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Features.Signing.Models; @@ -34,4 +35,11 @@ internal sealed class SigneeContext /// [JsonPropertyName("personSignee")] public PersonSignee? PersonSignee { get; set; } + + /// + /// The signature document, if it exists yet. + /// + /// This is not and should not be serialized and persisted in storage, it's looked up on-the-fly when the signee contexts are retrieved through + [JsonIgnore] + public SignDocument? SignDocument { get; set; } } diff --git a/src/Altinn.App.Core/Features/Signing/SigningService.cs b/src/Altinn.App.Core/Features/Signing/SigningService.cs index 01219f737..bbe49c3f9 100644 --- a/src/Altinn.App.Core/Features/Signing/SigningService.cs +++ b/src/Altinn.App.Core/Features/Signing/SigningService.cs @@ -6,16 +6,14 @@ using Altinn.App.Core.Features.Signing.Interfaces; using Altinn.App.Core.Features.Signing.Mocks; using Altinn.App.Core.Features.Signing.Models; -using Altinn.App.Core.Helpers.Serialization; using Altinn.App.Core.Internal.App; -using Altinn.App.Core.Internal.Data; -using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; using Altinn.App.Core.Internal.Registers; using Altinn.App.Core.Models; using Altinn.Platform.Register.Models; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Logging; +using JsonException = Newtonsoft.Json.JsonException; namespace Altinn.App.Core.Features.Signing; @@ -26,19 +24,11 @@ internal sealed class SigningService( ISigningDelegationService signingDelegationService, // ISigningNotificationService signingNotificationService, IEnumerable signeeProviders, - IDataClient dataClient, - IInstanceClient instanceClient, - ModelSerializationService modelSerialization, - IAppMetadata appMetadata, ILogger logger, Telemetry? telemetry = null ) : ISigningService { private static readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); - private readonly IDataClient _dataClient = dataClient; - private readonly IInstanceClient _instanceClient = instanceClient; - private readonly ModelSerializationService _modelSerialization = modelSerialization; - private readonly IAppMetadata _appMetadata = appMetadata; private readonly ILogger _logger = logger; private const string ApplicationJsonContentType = "application/json"; @@ -66,7 +56,7 @@ CancellationToken ct { using Activity? activity = telemetry?.StartAssignSigneesActivity(); - var instance = instanceMutator.Instance; + Instance instance = instanceMutator.Instance; string taskId = instance.Process.CurrentTask.ElementId; SigneesResult? signeesResult = await GetSignees(instance, signatureConfiguration); @@ -127,36 +117,18 @@ CancellationToken ct } public async Task> GetSigneeContexts( - Instance instance, + IInstanceDataMutator instanceMutator, AltinnSignatureConfiguration signatureConfiguration ) { using Activity? activity = telemetry?.StartReadSigneesActivity(); - // TODO: Get signees from state - ApplicationMetadata appMetadata = await _appMetadata.GetApplicationMetadata(); - - var cachedDataMutator = new InstanceDataUnitOfWork( - instance, - _dataClient, - _instanceClient, - appMetadata, - _modelSerialization - ); - // ! TODO: Remove nullable - IEnumerable dataElements = cachedDataMutator.GetDataElementsForType( - signatureConfiguration.SigneeStatesDataTypeId! - ); + List signeeContexts = await DownloadSigneeContexts(instanceMutator, signatureConfiguration); + List signDocuments = await DownloadSignDocuments(instanceMutator, signatureConfiguration); - DataElement signeeStateDataElement = dataElements.Single(); - ReadOnlyMemory data = await cachedDataMutator.GetBinaryData(signeeStateDataElement); - string asString = Encoding.UTF8.GetString(data.ToArray()); + await SynchronizeSigneeContextsWithSignDocuments(instanceMutator, signeeContexts, signDocuments); - var result = JsonSerializer.Deserialize(asString, _jsonSerializerOptions) ?? []; - - return [.. result]; - - // TODO: Get signees from policy?? + return signeeContexts; } //TODO: There is already logic for the sign action in the SigningUserAction class. Maybe move most of it here? @@ -287,6 +259,131 @@ await organisationClient.GetOrganization(organisationSignee.OrganisationNumber) } ); } + return organisationSigneeContexts; } + + private static async Task> DownloadSigneeContexts( + IInstanceDataMutator instanceMutator, + AltinnSignatureConfiguration signatureConfiguration + ) + { + string signeeStatesDataTypeId = + signatureConfiguration.SigneeStatesDataTypeId + ?? throw new ApplicationConfigException( + "SigneeStatesDataTypeId is not set in the signature configuration." + ); + + IEnumerable dataElements = instanceMutator.GetDataElementsForType(signeeStatesDataTypeId); + + DataElement signeeStateDataElement = + dataElements.SingleOrDefault() + ?? throw new ApplicationException( + $"Failed to find the data element containing signee contexts using dataTypeId {signatureConfiguration.SigneeStatesDataTypeId}." + ); + + ReadOnlyMemory data = await instanceMutator.GetBinaryData(signeeStateDataElement); + string signeeStateSerialized = Encoding.UTF8.GetString(data.ToArray()); + + List signeeContexts = + JsonSerializer.Deserialize>(signeeStateSerialized, _jsonSerializerOptions) ?? []; + + return signeeContexts; + } + + private static async Task> DownloadSignDocuments( + IInstanceDataMutator instanceMutator, + AltinnSignatureConfiguration signatureConfiguration + ) + { + List signatureDataElements = instanceMutator + .Instance.Data.Where(x => x.DataType == signatureConfiguration.SignatureDataType) + .ToList(); + + List signDocuments = []; + //TODO: Is GetBinaryData safe to do in parallel? If so, do it. + foreach (DataElement signatureDataElement in signatureDataElements) + { + ReadOnlyMemory data = await instanceMutator.GetBinaryData(signatureDataElement); + string signDocumentSerialized = Encoding.UTF8.GetString(data.ToArray()); + + SignDocument signDocument = + JsonSerializer.Deserialize(signDocumentSerialized, _jsonSerializerOptions) + ?? throw new JsonException("Could not deserialize signature document."); + + signDocuments.Add(signDocument); + } + + return signDocuments; + } + + /// + /// This method exists to ensure we have a SigneeContext for both signees that have been delegated access to sign and signees that have signed using access granted through the policy.xml file. + /// + private async Task SynchronizeSigneeContextsWithSignDocuments( + IInstanceDataMutator instanceMutator, + List signeeContexts, + List signDocuments + ) + { + foreach (SignDocument signDocument in signDocuments) + { + SigneeContext? matchingSigneeContext = signeeContexts.FirstOrDefault(x => + x.PersonSignee?.SocialSecurityNumber == signDocument.SigneeInfo.PersonNumber + || x.OrganisationSignee?.OrganisationNumber == signDocument.SigneeInfo.OrganisationNumber + ); + + if (matchingSigneeContext is not null) + { + // If the signee has been delegated access to sign there will be a matching SigneeContext. Setting the sign document property on this context. + matchingSigneeContext.SignDocument = signDocument; + } + else + { + // If the signee has signed using access granted through the policy.xml file, there is no persisted signee context. We create a signee context on the fly. + Party party = await altinnPartyClient.LookupParty( + new PartyLookup + { + Ssn = signDocument.SigneeInfo.PersonNumber, + OrgNo = signDocument.SigneeInfo.OrganisationNumber, + } + ); + + PersonSignee? personSignee = party.Person is not null + ? new PersonSignee + { + SocialSecurityNumber = party.Person.SSN, + DisplayName = party.Person.Name, + FullName = party.Person.Name, + OnBehalfOfOrganisation = party.Organization?.Name, + } + : null; + + OrganisationSignee? organisationSignee = party.Organization is not null + ? new OrganisationSignee + { + OrganisationNumber = party.Organization.OrgNumber, + DisplayName = party.Organization.Name, + } + : null; + + signeeContexts.Add( + new SigneeContext + { + TaskId = instanceMutator.Instance.Process.CurrentTask.ElementId, + Party = party, + PersonSignee = personSignee, + OrganisationSignee = organisationSignee, + SigneeState = new SigneeState() + { + IsAccessDelegated = true, + SignatureRequestEmailSent = true, + SignatureRequestSmsSent = true, + IsReceiptSent = false, + }, + } + ); + } + } + } } diff --git a/test/Altinn.App.Core.Tests/Features/Signing/SigningServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Signing/SigningServiceTests.cs new file mode 100644 index 000000000..3015bebf1 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Signing/SigningServiceTests.cs @@ -0,0 +1,142 @@ +using System.Text; +using System.Text.Json; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Signing; +using Altinn.App.Core.Features.Signing.Interfaces; +using Altinn.App.Core.Features.Signing.Models; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Instances; +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Internal.Registers; +using Altinn.App.Core.Models; +using Altinn.Platform.Register.Models; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Altinn.App.Core.Tests.Features.Signing; + +public class SigningServiceTests +{ + private readonly SigningService _signingService; + + private readonly Mock _personClient = new(MockBehavior.Strict); + private readonly Mock _organizationClient = new(MockBehavior.Strict); + private readonly Mock _altinnPartyClient = new(MockBehavior.Strict); + private readonly Mock _signingDelegationService = new(MockBehavior.Strict); + private readonly Mock _signeeProvider = new(MockBehavior.Strict); + private readonly Mock> _logger = new(MockBehavior.Strict); + + public SigningServiceTests() + { + _signingService = new SigningService( + _personClient.Object, + _organizationClient.Object, + _altinnPartyClient.Object, + _signingDelegationService.Object, + [_signeeProvider.Object], + _logger.Object + ); + } + + [Fact] + public async Task GetSigneeContexts() + { + // Arrange + var signatureConfiguration = new AltinnSignatureConfiguration + { + SigneeStatesDataTypeId = "signeeStates", + SignatureDataType = "signature", + }; + + var cachedInstanceMutator = new Mock(); + + var signeeStateDataElement = new DataElement + { + Id = Guid.NewGuid().ToString(), + DataType = signatureConfiguration.SigneeStatesDataTypeId, + }; + + var signDocumentDataElement = new DataElement + { + Id = Guid.NewGuid().ToString(), + DataType = signatureConfiguration.SignatureDataType, + }; + + Instance instance = new() + { + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "Task_1" } }, + Data = [signeeStateDataElement, signDocumentDataElement], + }; + + var org = new Organization { OrgNumber = "123456789", Name = "An org" }; + + var signeeState = new List + { + new() + { + TaskId = instance.Process.CurrentTask.ElementId, + SigneeState = new SigneeState { IsAccessDelegated = true }, + OrganisationSignee = new OrganisationSignee + { + DisplayName = org.Name, + OrganisationNumber = org.OrgNumber, + }, + Party = new Party + { + Organization = new Organization { OrgNumber = org.OrgNumber, Name = org.Name }, + }, + }, + }; + + var signDocument = new SignDocument + { + SigneeInfo = new Signee { OrganisationNumber = signeeState.First().Party.Organization.OrgNumber }, + }; + + cachedInstanceMutator.Setup(x => x.Instance).Returns(instance); + cachedInstanceMutator + .Setup(x => x.GetBinaryData(new DataElementIdentifier(signeeStateDataElement.Id))) + .ReturnsAsync(new ReadOnlyMemory(ToBytes(signeeState))); + + cachedInstanceMutator + .Setup(x => x.GetBinaryData(new DataElementIdentifier(signDocumentDataElement.Id))) + .ReturnsAsync(new ReadOnlyMemory(ToBytes(signDocument))); + + // Act + List result = await _signingService.GetSigneeContexts( + cachedInstanceMutator.Object, + signatureConfiguration + ); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + + SigneeContext signeeContext = result.First(); + signeeContext.Should().NotBeNull(); + signeeContext.TaskId.Should().Be(instance.Process.CurrentTask.ElementId); + + signeeContext.OrganisationSignee.Should().NotBeNull(); + signeeContext.OrganisationSignee?.DisplayName.Should().Be(org.Name); + signeeContext.OrganisationSignee?.OrganisationNumber.Should().Be(org.OrgNumber); + + signeeContext.Party.Should().NotBeNull(); + signeeContext.Party.Organization.Should().NotBeNull(); + signeeContext.Party.Organization?.OrgNumber.Should().Be(org.OrgNumber); + signeeContext.Party.Organization?.Name.Should().Be(org.Name); + + signeeContext.SigneeState.Should().NotBeNull(); + signeeContext.SigneeState.IsAccessDelegated.Should().BeTrue(); + + signeeContext.SignDocument.Should().NotBeNull(); + signeeContext.SignDocument?.SigneeInfo.Should().NotBeNull(); + signeeContext.SignDocument?.SigneeInfo?.OrganisationNumber.Should().Be(org.OrgNumber); + } + + private static byte[] ToBytes(T obj) + { + return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(obj)); + } +}