From 9cb9bc6ebcaedaad33510327c0b985776da127cb Mon Sep 17 00:00:00 2001 From: Daniel Skovli Date: Thu, 21 Nov 2024 15:45:30 +0100 Subject: [PATCH] Correspondence client (#897) --- .../Extensions/HttpClientBuilderExtensions.cs | 40 +- .../Extensions/ServiceCollectionExtensions.cs | 38 +- .../Extensions/WebHostBuilderExtensions.cs | 39 +- .../RequestHandling/RequestPartValidator.cs | 159 +++--- .../Configuration/PlatformSettings.cs | 5 + .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Correspondence/Builder/BuilderUtils.cs | 29 + .../CorrespondenceAttachmentBuilder.cs | 100 ++++ .../Builder/CorrespondenceContentBuilder.cs | 80 +++ .../CorrespondenceNotificationBuilder.cs | 143 +++++ .../Builder/CorrespondenceRequestBuilder.cs | 313 +++++++++++ .../ICorrespondenceAttachmentBuilder.cs | 94 ++++ .../Builder/ICorrespondenceContentBuilder.cs | 73 +++ .../ICorrespondenceNotificationBuilder.cs | 115 ++++ .../Builder/ICorrespondenceRequestBuilder.cs | 308 ++++++++++ .../CorrespondenceAuthorisationFactory.cs | 26 + .../Correspondence/CorrespondenceClient.cs | 241 ++++++++ .../CorrespondenceArgumentException.cs | 18 + .../Exceptions/CorrespondenceException.cs | 18 + .../CorrespondenceRequestException.cs | 65 +++ .../CorrespondenceValueException.cs | 18 + .../Extensions/ServiceCollectionExtensions.cs | 16 + .../Correspondence/ICorrespondenceClient.cs | 31 + .../Models/CorrespondenceAttachment.cs | 58 ++ .../CorrespondenceAttachmentResponse.cs | 96 ++++ .../CorrespondenceAttachmentStatusResponse.cs | 35 ++ .../Models/CorrespondenceContent.cs | 43 ++ .../Models/CorrespondenceContentResponse.cs | 41 ++ .../Models/CorrespondenceDataLocationType.cs | 25 + .../CorrespondenceDataLocationTypeResponse.cs | 20 + .../Models/CorrespondenceDetailsResponse.cs | 35 ++ .../Models/CorrespondenceExternalReference.cs | 27 + .../Models/CorrespondenceNotification.cs | 115 ++++ .../CorrespondenceNotificationChannel.cs | 30 + ...rrespondenceNotificationDetailsResponse.cs | 27 + ...CorrespondenceNotificationOrderResponse.cs | 75 +++ ...espondenceNotificationRecipientResponse.cs | 39 ++ ...ndenceNotificationStatusDetailsResponse.cs | 33 ++ ...orrespondenceNotificationStatusResponse.cs | 25 + ...ndenceNotificationStatusSummaryResponse.cs | 27 + ...rrespondenceNotificationSummaryResponse.cs | 21 + .../CorrespondenceNotificationTemplate.cs | 20 + .../Models/CorrespondencePayload.cs | 88 +++ .../Models/CorrespondenceReferenceType.cs | 35 ++ .../Models/CorrespondenceReplyOption.cs | 27 + .../Models/CorrespondenceRequest.cs | 295 ++++++++++ .../Models/CorrespondenceStatus.cs | 70 +++ .../CorrespondenceStatusEventResponse.cs | 27 + .../Models/GetCorrespondenceStatusResponse.cs | 152 +++++ .../Models/OrganisationOrPersonIdentifier.cs | 145 +++++ .../Models/SendCorrespondenceResponse.cs | 21 + .../Maskinporten/Constants/JwtClaimTypes.cs | 40 ++ .../Constants/TokenAuthorities.cs | 17 + .../Maskinporten/Constants/TokenTypes.cs | 6 + .../MaskinportenDelegatingHandler.cs | 28 +- .../Extensions/HttpClientBuilderExtensions.cs | 22 + .../Extensions/ServiceCollectionExtensions.cs | 55 ++ .../Extensions/WebHostBuilderExtensions.cs | 34 ++ .../Maskinporten/IMaskinportenClient.cs | 40 +- .../Maskinporten/MaskinportenClient.cs | 225 ++++++-- .../Models/MaskinportenTokenResponse.cs | 48 +- .../Maskinporten/Models/TokenCacheEntry.cs | 4 +- .../Telemetry/Telemetry.Correspondence.cs | 55 ++ .../Telemetry/Telemetry.Maskinporten.cs | 31 +- .../Features/Telemetry/Telemetry.cs | 6 + .../Telemetry/TelemetryActivityExtensions.cs | 24 +- src/Altinn.App.Core/Models/JwtToken.cs | 132 +++++ .../Models/JwtTokenJsonConverter.cs | 28 + src/Altinn.App.Core/Models/LanguageCode.cs | 137 +++++ .../Models/LanguageCodeJsonConverter.cs | 29 + .../Models/NationalIdentityNumber.cs | 140 +++++ .../NationalIdentityNumberJsonConverter.cs | 32 ++ .../Models/OrganisationNumber.cs | 147 +++++ .../Models/OrganisationNumberJsonConverter.cs | 56 ++ .../Altinn.App.Api.Tests.csproj | 6 +- .../MaskinportenClientIntegrationTest.cs | 27 +- .../Mocks/JwtTokenMock.cs | 13 +- .../TestUtils/AppBuilder.cs | 1 - .../Utils/PrincipalUtil.cs | 41 +- .../Builder/CorrespondenceBuilderTests.cs | 532 ++++++++++++++++++ .../CorrespondenceClientTests.cs | 385 +++++++++++++ .../Models/CorrespondenceRequestTests.cs | 300 ++++++++++ .../Models/CorrespondenceResponseTests.cs | 372 ++++++++++++ .../Features/Correspondence/TestHelpers.cs | 37 ++ .../MaskinportenDelegatingHandlerTest.cs | 44 +- .../Maskinporten/MaskinportenClientTest.cs | 321 +++++++---- .../Models/MaskinportenSettingsTest.cs | 24 +- .../Models/MaskinportenTokenResponseTest.cs | 42 +- .../Features/Maskinporten/TestHelpers.cs | 70 ++- .../Models/JwtTokenTests.cs | 147 +++++ .../Models/LanguageCodeTests.cs | 57 ++ .../Models/NationalIdentityNumberTests.cs | 174 ++++++ .../Models/OrganisationNumberTests.cs | 151 +++++ 93 files changed, 7265 insertions(+), 446 deletions(-) create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/BuilderUtils.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceAttachmentBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceContentBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceNotificationBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceRequestBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceAttachmentBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceContentBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceNotificationBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceRequestBuilder.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/CorrespondenceAuthorisationFactory.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/CorrespondenceClient.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceArgumentException.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceException.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceRequestException.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceValueException.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/ICorrespondenceClient.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachment.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentStatusResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContent.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContentResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationType.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationTypeResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDetailsResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceExternalReference.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotification.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationChannel.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationDetailsResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationOrderResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationRecipientResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusDetailsResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusSummaryResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationSummaryResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationTemplate.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondencePayload.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReferenceType.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReplyOption.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceRequest.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatus.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatusEventResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/GetCorrespondenceStatusResponse.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/OrganisationOrPersonIdentifier.cs create mode 100644 src/Altinn.App.Core/Features/Correspondence/Models/SendCorrespondenceResponse.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Constants/JwtClaimTypes.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Constants/TokenAuthorities.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Constants/TokenTypes.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Extensions/HttpClientBuilderExtensions.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Altinn.App.Core/Features/Maskinporten/Extensions/WebHostBuilderExtensions.cs create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.Correspondence.cs create mode 100644 src/Altinn.App.Core/Models/JwtToken.cs create mode 100644 src/Altinn.App.Core/Models/JwtTokenJsonConverter.cs create mode 100644 src/Altinn.App.Core/Models/LanguageCode.cs create mode 100644 src/Altinn.App.Core/Models/LanguageCodeJsonConverter.cs create mode 100644 src/Altinn.App.Core/Models/NationalIdentityNumber.cs create mode 100644 src/Altinn.App.Core/Models/NationalIdentityNumberJsonConverter.cs create mode 100644 src/Altinn.App.Core/Models/OrganisationNumber.cs create mode 100644 src/Altinn.App.Core/Models/OrganisationNumberJsonConverter.cs create mode 100644 test/Altinn.App.Core.Tests/Features/Correspondence/Builder/CorrespondenceBuilderTests.cs create mode 100644 test/Altinn.App.Core.Tests/Features/Correspondence/CorrespondenceClientTests.cs create mode 100644 test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceRequestTests.cs create mode 100644 test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceResponseTests.cs create mode 100644 test/Altinn.App.Core.Tests/Features/Correspondence/TestHelpers.cs create mode 100644 test/Altinn.App.Core.Tests/Models/JwtTokenTests.cs create mode 100644 test/Altinn.App.Core.Tests/Models/LanguageCodeTests.cs create mode 100644 test/Altinn.App.Core.Tests/Models/NationalIdentityNumberTests.cs create mode 100644 test/Altinn.App.Core.Tests/Models/OrganisationNumberTests.cs diff --git a/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs index d0c5d8da8..15f258873 100644 --- a/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/HttpClientBuilderExtensions.cs @@ -1,5 +1,6 @@ using Altinn.App.Core.Features.Maskinporten; -using Altinn.App.Core.Features.Maskinporten.Delegates; +using Altinn.App.Core.Features.Maskinporten.Constants; +using Altinn.App.Core.Features.Maskinporten.Extensions; namespace Altinn.App.Api.Extensions; @@ -10,25 +11,46 @@ public static class HttpClientBuilderExtensions { /// /// - /// Sets up a middleware for the supplied , - /// which will inject an Authorization header with a Bearer token for all requests. + /// Authorises all requests with Maskinporten using the provided scopes, + /// and injects the resulting token in the Authorization header using the Bearer scheme. /// /// - /// If your target API does not use this authentication scheme, you should consider implementing - /// directly and handling authorization details manually. + /// If your target API does not use this authorisation scheme, you should consider implementing + /// directly and handling the specifics manually. /// /// /// The Http client builder /// The scope to claim authorization for with Maskinporten /// Additional scopes as required - public static IHttpClientBuilder UseMaskinportenAuthorization( + public static IHttpClientBuilder UseMaskinportenAuthorisation( this IHttpClientBuilder builder, string scope, params string[] additionalScopes ) { - var scopes = new[] { scope }.Concat(additionalScopes); - var factory = ActivatorUtilities.CreateFactory([typeof(IEnumerable)]); - return builder.AddHttpMessageHandler(provider => factory(provider, [scopes])); + return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.Maskinporten); + } + + /// + /// + /// Authorises all requests with Maskinporten using the provided scopes. + /// The resulting token is then exchanged for an Altinn issued token and injected in + /// the Authorization header using the Bearer scheme. + /// + /// + /// If your target API does not use this authorisation scheme, you should consider implementing + /// directly and handling the specifics manually. + /// + /// + /// The Http client builder + /// The scope to claim authorization for with Maskinporten + /// Additional scopes as required + public static IHttpClientBuilder UseMaskinportenAltinnAuthorisation( + this IHttpClientBuilder builder, + string scope, + params string[] additionalScopes + ) + { + return builder.AddMaskinportenHttpMessageHandler(scope, additionalScopes, TokenAuthorities.AltinnTokenExchange); } } diff --git a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs index 2b4e41f47..e5df7a314 100644 --- a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs @@ -11,7 +11,9 @@ using Altinn.App.Core.Constants; using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Correspondence.Extensions; using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Features.Maskinporten.Extensions; using Altinn.App.Core.Features.Maskinporten.Models; using Altinn.Common.PEP.Authorization; using Altinn.Common.PEP.Clients; @@ -82,7 +84,6 @@ IWebHostEnvironment env services.AddPlatformServices(config, env); services.AddAppServices(config, env); - services.AddMaskinportenClient(); services.ConfigureDataProtection(); var useOpenTelemetrySetting = config.GetValue("AppSettings:UseOpenTelemetry"); @@ -97,6 +98,11 @@ IWebHostEnvironment env AddApplicationInsights(services, config, env); } + // AddMaskinportenClient adds a keyed service. This needs to happen after AddApplicationInsights, + // due to a bug in app insights: https://github.com/microsoft/ApplicationInsights-dotnet/issues/2828 + services.AddMaskinportenClient(); + services.AddCorrespondenceClient(); + AddAuthenticationScheme(services, config, env); AddAuthorizationPolicies(services); AddAntiforgery(services); @@ -159,23 +165,6 @@ string configSectionPath return services; } - /// - /// Adds a singleton service to the service collection. - /// If no configuration is found, it binds one to the path "MaskinportenSettings". - /// - /// The service collection - private static IServiceCollection AddMaskinportenClient(this IServiceCollection services) - { - if (services.GetOptionsDescriptor() is null) - { - services.ConfigureMaskinportenClient("MaskinportenSettings"); - } - - services.AddSingleton(); - - return services; - } - /// /// Adds Application Insights to the service collection. /// @@ -492,19 +481,6 @@ private static void AddAntiforgery(IServiceCollection services) services.TryAddSingleton(); } - private static IServiceCollection RemoveOptions(this IServiceCollection services) - where TOptions : class - { - var descriptor = services.GetOptionsDescriptor(); - - if (descriptor is not null) - { - services.Remove(descriptor); - } - - return services; - } - private static (string? Key, string? ConnectionString) GetAppInsightsConfig( IConfiguration config, IHostEnvironment env diff --git a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs index e3fb8e9bf..7b7fee936 100644 --- a/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebHostBuilderExtensions.cs @@ -1,5 +1,5 @@ using Altinn.App.Core.Extensions; -using Microsoft.Extensions.FileProviders; +using Altinn.App.Core.Features.Maskinporten.Extensions; namespace Altinn.App.Api.Extensions; @@ -29,36 +29,19 @@ public static void ConfigureAppWebHost(this IWebHostBuilder builder, string[] ar configBuilder.AddInMemoryCollection(config); - configBuilder.AddMaskinportenSettingsFile(context); + configBuilder.AddMaskinportenSettingsFile( + context, + "MaskinportenSettingsFilepath", + "/mnt/app-secrets/maskinporten-settings.json" + ); + configBuilder.AddMaskinportenSettingsFile( + context, + "MaskinportenSettingsInternalFilepath", + "/mnt/app-secrets/maskinporten-settings-internal.json" + ); configBuilder.LoadAppConfig(args); } ); } - - private static IConfigurationBuilder AddMaskinportenSettingsFile( - this IConfigurationBuilder configurationBuilder, - WebHostBuilderContext context - ) - { - string jsonProvidedPath = - context.Configuration.GetValue("MaskinportenSettingsFilepath") - ?? "/mnt/app-secrets/maskinporten-settings.json"; - string jsonAbsolutePath = Path.GetFullPath(jsonProvidedPath); - - if (File.Exists(jsonAbsolutePath)) - { - string jsonDir = Path.GetDirectoryName(jsonAbsolutePath) ?? string.Empty; - string jsonFile = Path.GetFileName(jsonAbsolutePath); - - configurationBuilder.AddJsonFile( - provider: new PhysicalFileProvider(jsonDir), - path: jsonFile, - optional: true, - reloadOnChange: true - ); - } - - return configurationBuilder; - } } diff --git a/src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs b/src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs index f2a35b3ef..cb4904c36 100644 --- a/src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs +++ b/src/Altinn.App.Api/Helpers/RequestHandling/RequestPartValidator.cs @@ -1,115 +1,114 @@ using Altinn.Platform.Storage.Interface.Models; -namespace Altinn.App.Api.Helpers.RequestHandling +namespace Altinn.App.Api.Helpers.RequestHandling; + +/// +/// Represents a validator of a single with the help of app metadata +/// +public class RequestPartValidator { + private readonly Application _appInfo; + /// - /// Represents a validator of a single with the help of app metadata + /// Initialises a new instance of the class with the given application info. /// - public class RequestPartValidator + /// The application metadata to use when validating a . + public RequestPartValidator(Application appInfo) { - private readonly Application _appInfo; + _appInfo = appInfo; + } - /// - /// Initialises a new instance of the class with the given application info. - /// - /// The application metadata to use when validating a . - public RequestPartValidator(Application appInfo) + /// + /// Operation that can validate a using the internal . + /// + /// The request part to be validated. + /// null if no errors where found. Otherwise an error message. + public string? ValidatePart(RequestPart part) + { + if (part.Name == "instance") { - _appInfo = appInfo; - } + if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal)) + { + return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'"; + } - /// - /// Operation that can validate a using the internal . - /// - /// The request part to be validated. - /// null if no errors where found. Otherwise an error message. - public string? ValidatePart(RequestPart part) + //// TODO: Validate that the element can be read as an instance? + } + else { - if (part.Name == "instance") + DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name); + if (dataType == null) { - if (!part.ContentType.StartsWith("application/json", StringComparison.Ordinal)) - { - return $"Unexpected Content-Type '{part.ContentType}' of embedded instance template. Expecting 'application/json'"; - } + return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata"; + } - //// TODO: Validate that the element can be read as an instance? + if (part.ContentType == null) + { + return $"The multipart section named {part.Name} is missing Content-Type."; } else { - DataType? dataType = _appInfo.DataTypes.Find(e => e.Id == part.Name); - if (dataType == null) - { - return $"Multipart section named, '{part.Name}' does not correspond to an element type in application metadata"; - } - - if (part.ContentType == null) - { - return $"The multipart section named {part.Name} is missing Content-Type."; - } - else - { - string contentTypeWithoutEncoding = part.ContentType.Split(";")[0]; - - // restrict content type if allowedContentTypes is specified - if ( - dataType.AllowedContentTypes != null - && dataType.AllowedContentTypes.Count > 0 - && !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding) - ) - { - return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'"; - } - } - - long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length; - - if (contentSize == 0) - { - return $"The multipart section named {part.Name} has no data. Cannot process empty part."; - } + string contentTypeWithoutEncoding = part.ContentType.Split(";")[0]; + // restrict content type if allowedContentTypes is specified if ( - dataType.MaxSize.HasValue - && dataType.MaxSize > 0 - && contentSize > (long)dataType.MaxSize.Value * 1024 * 1024 + dataType.AllowedContentTypes != null + && dataType.AllowedContentTypes.Count > 0 + && !dataType.AllowedContentTypes.Contains(contentTypeWithoutEncoding) ) { - return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'"; + return $"The multipart section named {part.Name} has a Content-Type '{part.ContentType}' which is invalid for element type '{dataType.Id}'"; } } - return null; + long contentSize = part.FileSize != 0 ? part.FileSize : part.Bytes.Length; + + if (contentSize == 0) + { + return $"The multipart section named {part.Name} has no data. Cannot process empty part."; + } + + if ( + dataType.MaxSize.HasValue + && dataType.MaxSize > 0 + && contentSize > (long)dataType.MaxSize.Value * 1024 * 1024 + ) + { + return $"The multipart section named {part.Name} exceeds the size limit of element type '{dataType.Id}'"; + } } - /// - /// Operation that can validate a list of elements using the internal . - /// - /// The list of request parts to be validated. - /// null if no errors where found. Otherwise an error message. - public string? ValidateParts(List parts) + return null; + } + + /// + /// Operation that can validate a list of elements using the internal . + /// + /// The list of request parts to be validated. + /// null if no errors where found. Otherwise an error message. + public string? ValidateParts(List parts) + { + foreach (RequestPart part in parts) { - foreach (RequestPart part in parts) + string? partError = ValidatePart(part); + if (partError != null) { - string? partError = ValidatePart(part); - if (partError != null) - { - return partError; - } + return partError; } + } - foreach (DataType dataType in _appInfo.DataTypes) + foreach (DataType dataType in _appInfo.DataTypes) + { + if (dataType.MaxCount > 0) { - if (dataType.MaxCount > 0) + int partCount = parts.Count(p => p.Name == dataType.Id); + if (dataType.MaxCount < partCount) { - int partCount = parts.Count(p => p.Name == dataType.Id); - if (dataType.MaxCount < partCount) - { - return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows."; - } + return $"The list of parts contains more elements of type '{dataType.Id}' than the element type allows."; } } - - return null; } + + return null; } } diff --git a/src/Altinn.App.Core/Configuration/PlatformSettings.cs b/src/Altinn.App.Core/Configuration/PlatformSettings.cs index 0cc5e033f..df44d25cf 100644 --- a/src/Altinn.App.Core/Configuration/PlatformSettings.cs +++ b/src/Altinn.App.Core/Configuration/PlatformSettings.cs @@ -46,6 +46,11 @@ public class PlatformSettings /// public string ApiNotificationEndpoint { get; set; } = "http://localhost:5101/notifications/api/v1/"; + /// + /// Gets or sets the url for the Correspondence API endpoint. + /// + public string ApiCorrespondenceEndpoint { get; set; } = "http://localhost:5101/correspondence/api/v1/"; // TODO: which port for localtest? + /// /// Gets or sets the subscription key value to use in requests against the platform. /// A new subscription key is generated automatically every time an app is deployed to an environment. The new key is then automatically diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index e74a0bf38..ecfd01752 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -371,18 +371,12 @@ private static void AddFileValidatorServices(IServiceCollection services) services.TryAddTransient(); } - internal static IEnumerable GetOptionsDescriptors(this IServiceCollection services) + internal static bool IsConfigured(this IServiceCollection services) where TOptions : class { - return services.Where(d => + return services.Any(d => d.ServiceType == typeof(IConfigureOptions) || d.ServiceType == typeof(IOptionsChangeTokenSource) ); } - - internal static ServiceDescriptor? GetOptionsDescriptor(this IServiceCollection services) - where TOptions : class - { - return services.GetOptionsDescriptors().FirstOrDefault(); - } } diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/BuilderUtils.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/BuilderUtils.cs new file mode 100644 index 000000000..c6e6aeaec --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/BuilderUtils.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; +using Altinn.App.Core.Features.Correspondence.Exceptions; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +internal static class BuilderUtils +{ + /// + /// Because of the interface-chaining in this builder, some properties are guaranteed to be non-null. + /// But the compiler doesn't trust that, so we add this check where needed. + /// + /// Additionally this method checks for empty strings and empty data allocations. + /// + /// The value to assert + /// The error message to throw, if the value was null + /// + internal static void NotNullOrEmpty([NotNull] object? value, string? errorMessage = null) + { + if ( + value is null + || value is string str && string.IsNullOrWhiteSpace(str) + || value is ReadOnlyMemory { IsEmpty: true } + || value is DateTimeOffset dt && dt == DateTimeOffset.MinValue + ) + { + throw new CorrespondenceValueException(errorMessage); + } + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceAttachmentBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceAttachmentBuilder.cs new file mode 100644 index 000000000..5b33b1e12 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceAttachmentBuilder.cs @@ -0,0 +1,100 @@ +using Altinn.App.Core.Features.Correspondence.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Builder factory for creating objects +/// +public class CorrespondenceAttachmentBuilder : ICorrespondenceAttachmentBuilder +{ + private string? _filename; + private string? _name; + private string? _sendersReference; + private string? _dataType; + private ReadOnlyMemory? _data; + private bool? _isEncrypted; + private CorrespondenceDataLocationType _dataLocationType = + CorrespondenceDataLocationType.ExistingCorrespondenceAttachment; + + private CorrespondenceAttachmentBuilder() { } + + /// + /// Creates a new instance + /// + public static ICorrespondenceAttachmentBuilderFilename Create() => new CorrespondenceAttachmentBuilder(); + + /// + public ICorrespondenceAttachmentBuilderName WithFilename(string filename) + { + BuilderUtils.NotNullOrEmpty(filename, "Filename cannot be empty"); + _filename = filename; + return this; + } + + /// + public ICorrespondenceAttachmentBuilderSendersReference WithName(string name) + { + BuilderUtils.NotNullOrEmpty(name, "Name cannot be empty"); + _name = name; + return this; + } + + /// + public ICorrespondenceAttachmentBuilderDataType WithSendersReference(string sendersReference) + { + BuilderUtils.NotNullOrEmpty(sendersReference, "Senders reference cannot be empty"); + _sendersReference = sendersReference; + return this; + } + + /// + public ICorrespondenceAttachmentBuilderData WithDataType(string dataType) + { + BuilderUtils.NotNullOrEmpty(dataType, "Data type cannot be empty"); + _dataType = dataType; + return this; + } + + /// + public ICorrespondenceAttachmentBuilder WithData(ReadOnlyMemory data) + { + BuilderUtils.NotNullOrEmpty(data, "Data cannot be empty"); + _data = data; + return this; + } + + /// + public ICorrespondenceAttachmentBuilder WithIsEncrypted(bool isEncrypted) + { + _isEncrypted = isEncrypted; + return this; + } + + /// + public ICorrespondenceAttachmentBuilder WithDataLocationType(CorrespondenceDataLocationType dataLocationType) + { + _dataLocationType = dataLocationType; + return this; + } + + /// + public CorrespondenceAttachment Build() + { + BuilderUtils.NotNullOrEmpty(_filename); + BuilderUtils.NotNullOrEmpty(_name); + BuilderUtils.NotNullOrEmpty(_sendersReference); + BuilderUtils.NotNullOrEmpty(_dataType); + BuilderUtils.NotNullOrEmpty(_data); + + return new CorrespondenceAttachment + { + Filename = _filename, + Name = _name, + SendersReference = _sendersReference, + DataType = _dataType, + Data = _data.Value, + IsEncrypted = _isEncrypted, + DataLocationType = _dataLocationType, + }; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceContentBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceContentBuilder.cs new file mode 100644 index 000000000..e0ead1344 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceContentBuilder.cs @@ -0,0 +1,80 @@ +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Builder factory for creating objects +/// +public class CorrespondenceContentBuilder : ICorrespondenceContentBuilder +{ + private string? _title; + private LanguageCode? _language; + private string? _summary; + private string? _body; + + private CorrespondenceContentBuilder() { } + + /// + /// Creates a new instance + /// + /// + public static ICorrespondenceContentBuilderLanguage Create() => new CorrespondenceContentBuilder(); + + /// + public ICorrespondenceContentBuilderTitle WithLanguage(LanguageCode language) + { + BuilderUtils.NotNullOrEmpty(language, "Language cannot be empty"); + _language = language; + return this; + } + + /// + public ICorrespondenceContentBuilderTitle WithLanguage(string language) + { + BuilderUtils.NotNullOrEmpty(language, "Language cannot be empty"); + _language = LanguageCode.Parse(language); + return this; + } + + /// + public ICorrespondenceContentBuilderSummary WithTitle(string title) + { + BuilderUtils.NotNullOrEmpty(title, "Title cannot be empty"); + _title = title; + return this; + } + + /// + public ICorrespondenceContentBuilderBody WithSummary(string summary) + { + BuilderUtils.NotNullOrEmpty(summary, "Summary cannot be empty"); + _summary = summary; + return this; + } + + /// + public ICorrespondenceContentBuilder WithBody(string body) + { + BuilderUtils.NotNullOrEmpty(body, "Body cannot be empty"); + _body = body; + return this; + } + + /// + public CorrespondenceContent Build() + { + BuilderUtils.NotNullOrEmpty(_title); + BuilderUtils.NotNullOrEmpty(_language); + BuilderUtils.NotNullOrEmpty(_summary); + BuilderUtils.NotNullOrEmpty(_body); + + return new CorrespondenceContent + { + Title = _title, + Language = _language.Value, + Summary = _summary, + Body = _body, + }; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceNotificationBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceNotificationBuilder.cs new file mode 100644 index 000000000..a2ab06331 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceNotificationBuilder.cs @@ -0,0 +1,143 @@ +using Altinn.App.Core.Features.Correspondence.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Builder factory for creating objects +/// +public class CorrespondenceNotificationBuilder : ICorrespondenceNotificationBuilder +{ + private CorrespondenceNotificationTemplate? _notificationTemplate; + private string? _emailSubject; + private string? _emailBody; + private string? _smsBody; + private bool? _sendReminder; + private string? _reminderEmailSubject; + private string? _reminderEmailBody; + private string? _reminderSmsBody; + private CorrespondenceNotificationChannel? _notificationChannel; + private CorrespondenceNotificationChannel? _reminderNotificationChannel; + private string? _sendersReference; + private DateTimeOffset? _requestedSendTime; + + private CorrespondenceNotificationBuilder() { } + + /// + /// Creates a new instance + /// + /// + public static ICorrespondenceNotificationBuilderTemplate Create() => new CorrespondenceNotificationBuilder(); + + /// + public ICorrespondenceNotificationBuilder WithNotificationTemplate( + CorrespondenceNotificationTemplate notificationTemplate + ) + { + BuilderUtils.NotNullOrEmpty(notificationTemplate, "Notification template cannot be empty"); + _notificationTemplate = notificationTemplate; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithEmailSubject(string? emailSubject) + { + _emailSubject = emailSubject; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithEmailBody(string? emailBody) + { + _emailBody = emailBody; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithSmsBody(string? smsBody) + { + _smsBody = smsBody; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithSendReminder(bool? sendReminder) + { + _sendReminder = sendReminder; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithReminderEmailSubject(string? reminderEmailSubject) + { + _reminderEmailSubject = reminderEmailSubject; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithReminderEmailBody(string? reminderEmailBody) + { + _reminderEmailBody = reminderEmailBody; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithReminderSmsBody(string? reminderSmsBody) + { + _reminderSmsBody = reminderSmsBody; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithNotificationChannel( + CorrespondenceNotificationChannel? notificationChannel + ) + { + _notificationChannel = notificationChannel; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithReminderNotificationChannel( + CorrespondenceNotificationChannel? reminderNotificationChannel + ) + { + _reminderNotificationChannel = reminderNotificationChannel; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithSendersReference(string? sendersReference) + { + _sendersReference = sendersReference; + return this; + } + + /// + public ICorrespondenceNotificationBuilder WithRequestedSendTime(DateTimeOffset? requestedSendTime) + { + _requestedSendTime = requestedSendTime; + return this; + } + + /// + public CorrespondenceNotification Build() + { + BuilderUtils.NotNullOrEmpty(_notificationTemplate); + + return new CorrespondenceNotification + { + NotificationTemplate = _notificationTemplate.Value, + EmailSubject = _emailSubject, + EmailBody = _emailBody, + SmsBody = _smsBody, + SendReminder = _sendReminder, + ReminderEmailSubject = _reminderEmailSubject, + ReminderEmailBody = _reminderEmailBody, + ReminderSmsBody = _reminderSmsBody, + NotificationChannel = _notificationChannel, + ReminderNotificationChannel = _reminderNotificationChannel, + SendersReference = _sendersReference, + RequestedSendTime = _requestedSendTime, + }; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceRequestBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceRequestBuilder.cs new file mode 100644 index 000000000..517465b12 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/CorrespondenceRequestBuilder.cs @@ -0,0 +1,313 @@ +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Builder factory for creating objects +/// +public class CorrespondenceRequestBuilder : ICorrespondenceRequestBuilder +{ + private string? _resourceId; + private OrganisationNumber? _sender; + private string? _sendersReference; + private CorrespondenceContent? _content; + private List? _contentAttachments; + private DateTimeOffset? _allowSystemDeleteAfter; + private DateTimeOffset? _dueDateTime; + private List? _recipients; + private DateTimeOffset? _requestedPublishTime; + private string? _messageSender; + private List? _externalReferences; + private Dictionary? _propertyList; + private List? _replyOptions; + private CorrespondenceNotification? _notification; + private bool? _ignoreReservation; + private List? _existingAttachments; + + private CorrespondenceRequestBuilder() { } + + /// + /// Creates a new instance + /// + public static ICorrespondenceRequestBuilderResourceId Create() => new CorrespondenceRequestBuilder(); + + /// + public ICorrespondenceRequestBuilderSender WithResourceId(string resourceId) + { + BuilderUtils.NotNullOrEmpty(resourceId, "Resource ID cannot be empty"); + _resourceId = resourceId; + return this; + } + + /// + public ICorrespondenceRequestBuilderSendersReference WithSender(OrganisationNumber sender) + { + BuilderUtils.NotNullOrEmpty(sender, "Sender cannot be empty"); + _sender = sender; + return this; + } + + /// + public ICorrespondenceRequestBuilderSendersReference WithSender(string sender) + { + BuilderUtils.NotNullOrEmpty(sender, "Sender cannot be empty"); + _sender = OrganisationNumber.Parse(sender); + return this; + } + + /// + public ICorrespondenceRequestBuilderRecipients WithSendersReference(string sendersReference) + { + BuilderUtils.NotNullOrEmpty(sendersReference, "Senders reference cannot be empty"); + _sendersReference = sendersReference; + return this; + } + + /// + public ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipient(OrganisationOrPersonIdentifier recipient) + { + BuilderUtils.NotNullOrEmpty(recipient, "Recipients cannot be empty"); + return WithRecipients([recipient]); + } + + /// + public ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipient(string recipient) + { + BuilderUtils.NotNullOrEmpty(recipient, "Recipients cannot be empty"); + return WithRecipients([recipient]); + } + + /// + public ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipients(IEnumerable recipients) + { + BuilderUtils.NotNullOrEmpty(recipients); + return WithRecipients(recipients.Select(OrganisationOrPersonIdentifier.Parse)); + } + + /// + public ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipients( + IEnumerable recipients + ) + { + BuilderUtils.NotNullOrEmpty(recipients, "Recipients cannot be empty"); + _recipients ??= []; + _recipients.AddRange(recipients); + return this; + } + + /// + public ICorrespondenceRequestBuilderContent WithAllowSystemDeleteAfter(DateTimeOffset allowSystemDeleteAfter) + { + BuilderUtils.NotNullOrEmpty(allowSystemDeleteAfter, "AllowSystemDeleteAfter cannot be empty"); + _allowSystemDeleteAfter = allowSystemDeleteAfter; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithContent(CorrespondenceContent content) + { + BuilderUtils.NotNullOrEmpty(content, "Content cannot be empty"); + _content = content; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithContent(ICorrespondenceContentBuilder builder) + { + return WithContent(builder.Build()); + } + + /// + public ICorrespondenceRequestBuilder WithContent( + LanguageCode language, + string title, + string summary, + string body + ) + { + _content = new CorrespondenceContent + { + Title = title, + Summary = summary, + Body = body, + Language = language, + }; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithContent(string language, string title, string summary, string body) + { + _content = new CorrespondenceContent + { + Title = title, + Summary = summary, + Body = body, + Language = LanguageCode.Parse(language), + }; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithDueDateTime(DateTimeOffset dueDateTime) + { + BuilderUtils.NotNullOrEmpty(dueDateTime, "DueDateTime cannot be empty"); + _dueDateTime = dueDateTime; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithRequestedPublishTime(DateTimeOffset requestedPublishTime) + { + _requestedPublishTime = requestedPublishTime; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithMessageSender(string messageSender) + { + _messageSender = messageSender; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithExternalReference(CorrespondenceExternalReference externalReference) + { + return WithExternalReferences([externalReference]); + } + + /// + public ICorrespondenceRequestBuilder WithExternalReference(CorrespondenceReferenceType type, string value) + { + return WithExternalReferences( + [new CorrespondenceExternalReference { ReferenceType = type, ReferenceValue = value }] + ); + } + + /// + public ICorrespondenceRequestBuilder WithExternalReferences( + IEnumerable externalReferences + ) + { + _externalReferences ??= []; + _externalReferences.AddRange(externalReferences); + return this; + } + + /// + public ICorrespondenceRequestBuilder WithPropertyList(IReadOnlyDictionary propertyList) + { + _propertyList ??= []; + foreach (var (key, value) in propertyList) + { + _propertyList[key] = value; + } + + return this; + } + + /// + public ICorrespondenceRequestBuilder WithReplyOption(CorrespondenceReplyOption replyOption) + { + return WithReplyOptions([replyOption]); + } + + /// + public ICorrespondenceRequestBuilder WithReplyOption(string linkUrl, string linkText) + { + return WithReplyOptions([new CorrespondenceReplyOption { LinkUrl = linkUrl, LinkText = linkText }]); + } + + /// + public ICorrespondenceRequestBuilder WithReplyOptions(IEnumerable replyOptions) + { + _replyOptions ??= []; + _replyOptions.AddRange(replyOptions); + return this; + } + + /// + public ICorrespondenceRequestBuilder WithNotification(CorrespondenceNotification notification) + { + _notification = notification; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithNotification(ICorrespondenceNotificationBuilder builder) + { + return WithNotification(builder.Build()); + } + + /// + public ICorrespondenceRequestBuilder WithIgnoreReservation(bool ignoreReservation) + { + _ignoreReservation = ignoreReservation; + return this; + } + + /// + public ICorrespondenceRequestBuilder WithExistingAttachment(Guid existingAttachment) + { + return WithExistingAttachments([existingAttachment]); + } + + /// + public ICorrespondenceRequestBuilder WithExistingAttachments(IEnumerable existingAttachments) + { + _existingAttachments ??= []; + _existingAttachments.AddRange(existingAttachments); + return this; + } + + /// + public ICorrespondenceRequestBuilder WithAttachment(CorrespondenceAttachment attachment) + { + return WithAttachments([attachment]); + } + + /// + public ICorrespondenceRequestBuilder WithAttachment(ICorrespondenceAttachmentBuilder builder) + { + return WithAttachments([builder.Build()]); + } + + /// + public ICorrespondenceRequestBuilder WithAttachments(IEnumerable attachments) + { + _contentAttachments ??= []; + _contentAttachments.AddRange(attachments); + return this; + } + + /// + public CorrespondenceRequest Build() + { + BuilderUtils.NotNullOrEmpty(_resourceId); + BuilderUtils.NotNullOrEmpty(_sender); + BuilderUtils.NotNullOrEmpty(_sendersReference); + BuilderUtils.NotNullOrEmpty(_content); + BuilderUtils.NotNullOrEmpty(_allowSystemDeleteAfter); + BuilderUtils.NotNullOrEmpty(_recipients); + + return new CorrespondenceRequest + { + ResourceId = _resourceId, + Sender = _sender.Value, + SendersReference = _sendersReference, + Content = _content with { Attachments = _contentAttachments }, + AllowSystemDeleteAfter = _allowSystemDeleteAfter.Value, + DueDateTime = _dueDateTime, + Recipients = _recipients, + RequestedPublishTime = _requestedPublishTime, + MessageSender = _messageSender, + ExternalReferences = _externalReferences, + PropertyList = _propertyList, + ReplyOptions = _replyOptions, + Notification = _notification, + IgnoreReservation = _ignoreReservation, + ExistingAttachments = _existingAttachments, + }; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceAttachmentBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceAttachmentBuilder.cs new file mode 100644 index 000000000..2a8debadb --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceAttachmentBuilder.cs @@ -0,0 +1,94 @@ +using System.Net.Mime; +using Altinn.App.Core.Features.Correspondence.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceAttachmentBuilderFilename +{ + /// + /// Sets the filename of the attachment + /// + /// The attachment filename + ICorrespondenceAttachmentBuilderName WithFilename(string filename); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceAttachmentBuilderName +{ + /// + /// Sets the display name of the attachment + /// + /// The display name + ICorrespondenceAttachmentBuilderSendersReference WithName(string name); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceAttachmentBuilderSendersReference +{ + /// + /// Sets the senders reference for the attachment + /// + /// The reference value + ICorrespondenceAttachmentBuilderDataType WithSendersReference(string sendersReference); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceAttachmentBuilderDataType +{ + /// + /// Sets the data type of the attachment in MIME format + /// + /// See + /// The MIME type of the attachment + ICorrespondenceAttachmentBuilderData WithDataType(string dataType); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceAttachmentBuilderData +{ + /// + /// Sets the data content of the attachment + /// + /// The data + ICorrespondenceAttachmentBuilder WithData(ReadOnlyMemory data); +} + +/// +/// Indicates that the instance has completed all required steps and can proceed to +/// +public interface ICorrespondenceAttachmentBuilder + : ICorrespondenceAttachmentBuilderFilename, + ICorrespondenceAttachmentBuilderName, + ICorrespondenceAttachmentBuilderSendersReference, + ICorrespondenceAttachmentBuilderDataType, + ICorrespondenceAttachmentBuilderData +{ + /// + /// Sets whether the attachment is encrypted or not + /// + /// `true` for encrypted, `false` otherwise + ICorrespondenceAttachmentBuilder WithIsEncrypted(bool isEncrypted); + + /// + /// Sets the storage location of the attachment data + /// + /// In this context, it is extremely likely that the storage location is + /// The data storage location + ICorrespondenceAttachmentBuilder WithDataLocationType(CorrespondenceDataLocationType dataLocationType); + + /// + /// Builds the correspondence attachment + /// + CorrespondenceAttachment Build(); +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceContentBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceContentBuilder.cs new file mode 100644 index 000000000..31b727bda --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceContentBuilder.cs @@ -0,0 +1,73 @@ +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceContentBuilderLanguage +{ + /// + /// Sets the language of the correspondence content + /// + /// The content language + ICorrespondenceContentBuilderTitle WithLanguage(LanguageCode language); + + /// + /// Sets the language of the correspondence content + /// + /// The content language in ISO 639-1 format + ICorrespondenceContentBuilderTitle WithLanguage(string language); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceContentBuilderTitle +{ + /// + /// Sets the title of the correspondence content + /// + /// The correspondence title + ICorrespondenceContentBuilderSummary WithTitle(string title); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceContentBuilderSummary +{ + /// + /// Sets the summary of the correspondence content + /// + /// The summary of the message + ICorrespondenceContentBuilderBody WithSummary(string summary); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceContentBuilderBody +{ + /// + /// Sets the body of the correspondence content + /// + /// The full text (body) of the message + ICorrespondenceContentBuilder WithBody(string body); +} + +/// +/// Indicates that the instance has completed all required steps and can proceed to +/// +public interface ICorrespondenceContentBuilder + : ICorrespondenceContentBuilderTitle, + ICorrespondenceContentBuilderLanguage, + ICorrespondenceContentBuilderSummary, + ICorrespondenceContentBuilderBody +{ + /// + /// Builds the correspondence content + /// + CorrespondenceContent Build(); +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceNotificationBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceNotificationBuilder.cs new file mode 100644 index 000000000..540e48ee2 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceNotificationBuilder.cs @@ -0,0 +1,115 @@ +using Altinn.App.Core.Features.Correspondence.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceNotificationBuilderTemplate +{ + /// + /// Sets the notification template for the correspondence notification + /// + /// The notification template + ICorrespondenceNotificationBuilder WithNotificationTemplate( + CorrespondenceNotificationTemplate notificationTemplate + ); +} + +/// +/// Indicates that the instance has completed all required steps and can proceed to +/// +public interface ICorrespondenceNotificationBuilder : ICorrespondenceNotificationBuilderTemplate +{ + /// + /// Sets the email subject for the correspondence notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The email subject + ICorrespondenceNotificationBuilder WithEmailSubject(string? emailSubject); + + /// + /// Sets the email body for the correspondence notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The email content (body) + ICorrespondenceNotificationBuilder WithEmailBody(string? emailBody); + + /// + /// Sets the SMS body for the correspondence notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The SMS content (body) + ICorrespondenceNotificationBuilder WithSmsBody(string? smsBody); + + /// + /// Sets whether a reminder should be sent for the correspondence notification, if not actioned within an appropriate time frame + /// + /// `true` if yes, `false` if no + ICorrespondenceNotificationBuilder WithSendReminder(bool? sendReminder); + + /// + /// Sets the email subject for the reminder notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The reminder email subject + ICorrespondenceNotificationBuilder WithReminderEmailSubject(string? reminderEmailSubject); + + /// + /// Sets the email body for the reminder notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The reminder email content (body) + ICorrespondenceNotificationBuilder WithReminderEmailBody(string? reminderEmailBody); + + /// + /// Sets the SMS body for the reminder notification + /// + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// The reminder SMS content (body) + ICorrespondenceNotificationBuilder WithReminderSmsBody(string? reminderSmsBody); + + /// + /// Sets the notification channel for the correspondence notification + /// + /// The notification channel to use + ICorrespondenceNotificationBuilder WithNotificationChannel(CorrespondenceNotificationChannel? notificationChannel); + + /// + /// Sets the notification channel for the reminder notification + /// + /// The notification channel to use + ICorrespondenceNotificationBuilder WithReminderNotificationChannel( + CorrespondenceNotificationChannel? reminderNotificationChannel + ); + + /// + /// Sets the senders reference for the correspondence notification + /// + /// The senders reference value + /// + ICorrespondenceNotificationBuilder WithSendersReference(string? sendersReference); + + /// + /// Sets the requested send time for the correspondence notification + /// + /// The requested send time + ICorrespondenceNotificationBuilder WithRequestedSendTime(DateTimeOffset? requestedSendTime); + + /// + /// Builds the instance + /// + CorrespondenceNotification Build(); +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceRequestBuilder.cs b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceRequestBuilder.cs new file mode 100644 index 000000000..3dbdad2e7 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Builder/ICorrespondenceRequestBuilder.cs @@ -0,0 +1,308 @@ +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Builder; + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderResourceId +{ + /// + /// Sets the Resource Id for the correspondence + /// + /// The resource ID as registered in the Altinn Resource Registry + ICorrespondenceRequestBuilderSender WithResourceId(string resourceId); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderSender +{ + /// + /// Sets the sender of the correspondence + /// + /// The correspondence sender + ICorrespondenceRequestBuilderSendersReference WithSender(OrganisationNumber sender); + + /// + /// Sets the sender of the correspondence + /// + /// A string representing a Norwegian organisation number (e.g. 991825827 or 0192:991825827) + ICorrespondenceRequestBuilderSendersReference WithSender(string sender); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderSendersReference +{ + /// + /// Sets the senders reference for the correspondence + /// + /// The correspondence reference + ICorrespondenceRequestBuilderRecipients WithSendersReference(string sendersReference); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderRecipients +{ + /// + /// Adds a recipient to the correspondence + /// + /// + /// This method respects any existing options already stored in + /// + /// A recipient + ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipient(OrganisationOrPersonIdentifier recipient); + + /// + /// Adds a recipient to the correspondence + /// + /// + /// This method respects any existing options already stored in + /// + /// A recipient: Either a Norwegian organisation number or national identity number + ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipient(string recipient); + + /// + /// Adds recipients to the correspondence + /// + /// + /// This method respects any existing options already stored in + /// + /// A list of recipients + ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipients( + IEnumerable recipients + ); + + /// + /// Adds recipients to the correspondence + /// + /// + /// This method respects any existing options already stored in + /// + /// A list of recipients: Either Norwegian organisation numbers or national identity numbers + ICorrespondenceRequestBuilderAllowSystemDeleteAfter WithRecipients(IEnumerable recipients); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderAllowSystemDeleteAfter +{ + /// + /// Sets the date and time when the correspondence can be deleted from the system + /// + /// The point in time when the correspondence may be safely deleted + ICorrespondenceRequestBuilderContent WithAllowSystemDeleteAfter(DateTimeOffset allowSystemDeleteAfter); +} + +/// +/// Indicates that the instance is on the step +/// +public interface ICorrespondenceRequestBuilderContent +{ + /// + /// Sets the content of the correspondence + /// + /// The correspondence content + ICorrespondenceRequestBuilder WithContent(CorrespondenceContent content); + + /// + /// Sets the content of the correspondence + /// + /// A instance in the stage + ICorrespondenceRequestBuilder WithContent(ICorrespondenceContentBuilder builder); + + /// + /// Sets the content of the correspondence + /// + /// The message language + /// The message title + /// The message summary + /// The message body + ICorrespondenceRequestBuilder WithContent( + LanguageCode language, + string title, + string summary, + string body + ); + + /// + /// Sets the content of the correspondence + /// + /// The message language in ISO 639-1 format + /// The message title + /// The message summary + /// The message body + ICorrespondenceRequestBuilder WithContent(string language, string title, string summary, string body); +} + +/// +/// Indicates that the instance has completed all +/// required steps and can proceed to +/// +public interface ICorrespondenceRequestBuilder + : ICorrespondenceRequestBuilderResourceId, + ICorrespondenceRequestBuilderSender, + ICorrespondenceRequestBuilderSendersReference, + ICorrespondenceRequestBuilderRecipients, + ICorrespondenceRequestBuilderAllowSystemDeleteAfter, + ICorrespondenceRequestBuilderContent +{ + /// + /// Sets due date and time for the correspondence + /// + /// The point in time when the correspondence is due + /// + ICorrespondenceRequestBuilder WithDueDateTime(DateTimeOffset dueDateTime); + + /// + /// Sets the requested publish time for the correspondence + /// + /// The point in time when the correspondence should be published + ICorrespondenceRequestBuilder WithRequestedPublishTime(DateTimeOffset requestedPublishTime); + + /// + /// Set the message sender for the correspondence + /// + /// The name of the message sender + /// + ICorrespondenceRequestBuilder WithMessageSender(string messageSender); + + /// + /// Adds an external reference to the correspondence + /// + /// This method respects any existing references already stored in + /// + /// + /// A item + ICorrespondenceRequestBuilder WithExternalReference(CorrespondenceExternalReference externalReference); + + /// + /// Adds an external reference to the correspondence + /// + /// This method respects any existing references already stored in + /// + /// + /// The reference type to add + /// The reference value + ICorrespondenceRequestBuilder WithExternalReference(CorrespondenceReferenceType type, string value); + + /// + /// Adds external references to the correspondence + /// + /// This method respects any existing references already stored in + /// + /// + /// A list of items + ICorrespondenceRequestBuilder WithExternalReferences( + IEnumerable externalReferences + ); + + /// + /// Sets the property list for the correspondence + /// + /// A key-value list of arbitrary properties to associate with the correspondence + ICorrespondenceRequestBuilder WithPropertyList(IReadOnlyDictionary propertyList); + + /// + /// Adds a reply option to the correspondence + /// + /// This method respects any existing options already stored in + /// + /// + /// A item + ICorrespondenceRequestBuilder WithReplyOption(CorrespondenceReplyOption replyOption); + + /// + /// Adds a reply option to the correspondence + /// + /// This method respects any existing options already stored in + /// + /// + /// The URL to be used as a reply/response to a correspondence + /// The text to display for the link + ICorrespondenceRequestBuilder WithReplyOption(string linkUrl, string linkText); + + /// + /// Adds reply options to the correspondence + /// + /// This method respects any existing options already stored in + /// + /// + /// A list of items + ICorrespondenceRequestBuilder WithReplyOptions(IEnumerable replyOptions); + + /// + /// Sets the notification for the correspondence + /// + /// The notification details to be associated with the correspondence + ICorrespondenceRequestBuilder WithNotification(CorrespondenceNotification notification); + + /// + /// Sets the notification for the correspondence + /// + /// A instance in the stage + ICorrespondenceRequestBuilder WithNotification(ICorrespondenceNotificationBuilder builder); + + /// + /// Sets whether the correspondence can override reservation against digital communication in KRR + /// + /// A boolean value indicating whether or not reservations can be ignored + ICorrespondenceRequestBuilder WithIgnoreReservation(bool ignoreReservation); + + /// + /// Adds an existing attachment reference to the correspondence + /// + /// This method respects any existing references already stored in + /// + /// + /// A item pointing to an existing attachment + ICorrespondenceRequestBuilder WithExistingAttachment(Guid existingAttachment); + + /// + /// Adds existing attachment references to the correspondence + /// + /// This method respects any existing references already stored in + /// + /// + /// A list of items pointing to existing attachments + ICorrespondenceRequestBuilder WithExistingAttachments(IEnumerable existingAttachments); + + /// + /// Adds an attachment to the correspondence + /// + /// This method respects any existing attachments already stored in + /// + /// + /// A item + ICorrespondenceRequestBuilder WithAttachment(CorrespondenceAttachment attachment); + + /// + /// Adds an attachment to the correspondence + /// + /// This method respects any existing attachments already stored in + /// + /// + /// A instance in the stage + ICorrespondenceRequestBuilder WithAttachment(ICorrespondenceAttachmentBuilder builder); + + /// + /// Adds attachments to the correspondence + /// + /// This method respects any existing attachments already stored in + /// + /// + /// A List of items + ICorrespondenceRequestBuilder WithAttachments(IEnumerable attachments); + + /// + /// Builds the instance + /// + CorrespondenceRequest Build(); +} diff --git a/src/Altinn.App.Core/Features/Correspondence/CorrespondenceAuthorisationFactory.cs b/src/Altinn.App.Core/Features/Correspondence/CorrespondenceAuthorisationFactory.cs new file mode 100644 index 000000000..50f91bec1 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/CorrespondenceAuthorisationFactory.cs @@ -0,0 +1,26 @@ +using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Models; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.Correspondence; + +internal sealed class CorrespondenceAuthorisationFactory +{ + private IMaskinportenClient? _maskinportenClient; + private readonly IServiceProvider _serviceProvider; + + public Func> Maskinporten => + async () => + { + _maskinportenClient ??= _serviceProvider.GetRequiredService(); + + return await _maskinportenClient.GetAltinnExchangedToken( + ["altinn:correspondence.write", "altinn:serviceowner/instances.read"] + ); + }; + + public CorrespondenceAuthorisationFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/CorrespondenceClient.cs b/src/Altinn.App.Core/Features/Correspondence/CorrespondenceClient.cs new file mode 100644 index 000000000..7b5be70cc --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/CorrespondenceClient.cs @@ -0,0 +1,241 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Constants; +using Altinn.App.Core.Features.Correspondence.Exceptions; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Features.Maskinporten.Constants; +using Altinn.App.Core.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using CorrespondenceResult = Altinn.App.Core.Features.Telemetry.Correspondence.CorrespondenceResult; + +namespace Altinn.App.Core.Features.Correspondence; + +/// +internal sealed class CorrespondenceClient : ICorrespondenceClient +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly PlatformSettings _platformSettings; + private readonly Telemetry? _telemetry; + + private readonly CorrespondenceAuthorisationFactory _authorisationFactory; + + public CorrespondenceClient( + IHttpClientFactory httpClientFactory, + IOptions platformSettings, + IServiceProvider serviceProvider, + ILogger logger, + Telemetry? telemetry = null + ) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _platformSettings = platformSettings.Value; + _telemetry = telemetry; + _authorisationFactory = new CorrespondenceAuthorisationFactory(serviceProvider); + } + + private async Task AuthorisationFactory(CorrespondencePayloadBase payload) + { + if (payload.AccessTokenFactory is null && payload.AuthorisationMethod is null) + { + throw new CorrespondenceArgumentException( + "Neither AccessTokenFactory nor AuthorisationMethod was provided in the CorrespondencePayload object" + ); + } + + if (payload.AccessTokenFactory is not null) + { + return await payload.AccessTokenFactory(); + } + + return payload.AuthorisationMethod switch + { + CorrespondenceAuthorisation.Maskinporten => await _authorisationFactory.Maskinporten(), + _ => throw new CorrespondenceArgumentException( + $"Unknown CorrespondenceAuthorisation `{payload.AuthorisationMethod}`" + ), + }; + } + + /// + public async Task Send( + SendCorrespondencePayload payload, + CancellationToken cancellationToken = default + ) + { + _logger.LogDebug("Sending Correspondence request"); + using Activity? activity = _telemetry?.StartSendCorrespondenceActivity(); + + try + { + using MultipartFormDataContent content = payload.CorrespondenceRequest.Serialise(); + using HttpRequestMessage request = await AuthenticatedHttpRequestFactory( + method: HttpMethod.Post, + uri: GetUri("correspondence/upload"), + content: content, + payload: payload + ); + + var response = await HandleServerCommunication(request, cancellationToken); + activity?.SetCorrespondence(response); + _telemetry?.RecordCorrespondenceOrder(CorrespondenceResult.Success); + + return response; + } + catch (CorrespondenceException e) + { + var requestException = e as CorrespondenceRequestException; + + _logger.LogError( + e, + "Failed to send correspondence: status={StatusCode} response={Response}", + requestException?.HttpStatusCode.ToString() ?? "Unknown", + requestException?.ResponseBody ?? "No response body" + ); + + activity?.Errored(e, requestException?.ProblemDetails?.Detail); + _telemetry?.RecordCorrespondenceOrder(CorrespondenceResult.Error); + throw; + } + catch (Exception e) + { + activity?.Errored(e); + _logger.LogError(e, "Failed to send correspondence: {Exception}", e); + _telemetry?.RecordCorrespondenceOrder(CorrespondenceResult.Error); + throw new CorrespondenceRequestException($"Failed to send correspondence: {e}", e); + } + } + + /// + public async Task GetStatus( + GetCorrespondenceStatusPayload payload, + CancellationToken cancellationToken = default + ) + { + _logger.LogDebug("Fetching correspondence status"); + using Activity? activity = _telemetry?.StartCorrespondenceStatusActivity(payload.CorrespondenceId); + + try + { + using HttpRequestMessage request = await AuthenticatedHttpRequestFactory( + method: HttpMethod.Get, + uri: GetUri($"correspondence/{payload.CorrespondenceId}/details"), + content: null, + payload: payload + ); + + return await HandleServerCommunication(request, cancellationToken); + } + catch (CorrespondenceException e) + { + var requestException = e as CorrespondenceRequestException; + + _logger.LogError( + e, + "Failed to fetch correspondence status: status={StatusCode} response={Response}", + requestException?.HttpStatusCode.ToString() ?? "Unknown", + requestException?.ResponseBody ?? "No response body" + ); + + activity?.Errored(e, requestException?.ProblemDetails?.Detail); + throw; + } + catch (Exception e) + { + activity?.Errored(e); + _logger.LogError(e, "Failed to fetch correspondence status: {Exception}", e); + throw new CorrespondenceRequestException($"Failed to fetch correspondence status: {e}", e); + } + } + + private async Task AuthenticatedHttpRequestFactory( + HttpMethod method, + string uri, + HttpContent? content, + CorrespondencePayloadBase payload + ) + { + _logger.LogDebug("Fetching access token via factory"); + JwtToken accessToken = await AuthorisationFactory(payload); + + _logger.LogDebug("Constructing authorized http request for target uri {TargetEndpoint}", uri); + HttpRequestMessage request = new(method, uri) { Content = content }; + + request.Headers.Authorization = new AuthenticationHeaderValue(TokenTypes.Bearer, accessToken); + request.Headers.TryAddWithoutValidation(General.SubscriptionKeyHeaderName, _platformSettings.SubscriptionKey); + + return request; + } + + private ProblemDetails? GetProblemDetails(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(responseBody); + } + catch (Exception e) + { + _logger.LogError(e, "Error parsing ProblemDetails from Correspondence api"); + } + + return null; + } + + private string GetUri(string relativePath) + { + string baseUri = _platformSettings.ApiCorrespondenceEndpoint.TrimEnd('/'); + return $"{baseUri}/{relativePath.TrimStart('/')}"; + } + + private async Task HandleServerCommunication( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + using HttpClient client = _httpClientFactory.CreateClient(); + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken); + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (response.StatusCode != HttpStatusCode.OK) + { + var problemDetails = GetProblemDetails(responseBody); + throw new CorrespondenceRequestException( + $"Correspondence request failed with status {response.StatusCode}: {problemDetails?.Detail}", + problemDetails, + response.StatusCode, + responseBody + ); + } + + _logger.LogDebug("Correspondence request succeeded: {Response}", responseBody); + + try + { + return JsonSerializer.Deserialize(responseBody) + ?? throw new CorrespondenceRequestException( + "Literal null content received from Correspondence API server" + ); + } + catch (Exception e) + { + throw new CorrespondenceRequestException( + $"Invalid response from Correspondence API server: {responseBody}", + null, + response.StatusCode, + responseBody, + e + ); + } + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceArgumentException.cs b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceArgumentException.cs new file mode 100644 index 000000000..4e4d1705b --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceArgumentException.cs @@ -0,0 +1,18 @@ +namespace Altinn.App.Core.Features.Correspondence.Exceptions; + +/// +/// An exception that indicates an invalid method argument is being used in a correspondence operation +/// +public class CorrespondenceArgumentException : CorrespondenceException +{ + /// + public CorrespondenceArgumentException() { } + + /// + public CorrespondenceArgumentException(string? message) + : base(message) { } + + /// + public CorrespondenceArgumentException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceException.cs b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceException.cs new file mode 100644 index 000000000..400637d23 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceException.cs @@ -0,0 +1,18 @@ +namespace Altinn.App.Core.Features.Correspondence.Exceptions; + +/// +/// Generic Correspondence related exception. Something went wrong, and it was related to Correspondence. +/// +public abstract class CorrespondenceException : Exception +{ + /// + protected CorrespondenceException() { } + + /// + protected CorrespondenceException(string? message) + : base(message) { } + + /// + protected CorrespondenceException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceRequestException.cs b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceRequestException.cs new file mode 100644 index 000000000..158150609 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceRequestException.cs @@ -0,0 +1,65 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; + +namespace Altinn.App.Core.Features.Correspondence.Exceptions; + +/// +/// An exception that indicates an error was returned from the correspondence server +/// +public class CorrespondenceRequestException : CorrespondenceException +{ + /// + /// Problem details object from the Correspondence API server, if available + /// + public ProblemDetails? ProblemDetails { get; init; } + + /// + /// Http status code related to the request, if available + /// + public HttpStatusCode? HttpStatusCode { get; init; } + + /// + /// The request body, if available + /// + public string? ResponseBody { get; init; } + + /// + public CorrespondenceRequestException() { } + + /// + public CorrespondenceRequestException(string? message) + : base(message) { } + + /// + public CorrespondenceRequestException( + string? message, + ProblemDetails? problemDetails, + HttpStatusCode? httpStatusCode, + string? responseBody + ) + : base(message) + { + ProblemDetails = problemDetails; + HttpStatusCode = httpStatusCode; + ResponseBody = responseBody; + } + + /// + public CorrespondenceRequestException( + string? message, + ProblemDetails? problemDetails, + HttpStatusCode? httpStatusCode, + string? responseBody, + Exception? innerException + ) + : base(message, innerException) + { + ProblemDetails = problemDetails; + HttpStatusCode = httpStatusCode; + ResponseBody = responseBody; + } + + /// + public CorrespondenceRequestException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceValueException.cs b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceValueException.cs new file mode 100644 index 000000000..bac26c612 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Exceptions/CorrespondenceValueException.cs @@ -0,0 +1,18 @@ +namespace Altinn.App.Core.Features.Correspondence.Exceptions; + +/// +/// An exception that indicates an invalid value is being used in a correspondence operation +/// +public class CorrespondenceValueException : CorrespondenceException +{ + /// + public CorrespondenceValueException() { } + + /// + public CorrespondenceValueException(string? message) + : base(message) { } + + /// + public CorrespondenceValueException(string? message, Exception? innerException) + : base(message, innerException) { } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Features/Correspondence/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..6b4129d1f --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.Correspondence.Extensions; + +internal static class ServiceCollectionExtensions +{ + /// + /// Adds a service to the service collection. + /// + /// The service collection + public static IServiceCollection AddCorrespondenceClient(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/ICorrespondenceClient.cs b/src/Altinn.App.Core/Features/Correspondence/ICorrespondenceClient.cs new file mode 100644 index 000000000..3fc4c55cb --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/ICorrespondenceClient.cs @@ -0,0 +1,31 @@ +using Altinn.App.Core.Features.Correspondence.Models; + +namespace Altinn.App.Core.Features.Correspondence; + +/// +/// Contains logic for interacting with the correspondence message service +/// +public interface ICorrespondenceClient +{ + /// + /// Sends a correspondence + /// + /// The payload + /// An optional cancellation token + /// + Task Send( + SendCorrespondencePayload payload, + CancellationToken cancellationToken = default + ); + + /// + /// Fetches the status of a correspondence + /// + /// The payload + /// An optional cancellation token + /// + Task GetStatus( + GetCorrespondenceStatusPayload payload, + CancellationToken cancellationToken = default + ); +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachment.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachment.cs new file mode 100644 index 000000000..ecadf589b --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachment.cs @@ -0,0 +1,58 @@ +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents an attachment to a correspondence +/// +public sealed record CorrespondenceAttachment : MultipartCorrespondenceItem +{ + /// + /// The filename of the attachment + /// + public required string Filename { get; init; } + + /// + /// The display name of the attachment + /// + public required string Name { get; init; } + + /// + /// A value indicating whether the attachment is encrypted or not + /// + public bool? IsEncrypted { get; init; } + + /// + /// A reference value given to the attachment by the creator + /// + public required string SendersReference { get; init; } + + /// + /// The attachment data type in MIME format + /// + public required string DataType { get; init; } + + /// + /// Specifies the storage location of the attachment data + /// + public CorrespondenceDataLocationType DataLocationType { get; init; } = + CorrespondenceDataLocationType.ExistingCorrespondenceAttachment; + + /// + /// The file stream + /// + public required ReadOnlyMemory Data { get; init; } + + internal void Serialise(MultipartFormDataContent content, int index, string? filenameOverride = null) + { + const string typePrefix = "Correspondence.Content.Attachments"; + string prefix = $"{typePrefix}[{index}]"; + string actualFilename = filenameOverride ?? Filename; + + AddRequired(content, actualFilename, $"{prefix}.Filename"); + AddRequired(content, Name, $"{prefix}.Name"); + AddRequired(content, SendersReference, $"{prefix}.SendersReference"); + AddRequired(content, DataType, $"{prefix}.DataType"); + AddRequired(content, DataLocationType.ToString(), $"{prefix}.DataLocationType"); + AddRequired(content, Data, "Attachments", actualFilename); // NOTE: No prefix! + AddIfNotNull(content, IsEncrypted?.ToString(), $"{prefix}.IsEncrypted"); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentResponse.cs new file mode 100644 index 000000000..e022f542f --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentResponse.cs @@ -0,0 +1,96 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a binary attachment to a Correspondence +/// +public sealed record CorrespondenceAttachmentResponse +{ + /// + /// A unique id for the correspondence attachment + /// + [JsonPropertyName("id")] + public Guid Id { get; init; } + + /// + /// The date and time when the attachment was created + /// + [JsonPropertyName("created")] + public DateTimeOffset Created { get; init; } + + /// + /// The location of the attachment data + /// + [JsonPropertyName("dataLocationType")] + public CorrespondenceDataLocationTypeResponse DataLocationType { get; init; } + + /// + /// The current status of the attachment + /// + [JsonPropertyName("status")] + public CorrespondenceAttachmentStatusResponse Status { get; init; } + + /// + /// The text description of the status code + /// + [JsonPropertyName("statusText")] + public required string StatusText { get; init; } + + /// + /// The date and time when the current attachment status was changed + /// + [JsonPropertyName("statusChanged")] + public DateTimeOffset StatusChanged { get; init; } + + /// + /// The date and time when the attachment expires + /// + [JsonPropertyName("expirationTime")] + public DateTimeOffset ExpirationTime { get; init; } + + /// + /// The filename of the attachment + /// + [JsonPropertyName("fileName")] + public string? FileName { get; init; } + + /// + /// The display name of the attachment + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// The name of the restriction policy restricting access to this element + /// + /// + /// An empty value indicates no restriction above the ones governing the correspondence referencing this attachment + /// + [JsonPropertyName("restrictionName")] + public string? RestrictionName { get; init; } + + /// + /// Indicates if the attachment is encrypted or not + /// + [JsonPropertyName("isEncrypted")] + public bool IsEncrypted { get; init; } + + /// + /// MD5 checksum of the file data + /// + [JsonPropertyName("checksum")] + public string? Checksum { get; init; } + + /// + /// A reference value given to the attachment by the creator + /// + [JsonPropertyName("sendersReference")] + public required string SendersReference { get; init; } + + /// + /// The attachment data type in MIME format + /// + [JsonPropertyName("dataType")] + public required string DataType { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentStatusResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentStatusResponse.cs new file mode 100644 index 000000000..4062fb383 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceAttachmentStatusResponse.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents the status of an attachment +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceAttachmentStatusResponse +{ + /// + /// Attachment has been Initialized. + /// + Initialized, + + /// + /// Awaiting processing of upload + /// + UploadProcessing, + + /// + /// Published and available for download + /// + Published, + + /// + /// Purged + /// + Purged, + + /// + /// Failed + /// + Failed, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContent.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContent.cs new file mode 100644 index 000000000..c6b8679ab --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContent.cs @@ -0,0 +1,43 @@ +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// The message content in a correspondence +/// +public sealed record CorrespondenceContent : MultipartCorrespondenceItem +{ + /// + /// The correspondence message title (subject) + /// + public required string Title { get; init; } + + /// + /// The language of the correspondence, specified according to ISO 639-1 + /// + public required LanguageCode Language { get; init; } + + /// + /// The summary text of the correspondence message + /// + public required string Summary { get; init; } + + /// + /// The full text (body) of the correspondence message + /// + public required string Body { get; init; } + + /// + /// File attachments to associate with this correspondence + /// + public IReadOnlyList? Attachments { get; init; } + + internal void Serialise(MultipartFormDataContent content) + { + AddRequired(content, Language.Value, "Correspondence.Content.Language"); + AddRequired(content, Title, "Correspondence.Content.MessageTitle"); + AddRequired(content, Summary, "Correspondence.Content.MessageSummary"); + AddRequired(content, Body, "Correspondence.Content.MessageBody"); + SerializeAttachmentItems(content, Attachments); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContentResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContentResponse.cs new file mode 100644 index 000000000..b7309c0f1 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceContentResponse.cs @@ -0,0 +1,41 @@ +using System.Text.Json.Serialization; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents the content of a correspondence +/// +public sealed record CorrespondenceContentResponse +{ + /// + /// The language of the correspondence, specified according to ISO 639-1 + /// + [JsonPropertyName("language")] + [JsonConverter(typeof(LanguageCodeJsonConverter))] + public LanguageCode Language { get; init; } + + /// + /// The correspondence message title (subject) + /// + [JsonPropertyName("messageTitle")] + public required string MessageTitle { get; init; } + + /// + /// The summary text of the correspondence + /// + [JsonPropertyName("messageSummary")] + public required string MessageSummary { get; init; } + + /// + /// The main body of the correspondence + /// + [JsonPropertyName("messageBody")] + public required string MessageBody { get; init; } + + /// + /// A list of attachments for the correspondence + /// + [JsonPropertyName("attachments")] + public IEnumerable? Attachments { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationType.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationType.cs new file mode 100644 index 000000000..54b995c90 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationType.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// The location of the attachment during the correspondence initialization +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceDataLocationType +{ + /// + /// Specifies that the attachment data will need to be uploaded to Altinn Correspondence via the Upload Attachment operation + /// + NewCorrespondenceAttachment, + + /// + /// Specifies that the attachment already exist in Altinn Correspondence storage + /// + ExistingCorrespondenceAttachment, + + /// + /// Specifies that the attachment data already exist in an external storage + /// + ExisitingExternalStorage, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationTypeResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationTypeResponse.cs new file mode 100644 index 000000000..7d0568502 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDataLocationTypeResponse.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Defines the location of the attachment data +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceDataLocationTypeResponse +{ + /// + /// Specifies that the attachment data is stored in the Altinn correspondence storage + /// + AltinnCorrespondenceAttachment, + + /// + /// Specifies that the attachment data is stored in an external storage controlled by the sender + /// + ExternalStorage, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDetailsResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDetailsResponse.cs new file mode 100644 index 000000000..a24fb182c --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceDetailsResponse.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Details about the correspondence +/// +public sealed record CorrespondenceDetailsResponse +{ + /// + /// The correspondence identifier + /// + [JsonPropertyName("correspondenceId")] + public Guid CorrespondenceId { get; init; } + + /// + /// The status of the correspondence + /// + [JsonPropertyName("status")] + public CorrespondenceStatus Status { get; init; } + + /// + /// The recipient of the correspondence + /// + [JsonPropertyName("recipient")] + [OrganisationOrPersonIdentifierJsonConverter(OrganisationNumberFormat.International)] + public required OrganisationOrPersonIdentifier Recipient { get; init; } + + /// + /// Notifications linked to the correspondence + /// + [JsonPropertyName("notifications")] + public IReadOnlyList? Notifications { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceExternalReference.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceExternalReference.cs new file mode 100644 index 000000000..219c31f98 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceExternalReference.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a reference to another item in the Altinn ecosystem +/// +public sealed record CorrespondenceExternalReference : MultipartCorrespondenceListItem +{ + /// + /// The reference type + /// + [JsonPropertyName("referenceType")] + public required CorrespondenceReferenceType ReferenceType { get; init; } + + /// + /// The reference value + /// + [JsonPropertyName("referenceValue")] + public required string ReferenceValue { get; init; } + + internal override void Serialise(MultipartFormDataContent content, int index) + { + AddRequired(content, ReferenceType.ToString(), $"Correspondence.ExternalReferences[{index}].ReferenceType"); + AddRequired(content, ReferenceValue, $"Correspondence.ExternalReferences[{index}].ReferenceValue"); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotification.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotification.cs new file mode 100644 index 000000000..4d3ae3f5d --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotification.cs @@ -0,0 +1,115 @@ +using System.ComponentModel.DataAnnotations; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a notification to be sent to the recipient of a correspondence +/// +public sealed record CorrespondenceNotification : MultipartCorrespondenceItem +{ + /// + /// The notification template for use for notifications + /// + public required CorrespondenceNotificationTemplate NotificationTemplate { get; init; } + + /// + /// The email subject to use for notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(128, MinimumLength = 0)] + public string? EmailSubject { get; init; } + + /// + /// The email body content to use for notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(1024, MinimumLength = 0)] + public string? EmailBody { get; init; } + + /// + /// The sms content to use for notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(160, MinimumLength = 0)] + public string? SmsBody { get; init; } + + /// + /// Should a reminder be sent if this correspondence has not been actioned within an appropriate time frame? + /// + public bool? SendReminder { get; init; } + + /// + /// The email subject to use for reminder notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(128, MinimumLength = 0)] + public string? ReminderEmailSubject { get; init; } + + /// + /// The email body content to use for reminder notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(1024, MinimumLength = 0)] + public string? ReminderEmailBody { get; init; } + + /// + /// The sms content to use for reminder notifications + /// + /// Depending on the in use, this value may be padded according to the template logic + /// + /// + [StringLength(160, MinimumLength = 0)] + public string? ReminderSmsBody { get; init; } + + /// + /// Where should the notifications be sent? + /// + public CorrespondenceNotificationChannel? NotificationChannel { get; init; } + + /// + /// Where should the reminder notifications be sent? + /// + public CorrespondenceNotificationChannel? ReminderNotificationChannel { get; init; } + + /// + /// Senders reference for this notification + /// + public string? SendersReference { get; init; } + + /// + /// The date and time for when the notification should be sent + /// + public DateTimeOffset? RequestedSendTime { get; init; } + + internal void Serialise(MultipartFormDataContent content) + { + ValidateAllProperties(nameof(CorrespondenceNotification)); + + AddRequired(content, NotificationTemplate.ToString(), "Correspondence.Notification.NotificationTemplate"); + AddIfNotNull(content, EmailSubject, "Correspondence.Notification.EmailSubject"); + AddIfNotNull(content, EmailBody, "Correspondence.Notification.EmailBody"); + AddIfNotNull(content, SmsBody, "Correspondence.Notification.SmsBody"); + AddIfNotNull(content, SendReminder?.ToString(), "Correspondence.Notification.SendReminder"); + AddIfNotNull(content, ReminderEmailSubject, "Correspondence.Notification.ReminderEmailSubject"); + AddIfNotNull(content, ReminderEmailBody, "Correspondence.Notification.ReminderEmailBody"); + AddIfNotNull(content, ReminderSmsBody, "Correspondence.Notification.ReminderSmsBody"); + AddIfNotNull(content, NotificationChannel.ToString(), "Correspondence.Notification.NotificationChannel"); + AddIfNotNull(content, SendersReference, "Correspondence.Notification.SendersReference"); + AddIfNotNull(content, RequestedSendTime?.ToString("O"), "Correspondence.Notification.RequestedSendTime"); + AddIfNotNull( + content, + ReminderNotificationChannel.ToString(), + "Correspondence.Notification.ReminderNotificationChannel" + ); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationChannel.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationChannel.cs new file mode 100644 index 000000000..b97e67579 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationChannel.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Available notification channels (methods) +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceNotificationChannel +{ + /// + /// The selected channel for the notification is email. + /// + Email, + + /// + /// The selected channel for the notification is sms. + /// + Sms, + + /// + /// The selected channel for the notification is email preferred. + /// + EmailPreferred, + + /// + /// The selected channel for the notification is SMS preferred. + /// + SmsPreferred, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationDetailsResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationDetailsResponse.cs new file mode 100644 index 000000000..57c7a19ed --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationDetailsResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Details about a correspondence notification +/// +public sealed record CorrespondenceNotificationDetailsResponse +{ + /// + /// The notification order identifier + /// + [JsonPropertyName("orderId")] + public Guid? OrderId { get; init; } + + /// + /// Whether or not this is a reminder notification + /// + [JsonPropertyName("isReminder")] + public bool? IsReminder { get; init; } + + /// + /// The status of the notification + /// + [JsonPropertyName("status")] + public CorrespondenceNotificationStatusResponse Status { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationOrderResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationOrderResponse.cs new file mode 100644 index 000000000..51e130c35 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationOrderResponse.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a notification connected to a specific correspondence +/// +public sealed record CorrespondenceNotificationOrderResponse +{ + /// + /// The id of the notification order + /// + [JsonPropertyName("id")] + public required string Id { get; set; } + + /// + /// The senders reference of the notification + /// + [JsonPropertyName("sendersReference")] + public string? SendersReference { get; set; } + + /// + /// The requested send time of the notification + /// + [JsonPropertyName("requestedSendTime")] + public DateTimeOffset RequestedSendTime { get; set; } + + /// + /// The short name of the creator of the notification order + /// + [JsonPropertyName("creator")] + public required string Creator { get; init; } + + /// + /// The date and time of when the notification order was created + /// + [JsonPropertyName("created")] + public DateTimeOffset Created { get; init; } + + /// + /// Indicates if the notification is a reminder notification + /// + [JsonPropertyName("isReminder")] + public bool IsReminder { get; init; } + + /// + /// The preferred notification channel of the notification order + /// + [JsonPropertyName("notificationChannel")] + public CorrespondenceNotificationChannel NotificationChannel { get; init; } + + /// + /// Indicates if notifications generated by this order should ignore KRR reservations + /// + [JsonPropertyName("ignoreReservation")] + public bool? IgnoreReservation { get; init; } + + /// + /// The id of the resource that this notification relates to + /// + [JsonPropertyName("resourceId")] + public string? ResourceId { get; init; } + + /// + /// The processing status of the notification order + /// + [JsonPropertyName("processingStatus")] + public CorrespondenceNotificationStatusSummaryResponse? ProcessingStatus { get; init; } + + /// + /// The summary of the notifications statuses + /// + [JsonPropertyName("notificationStatusDetails")] + public CorrespondenceNotificationSummaryResponse? NotificationStatusDetails { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationRecipientResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationRecipientResponse.cs new file mode 100644 index 000000000..8f1f93b65 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationRecipientResponse.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a recipient of a notification +/// +public sealed record CorrespondenceNotificationRecipientResponse +{ + /// + /// The email address of the recipient + /// + [JsonPropertyName("emailAddress")] + public string? EmailAddress { get; init; } + + /// + /// The mobile phone number of the recipient + /// + [JsonPropertyName("mobileNumber")] + public string? MobileNumber { get; init; } + + /// + /// The organization number of the recipient + /// + [JsonPropertyName("organizationNumber")] + public string? OrganisationNumber { get; init; } + + /// + /// The SSN/identity number of the recipient + /// + [JsonPropertyName("nationalIdentityNumber")] + public string? NationalIdentityNumber { get; init; } + + /// + /// Indicates if the recipient is reserved from receiving communication + /// + [JsonPropertyName("isReserved")] + public bool? IsReserved { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusDetailsResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusDetailsResponse.cs new file mode 100644 index 000000000..79ffd4dda --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusDetailsResponse.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a status overview from a single notification channel +/// +public sealed record CorrespondenceNotificationStatusDetailsResponse +{ + /// + /// The notification id + /// + [JsonPropertyName("id")] + public Guid Id { get; init; } + + /// + /// Indicates if the sending of the notification was successful + /// + [JsonPropertyName("succeeded")] + public bool Succeeded { get; init; } + + /// + /// The recipient of the notification. Either an organisation number or identity number + /// + [JsonPropertyName("recipient")] + public CorrespondenceNotificationRecipientResponse? Recipient { get; init; } + + /// + /// The result status of the notification + /// + [JsonPropertyName("sendStatus")] + public CorrespondenceNotificationStatusSummaryResponse? SendStatus { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusResponse.cs new file mode 100644 index 000000000..bb186574c --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusResponse.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// The status of a correspondence notification +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceNotificationStatusResponse +{ + /// + /// Notification has been scheduled successfully + /// + Success, + + /// + /// Notification cannot be delivered because of missing contact information + /// + MissingContact, + + /// + /// Notification has failed + /// + Failure, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusSummaryResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusSummaryResponse.cs new file mode 100644 index 000000000..56bcd8c46 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationStatusSummaryResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents the status summary of a notification +/// +public sealed record CorrespondenceNotificationStatusSummaryResponse +{ + /// + /// The status + /// + [JsonPropertyName("status")] + public required string Status { get; init; } + + /// + /// The status description + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// + /// The date and time of when the status was last updated + /// + [JsonPropertyName("lastUpdate")] + public DateTimeOffset LastUpdate { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationSummaryResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationSummaryResponse.cs new file mode 100644 index 000000000..b10bd14a2 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationSummaryResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a summary of status overviews from all notification channels +/// +public sealed record CorrespondenceNotificationSummaryResponse +{ + /// + /// Notifications sent via Email + /// + [JsonPropertyName("email")] + public CorrespondenceNotificationStatusDetailsResponse? Email { get; init; } + + /// + /// Notifications sent via SMS + /// + [JsonPropertyName("sms")] + public CorrespondenceNotificationStatusDetailsResponse? Sms { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationTemplate.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationTemplate.cs new file mode 100644 index 000000000..ff48101a7 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceNotificationTemplate.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// The message template to use for notifications +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceNotificationTemplate +{ + /// + /// Fully customizable template (e.g. no template) + /// + CustomMessage, + + /// + /// Standard Altinn notification template ("You have received a message in Altinn...") + /// + GenericAltinnMessage, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondencePayload.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondencePayload.cs new file mode 100644 index 000000000..2899d6a9f --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondencePayload.cs @@ -0,0 +1,88 @@ +using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Defines an authorisation method to use with the correspondence server +/// +public enum CorrespondenceAuthorisation +{ + /// + /// Uses the built-in for authorization + /// + Maskinporten, +} + +/// +/// Authorisation properties which are common for all correspondence interaction +/// +public abstract record CorrespondencePayloadBase +{ + internal Func>? AccessTokenFactory { get; init; } + + internal CorrespondenceAuthorisation? AuthorisationMethod { get; init; } +} + +/// +/// Represents the payload for sending a correspondence +/// +public sealed record SendCorrespondencePayload : CorrespondencePayloadBase +{ + internal CorrespondenceRequest CorrespondenceRequest { get; init; } + + /// + /// Instantiates a new payload for + /// + /// The correspondence request to send + /// Access token factory delegate (e.g. ) to use for authorisation + public SendCorrespondencePayload(CorrespondenceRequest request, Func> accessTokenFactory) + { + CorrespondenceRequest = request; + AccessTokenFactory = accessTokenFactory; + } + + /// + /// Instantiates a new payload for + /// + /// The correspondence request to send + /// The built-in authorisation method to use + public SendCorrespondencePayload(CorrespondenceRequest request, CorrespondenceAuthorisation authorisation) + { + CorrespondenceRequest = request; + AuthorisationMethod = authorisation; + } +} + +/// +/// Represents a payload for querying the status of a correspondence +/// +public sealed record GetCorrespondenceStatusPayload : CorrespondencePayloadBase +{ + /// + /// The correspondence identifier + /// + public Guid CorrespondenceId { get; init; } + + /// + /// Instantiates a new payload for + /// + /// The correspondence identifier to retrieve information about + /// Access token factory delegate (e.g. ) to use for authorisation + public GetCorrespondenceStatusPayload(Guid correspondenceId, Func> accessTokenFactory) + { + CorrespondenceId = correspondenceId; + AccessTokenFactory = accessTokenFactory; + } + + /// + /// Instantiates a new payload for + /// + /// The correspondence identifier to retrieve information about + /// The built-in authorisation method to use + public GetCorrespondenceStatusPayload(Guid correspondenceId, CorrespondenceAuthorisation authorisation) + { + CorrespondenceId = correspondenceId; + AuthorisationMethod = authorisation; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReferenceType.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReferenceType.cs new file mode 100644 index 000000000..343f703d5 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReferenceType.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Defines the type of an external reference +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceReferenceType +{ + /// + /// A generic reference + /// + Generic, + + /// + /// A reference to an Altinn App Instance + /// + AltinnAppInstance, + + /// + /// A reference to an Altinn Broker File Transfer + /// + AltinnBrokerFileTransfer, + + /// + /// A reference to a Dialogporten Dialog ID + /// + DialogportenDialogId, + + /// + /// A reference to a Dialogporten Process ID + /// + DialogportenProcessId, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReplyOption.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReplyOption.cs new file mode 100644 index 000000000..6426bf88a --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceReplyOption.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Methods for recipients to respond to a correspondence, in addition to the normal Read and Confirm operations +/// +public sealed record CorrespondenceReplyOption : MultipartCorrespondenceListItem +{ + /// + /// The URL to be used as a reply/response to a correspondence + /// + [JsonPropertyName("linkURL")] + public required string LinkUrl { get; init; } + + /// + /// The link text + /// + [JsonPropertyName("linkText")] + public string? LinkText { get; init; } + + internal override void Serialise(MultipartFormDataContent content, int index) + { + AddRequired(content, LinkUrl, $"Correspondence.ReplyOptions[{index}].LinkUrl"); + AddIfNotNull(content, LinkText, $"Correspondence.ReplyOptions[{index}].LinkText"); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceRequest.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceRequest.cs new file mode 100644 index 000000000..69b9636f4 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceRequest.cs @@ -0,0 +1,295 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using Altinn.App.Core.Features.Correspondence.Exceptions; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a correspondence item that is serialisable as multipart form data +/// +public abstract record MultipartCorrespondenceItem +{ + internal static void AddRequired(MultipartFormDataContent content, string value, string name) + { + if (string.IsNullOrWhiteSpace(value)) + throw new CorrespondenceValueException($"Required value is missing: {name}"); + + content.Add(new StringContent(value), name); + } + + internal static void AddRequired( + MultipartFormDataContent content, + ReadOnlyMemory data, + string name, + string filename + ) + { + if (data.IsEmpty) + throw new CorrespondenceValueException($"Required value is missing: {name}"); + + content.Add(new ReadOnlyMemoryContent(data), name, filename); + } + + internal static void AddIfNotNull(MultipartFormDataContent content, string? value, string name) + { + if (!string.IsNullOrWhiteSpace(value)) + content.Add(new StringContent(value), name); + } + + internal static void AddListItems( + MultipartFormDataContent content, + IReadOnlyList? items, + Func valueFactory, + Func keyFactory + ) + { + if (IsEmptyCollection(items)) + return; + + for (int i = 0; i < items.Count; i++) + { + string key = keyFactory.Invoke(i); + string value = valueFactory.Invoke(items[i]); + content.Add(new StringContent(value), key); + } + } + + internal static void SerializeListItems( + MultipartFormDataContent content, + IReadOnlyList? items + ) + { + if (IsEmptyCollection(items)) + return; + + for (int i = 0; i < items.Count; i++) + { + items[i].Serialise(content, i); + } + } + + internal static void SerializeAttachmentItems( + MultipartFormDataContent content, + IReadOnlyList? attachments + ) + { + if (IsEmptyCollection(attachments)) + return; + + // Ensure unique filenames + var overrides = CalculateFilenameOverrides(attachments); + + // Serialise + for (int i = 0; i < attachments.Count; i++) + { + attachments[i].Serialise(content, i, overrides.GetValueOrDefault(attachments[i])); + } + } + + internal static Dictionary CalculateFilenameOverrides( + IEnumerable attachments + ) + { + var overrides = new Dictionary(ReferenceEqualityComparer.Instance); + var hasDuplicateFilenames = attachments + .GroupBy(x => x.Filename.ToLowerInvariant()) + .Where(x => x.Count() > 1) + .Select(x => x.ToList()); + + foreach (var duplicates in hasDuplicateFilenames) + { + for (int i = 0; i < duplicates.Count; i++) + { + int uniqueId = i + 1; + string filename = Path.GetFileNameWithoutExtension(duplicates[i].Filename); + string extension = Path.GetExtension(duplicates[i].Filename); + overrides.Add(duplicates[i], $"{filename}({uniqueId}){extension}"); + } + } + + return overrides; + } + + internal static void AddDictionaryItems( + MultipartFormDataContent content, + IReadOnlyDictionary? items, + Func valueFactory, + Func keyFactory + ) + { + if (IsEmptyCollection(items)) + return; + + foreach (var (dictKey, dictValue) in items) + { + string key = keyFactory.Invoke(dictKey); + string value = valueFactory.Invoke(dictValue); + content.Add(new StringContent(value), key); + } + } + + private static bool IsEmptyCollection([NotNullWhen(false)] IReadOnlyCollection? collection) + { + return collection is null || collection.Count == 0; + } + + internal void ValidateAllProperties(string dataTypeName) + { + var validationResults = new List(); + var validationContext = new ValidationContext(this); + bool isValid = Validator.TryValidateObject( + this, + validationContext, + validationResults, + validateAllProperties: true + ); + + if (isValid is false) + { + throw new CorrespondenceValueException( + $"Validation failed for {dataTypeName}", + new AggregateException(validationResults.Select(x => new ValidationException(x.ErrorMessage))) + ); + } + } +} + +/// +/// Represents a correspondence list item that is serialisable as multipart form data +/// +public abstract record MultipartCorrespondenceListItem : MultipartCorrespondenceItem +{ + internal abstract void Serialise(MultipartFormDataContent content, int index); +} + +/// +/// Represents and Altinn Correspondence request +/// +public sealed record CorrespondenceRequest : MultipartCorrespondenceItem +{ + /// + /// The Resource Id for the correspondence service + /// + public required string ResourceId { get; init; } + + /// + /// The sending organisation of the correspondence + /// + public required OrganisationNumber Sender { get; init; } + + /// + /// A reference value given to the message by the creator + /// + public required string SendersReference { get; init; } + + /// + /// The content of the message + /// + public required CorrespondenceContent Content { get; init; } + + /// + /// When should the correspondence become visible to the recipient? + /// If omitted, the correspondence is available immediately + /// + public DateTimeOffset? RequestedPublishTime { get; init; } + + /// + /// When can Altinn remove the correspondence from its database? + /// + public required DateTimeOffset AllowSystemDeleteAfter { get; init; } + + /// + /// When must the recipient respond by? + /// + public DateTimeOffset? DueDateTime { get; init; } + + /// + /// The recipients of the correspondence. Either Norwegian organisation numbers or national identity numbers + /// + public required IReadOnlyList Recipients { get; init; } + + /// + /// An alternative name for the sender of the correspondence. The name will be displayed instead of the organisation name + /// + public string? MessageSender { get; init; } + + /// + /// Reference to other items in the Altinn ecosystem + /// + public IReadOnlyList? ExternalReferences { get; init; } + + /// + /// User-defined properties related to the correspondence + /// + public IReadOnlyDictionary? PropertyList { get; init; } + + /// + /// Options for how the recipient can reply to the correspondence + /// + public IReadOnlyList? ReplyOptions { get; init; } + + /// + /// Notifications associated with this correspondence + /// + public CorrespondenceNotification? Notification { get; init; } + + /// + /// Specifies whether the correspondence can override reservation against digital communication in KRR + /// + public bool? IgnoreReservation { get; init; } + + /// + /// Existing attachments that should be added to the correspondence + /// + public IReadOnlyList? ExistingAttachments { get; init; } + + /// + /// Serialises the entire object to a provided instance + /// + /// The multipart object to serialise into + internal void Serialise(MultipartFormDataContent content) + { + AddRequired(content, ResourceId, "Correspondence.ResourceId"); + AddRequired(content, Sender.Get(OrganisationNumberFormat.International), "Correspondence.Sender"); + AddRequired(content, SendersReference, "Correspondence.SendersReference"); + AddRequired(content, AllowSystemDeleteAfter.ToString("O"), "Correspondence.AllowSystemDeleteAfter"); + AddIfNotNull(content, MessageSender, "Correspondence.MessageSender"); + AddIfNotNull(content, RequestedPublishTime?.ToString("O"), "Correspondence.RequestedPublishTime"); + AddIfNotNull(content, DueDateTime?.ToString("O"), "Correspondence.DueDateTime"); + AddIfNotNull(content, IgnoreReservation?.ToString(), "Correspondence.IgnoreReservation"); + AddDictionaryItems(content, PropertyList, x => x, key => $"Correspondence.PropertyList.{key}"); + AddListItems(content, ExistingAttachments, x => x.ToString(), i => $"Correspondence.ExistingAttachments[{i}]"); + AddListItems( + content, + Recipients, + x => + x switch + { + OrganisationOrPersonIdentifier.Organisation org => org.Value.Get( + OrganisationNumberFormat.International + ), + OrganisationOrPersonIdentifier.Person person => person.Value, + _ => throw new CorrespondenceValueException( + $"Unknown OrganisationOrPersonIdentifier type `{x.GetType()}` ({nameof(Recipients)})" + ), + }, + i => $"Recipients[{i}]" + ); + + Content.Serialise(content); + Notification?.Serialise(content); + SerializeListItems(content, ExternalReferences); + SerializeListItems(content, ReplyOptions); + } + + /// + /// Serialises the entire object to a newly created + /// + internal MultipartFormDataContent Serialise() + { + var content = new MultipartFormDataContent(); + Serialise(content); + return content; + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatus.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatus.cs new file mode 100644 index 000000000..cb56c60dd --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatus.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// The status of a correspondence +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CorrespondenceStatus +{ + /// + /// Correspondence has been Initialized + /// + Initialized, + + /// + /// Correspondence is ready for publish, but not available for recipient + /// + ReadyForPublish, + + /// + /// Correspondence has been published, and is available for recipient + /// + Published, + + /// + /// Correspondence fetched by recipient + /// + Fetched, + + /// + /// Correspondence read by recipient + /// + Read, + + /// + /// Recipient has replied to the correspondence + /// + Replied, + + /// + /// Correspondence confirmed by recipient + /// + Confirmed, + + /// + /// Correspondence has been purged by recipient + /// + PurgedByRecipient, + + /// + /// Correspondence has been purged by Altinn + /// + PurgedByAltinn, + + /// + /// Correspondence has been archived + /// + Archived, + + /// + /// Recipient has opted out of digital communication in KRR + /// + Reserved, + + /// + /// Correspondence has failed + /// + Failed, +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatusEventResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatusEventResponse.cs new file mode 100644 index 000000000..06f0d5fdb --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/CorrespondenceStatusEventResponse.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents a correspondence status event +/// +public sealed record CorrespondenceStatusEventResponse +{ + /// + /// The event status indicator + /// + [JsonPropertyName("status")] + public CorrespondenceStatus Status { get; init; } + + /// + /// Description of the status + /// + [JsonPropertyName("statusText")] + public required string StatusText { get; init; } + + /// + /// Timestamp for when this correspondence status event occurred + /// + [JsonPropertyName("statusChanged")] + public DateTimeOffset StatusChanged { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/GetCorrespondenceStatusResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/GetCorrespondenceStatusResponse.cs new file mode 100644 index 000000000..372f8aa3b --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/GetCorrespondenceStatusResponse.cs @@ -0,0 +1,152 @@ +using System.Text.Json.Serialization; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Response after a successful request +/// +public sealed record GetCorrespondenceStatusResponse +{ + /// + /// The status history for the correspondence + /// + [JsonPropertyName("statusHistory")] + public required IEnumerable StatusHistory { get; init; } + + /// + /// Notifications directly related to this correspondence + /// + [JsonPropertyName("notifications")] + public IEnumerable? Notifications { get; init; } + + /// + /// The recipient of the correspondence. Either an organisation number or identity number + /// + [JsonPropertyName("recipient")] + public required string Recipient { get; init; } + + /// + /// Indicates if the correspondence has been set as unread by the recipient + /// + [JsonPropertyName("markedUnread")] + public bool? MarkedUnread { get; init; } + + /// + /// Unique Id for this correspondence + /// + [JsonPropertyName("correspondenceId")] + public Guid CorrespondenceId { get; init; } + + /// + /// The correspondence content. Contains information about the correspondence body, subject etc. + /// + [JsonPropertyName("content")] + public CorrespondenceContentResponse? Content { get; init; } + + /// + /// When the correspondence was created + /// + [JsonPropertyName("created")] + public DateTimeOffset Created { get; init; } + + /// + /// The current status for the correspondence + /// + [JsonPropertyName("status")] + public CorrespondenceStatus Status { get; init; } + + /// + /// The current status text for the correspondence + /// + [JsonPropertyName("statusText")] + public string? StatusText { get; init; } + + /// + /// Timestamp for when the current correspondence status was changed + /// + [JsonPropertyName("statusChanged")] + public DateTimeOffset StatusChanged { get; init; } + + /// + /// The resource id for the correspondence service + /// + [JsonPropertyName("resourceId")] + public required string ResourceId { get; init; } + + /// + /// The sending organisation of the correspondence + /// + [JsonPropertyName("sender")] + [OrganisationNumberJsonConverter(OrganisationNumberFormat.International)] + public OrganisationNumber Sender { get; init; } + + /// + /// A reference value given to the message by the creator + /// + [JsonPropertyName("sendersReference")] + public required string SendersReference { get; init; } + + /// + /// An alternative name for the sender of the correspondence. The name will be displayed instead of the organization name + /// + [JsonPropertyName("messageSender")] + public string? MessageSender { get; init; } + + /// + /// When the correspondence should become visible to the recipient + /// + [JsonPropertyName("requestedPublishTime")] + public DateTimeOffset? RequestedPublishTime { get; init; } + + /// + /// The date for when Altinn can remove the correspondence from its database + /// + [JsonPropertyName("allowSystemDeleteAfter")] + public DateTimeOffset? AllowSystemDeleteAfter { get; init; } + + /// + /// A date and time for when the recipient must reply + /// + [JsonPropertyName("dueDateTime")] + public DateTimeOffset? DueDateTime { get; init; } + + /// + /// Reference to other items in the Altinn ecosystem + /// + [JsonPropertyName("externalReferences")] + public IEnumerable? ExternalReferences { get; init; } + + /// + /// User-defined properties related to the correspondence + /// + [JsonPropertyName("propertyList")] + public IReadOnlyDictionary? PropertyList { get; init; } + + /// + /// Options for how the recipient can reply to the correspondence + /// + [JsonPropertyName("replyOptions")] + public IEnumerable? ReplyOptions { get; init; } + + /// + /// Specifies whether the correspondence can override reservation against digital communication in KRR + /// + [JsonPropertyName("ignoreReservation")] + public bool? IgnoreReservation { get; init; } + + /// + /// The time the correspondence was published + /// + /// + /// A null value means the correspondence has not yet been published + /// + [JsonPropertyName("published")] + public DateTimeOffset? Published { get; init; } + + /// + /// Specifies whether reading the correspondence needs to be confirmed by the recipient + /// + [JsonPropertyName("isConfirmationNeeded")] + public bool IsConfirmationNeeded { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/OrganisationOrPersonIdentifier.cs b/src/Altinn.App.Core/Features/Correspondence/Models/OrganisationOrPersonIdentifier.cs new file mode 100644 index 000000000..549f25b1c --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/OrganisationOrPersonIdentifier.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Models; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Represents either an organisation or a person +/// +[OrganisationOrPersonIdentifierJsonConverter(OrganisationNumberFormat.International)] +public abstract record OrganisationOrPersonIdentifier +{ + /// + /// Represents an organisation + /// + /// The organisation number + public sealed record Organisation(OrganisationNumber Value) : OrganisationOrPersonIdentifier + { + /// + public override string ToString() + { + return Value.ToString(); + } + } + + /// + /// Represents a person + /// + /// The national identity number + public sealed record Person(NationalIdentityNumber Value) : OrganisationOrPersonIdentifier + { + /// + public override string ToString() + { + return Value.ToString(); + } + } + + /// + /// Creates a new instance of + /// + /// The organisation number + public static Organisation Create(OrganisationNumber value) + { + return new Organisation(value); + } + + /// + /// Creates a new instance of + /// + /// The national identity number + public static Person Create(NationalIdentityNumber value) + { + return new Person(value); + } + + /// + /// Attempts to parse a string containing either an or a + /// + /// The string to parse + /// The supplied string is not a valid format for either type + public static OrganisationOrPersonIdentifier Parse(string value) + { + if (OrganisationNumber.TryParse(value, out var organisationNumber)) + { + return Create(organisationNumber); + } + + if (NationalIdentityNumber.TryParse(value, out var nationalIdentityNumber)) + { + return Create(nationalIdentityNumber); + } + + throw new FormatException( + $"OrganisationOrPersonIdentifier value `{value}` is not a valid organisation number nor a national identity number" + ); + } +} + +/// +/// Json converter to transform between and +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, AllowMultiple = false)] +internal class OrganisationOrPersonIdentifierJsonConverterAttribute : JsonConverterAttribute +{ + private OrganisationNumberFormat _format { get; } + + /// + /// The desired organisation number format to use for serialization + public OrganisationOrPersonIdentifierJsonConverterAttribute(OrganisationNumberFormat format) + { + _format = format; + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert) + { + return new OrganisationOrPersonJsonIdentifierConverter(_format); + } +} + +internal class OrganisationOrPersonJsonIdentifierConverter : JsonConverter +{ + private OrganisationNumberFormat _format { get; init; } + + public OrganisationOrPersonJsonIdentifierConverter(OrganisationNumberFormat format) + { + _format = format; + } + + /// + public override OrganisationOrPersonIdentifier Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string token for OrganisationOrPersonIdentifier property."); + } + + var tokenValue = + reader.GetString() ?? throw new JsonException("OrganisationOrPersonIdentifier string value is null."); + + return OrganisationOrPersonIdentifier.Parse(tokenValue); + } + + /// + public override void Write( + Utf8JsonWriter writer, + OrganisationOrPersonIdentifier value, + JsonSerializerOptions options + ) + { + string stringValue = value switch + { + OrganisationOrPersonIdentifier.Organisation org => org.Value.Get(_format), + OrganisationOrPersonIdentifier.Person person => person.Value, + _ => throw new JsonException($"Unknown type `{value.GetType()}` ({nameof(value)})"), + }; + + writer.WriteStringValue(stringValue); + } +} diff --git a/src/Altinn.App.Core/Features/Correspondence/Models/SendCorrespondenceResponse.cs b/src/Altinn.App.Core/Features/Correspondence/Models/SendCorrespondenceResponse.cs new file mode 100644 index 000000000..b82aa9870 --- /dev/null +++ b/src/Altinn.App.Core/Features/Correspondence/Models/SendCorrespondenceResponse.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Features.Correspondence.Models; + +/// +/// Response after a successful request +/// +public sealed record SendCorrespondenceResponse +{ + /// + /// The correspondences that were processed + /// + [JsonPropertyName("correspondences")] + public required IReadOnlyList Correspondences { get; init; } + + /// + /// The attachments linked to the correspondence + /// + [JsonPropertyName("attachmentIds")] + public IReadOnlyList? AttachmentIds { get; init; } +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Constants/JwtClaimTypes.cs b/src/Altinn.App.Core/Features/Maskinporten/Constants/JwtClaimTypes.cs new file mode 100644 index 000000000..f14998f26 --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Constants/JwtClaimTypes.cs @@ -0,0 +1,40 @@ +namespace Altinn.App.Core.Features.Maskinporten.Constants; + +/// +/// Relevant known Digdir JWT claim types +/// +internal static class JwtClaimTypes +{ + public const string Expiration = "exp"; + public const string IssuedAt = "iat"; + public const string JwtId = "jti"; + public const string Audience = "aud"; + public const string Scope = "scope"; + public const string Issuer = "iss"; + + public static class Altinn + { + public const string AuthenticationLevel = "urn:altinn:authlevel"; + public const string UserId = "urn:altinn:userid"; + public const string PartyId = "urn:altinn:partyid"; + public const string RepresentingPartyId = "urn:altinn:representingpartyid"; + public const string UserName = "urn:altinn:username"; + public const string Developer = "urn:altinn:developer"; + public const string DeveloperToken = "urn:altinn:developertoken"; + public const string DeveloperTokenId = "urn:altinn:developertokenid"; + public const string AuthenticateMethod = "urn:altinn:authenticatemethod"; + public const string Org = "urn:altinn:org"; + public const string OrgNumber = "urn:altinn:orgNumber"; + } + + public static class Maskinporten + { + public const string AuthenticationMethod = "client_amr"; + public const string ClientId = "client_id"; + public const string TokenType = "token_type"; + public const string Consumer = "consumer"; + public const string Supplier = "supplier"; + public const string DelegationSource = "delegation_source"; + public const string PersonIdentifier = "pid"; + } +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenAuthorities.cs b/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenAuthorities.cs new file mode 100644 index 000000000..d076ff87d --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenAuthorities.cs @@ -0,0 +1,17 @@ +namespace Altinn.App.Core.Features.Maskinporten.Constants; + +/// +/// Supported token authorities +/// +internal enum TokenAuthorities +{ + /// + /// Maskinporten + /// + Maskinporten, + + /// + /// Altinn Authentication token exchange + /// + AltinnTokenExchange, +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenTypes.cs b/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenTypes.cs new file mode 100644 index 000000000..55729a1d1 --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Constants/TokenTypes.cs @@ -0,0 +1,6 @@ +namespace Altinn.App.Core.Features.Maskinporten.Constants; + +internal static class TokenTypes +{ + public const string Bearer = "Bearer"; +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs b/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs index e3ac0503d..c5dfd25f3 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/Delegates/MaskinportenDelegatingHandler.cs @@ -1,4 +1,5 @@ using System.Net.Http.Headers; +using Altinn.App.Core.Features.Maskinporten.Constants; using Altinn.App.Core.Features.Maskinporten.Exceptions; using Microsoft.Extensions.Logging; @@ -10,6 +11,7 @@ namespace Altinn.App.Core.Features.Maskinporten.Delegates; internal sealed class MaskinportenDelegatingHandler : DelegatingHandler { public IEnumerable Scopes { get; init; } + internal readonly TokenAuthorities Authorities; private readonly ILogger _logger; private readonly IMaskinportenClient _maskinportenClient; @@ -17,10 +19,12 @@ internal sealed class MaskinportenDelegatingHandler : DelegatingHandler /// /// Creates a new instance of . /// - /// A list of scopes to claim authorization for with Maskinporten + /// The token authority to authorise with + /// A list of scopes to claim authorisation for /// A instance /// Optional logger interface public MaskinportenDelegatingHandler( + TokenAuthorities authorities, IEnumerable scopes, IMaskinportenClient maskinportenClient, ILogger logger @@ -29,6 +33,7 @@ ILogger logger Scopes = scopes; _logger = logger; _maskinportenClient = maskinportenClient; + Authorities = authorities; } /// @@ -39,21 +44,18 @@ CancellationToken cancellationToken { _logger.LogDebug("Executing custom `SendAsync` method; injecting authentication headers"); - var auth = await _maskinportenClient.GetAccessToken(Scopes, cancellationToken); - if (!auth.TokenType.Equals(TokenTypes.Bearer, StringComparison.OrdinalIgnoreCase)) + var token = Authorities switch { - throw new MaskinportenUnsupportedTokenException( - $"Unsupported token type received from Maskinporten: {auth.TokenType}" - ); - } + TokenAuthorities.Maskinporten => await _maskinportenClient.GetAccessToken(Scopes, cancellationToken), + TokenAuthorities.AltinnTokenExchange => await _maskinportenClient.GetAltinnExchangedToken( + Scopes, + cancellationToken + ), + _ => throw new MaskinportenAuthenticationException($"Unknown authority `{Authorities}`"), + }; - request.Headers.Authorization = new AuthenticationHeaderValue(TokenTypes.Bearer, auth.AccessToken); + request.Headers.Authorization = new AuthenticationHeaderValue(TokenTypes.Bearer, token.Value); return await base.SendAsync(request, cancellationToken); } } - -internal static class TokenTypes -{ - public const string Bearer = "Bearer"; -} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Extensions/HttpClientBuilderExtensions.cs b/src/Altinn.App.Core/Features/Maskinporten/Extensions/HttpClientBuilderExtensions.cs new file mode 100644 index 000000000..f873fbf2e --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Extensions/HttpClientBuilderExtensions.cs @@ -0,0 +1,22 @@ +using Altinn.App.Core.Features.Maskinporten.Constants; +using Altinn.App.Core.Features.Maskinporten.Delegates; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.App.Core.Features.Maskinporten.Extensions; + +internal static class HttpClientBuilderExtensions +{ + public static IHttpClientBuilder AddMaskinportenHttpMessageHandler( + this IHttpClientBuilder builder, + string scope, + IEnumerable additionalScopes, + TokenAuthorities authorities + ) + { + var scopes = new[] { scope }.Concat(additionalScopes); + var factory = ActivatorUtilities.CreateFactory( + [typeof(TokenAuthorities), typeof(IEnumerable)] + ); + return builder.AddHttpMessageHandler(provider => factory(provider, [authorities, scopes])); + } +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Features/Maskinporten/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..21c2191a6 --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,55 @@ +using Altinn.App.Core.Extensions; +using Altinn.App.Core.Features.Maskinporten.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Altinn.App.Core.Features.Maskinporten.Extensions; + +internal static class ServiceCollectionExtensions +{ + /// + /// Adds a singleton service to the service collection. + /// If no configuration is found, it binds one to the path "MaskinportenSettings". + /// + /// The service collection + public static IServiceCollection AddMaskinportenClient(this IServiceCollection services) + { + // Only add MaskinportenSettings if not already configured. + // Users sometimes wish to bind the default options to another configuration path than "MaskinportenSettings". + if (services.IsConfigured() is false) + { + services.ConfigureMaskinportenClient("MaskinportenSettings"); + } + + services.TryAddSingleton(sp => + ActivatorUtilities.CreateInstance(sp, MaskinportenClient.VariantDefault) + ); + + services.ConfigureMaskinportenClient("MaskinportenSettingsInternal", MaskinportenClient.VariantInternal); + services.AddKeyedSingleton( + MaskinportenClient.VariantInternal, + (sp, key) => ActivatorUtilities.CreateInstance(sp, MaskinportenClient.VariantInternal) + ); + + return services; + } + + /// + /// Binds a configuration to the supplied config section path and options name + /// + /// The service collection + /// The configuration section path, e.g. "MaskinportenSettingsInternal" + /// The options name to bind to, e.g. + public static IServiceCollection ConfigureMaskinportenClient( + this IServiceCollection services, + string configSectionPath, + string? optionsName = default + ) + { + services + .AddOptions(optionsName ?? Microsoft.Extensions.Options.Options.DefaultName) + .BindConfiguration(configSectionPath) + .ValidateDataAnnotations(); + return services; + } +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/Extensions/WebHostBuilderExtensions.cs b/src/Altinn.App.Core/Features/Maskinporten/Extensions/WebHostBuilderExtensions.cs new file mode 100644 index 000000000..e56aa3963 --- /dev/null +++ b/src/Altinn.App.Core/Features/Maskinporten/Extensions/WebHostBuilderExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; + +namespace Altinn.App.Core.Features.Maskinporten.Extensions; + +internal static class WebHostBuilderExtensions +{ + public static IConfigurationBuilder AddMaskinportenSettingsFile( + this IConfigurationBuilder configurationBuilder, + WebHostBuilderContext context, + string configurationKey, + string defaultFileLocation + ) + { + string jsonProvidedPath = context.Configuration.GetValue(configurationKey) ?? defaultFileLocation; + string jsonAbsolutePath = Path.GetFullPath(jsonProvidedPath); + + if (File.Exists(jsonAbsolutePath)) + { + string jsonDir = Path.GetDirectoryName(jsonAbsolutePath) ?? string.Empty; + string jsonFile = Path.GetFileName(jsonAbsolutePath); + + configurationBuilder.AddJsonFile( + provider: new PhysicalFileProvider(jsonDir), + path: jsonFile, + optional: true, + reloadOnChange: true + ); + } + + return configurationBuilder; + } +} diff --git a/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs b/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs index 254adfdc4..8f25a2e56 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/IMaskinportenClient.cs @@ -1,9 +1,9 @@ -using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; namespace Altinn.App.Core.Features.Maskinporten; /// -/// Contains logic for handling authorization requests with Maskinporten. +/// Contains logic for handling authorisation requests with Maskinporten. /// public interface IMaskinportenClient { @@ -12,21 +12,43 @@ public interface IMaskinportenClient /// Sends an authorization request to Maskinporten and retrieves a JWT Bearer token for successful requests. /// /// - /// Will cache tokens per scope, for the lifetime duration as defined in the token Maskinporten token payload, + /// Will cache tokens per scope, for the lifetime duration as defined in the Maskinporten token payload, /// which means this method is safe to call in a loop or concurrent environment without encountering rate concerns. /// /// /// A list of scopes to claim authorization for with Maskinporten. /// An optional cancellation token to be forwarded to internal http calls. - /// A which contains an access token, amongst other things. - /// - /// Authentication failed. This could be caused by an authentication/authorization issue or a myriad of other circumstances. + /// A which contains an access token, amongst other things. + /// + /// Authentication failed. This could be caused by an authentication/authorisation issue or a myriad of other circumstances. /// - /// + /// /// The Maskinporten configuration is incomplete or invalid. Very possibly because of a missing or corrupt maskinporten-settings.json file. /// - /// The token received from Maskinporten has already expired. - public Task GetAccessToken( + /// The token received from Maskinporten has already expired. + public Task GetAccessToken(IEnumerable scopes, CancellationToken cancellationToken = default); + + /// + /// + /// Sends an authorization request to Maskinporten, then exchanges the grant for an Altinn issued token. + /// + /// + /// Will cache tokens per scope, for the lifetime duration as defined in the Altinn token payload, + /// which means this method is safe to call in a loop or concurrent environment without encountering rate concerns. + /// + /// + /// A list of scopes to claim authorization for with Maskinporten. These scopes will carry through to the Altinn issued token. + /// An optional cancellation token to be forwarded to internal http calls. + /// A which contains an access token, amongst other things. + /// + /// Authentication failed. This could be caused by an authentication/authorisation issue or a myriad of other circumstances. + /// + /// + /// The Maskinporten configuration is incomplete or invalid. Very possibly because of a missing or corrupt maskinporten-settings.json file. + /// + /// The token received from Maskinporten and/or Altinn Authentication has already expired. + /// + public Task GetAltinnExchangedToken( IEnumerable scopes, CancellationToken cancellationToken = default ); diff --git a/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs b/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs index d3bb6e30d..638ecacc3 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/MaskinportenClient.cs @@ -1,6 +1,11 @@ +using System.Net.Http.Headers; using System.Text.Json; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Constants; +using Altinn.App.Core.Features.Maskinporten.Constants; using Altinn.App.Core.Features.Maskinporten.Exceptions; using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -10,34 +15,49 @@ namespace Altinn.App.Core.Features.Maskinporten; /// -public sealed class MaskinportenClient : IMaskinportenClient +internal sealed class MaskinportenClient : IMaskinportenClient { /// /// The margin to take into consideration when determining if a token has expired (seconds). /// This value represents the worst-case latency scenario for outbound connections carrying the access token. /// - internal const int TokenExpirationMargin = 30; + internal static readonly TimeSpan TokenExpirationMargin = TimeSpan.FromSeconds(30); - private const string CacheKeySalt = "maskinportenScope-"; - private static readonly HybridCacheEntryOptions _defaultCacheExpiration = CacheExpiry(TimeSpan.FromSeconds(60)); + internal MaskinportenSettings Settings => + _options.Get(Variant == VariantDefault ? Microsoft.Extensions.Options.Options.DefaultName : Variant); + + internal const string VariantDefault = "default"; + internal const string VariantInternal = "internal"; + internal readonly string Variant; + + private readonly string _maskinportenCacheKeySalt; + private readonly string _altinnCacheKeySalt; + private static readonly HybridCacheEntryOptions _defaultCacheExpiration = CacheExpiryFactory( + TimeSpan.FromSeconds(60) + ); private readonly ILogger _logger; private readonly IOptionsMonitor _options; + private readonly PlatformSettings _platformSettings; private readonly IHttpClientFactory _httpClientFactory; - private readonly TimeProvider _timeprovider; + private readonly TimeProvider _timeProvider; private readonly HybridCache _tokenCache; private readonly Telemetry? _telemetry; /// /// Instantiates a new object. /// + /// Variant (default/internal). /// Maskinporten settings. + /// Platform settings. /// HttpClient factory. /// Token cache store. /// Logger interface. /// Optional TimeProvider implementation. /// Optional telemetry service. public MaskinportenClient( + string variant, IOptionsMonitor options, + IOptions platformSettings, IHttpClientFactory httpClientFactory, HybridCache tokenCache, ILogger logger, @@ -45,63 +65,134 @@ public MaskinportenClient( Telemetry? telemetry = null ) { + if (variant != VariantDefault && variant != VariantInternal) + throw new ArgumentException($"Invalid variant '{variant}' provided to MaskinportenClient"); + + Variant = variant; + _maskinportenCacheKeySalt = $"maskinportenScope-{variant}"; + _altinnCacheKeySalt = $"maskinportenScope-altinn-{variant}"; _options = options; + _platformSettings = platformSettings.Value; _telemetry = telemetry; - _timeprovider = timeProvider ?? TimeProvider.System; + _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger; _httpClientFactory = httpClientFactory; _tokenCache = tokenCache; } /// - public async Task GetAccessToken( + public async Task GetAccessToken( IEnumerable scopes, CancellationToken cancellationToken = default ) { string formattedScopes = FormattedScopes(scopes); - string cacheKey = $"{CacheKeySalt}_{formattedScopes}"; - DateTimeOffset referenceTime = _timeprovider.GetUtcNow(); + string cacheKey = $"{_maskinportenCacheKeySalt}_{formattedScopes}"; + DateTimeOffset referenceTime = _timeProvider.GetUtcNow(); - _telemetry?.StartGetAccessTokenActivity(_options.CurrentValue.ClientId, formattedScopes); + _logger.LogDebug("Retrieving Maskinporten token for scopes: {Scopes}", formattedScopes); + _telemetry?.StartGetAccessTokenActivity(Variant, Settings.ClientId, formattedScopes); var result = await _tokenCache.GetOrCreateAsync( cacheKey, - async cancel => + (Self: this, FormattedScopes: formattedScopes, ReferenceTime: referenceTime), + static async (state, cancellationToken) => { - // Fetch token - var token = await HandleMaskinportenAuthentication(formattedScopes, cancel); - var now = _timeprovider.GetUtcNow(); - var cacheExpiry = referenceTime.AddSeconds(token.ExpiresIn - TokenExpirationMargin); - if (cacheExpiry <= now) + state.Self._logger.LogDebug("Token is not in cache, generating new"); + + JwtToken token = await state.Self.HandleMaskinportenAuthentication( + state.FormattedScopes, + cancellationToken + ); + + var expiresIn = state.Self.GetTokenExpiryWithMargin(token); + if (expiresIn <= TimeSpan.Zero) { throw new MaskinportenTokenExpiredException( $"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {token}" ); } - // Wrap and return - return new TokenCacheEntry( - Token: token, - Expiration: cacheExpiry - referenceTime, - HasSetExpiration: false + return new TokenCacheEntry(Token: token, ExpiresIn: expiresIn, HasSetExpiration: false); + }, + cancellationToken: cancellationToken, + options: _defaultCacheExpiration + ); + + if (result.HasSetExpiration is false) + { + _logger.LogDebug("Updating token cache with appropriate expiration"); + result = result with { HasSetExpiration = true }; + await _tokenCache.SetAsync( + cacheKey, + result, + options: CacheExpiryFactory(result.ExpiresIn), + cancellationToken: cancellationToken + ); + } + else + { + _logger.LogDebug("Token retrieved from cache: {Token}", result.Token); + _telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.Cached); + } + + return result.Token; + } + + /// + public async Task GetAltinnExchangedToken( + IEnumerable scopes, + CancellationToken cancellationToken = default + ) + { + string formattedScopes = FormattedScopes(scopes); + string cacheKey = $"{_altinnCacheKeySalt}_{formattedScopes}"; + + _logger.LogDebug("Retrieving Altinn token for scopes: {Scopes}", formattedScopes); + _telemetry?.StartGetAltinnExchangedAccessTokenActivity(Variant, Settings.ClientId, formattedScopes); + + var result = await _tokenCache.GetOrCreateAsync( + cacheKey, + (Self: this, Scopes: scopes), + static async (state, cancellationToken) => + { + state.Self._logger.LogDebug("Token is not in cache, generating new"); + JwtToken maskinportenToken = await state.Self.GetAccessToken(state.Scopes, cancellationToken); + JwtToken altinnToken = await state.Self.HandleMaskinportenAltinnTokenExchange( + maskinportenToken, + cancellationToken ); + + var expiresIn = state.Self.GetTokenExpiryWithMargin(altinnToken); + if (expiresIn <= TimeSpan.Zero) + { + throw new MaskinportenTokenExpiredException( + $"Access token cannot be used because it has a calculated expiration in the past (taking into account a margin of {TokenExpirationMargin} seconds): {altinnToken}" + ); + } + + return new TokenCacheEntry(Token: altinnToken, ExpiresIn: expiresIn, HasSetExpiration: false); }, cancellationToken: cancellationToken, options: _defaultCacheExpiration ); - // Update cache with token expiration if applicable if (result.HasSetExpiration is false) { + _logger.LogDebug("Updating token cache with appropriate expiration"); result = result with { HasSetExpiration = true }; await _tokenCache.SetAsync( cacheKey, result, - options: CacheExpiry(result.Expiration), + options: CacheExpiryFactory(result.ExpiresIn), cancellationToken: cancellationToken ); } + else + { + _logger.LogDebug("Token retrieved from cache: {Token}", result.Token); + _telemetry?.RecordMaskinportenAltinnTokenExchangeRequest(Telemetry.Maskinporten.RequestResult.Cached); + } return result.Token; } @@ -113,32 +204,36 @@ await _tokenCache.SetAsync( /// An optional cancellation token. /// /// - private async Task HandleMaskinportenAuthentication( + private async Task HandleMaskinportenAuthentication( string formattedScopes, CancellationToken cancellationToken = default ) { try { - string jwt = GenerateJwtGrant(formattedScopes); - FormUrlEncodedContent payload = GenerateAuthenticationPayload(jwt); + _logger.LogDebug("Using MaskinportenClient.Variant={Variant} for authorization", Variant); + string jwtGrant = GenerateJwtGrant(formattedScopes); + FormUrlEncodedContent payload = AuthenticationPayloadFactory(jwtGrant); _logger.LogDebug( "Sending grant request to Maskinporten: {GrantRequest}", await payload.ReadAsStringAsync(cancellationToken) ); - string tokenAuthority = _options.CurrentValue.Authority.Trim('/'); + string tokenAuthority = Settings.Authority.Trim('/'); using HttpClient client = _httpClientFactory.CreateClient(); using HttpResponseMessage response = await client.PostAsync( $"{tokenAuthority}/token", payload, cancellationToken ); - MaskinportenTokenResponse token = await ParseServerResponse(response, cancellationToken); - _logger.LogDebug("Token retrieved successfully"); - return token; + MaskinportenTokenResponse tokenResponse = await ParseServerResponse(response, cancellationToken); + + _logger.LogDebug("Token retrieved successfully: {Token}", tokenResponse); + _telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.New); + + return tokenResponse.AccessToken; } catch (MaskinportenException) { @@ -146,10 +241,61 @@ await payload.ReadAsStringAsync(cancellationToken) } catch (Exception e) { + _telemetry?.RecordMaskinportenTokenRequest(Telemetry.Maskinporten.RequestResult.Error); throw new MaskinportenAuthenticationException($"Authentication with Maskinporten failed: {e.Message}", e); } } + /// + /// Handles the exchange of a Maskinporten token for an Altinn token. + /// + /// A Maskinporten issued token object + /// An optional cancellation token. + /// + /// + private async Task HandleMaskinportenAltinnTokenExchange( + JwtToken maskinportenToken, + CancellationToken cancellationToken = default + ) + { + try + { + _logger.LogDebug( + "Sending exchange request to Altinn Authentication with Bearer token: {MaskinportenToken}", + maskinportenToken + ); + + using HttpClient client = _httpClientFactory.CreateClient(); + string url = _platformSettings.ApiAuthenticationEndpoint.TrimEnd('/') + "/exchange/maskinporten"; + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.TryAddWithoutValidation( + General.SubscriptionKeyHeaderName, + _platformSettings.SubscriptionKey + ); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", maskinportenToken.Value); + + using HttpResponseMessage response = await client.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + + string token = await response.Content.ReadAsStringAsync(cancellationToken); + + _logger.LogDebug("Token retrieved successfully"); + _telemetry?.RecordMaskinportenAltinnTokenExchangeRequest(Telemetry.Maskinporten.RequestResult.New); + + return JwtToken.Parse(token); + } + catch (MaskinportenException) + { + throw; + } + catch (Exception e) + { + _telemetry?.RecordMaskinportenAltinnTokenExchangeRequest(Telemetry.Maskinporten.RequestResult.Error); + throw new MaskinportenAuthenticationException($"Authentication with Altinn failed: {e.Message}", e); + } + } + /// /// Generates a JWT grant for the supplied scope claims along with the pre-configured client id and private key. /// @@ -161,7 +307,7 @@ internal string GenerateJwtGrant(string formattedScopes) MaskinportenSettings? settings; try { - settings = _options.CurrentValue; + settings = Settings; } catch (OptionsValidationException e) { @@ -171,7 +317,7 @@ internal string GenerateJwtGrant(string formattedScopes) ); } - var now = _timeprovider.GetUtcNow(); + var now = _timeProvider.GetUtcNow(); var expiry = now.AddMinutes(2); var jwtDescriptor = new SecurityTokenDescriptor { @@ -182,8 +328,8 @@ internal string GenerateJwtGrant(string formattedScopes) SigningCredentials = new SigningCredentials(settings.GetJsonWebKey(), SecurityAlgorithms.RsaSha256), Claims = new Dictionary { - ["scope"] = formattedScopes, - ["jti"] = Guid.NewGuid().ToString(), + [JwtClaimTypes.Scope] = formattedScopes, + [JwtClaimTypes.JwtId] = Guid.NewGuid().ToString(), }, }; @@ -200,7 +346,7 @@ internal string GenerateJwtGrant(string formattedScopes) /// /// /// The JWT token generated by . - internal static FormUrlEncodedContent GenerateAuthenticationPayload(string jwtAssertion) + internal static FormUrlEncodedContent AuthenticationPayloadFactory(string jwtAssertion) { return new FormUrlEncodedContent( new Dictionary @@ -218,7 +364,7 @@ internal static FormUrlEncodedContent GenerateAuthenticationPayload(string jwtAs /// An optional cancellation token. /// A for successful requests. /// Authentication failed. - /// This could be caused by an authentication/authorization issue or a myriad of tother circumstances. + /// This could be caused by an authentication/authorisation issue or a myriad of other circumstances. internal static async Task ParseServerResponse( HttpResponseMessage httpResponse, CancellationToken cancellationToken = default @@ -266,7 +412,7 @@ internal static async Task ParseServerResponse( /// A single string containing the supplied scopes. internal static string FormattedScopes(IEnumerable scopes) => string.Join(" ", scopes); - internal static HybridCacheEntryOptions CacheExpiry(TimeSpan localExpiry, TimeSpan? overallExpiry = null) + private static HybridCacheEntryOptions CacheExpiryFactory(TimeSpan localExpiry, TimeSpan? overallExpiry = null) { return new HybridCacheEntryOptions { @@ -274,4 +420,9 @@ internal static HybridCacheEntryOptions CacheExpiry(TimeSpan localExpiry, TimeSp Expiration = overallExpiry ?? localExpiry, }; } + + private TimeSpan GetTokenExpiryWithMargin(JwtToken token) + { + return token.ExpiresAt - _timeProvider.GetUtcNow() - TokenExpirationMargin; + } } diff --git a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs b/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs index 4a9f63593..cbab31935 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/Models/MaskinportenTokenResponse.cs @@ -1,71 +1,43 @@ -using System.ComponentModel; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; +using Altinn.App.Core.Models; namespace Altinn.App.Core.Features.Maskinporten.Models; /// -/// The response received from Maskinporten after a successful grant request. +/// The response received from Maskinporten after a successful grant request /// -[ImmutableObject(true)] -public sealed partial record MaskinportenTokenResponse +internal sealed record MaskinportenTokenResponse { - private static readonly Regex _jwtStructurePattern = JwtRegexFactory(); - /// - /// The JWT access token to be used in the Authorization header for downstream requests. + /// The JWT access token to be used for authorisation of http requests /// [JsonPropertyName("access_token")] - public required string AccessToken { get; init; } + [JsonConverter(typeof(JwtTokenJsonConverter))] + public required JwtToken AccessToken { get; init; } /// - /// The type of JWT received. In this context, the value is always `Bearer`. + /// The type of JWT received. In this context, the value is always `Bearer` /// [JsonPropertyName("token_type")] public required string TokenType { get; init; } /// - /// The number of seconds until token expiry. Typically set to 120 = 2 minutes. + /// The number of seconds until token expiry. Typically set to 120 = 2 minutes /// [JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; } /// - /// The scope(s) associated with the authorization token (). + /// The scope(s) associated with the authorization token () /// [JsonPropertyName("scope")] public required string Scope { get; init; } - /// - /// Convenience conversion of to an actual instant in time. - /// - public DateTime ExpiresAt => _createdAt.AddSeconds(ExpiresIn); - - /// - /// Internal tracker used by to calculate an expiry . - /// - private readonly DateTime _createdAt = DateTime.UtcNow; - - /// - /// Is the token expired? - /// - public bool IsExpired() - { - return ExpiresAt < DateTime.UtcNow; - } - /// /// Stringifies the content of this instance, while masking the JWT signature part of /// public override string ToString() { - var accessTokenMatch = _jwtStructurePattern.Match(AccessToken); - var maskedToken = accessTokenMatch.Success - ? $"{accessTokenMatch.Groups[1]}.{accessTokenMatch.Groups[2]}.xxx" - : ""; - return $"{nameof(AccessToken)}: {maskedToken}, {nameof(TokenType)}: {TokenType}, {nameof(Scope)}: {Scope}, {nameof(ExpiresIn)}: {ExpiresIn}, {nameof(ExpiresAt)}: {ExpiresAt}"; + return $"{nameof(AccessToken)}: {AccessToken}, {nameof(TokenType)}: {TokenType}, {nameof(Scope)}: {Scope}, {nameof(ExpiresIn)}: {ExpiresIn}"; } - - [GeneratedRegex(@"^(.+)\.(.+)\.(.+)$", RegexOptions.Multiline)] - private static partial Regex JwtRegexFactory(); } diff --git a/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs b/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs index f71ead563..2b747ae12 100644 --- a/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs +++ b/src/Altinn.App.Core/Features/Maskinporten/Models/TokenCacheEntry.cs @@ -1,6 +1,8 @@ using System.ComponentModel; +using Altinn.App.Core.Models; namespace Altinn.App.Core.Features.Maskinporten.Models; +// `ImmutableObject` prevents serialization with HybridCache [ImmutableObject(true)] -internal sealed record TokenCacheEntry(MaskinportenTokenResponse Token, TimeSpan Expiration, bool HasSetExpiration); +internal sealed record TokenCacheEntry(JwtToken Token, TimeSpan ExpiresIn, bool HasSetExpiration); diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Correspondence.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Correspondence.cs new file mode 100644 index 000000000..4f8cde608 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Correspondence.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using NetEscapades.EnumGenerators; +using static Altinn.App.Core.Features.Telemetry.Correspondence; +using Tag = System.Collections.Generic.KeyValuePair; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + private void InitCorrespondence(InitContext context) + { + InitMetricCounter( + context, + MetricNameOrder, + init: static m => + { + foreach (var result in CorrespondenceResultExtensions.GetValues()) + { + m.Add(0, new Tag(InternalLabels.Result, result.ToStringFast())); + } + } + ); + } + + internal Activity? StartSendCorrespondenceActivity() + { + return ActivitySource.StartActivity("Correspondence.Send"); + } + + internal Activity? StartCorrespondenceStatusActivity(Guid correspondenceId) + { + var activity = ActivitySource.StartActivity("Correspondence.Status"); + activity?.AddTag(Labels.CorrespondenceId, correspondenceId); + return activity; + } + + internal void RecordCorrespondenceOrder(CorrespondenceResult result) => + _counters[MetricNameOrder].Add(1, new Tag(InternalLabels.Result, result.ToStringFast())); + + internal static class Correspondence + { + internal static readonly string MetricNameOrder = Metrics.CreateLibName("correspondence_orders"); + + [EnumExtensions] + internal enum CorrespondenceResult + { + [Display(Name = "success")] + Success, + + [Display(Name = "error")] + Error, + } + } +} diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Maskinporten.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Maskinporten.cs index 1c771e9dc..db7d91042 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.Maskinporten.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.Maskinporten.cs @@ -21,11 +21,32 @@ private void InitMaskinporten(InitContext context) } } ); + InitMetricCounter( + context, + MetricNameTokenExchangeRequest, + init: static m => + { + foreach (var result in RequestResultExtensions.GetValues()) + { + m.Add(0, new Tag(InternalLabels.Result, result.ToStringFast())); + } + } + ); } - internal Activity? StartGetAccessTokenActivity(string clientId, string scopes) + internal Activity? StartGetAccessTokenActivity(string variant, string clientId, string scopes) { var activity = ActivitySource.StartActivity("Maskinporten.GetAccessToken"); + activity?.SetTag("maskinporten.variant", variant); + activity?.SetTag("maskinporten.scopes", scopes); + activity?.SetTag("maskinporten.client_id", clientId); + return activity; + } + + internal Activity? StartGetAltinnExchangedAccessTokenActivity(string variant, string clientId, string scopes) + { + var activity = ActivitySource.StartActivity("Maskinporten.GetAltinnExchangedAccessToken"); + activity?.SetTag("maskinporten.variant", variant); activity?.SetTag("maskinporten.scopes", scopes); activity?.SetTag("maskinporten.client_id", clientId); return activity; @@ -36,9 +57,17 @@ internal void RecordMaskinportenTokenRequest(RequestResult result) _counters[MetricNameTokenRequest].Add(1, new Tag(InternalLabels.Result, result.ToStringFast())); } + internal void RecordMaskinportenAltinnTokenExchangeRequest(RequestResult result) + { + _counters[MetricNameTokenExchangeRequest].Add(1, new Tag(InternalLabels.Result, result.ToStringFast())); + } + internal static class Maskinporten { internal static readonly string MetricNameTokenRequest = Metrics.CreateLibName("maskinporten_token_requests"); + internal static readonly string MetricNameTokenExchangeRequest = Metrics.CreateLibName( + "maskinporten_altinn_exchange_requests" + ); [EnumExtensions] internal enum RequestResult diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs index fc1a14933..6732e0894 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.cs @@ -76,6 +76,7 @@ internal void Init() InitProcesses(context); InitValidation(context); InitMaskinporten(context); + InitCorrespondence(context); // NOTE: This Telemetry class is registered as a singleton // Metrics could be kept in fields of the respective objects that use them for instrumentation @@ -169,6 +170,11 @@ public static class Labels /// Label for the organisation number. /// public const string OrganisationNumber = "organisation.number"; + + /// + /// Label for the Correspondence ID. + /// + public const string CorrespondenceId = "correspondence.id"; } internal static class InternalLabels diff --git a/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs b/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs index b2c8dab43..2e9c37a49 100644 --- a/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs +++ b/src/Altinn.App.Core/Features/Telemetry/TelemetryActivityExtensions.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Altinn.App.Core.Features.Correspondence.Models; using Altinn.App.Core.Models.Process; using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Mvc; @@ -265,6 +266,27 @@ public static Activity SetOrganisationNumber(this Activity activity, string? org return activity; } + internal static Activity SetCorrespondence(this Activity activity, SendCorrespondenceResponse? response) + { + if (response is not null) + { + if (response.Correspondences is { Count: 1 }) + { + activity.SetTag(Labels.CorrespondenceId, response.Correspondences[0].CorrespondenceId); + } + + var tags = new ActivityTagsCollection(); + tags.Add("ids", response.Correspondences?.Select(c => c.CorrespondenceId.ToString()) ?? []); + tags.Add("statuses", response.Correspondences?.Select(c => c.Status.ToString()) ?? []); + tags.Add("count", response.Correspondences?.Count ?? 0); + tags.Add("attachments", response.AttachmentIds?.Count ?? 0); + tags.Add("operation", "send"); + activity.AddEvent(new ActivityEvent("correspondence", tags: tags)); + } + + return activity; + } + internal static Activity SetProblemDetails(this Activity activity, ProblemDetails problemDetails) { // Leave activity status to ASP.NET Core, as it will be set depending on status code? @@ -344,7 +366,7 @@ internal static Activity SetProblemDetails(this Activity activity, ProblemDetail internal static void Errored(this Activity activity, Exception? exception = null, string? error = null) { activity.SetStatus(ActivityStatusCode.Error, error); - if(exception is not null) + if (exception is not null) { activity.AddException(exception); } diff --git a/src/Altinn.App.Core/Models/JwtToken.cs b/src/Altinn.App.Core/Models/JwtToken.cs new file mode 100644 index 000000000..f60a092dd --- /dev/null +++ b/src/Altinn.App.Core/Models/JwtToken.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.IdentityModel.Tokens.Jwt; +using System.Text.RegularExpressions; + +namespace Altinn.App.Core.Models; + +/// +/// Represents an OAuth 2.0 access token in JWT format +/// Needs to be unencrypted +/// +[ImmutableObject(true)] // `ImmutableObject` prevents serialization with HybridCache +public readonly partial struct JwtToken : IEquatable +{ + /// + /// The access token value (JWT format) + /// + public string Value { get; } + + private readonly JwtSecurityToken _jwtSecurityToken; + + /// + /// The instant in time when the token expires + /// + public DateTimeOffset ExpiresAt => _jwtSecurityToken.ValidTo; + + /// + /// Is the token expired? + /// + public bool IsExpired(TimeProvider? timeProvider = null) => + ExpiresAt < (timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow); + + /// + /// The scope(s) associated with the token + /// + public string? Scope => _jwtSecurityToken.Payload.TryGetValue("scope", out var scope) ? scope.ToString() : null; + + private JwtToken(string jwtToken, JwtSecurityToken jwtSecurityToken) + { + Value = jwtToken; + _jwtSecurityToken = jwtSecurityToken; + } + + /// + /// Parses an access token + /// + /// The value to parse + /// The access token is not valid + public static JwtToken Parse(string value) + { + return TryParse(value, out var accessToken) + ? accessToken + : throw new FormatException($"Invalid access token format: {value}"); + } + + /// + /// Attempt to parse an access token + /// + /// The value to parse + /// The resulting instance + /// `true` on successful parse, `false` otherwise + public static bool TryParse(string value, out JwtToken jwtToken) + { + jwtToken = default; + + JwtSecurityTokenHandler handler = new(); + try + { + JwtSecurityToken jwt = handler.ReadJwtToken(value); + jwtToken = new JwtToken(value, jwt); + } + catch + { + return false; + } + + return true; + } + + // Matches a string with pattern eyXXX.eyXXX.XXX, allowing underscores and hyphens + [GeneratedRegex(@"^((?:ey[\w-]+\.){2})([\w-]+)$")] + private static partial Regex JwtRegex(); + + private static string MaskJwtSignature(string accessToken) + { + var accessTokenMatch = JwtRegex().Match(accessToken); + return accessTokenMatch.Success ? $"{accessTokenMatch.Groups[1]}" : ""; + } + + /// + /// Determines whether the specified object is equal to the current object + /// + public bool Equals(JwtToken other) => Value == other.Value; + + /// + /// Determines whether the specified object is equal to the current object + /// + public override bool Equals(object? obj) => obj is JwtToken other && Equals(other); + + /// + /// Returns the hash code for the access token value + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Returns a string representation of the access token with a masked signature component + /// + public override string ToString() => MaskJwtSignature(Value); + + /// + /// Returns a string representation of the access token with an intact signature component + /// + public string ToStringUnmasked() => Value; + + /// + /// Determines whether two specified instances of are equal + /// + public static bool operator ==(JwtToken left, JwtToken right) => left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal + /// + public static bool operator !=(JwtToken left, JwtToken right) => !left.Equals(right); + + /// + /// Implicit conversion from to string + /// + /// The access token instance + public static implicit operator string(JwtToken accessToken) + { + return accessToken.Value; + } +} diff --git a/src/Altinn.App.Core/Models/JwtTokenJsonConverter.cs b/src/Altinn.App.Core/Models/JwtTokenJsonConverter.cs new file mode 100644 index 000000000..01af8b792 --- /dev/null +++ b/src/Altinn.App.Core/Models/JwtTokenJsonConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models; + +/// +/// Json converter to transform between and +/// +internal class JwtTokenJsonConverter : JsonConverter +{ + /// + public override JwtToken Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string token for AccessToken property."); + } + + var tokenValue = reader.GetString() ?? throw new JsonException("AccessToken string value is null."); + return JwtToken.Parse(tokenValue); + } + + /// + public override void Write(Utf8JsonWriter writer, JwtToken value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/src/Altinn.App.Core/Models/LanguageCode.cs b/src/Altinn.App.Core/Models/LanguageCode.cs new file mode 100644 index 000000000..1038b6b3c --- /dev/null +++ b/src/Altinn.App.Core/Models/LanguageCode.cs @@ -0,0 +1,137 @@ +using System.Text.RegularExpressions; + +namespace Altinn.App.Core.Models; + +#pragma warning disable CA1000 + +/// +/// Specification details for language code ISO 639-1 +/// +public readonly partial struct Iso6391 : ILanguageCodeStandard +{ + /// + public static LanguageCodeValidationResult Validate(string code) + { + string? errorMessage = null; + if (string.IsNullOrWhiteSpace(code)) + errorMessage = "Code value cannot be empty."; + else if (code.Length != 2) + errorMessage = $"Invalid code length. Received {code.Length} characters, expected 2 (ISO 639-1)."; + else if (ValidationRegex().IsMatch(code) is false) + errorMessage = "Code value must only contain letters."; + + return new LanguageCodeValidationResult(errorMessage is null, errorMessage); + } + + [GeneratedRegex(@"^[a-zA-Z]{2}$")] + private static partial Regex ValidationRegex(); +} + +/// +/// Specifications for language code standards +/// +public interface ILanguageCodeStandard +{ + /// + /// Validation instructions for the language code implementation + /// + /// The code to validate, e.g. "no" in the case of ISO 639-1 + static abstract LanguageCodeValidationResult Validate(string code); +}; + +/// +/// The result of a language code validation +/// +/// Is the code valid? +/// If not valid, what is the reason given? +public sealed record LanguageCodeValidationResult(bool IsValid, string? ErrorMessage); + +/// +/// Represents a language code +/// +public readonly struct LanguageCode : IEquatable> + where TLangCodeStandard : struct, ILanguageCodeStandard +{ + /// + /// The language code value + /// + public string Value { get; } + + private LanguageCode(string code) + { + Value = code.ToLowerInvariant(); + } + + /// + /// Parses a language code + /// + /// The language code + /// The language code format is invalid + public static LanguageCode Parse(string code) + { + LanguageCodeValidationResult validationResult = TryParse(code, out var instance); + + return validationResult.IsValid + ? instance + : throw new FormatException($"Invalid language code format: {validationResult.ErrorMessage}"); + } + + /// + /// Attempts to parse a language code + /// + /// The code to parse + /// The resulting + public static LanguageCodeValidationResult TryParse(string code, out LanguageCode result) + { + var validationResult = TLangCodeStandard.Validate(code); + if (!validationResult.IsValid) + { + result = default; + return validationResult; + } + + result = new LanguageCode(code); + return new LanguageCodeValidationResult(true, null); + } + + /// + /// Determines whether the specified object is equal to the current object + /// + public bool Equals(LanguageCode other) => Value == other.Value; + + /// + /// Determines whether the specified object is equal to the current object + /// + public override bool Equals(object? obj) => obj is LanguageCode other && Equals(other); + + /// + /// Returns the hash code for the language code value + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Returns a string representation of the language code + /// + public override string ToString() => Value; + + /// + /// Determines whether two specified instances of are equal + /// + public static bool operator ==(LanguageCode left, LanguageCode right) => + left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal + /// + public static bool operator !=(LanguageCode left, LanguageCode right) => + !left.Equals(right); + + /// + /// Implicit conversion from to string + /// + /// The language code instance + public static implicit operator string(LanguageCode languageCode) + { + return languageCode.Value; + } +} diff --git a/src/Altinn.App.Core/Models/LanguageCodeJsonConverter.cs b/src/Altinn.App.Core/Models/LanguageCodeJsonConverter.cs new file mode 100644 index 000000000..d64053afd --- /dev/null +++ b/src/Altinn.App.Core/Models/LanguageCodeJsonConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models; + +/// +/// Json converter to transform between and +/// +internal class LanguageCodeJsonConverter : JsonConverter> + where T : struct, ILanguageCodeStandard +{ + /// + public override LanguageCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string token for LanguageCode property."); + } + + var tokenValue = reader.GetString() ?? throw new JsonException("LanguageCode string value is null."); + return LanguageCode.Parse(tokenValue); + } + + /// + public override void Write(Utf8JsonWriter writer, LanguageCode value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/src/Altinn.App.Core/Models/NationalIdentityNumber.cs b/src/Altinn.App.Core/Models/NationalIdentityNumber.cs new file mode 100644 index 000000000..79e2775c3 --- /dev/null +++ b/src/Altinn.App.Core/Models/NationalIdentityNumber.cs @@ -0,0 +1,140 @@ +using System.Globalization; + +namespace Altinn.App.Core.Models; + +/// +/// Represents a Norwegian national identity number +/// +/// The validation in this type is hard coded to the Norwegian national identity number format +/// +/// +public readonly struct NationalIdentityNumber : IEquatable +{ + /// + /// The national identity number value + /// + public string Value { get; } + + private NationalIdentityNumber(string nationalIdentityNumber) + { + Value = nationalIdentityNumber; + } + + /// + /// Parses a national identity number + /// + /// The value to parse + /// The number is not valid + public static NationalIdentityNumber Parse(string value) + { + return TryParse(value, out var nationalIdentityNumber) + ? nationalIdentityNumber + : throw new FormatException($"Invalid national identity number format: {value}"); + } + + /// + /// Attempt to parse a national identity number + /// + /// The value to parse + /// The resulting instance + /// `true` on successful parse, `false` otherwise + public static bool TryParse(string value, out NationalIdentityNumber nationalIdentityNumber) + { + nationalIdentityNumber = default; + + if (value.Length != 11) + return false; + + ReadOnlySpan weightsDigit10 = [3, 7, 6, 1, 8, 9, 4, 5, 2]; + ReadOnlySpan weightsDigit11 = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]; + + int sum10 = 0; + int sum11 = 0; + + for (int i = 0; i < 11; i++) + { + if (!int.TryParse(value.AsSpan(i, 1), CultureInfo.InvariantCulture, out int currentDigit)) + return false; + + if (i < 9) + { + sum10 += currentDigit * weightsDigit10[i]; + sum11 += currentDigit * weightsDigit11[i]; + continue; + } + + if (i == 9) + { + var ctrl10 = Mod11(sum10) switch + { + 11 => 0, + var result => result, + }; + + if (ctrl10 != currentDigit) + return false; + + sum11 += ctrl10 * weightsDigit11[i]; + } + + if (i == 10) + { + var ctrl11 = Mod11(sum11) switch + { + 11 => 0, + var result => result, + }; + + if (ctrl11 != currentDigit) + return false; + } + } + + nationalIdentityNumber = new NationalIdentityNumber(value); + return true; + } + + private static int Mod11(int value) + { + return 11 - (value % 11); + } + + /// + /// Determines whether the specified object is equal to the current object + /// + public bool Equals(NationalIdentityNumber other) => Value == other.Value; + + /// + /// Determines whether the specified object is equal to the current object + /// + public override bool Equals(object? obj) => obj is NationalIdentityNumber other && Equals(other); + + /// + /// Returns the hash code for the national identity number value + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + /// Returns a string representation of the national identity number + /// + public override string ToString() => Value; + + /// + /// Determines whether two specified instances of are equal + /// + public static bool operator ==(NationalIdentityNumber left, NationalIdentityNumber right) => left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal + /// + public static bool operator !=(NationalIdentityNumber left, NationalIdentityNumber right) => !left.Equals(right); + + /// + /// Implicit conversion from to string + /// + /// The national identity number instance + public static implicit operator string(NationalIdentityNumber nationalIdentityNumber) + { + return nationalIdentityNumber.Value; + } +} diff --git a/src/Altinn.App.Core/Models/NationalIdentityNumberJsonConverter.cs b/src/Altinn.App.Core/Models/NationalIdentityNumberJsonConverter.cs new file mode 100644 index 000000000..f3d1261c7 --- /dev/null +++ b/src/Altinn.App.Core/Models/NationalIdentityNumberJsonConverter.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models; + +/// +/// Json converter to transform between and +/// +internal class NationalIdentityNumberJsonConverter : JsonConverter +{ + /// + public override NationalIdentityNumber Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string token for NationalIdentityNumber property."); + } + + var tokenValue = reader.GetString() ?? throw new JsonException("NationalIdentityNumber string value is null."); + return NationalIdentityNumber.Parse(tokenValue); + } + + /// + public override void Write(Utf8JsonWriter writer, NationalIdentityNumber value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/src/Altinn.App.Core/Models/OrganisationNumber.cs b/src/Altinn.App.Core/Models/OrganisationNumber.cs new file mode 100644 index 000000000..1d040c1b2 --- /dev/null +++ b/src/Altinn.App.Core/Models/OrganisationNumber.cs @@ -0,0 +1,147 @@ +using System.Globalization; + +namespace Altinn.App.Core.Models; + +/// +/// Represents the format of an organisation number +/// +public enum OrganisationNumberFormat +{ + /// + /// Represents only the locally recognised organisation number, e.g. "991825827". + /// + Local, + + /// + /// Represents only the locally recognised organisation number, e.g. "0192:991825827". + /// + International, +} + +/// +/// Represents a Norwegian organisation number +/// +/// The validation in this type is hard coded to the Norwegian organisation number format +/// +/// +public readonly struct OrganisationNumber : IEquatable +{ + private readonly string _local; + private readonly string _international; + + /// + /// Gets the organisation number as a string in the specified format + /// + /// The format to get + /// Invalid format provided + public string Get(OrganisationNumberFormat format) => + format switch + { + OrganisationNumberFormat.Local => _local, + OrganisationNumberFormat.International => _international, + _ => throw new ArgumentOutOfRangeException(nameof(format)), + }; + + private OrganisationNumber(string local, string international) + { + _local = local; + _international = international; + } + + /// + /// Parses an organisation number + /// + /// The value to parse + /// The organisation number is not valid + public static OrganisationNumber Parse(string value) + { + return TryParse(value, out var organisationNumber) + ? organisationNumber + : throw new FormatException($"Invalid organisation number format: {value}"); + } + + /// + /// Attempt to parse an organisation number + /// + /// The value to parse + /// The resulting instance + /// `true` on successful parse, `false` otherwise + public static bool TryParse(string value, out OrganisationNumber organisationNumber) + { + organisationNumber = default; + + // Either local="991825827" or international="0192:991825827" + if (value.Length != 9 && value.Length != 14) + return false; + + string local; + string international; + if (value.Length == 9) + { + local = value; + international = "0192:" + value; + } + else + { + if (!value.StartsWith("0192:", StringComparison.Ordinal)) + return false; + local = value.Substring(5); + international = value; + } + + ReadOnlySpan weights = [3, 2, 7, 6, 5, 4, 3, 2]; + + int sum = 0; + for (int i = 0; i < local.Length - 1; i++) + { + if (!int.TryParse(local.AsSpan(i, 1), CultureInfo.InvariantCulture, out int currentDigit)) + return false; + sum += currentDigit * weights[i]; + } + + int ctrlDigit = 11 - (sum % 11); + if (ctrlDigit == 11) + { + ctrlDigit = 0; + } + + if (!int.TryParse(local.AsSpan(local.Length - 1, 1), CultureInfo.InvariantCulture, out var lastDigit)) + return false; + + if (lastDigit != ctrlDigit) + return false; + + organisationNumber = new OrganisationNumber(local, international); + return true; + } + + /// + /// Determines whether the specified object is equal to the current object + /// + public bool Equals(OrganisationNumber other) => _local == other._local; + + /// + /// Determines whether the specified object is equal to the current object + /// + public override bool Equals(object? obj) => obj is OrganisationNumber other && Equals(other); + + /// + /// Returns the hash code for the value + /// + public override int GetHashCode() => _local.GetHashCode(); + + /// + /// Returns a string representation of the organisation number + /// + public override string ToString() => _local; + + /// + /// Determines whether two specified instances of are equal + /// + public static bool operator ==(OrganisationNumber left, OrganisationNumber right) => left.Equals(right); + + /// + /// Determines whether two specified instances of are not equal + /// + public static bool operator !=(OrganisationNumber left, OrganisationNumber right) => !left.Equals(right); +} diff --git a/src/Altinn.App.Core/Models/OrganisationNumberJsonConverter.cs b/src/Altinn.App.Core/Models/OrganisationNumberJsonConverter.cs new file mode 100644 index 000000000..c748d332f --- /dev/null +++ b/src/Altinn.App.Core/Models/OrganisationNumberJsonConverter.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Altinn.App.Core.Models; + +/// +/// Json converter to transform between and +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Class, AllowMultiple = false)] +internal class OrganisationNumberJsonConverterAttribute : JsonConverterAttribute +{ + private OrganisationNumberFormat _format { get; init; } + + /// + /// The desired organisation number format to use for serialization + public OrganisationNumberJsonConverterAttribute(OrganisationNumberFormat format) + { + _format = format; + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert) + { + return new OrganisationNumberJsonConverter(_format); + } +} + +internal class OrganisationNumberJsonConverter : JsonConverter +{ + private OrganisationNumberFormat _format { get; init; } + + public OrganisationNumberJsonConverter(OrganisationNumberFormat format) + { + _format = format; + } + + public override OrganisationNumber Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("Expected string token for OrganisationNumber property."); + } + + var numberValue = reader.GetString() ?? throw new JsonException("OrganisationNumber string value is null."); + return OrganisationNumber.Parse(numberValue); + } + + public override void Write(Utf8JsonWriter writer, OrganisationNumber value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Get(_format)); + } +} diff --git a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj index e6f01904a..61beaf98a 100644 --- a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj +++ b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj @@ -30,6 +30,10 @@ + + + + PreserveNewest @@ -47,4 +51,4 @@ - \ No newline at end of file + diff --git a/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs b/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs index b5d040294..6e6e8565a 100644 --- a/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs +++ b/test/Altinn.App.Api.Tests/Maskinporten/MaskinportenClientIntegrationTest.cs @@ -1,14 +1,13 @@ using Altinn.App.Api.Extensions; using Altinn.App.Api.Tests.Extensions; using Altinn.App.Api.Tests.TestUtils; -using Altinn.App.Core.Extensions; using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Features.Maskinporten.Constants; using Altinn.App.Core.Features.Maskinporten.Delegates; using Altinn.App.Core.Features.Maskinporten.Models; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.IdentityModel.Tokens; namespace Altinn.App.Api.Tests.Maskinporten; @@ -121,17 +120,30 @@ public void ConfigureMaskinportenClient_BindsToSpecifiedConfigPath() } [Theory] - [InlineData("client1", "scope1")] - [InlineData("client2", "scope1", "scope2", "scope3")] - public void UseMaskinportenAuthorization_AddsHandler_BindsToSpecifiedClient( + [InlineData(nameof(TokenAuthorities.Maskinporten), "client1", "scope1")] + [InlineData(nameof(TokenAuthorities.Maskinporten), "client2", "scope1", "scope2", "scope3")] + [InlineData(nameof(TokenAuthorities.AltinnTokenExchange), "doesntmatter")] + public void UseMaskinportenAuthorisation_AddsHandler_BindsToSpecifiedClient( + string tokenAuthority, string scope, params string[] additionalScopes ) { // Arrange + Enum.TryParse(tokenAuthority, false, out TokenAuthorities actualTokenAuthority); var app = AppBuilder.Build(registerCustomAppServices: services => - services.AddHttpClient().UseMaskinportenAuthorization(scope, additionalScopes) - ); + { + _ = actualTokenAuthority switch + { + TokenAuthorities.Maskinporten => services + .AddHttpClient() + .UseMaskinportenAuthorisation(scope, additionalScopes), + TokenAuthorities.AltinnTokenExchange => services + .AddHttpClient() + .UseMaskinportenAltinnAuthorisation(scope, additionalScopes), + _ => throw new ArgumentException($"Unknown TokenAuthority {tokenAuthority}"), + }; + }); // Act var client = app.Services.GetRequiredService(); @@ -142,6 +154,7 @@ params string[] additionalScopes Assert.NotNull(delegatingHandler); var inputScopes = new[] { scope }.Concat(additionalScopes); delegatingHandler.Scopes.Should().BeEquivalentTo(inputScopes); + delegatingHandler.Authorities.Should().Be(actualTokenAuthority); } private sealed class DummyHttpClient(HttpClient client) diff --git a/test/Altinn.App.Api.Tests/Mocks/JwtTokenMock.cs b/test/Altinn.App.Api.Tests/Mocks/JwtTokenMock.cs index 35ebd0444..ea9760fff 100644 --- a/test/Altinn.App.Api.Tests/Mocks/JwtTokenMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/JwtTokenMock.cs @@ -14,15 +14,22 @@ public static class JwtTokenMock /// Generates a token with a self signed certificate included in the integration test project. /// /// The claims principal to include in the token. - /// How long the token should be valid for. + /// How long the token should be valid for. + /// Alternative timeprovider, if applicable /// A new token. - public static string GenerateToken(ClaimsPrincipal principal, TimeSpan tokenExipry) + public static string GenerateToken( + ClaimsPrincipal principal, + TimeSpan tokenExpiry, + TimeProvider? timeProvider = null + ) { JwtSecurityTokenHandler tokenHandler = new JwtSecurityTokenHandler(); + var now = timeProvider?.GetUtcNow() ?? DateTimeOffset.UtcNow; SecurityTokenDescriptor tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(principal.Identity), - Expires = DateTime.UtcNow.AddSeconds(tokenExipry.TotalSeconds), + Expires = now.Add(tokenExpiry).UtcDateTime, + NotBefore = now.UtcDateTime, SigningCredentials = GetSigningCredentials(), Audience = "altinn.no", }; diff --git a/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs b/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs index 6fc035323..da954f912 100644 --- a/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs +++ b/test/Altinn.App.Api.Tests/TestUtils/AppBuilder.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; namespace Altinn.App.Api.Tests.TestUtils; diff --git a/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs b/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs index 7ead14239..8c5858c72 100644 --- a/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs +++ b/test/Altinn.App.Api.Tests/Utils/PrincipalUtil.cs @@ -1,5 +1,9 @@ using System.Security.Claims; using Altinn.App.Api.Tests.Mocks; +using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Features.Maskinporten.Constants; +using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; using AltinnCore.Authentication.Constants; namespace Altinn.App.Api.Tests.Utils; @@ -104,7 +108,13 @@ public static string GetSelfIdentifiedUserToken(string username, string partyId, return token; } - public static string GetOrgToken(string org, string orgNo, int authenticationLevel = 4) + public static string GetOrgToken( + string org, + string orgNo, + int authenticationLevel = 4, + TimeSpan? expiry = null, + TimeProvider? timeProvider = null + ) { List claims = new List(); string issuer = "www.altinn.no"; @@ -123,8 +133,35 @@ public static string GetOrgToken(string org, string orgNo, int authenticationLev ClaimsIdentity identity = new ClaimsIdentity("mock"); identity.AddClaims(claims); ClaimsPrincipal principal = new ClaimsPrincipal(identity); - string token = JwtTokenMock.GenerateToken(principal, new TimeSpan(1, 1, 1)); + expiry ??= new TimeSpan(1, 1, 1); + string token = JwtTokenMock.GenerateToken(principal, expiry.Value, timeProvider); return token; } + + internal static MaskinportenTokenResponse GetMaskinportenToken( + string scope, + TimeSpan? expiry = null, + TimeProvider? timeProvider = null + ) + { + List claims = []; + const string issuer = "https://test.maskinporten.no/"; + claims.Add(new Claim(JwtClaimTypes.Scope, scope, ClaimValueTypes.String, issuer)); + claims.Add(new Claim(JwtClaimTypes.Maskinporten.AuthenticationMethod, "Mock", ClaimValueTypes.String, issuer)); + + ClaimsIdentity identity = new("mock"); + identity.AddClaims(claims); + ClaimsPrincipal principal = new(identity); + expiry ??= TimeSpan.FromMinutes(2); + string accessToken = JwtTokenMock.GenerateToken(principal, expiry.Value, timeProvider); + + return new MaskinportenTokenResponse + { + AccessToken = JwtToken.Parse(accessToken), + ExpiresIn = (int)expiry.Value.TotalSeconds, + Scope = scope, + TokenType = "Bearer", + }; + } } diff --git a/test/Altinn.App.Core.Tests/Features/Correspondence/Builder/CorrespondenceBuilderTests.cs b/test/Altinn.App.Core.Tests/Features/Correspondence/Builder/CorrespondenceBuilderTests.cs new file mode 100644 index 000000000..2df94bd52 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Correspondence/Builder/CorrespondenceBuilderTests.cs @@ -0,0 +1,532 @@ +using System.Text; +using Altinn.App.Core.Features.Correspondence.Builder; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Features.Correspondence.Builder; + +public class CorrespondenceBuilderTests +{ + [Fact] + public void Build_WithOnlyRequiredProperties_ShouldReturnValidCorrespondence() + { + // Arrange + OrganisationNumber sender = TestHelpers.GetOrganisationNumber(1); + IReadOnlyList recipients = + [ + OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(1)), + OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(2)), + ]; + string resourceId = "resource-id"; + string sendersReference = "sender-reference"; + DateTimeOffset allowSystemDeleteAfter = DateTimeOffset.UtcNow.AddDays(60); + string contentTitle = "content-title"; + LanguageCode contentLanguage = LanguageCode.Parse("no"); + string contentSummary = "content-summary"; + string contentBody = "content-body"; + + var builder = CorrespondenceRequestBuilder + .Create() + .WithResourceId(resourceId) + .WithSender(sender) + .WithSendersReference(sendersReference) + .WithRecipients(recipients) + .WithAllowSystemDeleteAfter(allowSystemDeleteAfter) + .WithContent(contentLanguage, contentTitle, contentSummary, contentBody); + + // Act + var correspondence = builder.Build(); + + // Assert + correspondence.Should().NotBeNull(); + correspondence.ResourceId.Should().Be("resource-id"); + correspondence.Sender.Should().Be(sender); + correspondence.SendersReference.Should().Be("sender-reference"); + correspondence.AllowSystemDeleteAfter.Should().BeExactly(allowSystemDeleteAfter); + correspondence.Recipients.Should().BeEquivalentTo(recipients); + correspondence.Content.Title.Should().Be(contentTitle); + correspondence.Content.Language.Should().Be(contentLanguage); + correspondence.Content.Summary.Should().Be(contentSummary); + correspondence.Content.Body.Should().Be(contentBody); + } + + [Fact] + public void Build_WithAllProperties_ShouldReturnValidCorrespondence() + { + // Arrange + var sender = TestHelpers.GetOrganisationNumber(1); + var recipient = OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(2)); + var data = new + { + sender, + recipient, + messageSender = "message-sender", + resourceId = "resource-id", + sendersReference = "senders-ref", + dueDateTime = DateTimeOffset.Now.AddDays(30), + allowDeleteAfter = DateTimeOffset.Now.AddDays(60), + ignoreReservation = true, + requestedPublishTime = DateTimeOffset.Now.AddSeconds(45), + propertyList = new Dictionary { ["prop1"] = "value1", ["prop2"] = "value2" }, + content = new + { + title = "content-title", + language = LanguageCode.Parse("en"), + summary = "content-summary", + body = "content-body", + }, + notification = new + { + template = CorrespondenceNotificationTemplate.GenericAltinnMessage, + emailSubject = "email-subject-1", + emailBody = "email-body-1", + smsBody = "sms-body-1", + reminderEmailSubject = "reminder-email-subject-1", + reminderEmailBody = "reminder-email-body-1", + reminderSmsBody = "reminder-sms-body-1", + requestedSendTime = DateTimeOffset.Now.AddDays(1), + sendersReference = "notification-senders-ref-1", + sendReminder = true, + notificationChannel = CorrespondenceNotificationChannel.EmailPreferred, + reminderNotificationChannel = CorrespondenceNotificationChannel.SmsPreferred, + }, + attachments = new[] + { + new + { + sender, + filename = "file-1.txt", + name = "File 1", + sendersReference = "1234-1", + dataType = "text/plain", + data = "attachment-data-1", + dataLocationType = CorrespondenceDataLocationType.ExistingCorrespondenceAttachment, + isEncrypted = false, + restrictionName = "restriction-name-1", + }, + new + { + sender, + filename = "file-2.txt", + name = "File 2", + sendersReference = "1234-2", + dataType = "text/plain", + data = "attachment-data-2", + dataLocationType = CorrespondenceDataLocationType.NewCorrespondenceAttachment, + isEncrypted = true, + restrictionName = "restriction-name-2", + }, + new + { + sender, + filename = "file-3.txt", + name = "File 3", + sendersReference = "1234-3", + dataType = "text/plain", + data = "attachment-data-3", + dataLocationType = CorrespondenceDataLocationType.ExisitingExternalStorage, + isEncrypted = false, + restrictionName = "restriction-name-3", + }, + }, + existingAttachments = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }, + externalReferences = new[] + { + new { type = CorrespondenceReferenceType.Generic, value = "ref-1" }, + new { type = CorrespondenceReferenceType.AltinnAppInstance, value = "ref-2" }, + new { type = CorrespondenceReferenceType.DialogportenDialogId, value = "ref-3" }, + new { type = CorrespondenceReferenceType.DialogportenProcessId, value = "ref-4" }, + new { type = CorrespondenceReferenceType.AltinnBrokerFileTransfer, value = "ref-5" }, + }, + replyOptions = new[] + { + new { url = "reply-url-1", text = "reply-text-1" }, + new { url = "reply-url-2", text = "reply-text-2" }, + }, + }; + + var builder = CorrespondenceRequestBuilder + .Create() + .WithResourceId(data.resourceId) + .WithSender(data.sender) + .WithSendersReference(data.sendersReference) + .WithRecipient(data.recipient) + .WithAllowSystemDeleteAfter(data.allowDeleteAfter) + .WithContent( + CorrespondenceContentBuilder + .Create() + .WithLanguage(data.content.language) + .WithTitle(data.content.title) + .WithSummary(data.content.summary) + .WithBody(data.content.body) + ) + .WithNotification( + CorrespondenceNotificationBuilder + .Create() + .WithNotificationTemplate(data.notification.template) + .WithEmailSubject(data.notification.emailSubject) + .WithEmailBody(data.notification.emailBody) + .WithSmsBody(data.notification.smsBody) + .WithReminderEmailSubject(data.notification.reminderEmailSubject) + .WithReminderEmailBody(data.notification.reminderEmailBody) + .WithReminderSmsBody(data.notification.reminderSmsBody) + .WithRequestedSendTime(data.notification.requestedSendTime) + .WithSendersReference(data.notification.sendersReference) + .WithSendReminder(data.notification.sendReminder) + .WithNotificationChannel(data.notification.notificationChannel) + .WithReminderNotificationChannel(data.notification.reminderNotificationChannel) + ) + .WithDueDateTime(data.dueDateTime) + .WithMessageSender(data.messageSender) + .WithIgnoreReservation(data.ignoreReservation) + .WithRequestedPublishTime(data.requestedPublishTime) + .WithPropertyList(data.propertyList) + .WithAttachment( + CorrespondenceAttachmentBuilder + .Create() + .WithFilename(data.attachments[0].filename) + .WithName(data.attachments[0].name) + .WithSendersReference(data.attachments[0].sendersReference) + .WithDataType(data.attachments[0].dataType) + .WithData(Encoding.UTF8.GetBytes(data.attachments[0].data)) + .WithDataLocationType(data.attachments[0].dataLocationType) + .WithIsEncrypted(data.attachments[0].isEncrypted) + ) + .WithAttachment( + new CorrespondenceAttachment + { + Filename = data.attachments[1].filename, + Name = data.attachments[1].name, + SendersReference = data.attachments[1].sendersReference, + DataType = data.attachments[1].dataType, + Data = Encoding.UTF8.GetBytes(data.attachments[1].data), + DataLocationType = data.attachments[1].dataLocationType, + IsEncrypted = data.attachments[1].isEncrypted, + } + ) + .WithAttachments( + [ + new CorrespondenceAttachment + { + Filename = data.attachments[2].filename, + Name = data.attachments[2].name, + SendersReference = data.attachments[2].sendersReference, + DataType = data.attachments[2].dataType, + Data = Encoding.UTF8.GetBytes(data.attachments[2].data), + DataLocationType = data.attachments[2].dataLocationType, + IsEncrypted = data.attachments[2].isEncrypted, + }, + ] + ) + .WithExistingAttachment(data.existingAttachments[0]) + .WithExistingAttachments(data.existingAttachments.Skip(1).ToList()) + .WithExternalReference(data.externalReferences[0].type, data.externalReferences[0].value) + .WithExternalReferences( + data.externalReferences.Skip(1) + .Select(x => new CorrespondenceExternalReference + { + ReferenceType = x.type, + ReferenceValue = x.value, + }) + .ToList() + ) + .WithReplyOption(data.replyOptions[0].url, data.replyOptions[0].text) + .WithReplyOptions( + [ + new CorrespondenceReplyOption + { + LinkUrl = data.replyOptions[1].url, + LinkText = data.replyOptions[1].text, + }, + ] + ); + + // Act + var correspondence = builder.Build(); + + // Assert + Assert.NotNull(correspondence); + Assert.NotNull(correspondence.Content); + Assert.NotNull(correspondence.Content.Attachments); + Assert.NotNull(correspondence.Notification); + Assert.NotNull(correspondence.ExternalReferences); + Assert.NotNull(correspondence.ReplyOptions); + + correspondence.ResourceId.Should().Be(data.resourceId); + correspondence.Sender.Should().Be(data.sender); + correspondence.SendersReference.Should().Be(data.sendersReference); + correspondence.Recipients.Should().BeEquivalentTo([data.recipient]); + correspondence.DueDateTime.Should().Be(data.dueDateTime); + correspondence.AllowSystemDeleteAfter.Should().Be(data.allowDeleteAfter); + correspondence.IgnoreReservation.Should().Be(data.ignoreReservation); + correspondence.RequestedPublishTime.Should().Be(data.requestedPublishTime); + correspondence.PropertyList.Should().BeEquivalentTo(data.propertyList); + correspondence.MessageSender.Should().Be(data.messageSender); + + correspondence.Content.Title.Should().Be(data.content.title); + correspondence.Content.Language.Should().Be(data.content.language); + correspondence.Content.Summary.Should().Be(data.content.summary); + correspondence.Content.Body.Should().Be(data.content.body); + correspondence.Content.Attachments.Should().HaveCount(data.attachments.Length); + for (int i = 0; i < data.attachments.Length; i++) + { + correspondence.Content.Attachments[i].Filename.Should().Be(data.attachments[i].filename); + correspondence.Content.Attachments[i].Name.Should().Be(data.attachments[i].name); + correspondence.Content.Attachments[i].IsEncrypted.Should().Be(data.attachments[i].isEncrypted); + correspondence.Content.Attachments[i].SendersReference.Should().Be(data.attachments[i].sendersReference); + correspondence.Content.Attachments[i].DataType.Should().Be(data.attachments[i].dataType); + correspondence.Content.Attachments[i].DataLocationType.Should().Be(data.attachments[i].dataLocationType); + Encoding + .UTF8.GetString(correspondence.Content.Attachments[i].Data.Span) + .Should() + .Be(data.attachments[i].data); + } + + correspondence.Notification.NotificationTemplate.Should().Be(data.notification.template); + correspondence.Notification.EmailSubject.Should().Be(data.notification.emailSubject); + correspondence.Notification.EmailBody.Should().Be(data.notification.emailBody); + correspondence.Notification.SmsBody.Should().Be(data.notification.smsBody); + correspondence.Notification.ReminderEmailSubject.Should().Be(data.notification.reminderEmailSubject); + correspondence.Notification.ReminderEmailBody.Should().Be(data.notification.reminderEmailBody); + correspondence.Notification.ReminderSmsBody.Should().Be(data.notification.reminderSmsBody); + correspondence.Notification.RequestedSendTime.Should().Be(data.notification.requestedSendTime); + correspondence.Notification.SendersReference.Should().Be(data.notification.sendersReference); + correspondence.Notification.SendReminder.Should().Be(data.notification.sendReminder); + correspondence.Notification.NotificationChannel.Should().Be(data.notification.notificationChannel); + correspondence + .Notification.ReminderNotificationChannel.Should() + .Be(data.notification.reminderNotificationChannel); + + correspondence.ExistingAttachments.Should().BeEquivalentTo(data.existingAttachments); + + correspondence.ExternalReferences.Should().HaveCount(data.externalReferences.Length); + for (int i = 0; i < data.externalReferences.Length; i++) + { + correspondence.ExternalReferences[i].ReferenceType.Should().Be(data.externalReferences[i].type); + correspondence.ExternalReferences[i].ReferenceValue.Should().Be(data.externalReferences[i].value); + } + + correspondence.ReplyOptions.Should().HaveCount(data.replyOptions.Length); + for (int i = 0; i < data.replyOptions.Length; i++) + { + correspondence.ReplyOptions[i].LinkUrl.Should().Be(data.replyOptions[i].url); + correspondence.ReplyOptions[i].LinkText.Should().Be(data.replyOptions[i].text); + } + } + + [Fact] + public void Builder_UpdatesAndOverwritesValuesCorrectly() + { + // Arrange + var builder = CorrespondenceRequestBuilder + .Create() + .WithResourceId("resourceId-1") + .WithSender(TestHelpers.GetOrganisationNumber(1)) + .WithSendersReference("sender-reference-1") + .WithRecipient(OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(1))) + .WithAllowSystemDeleteAfter(DateTimeOffset.UtcNow.AddDays(1)) + .WithContent( + CorrespondenceContentBuilder + .Create() + .WithLanguage(LanguageCode.Parse("no")) + .WithTitle("content-title-1") + .WithSummary("content-summary-1") + .WithBody("content-body-1") + ) + .WithDueDateTime(DateTimeOffset.UtcNow.AddDays(1)) + .WithNotification( + CorrespondenceNotificationBuilder + .Create() + .WithNotificationTemplate(CorrespondenceNotificationTemplate.GenericAltinnMessage) + .WithEmailBody("email-body-1") + ) + .WithReplyOption("url1", "text1") + .WithExternalReference(CorrespondenceReferenceType.Generic, "aaa") + .WithPropertyList(new Dictionary { ["prop1"] = "value1", ["prop2"] = "value2" }) + .WithExistingAttachment(Guid.Parse("a3ac4826-5873-4ecb-9fe7-dc4cfccd0afa")) + .WithRequestedPublishTime(DateTime.Today) + .WithIgnoreReservation(true); + + builder.WithResourceId("resourceId-2"); + builder.WithSender(TestHelpers.GetOrganisationNumber(2).Get(OrganisationNumberFormat.Local)); + builder.WithSendersReference("sender-reference-2"); + builder.WithRecipient(TestHelpers.GetOrganisationNumber(2).Get(OrganisationNumberFormat.International)); + builder.WithRecipients( + [ + OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(3)), + OrganisationOrPersonIdentifier.Create(TestHelpers.GetNationalIdentityNumber(4)), + ] + ); + builder.WithRecipients( + [ + TestHelpers.GetOrganisationNumber(5).Get(OrganisationNumberFormat.Local), + TestHelpers.GetNationalIdentityNumber(6).Value, + ] + ); + builder.WithDueDateTime(DateTimeOffset.UtcNow.AddDays(2)); + builder.WithAllowSystemDeleteAfter(DateTimeOffset.UtcNow.AddDays(2)); + builder.WithContent("en", "content-title-2", "content-summary-2", "content-body-2"); + builder.WithNotification( + CorrespondenceNotificationBuilder + .Create() + .WithNotificationTemplate(CorrespondenceNotificationTemplate.CustomMessage) + .WithEmailBody("email-body-2") + ); + builder.WithExternalReference(CorrespondenceReferenceType.Generic, "aaa"); + builder.WithExternalReference( + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.AltinnAppInstance, + ReferenceValue = "bbb", + } + ); + builder.WithExternalReferences( + [ + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.DialogportenProcessId, + ReferenceValue = "ccc", + }, + ] + ); + builder.WithReplyOption("url2", "text2"); + builder.WithReplyOption(new CorrespondenceReplyOption { LinkUrl = "url3", LinkText = "text3" }); + builder.WithReplyOptions([new CorrespondenceReplyOption { LinkUrl = "url4", LinkText = "text4" }]); + builder.WithPropertyList(new Dictionary { ["prop2"] = "value2-redux", ["prop3"] = "value3" }); + builder.WithExistingAttachment(Guid.Parse("eeb67483-7d6d-40dc-9861-3fc1beff7608")); + builder.WithExistingAttachments([Guid.Parse("9a12dfd9-6c70-489c-8b3d-77bb188c64b3")]); + builder.WithRequestedPublishTime(DateTime.Today.AddDays(1)); + + // Act + var correspondence = builder.Build(); + + // Assert + Assert.NotNull(correspondence); + Assert.NotNull(correspondence.Notification); + + correspondence.ResourceId.Should().Be("resourceId-2"); + correspondence.Sender.Should().Be(TestHelpers.GetOrganisationNumber(2)); + correspondence.SendersReference.Should().Be("sender-reference-2"); + correspondence.AllowSystemDeleteAfter.Should().BeSameDateAs(DateTimeOffset.UtcNow.AddDays(2)); + correspondence.DueDateTime.Should().BeSameDateAs(DateTimeOffset.UtcNow.AddDays(2)); + correspondence.Recipients.Should().HaveCount(6); + correspondence + .Recipients.Select(x => x.ToString()) + .Should() + .BeEquivalentTo( + [ + TestHelpers.GetOrganisationNumber(1).Get(OrganisationNumberFormat.Local), + TestHelpers.GetOrganisationNumber(2).Get(OrganisationNumberFormat.Local), + TestHelpers.GetOrganisationNumber(3).Get(OrganisationNumberFormat.Local), + TestHelpers.GetNationalIdentityNumber(4).Value, + TestHelpers.GetOrganisationNumber(5).Get(OrganisationNumberFormat.Local), + TestHelpers.GetNationalIdentityNumber(6).Value, + ] + ); + correspondence.Content.Title.Should().Be("content-title-2"); + correspondence.Content.Language.Should().Be(LanguageCode.Parse("en")); + correspondence.Content.Summary.Should().Be("content-summary-2"); + correspondence.Content.Body.Should().Be("content-body-2"); + correspondence.Notification.NotificationTemplate.Should().Be(CorrespondenceNotificationTemplate.CustomMessage); + correspondence.Notification.EmailBody.Should().Be("email-body-2"); + correspondence + .ExternalReferences.Should() + .BeEquivalentTo( + [ + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.Generic, + ReferenceValue = "aaa", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.Generic, + ReferenceValue = "aaa", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.AltinnAppInstance, + ReferenceValue = "bbb", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.DialogportenProcessId, + ReferenceValue = "ccc", + }, + ] + ); + correspondence + .ReplyOptions.Should() + .BeEquivalentTo( + [ + new CorrespondenceReplyOption { LinkUrl = "url1", LinkText = "text1" }, + new CorrespondenceReplyOption { LinkUrl = "url2", LinkText = "text2" }, + new CorrespondenceReplyOption { LinkUrl = "url3", LinkText = "text3" }, + new CorrespondenceReplyOption { LinkUrl = "url4", LinkText = "text4" }, + ] + ); + correspondence + .PropertyList.Should() + .BeEquivalentTo( + new Dictionary + { + ["prop1"] = "value1", + ["prop2"] = "value2-redux", + ["prop3"] = "value3", + } + ); + correspondence + .ExistingAttachments!.Select(x => x.ToString()) + .Should() + .BeEquivalentTo( + [ + "a3ac4826-5873-4ecb-9fe7-dc4cfccd0afa", + "eeb67483-7d6d-40dc-9861-3fc1beff7608", + "9a12dfd9-6c70-489c-8b3d-77bb188c64b3", + ] + ); + correspondence.RequestedPublishTime.Should().BeSameDateAs(DateTime.Today.AddDays(1)); + correspondence.IgnoreReservation.Should().BeTrue(); + } + + [Fact] + public void Builder_ValueTypeOverloads_ValidateInput() + { + // Arrange + var baseBuilder = CorrespondenceRequestBuilder + .Create() + .WithResourceId("resourceId-1") + .WithSender(TestHelpers.GetOrganisationNumber(1)) + .WithSendersReference("sender-reference-1") + .WithRecipient(OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(1))) + .WithAllowSystemDeleteAfter(DateTimeOffset.UtcNow.AddDays(1)) + .WithContent( + CorrespondenceContentBuilder + .Create() + .WithLanguage(LanguageCode.Parse("no")) + .WithTitle("content-title-1") + .WithSummary("content-summary-1") + .WithBody("content-body-1") + ); + + // Act + var act1 = () => + { + baseBuilder.WithSender("123456789"); + }; + var act2 = () => + { + baseBuilder.WithRecipient("123456789"); + }; + var act3 = () => + { + CorrespondenceContentBuilder.Create().WithLanguage("nope"); + }; + + // Assert + act1.Should().Throw(); + act2.Should().Throw(); + act3.Should().Throw(); + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Correspondence/CorrespondenceClientTests.cs b/test/Altinn.App.Core.Tests/Features/Correspondence/CorrespondenceClientTests.cs new file mode 100644 index 000000000..45e62b47f --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Correspondence/CorrespondenceClientTests.cs @@ -0,0 +1,385 @@ +using System.Net; +using Altinn.App.Api.Tests.Utils; +using Altinn.App.Core.Features.Correspondence; +using Altinn.App.Core.Features.Correspondence.Builder; +using Altinn.App.Core.Features.Correspondence.Exceptions; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit.Sdk; + +namespace Altinn.App.Core.Tests.Features.Correspondence; + +public class CorrespondenceClientTests +{ + private sealed record Fixture(WebApplication App) : IAsyncDisposable + { + public Mock HttpClientFactoryMock => + Mock.Get(App.Services.GetRequiredService()); + + public Mock MaskinportenClientMock => + Mock.Get(App.Services.GetRequiredService()); + + public ICorrespondenceClient CorrespondenceClient => App.Services.GetRequiredService(); + + public static Fixture Create() + { + var mockHttpClientFactory = new Mock(); + var mockMaskinportenClient = new Mock(); + + var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => + { + services.AddSingleton(mockHttpClientFactory.Object); + services.AddSingleton(mockMaskinportenClient.Object); + services.Configure(options => + { + options.Authority = "https://maskinporten.dev/"; + options.ClientId = "test-client-id"; + options.JwkBase64 = + "ewogICAgICAicCI6ICItU09GNmp3V0N3b19nSlByTnJhcVNkNnZRckFzRmxZd1VScHQ0NC1BNlRXUnBoaUo4b3czSTNDWGxxUG1LeG5VWDVDcnd6SF8yeldTNGtaaU9zQTMtajhiUE9hUjZ2a3pRSG14YmFkWmFmZjBUckdJajNQUlhxcVdMRHdsZjNfNklDV2gzOFhodXNBeDVZRE0tRm8zZzRLVWVHM2NxMUFvTkJ4NHV6Sy1IRHMiLAogICAgICAia3R5IjogIlJTQSIsCiAgICAgICJxIjogIndwWUlpOVZJLUJaRk9aYUNaUmVhYm4xWElQbW8tbEJIendnc1RCdHVfeUJma1FQeGI1Q1ZnZFFnaVQ4dTR3Tkl4NC0zb2ROdXhsWGZING1Hc25xOWFRaFlRNFEyc2NPUHc5V2dNM1dBNE1GMXNQQXgzUGJLRkItU01RZmZ4aXk2cVdJSmRQSUJ4OVdFdnlseW9XbEhDcGZsUWplT3U2dk43WExsZ3c5T2JhVSIsCiAgICAgICJkIjogIks3Y3pqRktyWUJfRjJYRWdoQ1RQY2JTbzZZdExxelFwTlZleF9HZUhpTmprWmNpcEVaZ3g4SFhYLXpNSi01ZWVjaTZhY1ZjSzhhZzVhQy01Mk84LTU5aEU3SEE2M0FoRzJkWFdmamdQTXhaVE9MbnBheWtZbzNWa0NGNF9FekpLYmw0d2ludnRuTjBPc2dXaVZiTDFNZlBjWEdqbHNTUFBIUlAyaThDajRqX21OM2JVcy1FbVM5UzktSXlia1luYV9oNUMxMEluXy1tWHpsQ2dCNU9FTXFzd2tNUWRZVTBWbHVuWHM3YXlPT0h2WWpQMWFpYml0MEpyay1iWVFHSy1mUVFFVWNZRkFSN1ZLMkxIaUJwU0NvbzBiSjlCQ1BZb196bTVNVnVId21xbzNtdml1Vy1lMnVhbW5xVHpZUEVWRE1lMGZBSkZtcVBGcGVwTzVfcXE2USIsCiAgICAgICJlIjogIkFRQUIiLAogICAgICAidXNlIjogInNpZyIsCiAgICAgICJraWQiOiAiYXNkZjEyMzQiLAogICAgICAicWkiOiAicXpFUUdXOHBPVUgtR2pCaFUwVXNhWWtEM2dWTVJvTF9CbGlRckp4ZTAwY29YeUtIZGVEX2M1bDFDNFFJZzRJSjZPMnFZZ2wyamRnWVNmVHA0S2NDNk1Obm8tSVFiSnlPRDU2Qmo4eVJUUjA5TkZvTGhDUjNhY0xmMkhwTXNKNUlqbTdBUHFPVWlCeW9hVkExRlR4bzYtZGNfZ1NiQjh1ZDI2bFlFRHdsYWMwIiwKICAgICAgImRwIjogInRnTU14N2FFQ0NiQmctY005Vmo0Q2FXbGR0d01LWGxvTFNoWTFlSTJOS3BOTVFKR2JhdWdjTVRHQ21qTk1fblgzTVZ0cHRvMWFPbTMySlhCRjlqc1RHZWtONWJmVGNJbmZsZ3Bsc21uR2pMckNqN0xYTG9wWUxiUnBabF9iNm1JaThuU2ZCQXVQR2hEUzc4UWZfUXhFR1Bxb2h6cEZVTW5UQUxzOVI0Nkk1YyIsCiAgICAgICJhbGciOiAiUlMyNTYiLAogICAgICAiZHEiOiAibE40cF9ha1lZVXpRZTBWdHp4LW1zNTlLLUZ4bzdkQmJqOFhGOWhnSzdENzlQam5SRGJTRTNVWEgtcGlQSzNpSXhyeHFGZkZuVDJfRS15REJIMjBOMmZ4YllwUVZNQnpZc1UtUGQ2OFBBV1Nnd05TU29XVmhwdEdjaTh4bFlfMDJkWDRlbEF6T1ZlOUIxdXBEMjc5cWJXMVdKVG5TQmp4am1LVU5lQjVPdDAwIiwKICAgICAgIm4iOiAidlY3dW5TclNnekV3ZHo0dk8wTnNmWDB0R1NwT2RITE16aDFseUVtU2RYbExmeVYtcUxtbW9qUFI3S2pUU2NDbDI1SFI4SThvWG1mcDhSZ19vbnA0LUlZWW5ZV0RTNngxVlViOVlOQ3lFRTNQQTUtVjlOYzd5ckxxWXpyMTlOSkJmdmhJVEd5QUFVTjFCeW5JeXJ5NFFMbHRYYTRKSTFiLTh2QXNJQ0xyU1dQZDdibWxrOWo3bU1jV3JiWlNIZHNTMGNpVFgzYTc2UXdMb0F2SW54RlhCU0ludXF3ZVhnVjNCZDFQaS1DZGpCR0lVdXVyeVkybEwybmRnVHZUY2tZUTBYeEtGR3lCdDNaMEhJMzRBRFBrVEZneWFMX1F4NFpIZ3d6ZjRhTHBXaHF3OGVWanpPMXlucjJ3OUd4b2dSN1pWUjY3VFI3eUxSS3VrMWdIdFlkUkJ3IgogICAgfQ=="; + }); + }); + + return new Fixture(app); + } + + public async ValueTask DisposeAsync() => await App.DisposeAsync(); + } + + private static class PayloadFactory + { + private static readonly Func> _defaultTokenFactory = async () => + await Task.FromResult( + JwtToken.Parse( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJpdHMtYS1tZSJ9.wLLw4Timcl9gnQvA93RgREz-6S5y1UfzI_GYVI_XVDA" + ) + ); + + public static SendCorrespondencePayload Send( + Func>? tokenFactory = default, + CorrespondenceAuthorisation? authorisation = default + ) + { + var request = CorrespondenceRequestBuilder + .Create() + .WithResourceId("resource-id") + .WithSender(OrganisationNumber.Parse("991825827")) + .WithSendersReference("senders-ref") + .WithRecipient(OrganisationOrPersonIdentifier.Parse("213872702")) + .WithAllowSystemDeleteAfter(DateTime.Now.AddYears(1)) + .WithContent( + CorrespondenceContentBuilder + .Create() + .WithLanguage(LanguageCode.Parse("en")) + .WithTitle("message-title") + .WithSummary("message-summary") + .WithBody("message-body") + ) + .Build(); + + return authorisation is null + ? new SendCorrespondencePayload(request, tokenFactory ?? _defaultTokenFactory) + : new SendCorrespondencePayload(request, authorisation.Value); + } + + public static GetCorrespondenceStatusPayload GetStatus( + Func>? tokenFactory = default, + CorrespondenceAuthorisation? authorisation = default + ) + { + return authorisation is null + ? new GetCorrespondenceStatusPayload(Guid.NewGuid(), tokenFactory ?? _defaultTokenFactory) + : new GetCorrespondenceStatusPayload(Guid.NewGuid(), authorisation.Value); + } + } + + [Fact] + public async Task Send_SuccessfulResponse_ReturnsCorrectResponse() + { + // Arrange + await using var fixture = Fixture.Create(); + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockHttpClient = new Mock(); + + var payload = PayloadFactory.Send(); + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "correspondences": [ + { + "correspondenceId": "cf7a4a9f-45ce-46b9-b110-4f263b395842", + "status": "Initialized", + "recipient": "0192:213872702", + "notifications": [ + { + "orderId": "05119865-6d46-415c-a2d7-65b9cd173e13", + "isReminder": false, + "status": "Success" + } + ] + } + ], + "attachmentIds": [ + "25b87c22-e7cc-4c07-95eb-9afa32e3ee7b" + ] + } + """ + ), + }; + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseMessage); + + // Act + var result = await fixture.CorrespondenceClient.Send(payload); + + // Assert + Assert.NotNull(result); + result.AttachmentIds.Should().ContainSingle("25b87c22-e7cc-4c07-95eb-9afa32e3ee7b"); + result.Correspondences.Should().HaveCount(1); + result.Correspondences[0].CorrespondenceId.Should().Be("cf7a4a9f-45ce-46b9-b110-4f263b395842"); + } + + [Fact] + public async Task GetStatus_SuccessfulResponse_ReturnsCorrectResponse() + { + // Arrange + await using var fixture = Fixture.Create(); + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockHttpClient = new Mock(); + + var payload = PayloadFactory.GetStatus(); + var responseMessage = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "statusHistory": [ + { + "status": "Published", + "statusText": "Published" + } + ], + "recipient": "0192:213872702", + "correspondenceId": "94fa9dd9-734e-4712-9d49-4018aeb1a5dc", + "resourceId": "apps-correspondence-integrasjon2", + "sender": "0192:991825827", + "sendersReference": "1234", + "isConfirmationNeeded": true + } + """ + ), + }; + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseMessage); + + // Act + var result = await fixture.CorrespondenceClient.GetStatus(payload); + + // Assert + Assert.NotNull(result); + result.StatusHistory.Should().HaveCount(1); + result.StatusHistory.First().Status.Should().Be(CorrespondenceStatus.Published); + result.Recipient.Should().Be("0192:213872702"); + result.CorrespondenceId.Should().Be(Guid.Parse("94fa9dd9-734e-4712-9d49-4018aeb1a5dc")); + result.ResourceId.Should().Be("apps-correspondence-integrasjon2"); + result.Sender.Should().Be(OrganisationNumber.Parse("991825827")); + result.SendersReference.Should().Be("1234"); + result.IsConfirmationNeeded.Should().BeTrue(); + } + + [Theory] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task FailedResponse_ThrowsCorrespondenceRequestException(HttpStatusCode httpStatusCode) + { + // Arrange + await using var fixture = Fixture.Create(); + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockHttpClient = new Mock(); + var responseMessage = new HttpResponseMessage(httpStatusCode) + { + Content = httpStatusCode switch + { + HttpStatusCode.BadRequest => new StringContent( + """ + { + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "Bad Request", + "status": 400, + "detail": "For upload requests at least one attachment has to be included", + "traceId": "00-3ceaba074547008ac46f622fd67d6c6e-e4129c2b46370667-00" + } + """ + ), + _ => null, + }, + }; + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(responseMessage); + + // Act + Func send = async () => + { + await fixture.CorrespondenceClient.Send(PayloadFactory.Send()); + }; + Func getStatus = async () => + { + await fixture.CorrespondenceClient.GetStatus(PayloadFactory.GetStatus()); + }; + + // Assert + await send.Should().ThrowAsync(); + await getStatus.Should().ThrowAsync(); + } + + [Fact] + public async Task KnownCorrespondenceException_IsHandled() + { + // Arrange + await using var fixture = Fixture.Create(); + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockHttpClient = new Mock(); + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + () => + throw new CorrespondenceRequestException( + "Yikes", + new CorrespondenceRequestException("Sometimes there's an inner exception") + ) + ); + + // Act + Func send = async () => + { + await fixture.CorrespondenceClient.Send(PayloadFactory.Send()); + }; + Func getStatus = async () => + { + await fixture.CorrespondenceClient.GetStatus(PayloadFactory.GetStatus()); + }; + + // Assert + await send.Should() + .ThrowAsync() + .WithInnerExceptionExactly(typeof(CorrespondenceRequestException)); + await getStatus + .Should() + .ThrowAsync() + .WithInnerExceptionExactly(typeof(CorrespondenceRequestException)); + } + + [Fact] + public async Task UnexpectedException_IsHandled() + { + // Arrange + await using var fixture = Fixture.Create(); + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockHttpClient = new Mock(); + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => throw new HttpRequestException("Surprise!")); + + // Act + Func send = async () => + { + await fixture.CorrespondenceClient.Send(PayloadFactory.Send()); + }; + Func getStatus = async () => + { + await fixture.CorrespondenceClient.GetStatus(PayloadFactory.GetStatus()); + }; + + // Assert + await send.Should() + .ThrowAsync() + .WithInnerExceptionExactly(typeof(HttpRequestException)); + await getStatus + .Should() + .ThrowAsync() + .WithInnerExceptionExactly(typeof(HttpRequestException)); + } + + [Fact] + public async Task AuthorisationFactory_ImplementsMaskinportenCorrectly() + { + // Arrange + await using var fixture = Fixture.Create(); + IEnumerable? capturedMaskinportenScopes = null; + var mockHttpClientFactory = fixture.HttpClientFactoryMock; + var mockMaskinportenClient = fixture.MaskinportenClientMock; + var mockHttpClient = new Mock(); + var correspondencePayload = PayloadFactory.Send(authorisation: CorrespondenceAuthorisation.Maskinporten); + var altinnTokenResponse = PrincipalUtil.GetOrgToken("ttd"); + var altinnTokenWrapperResponse = JwtToken.Parse(altinnTokenResponse); + var correspondenceResponse = new SendCorrespondenceResponse + { + Correspondences = + [ + new CorrespondenceDetailsResponse + { + CorrespondenceId = Guid.NewGuid(), + Status = CorrespondenceStatus.Published, + Recipient = OrganisationOrPersonIdentifier.Parse("991825827"), + }, + ], + }; + + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + mockMaskinportenClient + .Setup(m => m.GetAltinnExchangedToken(It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (scopes, _) => + { + capturedMaskinportenScopes = scopes; + } + ) + .ReturnsAsync(() => altinnTokenWrapperResponse) + .Verifiable(Times.Once); + mockHttpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (HttpRequestMessage request, CancellationToken _) => + request.RequestUri!.AbsolutePath switch + { + var path when path.EndsWith("/exchange/maskinporten") => TestHelpers.ResponseMessageFactory( + altinnTokenResponse + ), + var path when path.EndsWith("/correspondence/upload") => TestHelpers.ResponseMessageFactory( + correspondenceResponse + ), + _ => throw FailException.ForFailure($"Unknown mock endpoint: {request.RequestUri}"), + } + ); + + // Act + var response = await fixture.CorrespondenceClient.Send(correspondencePayload); + + // Assert + response.Should().BeEquivalentTo(correspondenceResponse); + mockMaskinportenClient.Verify(); + capturedMaskinportenScopes + .Should() + .BeEquivalentTo(["altinn:correspondence.write", "altinn:serviceowner/instances.read"]); + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceRequestTests.cs b/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceRequestTests.cs new file mode 100644 index 000000000..525660fcc --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceRequestTests.cs @@ -0,0 +1,300 @@ +using System.Text; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Features.Correspondence.Models; + +public class CorrespondenceRequestTests +{ + [Fact] + public async Task Serialise_ShouldAddCorrectFields() + { + // Arrange + var multipartContent = new MultipartFormDataContent(); + var correspondence = new CorrespondenceRequest + { + ResourceId = "resource-id", + Sender = TestHelpers.GetOrganisationNumber(0), + SendersReference = "senders-reference", + RequestedPublishTime = DateTimeOffset.UtcNow.AddDays(1), + AllowSystemDeleteAfter = DateTimeOffset.UtcNow.AddDays(2), + DueDateTime = DateTimeOffset.UtcNow.AddDays(2), + IgnoreReservation = true, + MessageSender = "message-sender", + Recipients = + [ + OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(1)), + OrganisationOrPersonIdentifier.Create(TestHelpers.GetNationalIdentityNumber(1)), + ], + Content = new CorrespondenceContent + { + Title = "title", + Body = "body", + Summary = "summary", + Language = LanguageCode.Parse("no"), + Attachments = + [ + new CorrespondenceAttachment + { + Filename = "filename-1", + Name = "name-1", + SendersReference = "senders-reference-1", + DataType = "application/pdf", + Data = "data"u8.ToArray(), + }, + new CorrespondenceAttachment + { + Filename = "filename-2", + Name = "name-2", + SendersReference = "senders-reference-2", + DataType = "plain/text", + Data = "data"u8.ToArray(), + DataLocationType = CorrespondenceDataLocationType.NewCorrespondenceAttachment, + IsEncrypted = true, + }, + ], + }, + ExternalReferences = + [ + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.AltinnAppInstance, + ReferenceValue = "reference-1", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.AltinnBrokerFileTransfer, + ReferenceValue = "reference-2", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.DialogportenDialogId, + ReferenceValue = "reference-3", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.DialogportenProcessId, + ReferenceValue = "reference-4", + }, + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.Generic, + ReferenceValue = "reference-5", + }, + ], + PropertyList = new Dictionary { { "key-1", "value-1" }, { "key-2", "value-2" } }, + ReplyOptions = + [ + new CorrespondenceReplyOption { LinkUrl = "link-url-1", LinkText = "link-text-1" }, + new CorrespondenceReplyOption { LinkUrl = "link-url-2", LinkText = "link-text-2" }, + ], + Notification = new CorrespondenceNotification + { + NotificationTemplate = CorrespondenceNotificationTemplate.CustomMessage, + EmailSubject = "email-subject", + EmailBody = "email-body", + SmsBody = "sms-body", + SendReminder = true, + ReminderEmailSubject = "reminder-email-subject", + ReminderEmailBody = "reminder-email-body", + ReminderSmsBody = "reminder-sms-body", + NotificationChannel = CorrespondenceNotificationChannel.EmailPreferred, + ReminderNotificationChannel = CorrespondenceNotificationChannel.SmsPreferred, + SendersReference = "senders-reference", + RequestedSendTime = DateTimeOffset.UtcNow, + }, + ExistingAttachments = [Guid.NewGuid(), Guid.NewGuid()], + }; + + // Act + correspondence.Serialise(multipartContent); + // csharpier-ignore + + // Assert + var expectedSerialisation = new Dictionary + { + ["Recipients[0]"] = correspondence.Recipients[0], + ["Recipients[1]"] = correspondence.Recipients[1], + ["Correspondence.ResourceId"] = correspondence.ResourceId, + ["Correspondence.Sender"] = correspondence.Sender, + ["Correspondence.SendersReference"] = correspondence.SendersReference, + ["Correspondence.RequestedPublishTime"] = correspondence.RequestedPublishTime, + ["Correspondence.AllowSystemDeleteAfter"] = correspondence.AllowSystemDeleteAfter, + ["Correspondence.DueDateTime"] = correspondence.DueDateTime, + ["Correspondence.MessageSender"] = correspondence.MessageSender, + ["Correspondence.IgnoreReservation"] = correspondence.IgnoreReservation, + ["Correspondence.Content.Language"] = correspondence.Content.Language, + ["Correspondence.Content.MessageTitle"] = correspondence.Content.Title, + ["Correspondence.Content.MessageSummary"] = correspondence.Content.Summary, + ["Correspondence.Content.MessageBody"] = correspondence.Content.Body, + ["Correspondence.Content.Attachments[0].Filename"] = correspondence.Content.Attachments[0].Filename, + ["Correspondence.Content.Attachments[0].Name"] = correspondence.Content.Attachments[0].Name, + ["Correspondence.Content.Attachments[0].SendersReference"] = correspondence.Content.Attachments[0].SendersReference, + ["Correspondence.Content.Attachments[0].DataType"] = correspondence.Content.Attachments[0].DataType, + ["Correspondence.Content.Attachments[1].Filename"] = correspondence.Content.Attachments[1].Filename, + ["Correspondence.Content.Attachments[1].Name"] = correspondence.Content.Attachments[1].Name, + ["Correspondence.Content.Attachments[1].IsEncrypted"] = correspondence.Content.Attachments[1].IsEncrypted!, + ["Correspondence.Content.Attachments[1].SendersReference"] = correspondence.Content.Attachments[1].SendersReference, + ["Correspondence.Content.Attachments[1].DataType"] = correspondence.Content.Attachments[1].DataType, + ["Correspondence.ExternalReferences[0].ReferenceType"] = correspondence.ExternalReferences[0].ReferenceType, + ["Correspondence.ExternalReferences[0].ReferenceValue"] = correspondence.ExternalReferences[0].ReferenceValue!, + ["Correspondence.ExternalReferences[1].ReferenceType"] = correspondence.ExternalReferences[1].ReferenceType, + ["Correspondence.ExternalReferences[1].ReferenceValue"] = correspondence.ExternalReferences[1].ReferenceValue!, + ["Correspondence.ExternalReferences[2].ReferenceType"] = correspondence.ExternalReferences[2].ReferenceType, + ["Correspondence.ExternalReferences[2].ReferenceValue"] = correspondence.ExternalReferences[2].ReferenceValue!, + ["Correspondence.ExternalReferences[3].ReferenceType"] = correspondence.ExternalReferences[3].ReferenceType, + ["Correspondence.ExternalReferences[3].ReferenceValue"] = correspondence.ExternalReferences[3].ReferenceValue!, + ["Correspondence.ExternalReferences[4].ReferenceType"] = correspondence.ExternalReferences[4].ReferenceType, + ["Correspondence.ExternalReferences[4].ReferenceValue"] = correspondence.ExternalReferences[4].ReferenceValue!, + [$"Correspondence.PropertyList.{correspondence.PropertyList.Keys.First()}"] = correspondence.PropertyList.Values.First(), + [$"Correspondence.PropertyList.{correspondence.PropertyList.Keys.Last()}"] = correspondence.PropertyList.Values.Last(), + ["Correspondence.ReplyOptions[0].LinkUrl"] = correspondence.ReplyOptions[0].LinkUrl, + ["Correspondence.ReplyOptions[0].LinkText"] = correspondence.ReplyOptions[0].LinkText!, + ["Correspondence.ReplyOptions[1].LinkUrl"] = correspondence.ReplyOptions[1].LinkUrl, + ["Correspondence.ReplyOptions[1].LinkText"] = correspondence.ReplyOptions[1].LinkText!, + ["Correspondence.ExistingAttachments[0]"] = correspondence.ExistingAttachments[0], + ["Correspondence.ExistingAttachments[1]"] = correspondence.ExistingAttachments[1], + ["Correspondence.Notification.NotificationTemplate"] = correspondence.Notification.NotificationTemplate, + ["Correspondence.Notification.EmailSubject"] = correspondence.Notification.EmailSubject, + ["Correspondence.Notification.EmailBody"] = correspondence.Notification.EmailBody, + ["Correspondence.Notification.SmsBody"] = correspondence.Notification.SmsBody, + ["Correspondence.Notification.SendReminder"] = correspondence.Notification.SendReminder, + ["Correspondence.Notification.ReminderEmailSubject"] = correspondence.Notification.ReminderEmailSubject, + ["Correspondence.Notification.ReminderEmailBody"] = correspondence.Notification.ReminderEmailBody, + ["Correspondence.Notification.ReminderSmsBody"] = correspondence.Notification.ReminderSmsBody, + ["Correspondence.Notification.NotificationChannel"] = correspondence.Notification.NotificationChannel, + ["Correspondence.Notification.ReminderNotificationChannel"] = correspondence.Notification.ReminderNotificationChannel, + ["Correspondence.Notification.SendersReference"] = correspondence.Notification.SendersReference, + ["Correspondence.Notification.RequestedSendTime"] = correspondence.Notification.RequestedSendTime + }; + + foreach (var (key, value) in expectedSerialisation) + { + await AssertContent(multipartContent, key, value); + } + } + + [Theory] + [InlineData("clashingFilename.txt", new[] { "clashingFilename(1).txt", "clashingFilename(2).txt" })] + [InlineData("clashingFilename", new[] { "clashingFilename(1)", "clashingFilename(2)" })] + public async Task Serialise_ShouldHandleClashingFilenames(string clashingFilename, string[] expectedResolutions) + { + // Arrange + var correspondence = new CorrespondenceRequest + { + ResourceId = "resource-id", + Sender = TestHelpers.GetOrganisationNumber(0), + SendersReference = "senders-reference", + AllowSystemDeleteAfter = DateTimeOffset.UtcNow.AddDays(2), + DueDateTime = DateTimeOffset.UtcNow.AddDays(2), + Recipients = [OrganisationOrPersonIdentifier.Create(TestHelpers.GetOrganisationNumber(1))], + Content = new CorrespondenceContent + { + Title = "title", + Body = "body", + Summary = "summary", + Language = LanguageCode.Parse("no"), + Attachments = + [ + new CorrespondenceAttachment + { + Filename = clashingFilename, + Name = "name-1", + SendersReference = "senders-reference-1", + DataType = "application/pdf", + Data = Encoding.UTF8.GetBytes("data-1"), + }, + new CorrespondenceAttachment + { + Filename = clashingFilename, + Name = "name-2", + SendersReference = "senders-reference-2", + DataType = "plain/text", + Data = Encoding.UTF8.GetBytes("data-2"), + }, + ], + }, + }; + + // Act + MultipartFormDataContent multipartContent = correspondence.Serialise(); + + // Assert + await AssertContent(multipartContent, "Correspondence.Content.Attachments[0].Filename", expectedResolutions[0]); + await AssertContent(multipartContent, "Correspondence.Content.Attachments[1].Filename", expectedResolutions[1]); + } + + [Fact] + public void Serialise_ClashingFilenames_ShouldUseReferenceComparison() + { + // Arrange + ReadOnlyMemory data = Encoding.UTF8.GetBytes("data"); + List identicalAttachments = + [ + new CorrespondenceAttachment + { + Filename = "filename", + Name = "name", + SendersReference = "senders-reference", + DataType = "plain/text", + Data = data, + }, + new CorrespondenceAttachment + { + Filename = "filename", + Name = "name", + SendersReference = "senders-reference", + DataType = "plain/text", + Data = data, + }, + new CorrespondenceAttachment + { + Filename = "filename", + Name = "name", + SendersReference = "senders-reference", + DataType = "plain/text", + Data = data, + }, + ]; + var clonedAttachment = identicalAttachments.Last(); + + // Act + var processedAttachments = MultipartCorrespondenceItem.CalculateFilenameOverrides(identicalAttachments); + processedAttachments[clonedAttachment] = "overwritten"; + + // Assert + processedAttachments.Should().HaveCount(3); + processedAttachments[identicalAttachments[0]].Should().Contain("(1)"); + processedAttachments[identicalAttachments[1]].Should().Contain("(2)"); + processedAttachments[identicalAttachments[2]].Should().Contain("overwritten"); + } + + private static async Task AssertContent(MultipartFormDataContent content, string dispositionName, object value) + { + var item = content.GetItem(dispositionName); + var stringValue = FormattedString(value); + + item.Should().NotBeNull($"FormDataContent with name `{dispositionName}` was not found"); + item!.Headers.ContentDisposition!.Name.Should().NotBeNull(); + dispositionName.Should().Be(item.Headers.ContentDisposition.Name!.Trim('\"')); + stringValue.Should().Be(await item.ReadAsStringAsync()); + } + + private static string FormattedString(object value) + { + Assert.NotNull(value); + + return value switch + { + OrganisationNumber org => org.Get(OrganisationNumberFormat.International), + OrganisationOrPersonIdentifier.Organisation org => org.Value.Get(OrganisationNumberFormat.International), + DateTime dateTime => dateTime.ToString("O"), + DateTimeOffset dateTimeOffset => dateTimeOffset.ToString("O"), + _ => value.ToString() + ?? throw new NullReferenceException( + $"ToString method call for object `{nameof(value)} ({value.GetType()})` returned null" + ), + }; + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceResponseTests.cs b/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceResponseTests.cs new file mode 100644 index 000000000..dc2297165 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Correspondence/Models/CorrespondenceResponseTests.cs @@ -0,0 +1,372 @@ +using System.Globalization; +using System.Text.Json; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Features.Correspondence.Models; + +public class CorrespondenceResponseTests +{ + [Fact] + public void Send_ValidResponse_DeserializesCorrectly() + { + // Arrange + var encodedResponse = """ + { + "correspondences": [ + { + "correspondenceId": "d22d8dda-7b56-48c0-b287-5052aa255d5b", + "status": "Initialized", + "recipient": "0192:213872702", + "notifications": [ + { + "orderId": "0ee29355-f2ca-4cd9-98e0-e97a4242d321", + "isReminder": false, + "status": "Success" + } + ] + }, + { + "correspondenceId": "d22d8dda-7b56-48c0-b287-5052aa255d5b", + "status": "Published", + "recipient": "26267892619", + "notifications": [ + { + "orderId": "0ee29355-f2ca-4cd9-98e0-e97a4242d321", + "isReminder": true, + "status": "MissingContact" + } + ] + } + ], + "attachmentIds": [ + "cae24499-a5f9-425b-9c5b-4dac85fce891" + ] + } + """; + + // Act + var parsedResponse = JsonSerializer.Deserialize(encodedResponse); + + // Assert + Assert.NotNull(parsedResponse); + Assert.NotNull(parsedResponse.Correspondences); + Assert.NotNull(parsedResponse.AttachmentIds); + + parsedResponse.Correspondences.Should().HaveCount(2); + parsedResponse + .Correspondences[0] + .CorrespondenceId.Should() + .Be(Guid.Parse("d22d8dda-7b56-48c0-b287-5052aa255d5b")); + parsedResponse.Correspondences[0].Status.Should().Be(CorrespondenceStatus.Initialized); + parsedResponse + .Correspondences[0] + .Recipient.Should() + .Be(OrganisationOrPersonIdentifier.Create(OrganisationNumber.Parse("0192:213872702"))); + + parsedResponse.Correspondences[0].Notifications.Should().HaveCount(1); + parsedResponse + .Correspondences[0] + .Notifications![0] + .OrderId.Should() + .Be(Guid.Parse("0ee29355-f2ca-4cd9-98e0-e97a4242d321")); + parsedResponse.Correspondences[0].Notifications![0].IsReminder.Should().BeFalse(); + parsedResponse + .Correspondences[0] + .Notifications![0] + .Status.Should() + .Be(CorrespondenceNotificationStatusResponse.Success); + + parsedResponse.Correspondences[1].Status.Should().Be(CorrespondenceStatus.Published); + parsedResponse + .Correspondences[1] + .Recipient.Should() + .Be(OrganisationOrPersonIdentifier.Create(NationalIdentityNumber.Parse("26267892619"))); + parsedResponse.Correspondences[1].Notifications![0].IsReminder.Should().BeTrue(); + parsedResponse + .Correspondences[1] + .Notifications![0] + .Status.Should() + .Be(CorrespondenceNotificationStatusResponse.MissingContact); + + parsedResponse.AttachmentIds.Should().HaveCount(1); + parsedResponse.AttachmentIds[0].Should().Be(Guid.Parse("cae24499-a5f9-425b-9c5b-4dac85fce891")); + } + + [Fact] + public void Status_ValidResponse_DeserializesCorrectly() + { + // Arrange + var encodedResponse = """ + { + "statusHistory": [ + { + "status": "Initialized", + "statusText": "Initialized", + "statusChanged": "2024-11-14T11:05:56.843628+00:00" + }, + { + "status": "ReadyForPublish", + "statusText": "ReadyForPublish", + "statusChanged": "2024-11-14T11:06:00.165998+00:00" + }, + { + "status": "Published", + "statusText": "Published", + "statusChanged": "2024-11-14T11:06:56.208705+00:00" + } + ], + "notifications": [ + { + "id": "598e8044-5ec4-43f9-8ce2-6a37c24cc7df", + "sendersReference": "1234", + "requestedSendTime": "2024-11-14T12:10:57.031351Z", + "creator": "digdir", + "created": "2024-11-14T11:05:57.237047Z", + "isReminder": true, + "notificationChannel": "EmailPreferred", + "ignoreReservation": true, + "resourceId": "apps-correspondence-integrasjon2", + "processingStatus": { + "status": "Registered", + "description": "Order has been registered and is awaiting requested send time before processing.", + "lastUpdate": "2024-11-14T11:05:57.237047Z" + }, + "notificationStatusDetails": { + "email": null, + "sms": null + } + }, + { + "id": "7ab0ff62-8c5d-4a2e-8ad2-7e7236e847a4", + "sendersReference": "1234", + "requestedSendTime": "2024-11-14T11:10:57.031351Z", + "creator": "digdir", + "created": "2024-11-14T11:05:57.054356Z", + "isReminder": false, + "notificationChannel": "EmailPreferred", + "ignoreReservation": true, + "resourceId": "apps-correspondence-integrasjon2", + "processingStatus": { + "status": "Completed", + "description": "Order processing is completed. All notifications have been generated.", + "lastUpdate": "2024-11-14T11:05:57.054356Z" + }, + "notificationStatusDetails": { + "email": { + "id": "0dabcc5c-c3de-4636-922c-e7b351cdbbfa", + "succeeded": true, + "recipient": { + "emailAddress": "someone@digdir.no", + "mobileNumber": null, + "organizationNumber": "213872702", + "nationalIdentityNumber": null, + "isReserved": null + }, + "sendStatus": { + "status": "Succeeded", + "description": "The email has been accepted by the third party email service and will be sent shortly.", + "lastUpdate": "2024-11-14T11:10:12.693438Z" + } + }, + "sms": null + } + } + ], + "recipient": "0192:213872702", + "markedUnread": null, + "correspondenceId": "94fa9dd9-734e-4712-9d49-4018aeb1a5dc", + "content": { + "attachments": [ + { + "created": "2024-11-14T11:05:56.843622+00:00", + "dataLocationType": "AltinnCorrespondenceAttachment", + "status": "Published", + "statusText": "Published", + "statusChanged": "2024-11-14T11:06:00.102333+00:00", + "expirationTime": "0001-01-01T00:00:00+00:00", + "id": "a40fad32-dad1-442d-b4e1-2564d4561c07", + "fileName": "hello-world-3-1.pDf", + "name": "This is the PDF filename 🍕", + "isEncrypted": false, + "checksum": "27bb85ec3681e3cd1ed44a079f5fc501", + "sendersReference": "1234", + "dataType": "application/pdf" + } + ], + "language": "en", + "messageTitle": "This is the title 👋🏻", + "messageSummary": "This is the summary ✌️", + "messageBody": "This is the message\n\nHere is a newline.\n\nHere are some emojis: 📎👴🏻👨🏼‍🍳🥰" + }, + "created": "2024-11-14T11:05:56.575089+00:00", + "status": "Published", + "statusText": "Published", + "statusChanged": "2024-11-14T11:06:56.208705+00:00", + "resourceId": "apps-correspondence-integrasjon2", + "sender": "0192:991825827", + "sendersReference": "1234", + "messageSender": "Test Testesen", + "requestedPublishTime": "2024-05-29T13:31:28.290518+00:00", + "allowSystemDeleteAfter": "2025-05-29T13:31:28.290518+00:00", + "dueDateTime": "2025-05-29T13:31:28.290518+00:00", + "externalReferences": [ + { + "referenceValue": "test", + "referenceType": "AltinnBrokerFileTransfer" + }, + { + "referenceValue": "01932a59-edc3-7038-823e-cf46908cd83b", + "referenceType": "DialogportenDialogId" + } + ], + "propertyList": { + "anim5": "string", + "culpa_852": "string", + "deserunt_12": "string" + }, + "replyOptions": [ + { + "linkURL": "www.dgidir.no", + "linkText": "digdir" + } + ], + "notification": null, + "ignoreReservation": true, + "published": "2024-11-14T11:06:56.208705+00:00", + "isConfirmationNeeded": false + } + """; + + // Act + var parsedResponse = JsonSerializer.Deserialize(encodedResponse); + + // Assert + Assert.NotNull(parsedResponse); + parsedResponse.StatusHistory.Should().HaveCount(3); + parsedResponse + .StatusHistory.Last() + .Should() + .Be( + new CorrespondenceStatusEventResponse + { + Status = CorrespondenceStatus.Published, + StatusText = "Published", + StatusChanged = DateTime.Parse("2024-11-14T11:06:56.208705+00:00"), + } + ); + parsedResponse.Notifications.Should().HaveCount(2); + parsedResponse + .Notifications!.Last() + .Should() + .BeEquivalentTo( + new CorrespondenceNotificationOrderResponse + { + Id = "7ab0ff62-8c5d-4a2e-8ad2-7e7236e847a4", + SendersReference = "1234", + RequestedSendTime = DateTimeOffset.Parse("2024-11-14T11:10:57.031351Z"), + Creator = "digdir", + Created = DateTimeOffset.Parse("2024-11-14T11:05:57.054356Z"), + NotificationChannel = CorrespondenceNotificationChannel.EmailPreferred, + IgnoreReservation = true, + ResourceId = "apps-correspondence-integrasjon2", + ProcessingStatus = new CorrespondenceNotificationStatusSummaryResponse + { + Status = "Completed", + Description = "Order processing is completed. All notifications have been generated.", + LastUpdate = DateTimeOffset.Parse("2024-11-14T11:05:57.054356Z"), + }, + NotificationStatusDetails = new CorrespondenceNotificationSummaryResponse + { + Email = new CorrespondenceNotificationStatusDetailsResponse + { + Id = Guid.Parse("0dabcc5c-c3de-4636-922c-e7b351cdbbfa"), + Succeeded = true, + Recipient = new CorrespondenceNotificationRecipientResponse + { + EmailAddress = "someone@digdir.no", + OrganisationNumber = "213872702", + }, + SendStatus = new CorrespondenceNotificationStatusSummaryResponse + { + Status = "Succeeded", + Description = + "The email has been accepted by the third party email service and will be sent shortly.", + LastUpdate = DateTime.Parse("2024-11-14T11:10:12.693438Z").ToUniversalTime(), + }, + }, + }, + } + ); + parsedResponse.Recipient.Should().Be("0192:213872702"); + parsedResponse.CorrespondenceId.Should().Be(Guid.Parse("94fa9dd9-734e-4712-9d49-4018aeb1a5dc")); + parsedResponse + .Content.Should() + .BeEquivalentTo( + new CorrespondenceContentResponse + { + Language = LanguageCode.Parse("en"), + MessageTitle = "This is the title 👋🏻", + MessageSummary = "This is the summary ✌️", + MessageBody = "This is the message\n\nHere is a newline.\n\nHere are some emojis: 📎👴🏻👨🏼‍🍳🥰", + Attachments = + [ + new CorrespondenceAttachmentResponse + { + Created = DateTimeOffset.Parse("2024-11-14T11:05:56.843622+00:00"), + DataLocationType = CorrespondenceDataLocationTypeResponse.AltinnCorrespondenceAttachment, + Status = CorrespondenceAttachmentStatusResponse.Published, + StatusText = "Published", + StatusChanged = DateTimeOffset.Parse("2024-11-14T11:06:00.102333+00:00"), + Id = Guid.Parse("a40fad32-dad1-442d-b4e1-2564d4561c07"), + FileName = "hello-world-3-1.pDf", + Name = "This is the PDF filename 🍕", + Checksum = "27bb85ec3681e3cd1ed44a079f5fc501", + SendersReference = "1234", + DataType = "application/pdf", + }, + ], + } + ); + parsedResponse.Created.Should().Be(DateTimeOffset.Parse("2024-11-14T11:05:56.575089+00:00")); + parsedResponse.Status.Should().Be(CorrespondenceStatus.Published); + parsedResponse.StatusText.Should().Be("Published"); + parsedResponse.ResourceId.Should().Be("apps-correspondence-integrasjon2"); + parsedResponse.Sender.Should().Be(OrganisationNumber.Parse("0192:991825827")); + parsedResponse.SendersReference.Should().Be("1234"); + parsedResponse.MessageSender.Should().Be("Test Testesen"); + parsedResponse.RequestedPublishTime.Should().Be(DateTimeOffset.Parse("2024-05-29T13:31:28.290518+00:00")); + parsedResponse.AllowSystemDeleteAfter.Should().Be(DateTimeOffset.Parse("2025-05-29T13:31:28.290518+00:00")); + parsedResponse.DueDateTime.Should().Be(DateTimeOffset.Parse("2025-05-29T13:31:28.290518+00:00")); + parsedResponse.ExternalReferences.Should().HaveCount(2); + parsedResponse + .ExternalReferences!.Last() + .Should() + .Be( + new CorrespondenceExternalReference + { + ReferenceType = CorrespondenceReferenceType.DialogportenDialogId, + ReferenceValue = "01932a59-edc3-7038-823e-cf46908cd83b", + } + ); + parsedResponse + .PropertyList.Should() + .BeEquivalentTo( + new Dictionary + { + ["anim5"] = "string", + ["culpa_852"] = "string", + ["deserunt_12"] = "string", + } + ); + parsedResponse.ReplyOptions.Should().HaveCount(1); + parsedResponse + .ReplyOptions!.First() + .Should() + .Be(new CorrespondenceReplyOption { LinkUrl = "www.dgidir.no", LinkText = "digdir" }); + parsedResponse.IgnoreReservation.Should().BeTrue(); + parsedResponse.Published.Should().Be(DateTimeOffset.Parse("2024-11-14T11:06:56.208705+00:00")); + parsedResponse.IsConfirmationNeeded.Should().BeFalse(); + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Correspondence/TestHelpers.cs b/test/Altinn.App.Core.Tests/Features/Correspondence/TestHelpers.cs new file mode 100644 index 000000000..19ffd2062 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Correspondence/TestHelpers.cs @@ -0,0 +1,37 @@ +using System.Net; +using System.Text.Json; +using Altinn.App.Core.Features.Correspondence.Models; +using Altinn.App.Core.Models; +using Altinn.App.Core.Tests.Models; + +namespace Altinn.App.Core.Tests.Features.Correspondence; + +public static class TestHelpers +{ + public static OrganisationNumber GetOrganisationNumber(int index) + { + var i = index % OrganisationNumberTests.ValidOrganisationNumbers.Length; + return OrganisationNumber.Parse(OrganisationNumberTests.ValidOrganisationNumbers[i]); + } + + public static NationalIdentityNumber GetNationalIdentityNumber(int index) + { + var i = index % NationalIdentityNumberTests.ValidNationalIdentityNumbers.Length; + return NationalIdentityNumber.Parse(NationalIdentityNumberTests.ValidNationalIdentityNumbers[i]); + } + + public static HttpContent? GetItem(this MultipartFormDataContent content, string name) + { + return content.FirstOrDefault(item => item.Headers.ContentDisposition?.Name?.Trim('\"') == name); + } + + public static HttpResponseMessage ResponseMessageFactory( + T content, + HttpStatusCode statusCode = HttpStatusCode.OK + ) + { + string test = content as string ?? JsonSerializer.Serialize(content); + + return new HttpResponseMessage(statusCode) { Content = new StringContent(test) }; + } +} diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs index b97780fae..be0b706cf 100644 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs +++ b/test/Altinn.App.Core.Tests/Features/Maskinporten/Delegates/MaskinportenDelegatingHandlerTest.cs @@ -1,5 +1,5 @@ -using Altinn.App.Core.Features.Maskinporten.Exceptions; -using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Api.Tests.Utils; +using Altinn.App.Core.Features.Maskinporten.Constants; using FluentAssertions; using Moq; @@ -12,18 +12,14 @@ public async Task SendAsync_AddsAuthorizationHeader() { // Arrange var scopes = new[] { "scope1", "scope2" }; + var accessToken = PrincipalUtil.GetMaskinportenToken(scope: "-").AccessToken; var (client, handler) = TestHelpers.MockMaskinportenDelegatingHandlerFactory( + TokenAuthorities.Maskinporten, scopes, - new MaskinportenTokenResponse - { - TokenType = "Bearer", - Scope = "-", - AccessToken = "jwt-content-placeholder", - ExpiresIn = -1, - } + accessToken ); var httpClient = new HttpClient(handler); - var request = new HttpRequestMessage(HttpMethod.Get, "https://unittesting.to.nowhere"); + var request = new HttpRequestMessage(HttpMethod.Get, "https://some-maskinporten-url/token"); // Act await httpClient.SendAsync(request); @@ -32,32 +28,6 @@ public async Task SendAsync_AddsAuthorizationHeader() client.Verify(c => c.GetAccessToken(scopes, It.IsAny()), Times.Once); Assert.NotNull(request.Headers.Authorization); request.Headers.Authorization.Scheme.Should().Be("Bearer"); - request.Headers.Authorization.Parameter.Should().Be("jwt-content-placeholder"); - } - - [Fact] - public async Task SendAsync_OnlyAccepts_BearerTokens() - { - // Arrange - var (_, handler) = TestHelpers.MockMaskinportenDelegatingHandlerFactory( - ["scope1", "scope2"], - new MaskinportenTokenResponse - { - TokenType = "MAC", - Scope = "-", - AccessToken = "jwt-content-placeholder", - ExpiresIn = -1, - } - ); - var httpClient = new HttpClient(handler); - var request = new HttpRequestMessage(HttpMethod.Get, "https://unittesting.to.nowhere"); - - // Act - Func act = async () => await httpClient.SendAsync(request); - - // Assert - await act.Should() - .ThrowAsync() - .WithMessage("Unsupported token type received from Maskinporten: *"); + request.Headers.Authorization.Parameter.Should().Be(accessToken.ToStringUnmasked()); } } diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs index 4c9210cc1..59518d080 100644 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs +++ b/test/Altinn.App.Core.Tests/Features/Maskinporten/MaskinportenClientTest.cs @@ -1,16 +1,15 @@ using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Text.Json; +using Altinn.App.Api.Tests.Utils; using Altinn.App.Core.Features.Maskinporten; using Altinn.App.Core.Features.Maskinporten.Exceptions; using Altinn.App.Core.Features.Maskinporten.Models; using FluentAssertions; -using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Internal; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; using Moq; @@ -23,38 +22,92 @@ private sealed class FakeTime(DateTimeOffset startDateTime) : FakeTimeProvider(s public DateTimeOffset UtcNow => GetUtcNow(); } - private readonly Mock _mockHttpClientFactory; - private readonly FakeTime _fakeTimeProvider; - private readonly MaskinportenClient _maskinportenClient; - private readonly MaskinportenSettings _maskinportenSettings = new() + private sealed record Fixture(WebApplication App) : IAsyncDisposable { - Authority = "https://maskinporten.dev/", - ClientId = "test-client-id", - JwkBase64 = - "ewogICAgICAicCI6ICItU09GNmp3V0N3b19nSlByTnJhcVNkNnZRckFzRmxZd1VScHQ0NC1BNlRXUnBoaUo4b3czSTNDWGxxUG1LeG5VWDVDcnd6SF8yeldTNGtaaU9zQTMtajhiUE9hUjZ2a3pRSG14YmFkWmFmZjBUckdJajNQUlhxcVdMRHdsZjNfNklDV2gzOFhodXNBeDVZRE0tRm8zZzRLVWVHM2NxMUFvTkJ4NHV6Sy1IRHMiLAogICAgICAia3R5IjogIlJTQSIsCiAgICAgICJxIjogIndwWUlpOVZJLUJaRk9aYUNaUmVhYm4xWElQbW8tbEJIendnc1RCdHVfeUJma1FQeGI1Q1ZnZFFnaVQ4dTR3Tkl4NC0zb2ROdXhsWGZING1Hc25xOWFRaFlRNFEyc2NPUHc5V2dNM1dBNE1GMXNQQXgzUGJLRkItU01RZmZ4aXk2cVdJSmRQSUJ4OVdFdnlseW9XbEhDcGZsUWplT3U2dk43WExsZ3c5T2JhVSIsCiAgICAgICJkIjogIks3Y3pqRktyWUJfRjJYRWdoQ1RQY2JTbzZZdExxelFwTlZleF9HZUhpTmprWmNpcEVaZ3g4SFhYLXpNSi01ZWVjaTZhY1ZjSzhhZzVhQy01Mk84LTU5aEU3SEE2M0FoRzJkWFdmamdQTXhaVE9MbnBheWtZbzNWa0NGNF9FekpLYmw0d2ludnRuTjBPc2dXaVZiTDFNZlBjWEdqbHNTUFBIUlAyaThDajRqX21OM2JVcy1FbVM5UzktSXlia1luYV9oNUMxMEluXy1tWHpsQ2dCNU9FTXFzd2tNUWRZVTBWbHVuWHM3YXlPT0h2WWpQMWFpYml0MEpyay1iWVFHSy1mUVFFVWNZRkFSN1ZLMkxIaUJwU0NvbzBiSjlCQ1BZb196bTVNVnVId21xbzNtdml1Vy1lMnVhbW5xVHpZUEVWRE1lMGZBSkZtcVBGcGVwTzVfcXE2USIsCiAgICAgICJlIjogIkFRQUIiLAogICAgICAidXNlIjogInNpZyIsCiAgICAgICJraWQiOiAiYXNkZjEyMzQiLAogICAgICAicWkiOiAicXpFUUdXOHBPVUgtR2pCaFUwVXNhWWtEM2dWTVJvTF9CbGlRckp4ZTAwY29YeUtIZGVEX2M1bDFDNFFJZzRJSjZPMnFZZ2wyamRnWVNmVHA0S2NDNk1Obm8tSVFiSnlPRDU2Qmo4eVJUUjA5TkZvTGhDUjNhY0xmMkhwTXNKNUlqbTdBUHFPVWlCeW9hVkExRlR4bzYtZGNfZ1NiQjh1ZDI2bFlFRHdsYWMwIiwKICAgICAgImRwIjogInRnTU14N2FFQ0NiQmctY005Vmo0Q2FXbGR0d01LWGxvTFNoWTFlSTJOS3BOTVFKR2JhdWdjTVRHQ21qTk1fblgzTVZ0cHRvMWFPbTMySlhCRjlqc1RHZWtONWJmVGNJbmZsZ3Bsc21uR2pMckNqN0xYTG9wWUxiUnBabF9iNm1JaThuU2ZCQXVQR2hEUzc4UWZfUXhFR1Bxb2h6cEZVTW5UQUxzOVI0Nkk1YyIsCiAgICAgICJhbGciOiAiUlMyNTYiLAogICAgICAiZHEiOiAibE40cF9ha1lZVXpRZTBWdHp4LW1zNTlLLUZ4bzdkQmJqOFhGOWhnSzdENzlQam5SRGJTRTNVWEgtcGlQSzNpSXhyeHFGZkZuVDJfRS15REJIMjBOMmZ4YllwUVZNQnpZc1UtUGQ2OFBBV1Nnd05TU29XVmhwdEdjaTh4bFlfMDJkWDRlbEF6T1ZlOUIxdXBEMjc5cWJXMVdKVG5TQmp4am1LVU5lQjVPdDAwIiwKICAgICAgIm4iOiAidlY3dW5TclNnekV3ZHo0dk8wTnNmWDB0R1NwT2RITE16aDFseUVtU2RYbExmeVYtcUxtbW9qUFI3S2pUU2NDbDI1SFI4SThvWG1mcDhSZ19vbnA0LUlZWW5ZV0RTNngxVlViOVlOQ3lFRTNQQTUtVjlOYzd5ckxxWXpyMTlOSkJmdmhJVEd5QUFVTjFCeW5JeXJ5NFFMbHRYYTRKSTFiLTh2QXNJQ0xyU1dQZDdibWxrOWo3bU1jV3JiWlNIZHNTMGNpVFgzYTc2UXdMb0F2SW54RlhCU0ludXF3ZVhnVjNCZDFQaS1DZGpCR0lVdXVyeVkybEwybmRnVHZUY2tZUTBYeEtGR3lCdDNaMEhJMzRBRFBrVEZneWFMX1F4NFpIZ3d6ZjRhTHBXaHF3OGVWanpPMXlucjJ3OUd4b2dSN1pWUjY3VFI3eUxSS3VrMWdIdFlkUkJ3IgogICAgfQ==", - }; + internal static readonly MaskinportenSettings DefaultSettings = new() + { + Authority = "https://maskinporten.dev/", + ClientId = "test-client-id", + JwkBase64 = + "ewogICAgICAicCI6ICItU09GNmp3V0N3b19nSlByTnJhcVNkNnZRckFzRmxZd1VScHQ0NC1BNlRXUnBoaUo4b3czSTNDWGxxUG1LeG5VWDVDcnd6SF8yeldTNGtaaU9zQTMtajhiUE9hUjZ2a3pRSG14YmFkWmFmZjBUckdJajNQUlhxcVdMRHdsZjNfNklDV2gzOFhodXNBeDVZRE0tRm8zZzRLVWVHM2NxMUFvTkJ4NHV6Sy1IRHMiLAogICAgICAia3R5IjogIlJTQSIsCiAgICAgICJxIjogIndwWUlpOVZJLUJaRk9aYUNaUmVhYm4xWElQbW8tbEJIendnc1RCdHVfeUJma1FQeGI1Q1ZnZFFnaVQ4dTR3Tkl4NC0zb2ROdXhsWGZING1Hc25xOWFRaFlRNFEyc2NPUHc5V2dNM1dBNE1GMXNQQXgzUGJLRkItU01RZmZ4aXk2cVdJSmRQSUJ4OVdFdnlseW9XbEhDcGZsUWplT3U2dk43WExsZ3c5T2JhVSIsCiAgICAgICJkIjogIks3Y3pqRktyWUJfRjJYRWdoQ1RQY2JTbzZZdExxelFwTlZleF9HZUhpTmprWmNpcEVaZ3g4SFhYLXpNSi01ZWVjaTZhY1ZjSzhhZzVhQy01Mk84LTU5aEU3SEE2M0FoRzJkWFdmamdQTXhaVE9MbnBheWtZbzNWa0NGNF9FekpLYmw0d2ludnRuTjBPc2dXaVZiTDFNZlBjWEdqbHNTUFBIUlAyaThDajRqX21OM2JVcy1FbVM5UzktSXlia1luYV9oNUMxMEluXy1tWHpsQ2dCNU9FTXFzd2tNUWRZVTBWbHVuWHM3YXlPT0h2WWpQMWFpYml0MEpyay1iWVFHSy1mUVFFVWNZRkFSN1ZLMkxIaUJwU0NvbzBiSjlCQ1BZb196bTVNVnVId21xbzNtdml1Vy1lMnVhbW5xVHpZUEVWRE1lMGZBSkZtcVBGcGVwTzVfcXE2USIsCiAgICAgICJlIjogIkFRQUIiLAogICAgICAidXNlIjogInNpZyIsCiAgICAgICJraWQiOiAiYXNkZjEyMzQiLAogICAgICAicWkiOiAicXpFUUdXOHBPVUgtR2pCaFUwVXNhWWtEM2dWTVJvTF9CbGlRckp4ZTAwY29YeUtIZGVEX2M1bDFDNFFJZzRJSjZPMnFZZ2wyamRnWVNmVHA0S2NDNk1Obm8tSVFiSnlPRDU2Qmo4eVJUUjA5TkZvTGhDUjNhY0xmMkhwTXNKNUlqbTdBUHFPVWlCeW9hVkExRlR4bzYtZGNfZ1NiQjh1ZDI2bFlFRHdsYWMwIiwKICAgICAgImRwIjogInRnTU14N2FFQ0NiQmctY005Vmo0Q2FXbGR0d01LWGxvTFNoWTFlSTJOS3BOTVFKR2JhdWdjTVRHQ21qTk1fblgzTVZ0cHRvMWFPbTMySlhCRjlqc1RHZWtONWJmVGNJbmZsZ3Bsc21uR2pMckNqN0xYTG9wWUxiUnBabF9iNm1JaThuU2ZCQXVQR2hEUzc4UWZfUXhFR1Bxb2h6cEZVTW5UQUxzOVI0Nkk1YyIsCiAgICAgICJhbGciOiAiUlMyNTYiLAogICAgICAiZHEiOiAibE40cF9ha1lZVXpRZTBWdHp4LW1zNTlLLUZ4bzdkQmJqOFhGOWhnSzdENzlQam5SRGJTRTNVWEgtcGlQSzNpSXhyeHFGZkZuVDJfRS15REJIMjBOMmZ4YllwUVZNQnpZc1UtUGQ2OFBBV1Nnd05TU29XVmhwdEdjaTh4bFlfMDJkWDRlbEF6T1ZlOUIxdXBEMjc5cWJXMVdKVG5TQmp4am1LVU5lQjVPdDAwIiwKICAgICAgIm4iOiAidlY3dW5TclNnekV3ZHo0dk8wTnNmWDB0R1NwT2RITE16aDFseUVtU2RYbExmeVYtcUxtbW9qUFI3S2pUU2NDbDI1SFI4SThvWG1mcDhSZ19vbnA0LUlZWW5ZV0RTNngxVlViOVlOQ3lFRTNQQTUtVjlOYzd5ckxxWXpyMTlOSkJmdmhJVEd5QUFVTjFCeW5JeXJ5NFFMbHRYYTRKSTFiLTh2QXNJQ0xyU1dQZDdibWxrOWo3bU1jV3JiWlNIZHNTMGNpVFgzYTc2UXdMb0F2SW54RlhCU0ludXF3ZVhnVjNCZDFQaS1DZGpCR0lVdXVyeVkybEwybmRnVHZUY2tZUTBYeEtGR3lCdDNaMEhJMzRBRFBrVEZneWFMX1F4NFpIZ3d6ZjRhTHBXaHF3OGVWanpPMXlucjJ3OUd4b2dSN1pWUjY3VFI3eUxSS3VrMWdIdFlkUkJ3IgogICAgfQ==", + }; - public MaskinportenClientTests() - { - _mockHttpClientFactory = new Mock(); - _fakeTimeProvider = new FakeTime(DateTimeOffset.UtcNow); + internal static readonly MaskinportenSettings InternalSettings = DefaultSettings with + { + ClientId = "internal-client-id", + }; - var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => - services.Configure(options => options.Clock = _fakeTimeProvider) - ); + public FakeTime FakeTime => App.Services.GetRequiredService(); + public Mock HttpClientFactoryMock => + Moq.Mock.Get(App.Services.GetRequiredService()); - var tokenCache = app.Services.GetRequiredService(); - var mockLogger = new Mock>(); - var mockOptions = new Mock>(); - mockOptions.Setup(o => o.CurrentValue).Returns(_maskinportenSettings); - - _maskinportenClient = new MaskinportenClient( - mockOptions.Object, - _mockHttpClientFactory.Object, - tokenCache, - mockLogger.Object, - _fakeTimeProvider - ); + public MaskinportenClient Client(string variant) => + variant switch + { + MaskinportenClient.VariantInternal => (MaskinportenClient) + App.Services.GetRequiredKeyedService(MaskinportenClient.VariantInternal), + MaskinportenClient.VariantDefault => (MaskinportenClient) + App.Services.GetRequiredService(), + _ => throw new ArgumentException($"Unknown variant: {variant}"), + }; + + public static Fixture Create(bool configureMaskinporten = true) + { + var mockHttpClientFactory = new Mock(); + var fakeTimeProvider = new FakeTime(new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero)); + + var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => + { + services.AddSingleton(mockHttpClientFactory.Object); + services.Configure(options => options.Clock = fakeTimeProvider); + services.AddSingleton(fakeTimeProvider); + services.AddSingleton(fakeTimeProvider); + + if (configureMaskinporten) + { + services.Configure(options => + { + options.Authority = DefaultSettings.Authority; + options.ClientId = DefaultSettings.ClientId; + options.JwkBase64 = DefaultSettings.JwkBase64; + }); + services.Configure( + MaskinportenClient.VariantInternal, + options => + { + options.Authority = InternalSettings.Authority; + options.ClientId = InternalSettings.ClientId; + options.JwkBase64 = InternalSettings.JwkBase64; + } + ); + } + }); + + return new Fixture(app); + } + + public async ValueTask DisposeAsync() => await App.DisposeAsync(); + } + + public static TheoryData Variants => + new(MaskinportenClient.VariantDefault, MaskinportenClient.VariantInternal); + + [Fact] + public async Task Test_DI_And_Configuration() + { + // Arrange + await using var fixture = Fixture.Create(); + var defaultClient = fixture.Client(MaskinportenClient.VariantDefault); + var internalClient = fixture.Client(MaskinportenClient.VariantInternal); + Assert.NotNull(defaultClient); + Assert.NotNull(internalClient); + + // Assert + defaultClient.Should().NotBeSameAs(internalClient); + defaultClient.Settings.Should().BeEquivalentTo(Fixture.DefaultSettings); + internalClient.Settings.Should().BeEquivalentTo(Fixture.InternalSettings); + internalClient.Variant.Should().Be(MaskinportenClient.VariantInternal); + defaultClient.Variant.Should().Be(MaskinportenClient.VariantDefault); } [Fact] @@ -72,7 +125,7 @@ public async Task GenerateAuthenticationPayload_HasCorrectFormat() var jwt = "access-token-content"; // Act - var content = MaskinportenClient.GenerateAuthenticationPayload(jwt); + var content = MaskinportenClient.AuthenticationPayloadFactory(jwt); var parsed = await TestHelpers.ParseFormUrlEncodedContent(content); // Assert @@ -81,134 +134,195 @@ public async Task GenerateAuthenticationPayload_HasCorrectFormat() parsed["assertion"].Should().Be(jwt); } - [Fact] - public void GenerateJwtGrant_HasCorrectFormat() + [Theory] + [MemberData(nameof(Variants))] + public async Task GenerateJwtGrant_HasCorrectFormat(string variant) { // Arrange + await using var fixture = Fixture.Create(); + var settings = fixture.Client(variant).Settings; var scopes = "scope1 scope2"; // Act - var jwt = _maskinportenClient.GenerateJwtGrant(scopes); + var jwt = fixture.Client(variant).GenerateJwtGrant(scopes); var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt); // Assert parsed.Audiences.Count().Should().Be(1); - parsed.Audiences.First().Should().Be(_maskinportenSettings.Authority); - parsed.Issuer.Should().Be(_maskinportenSettings.ClientId); + parsed.Audiences.First().Should().Be(settings.Authority); + parsed.Issuer.Should().Be(settings.ClientId); parsed.Claims.First(x => x.Type == "scope").Value.Should().Be(scopes); } - [Fact] - public async Task GetAccessToken_ReturnsAToken() + [Theory] + [MemberData(nameof(Variants))] + public async Task GenerateJwtGrant_HandlesMissingSettings(string variant) { // Arrange - string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse + await using var fixture = Fixture.Create(configureMaskinporten: false); + + // Act + var act = () => { - AccessToken = "access-token-content", - ExpiresIn = 120, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer", + fixture.Client(variant).GenerateJwtGrant("scope"); }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) + + // Assert + act.Should().Throw(); + } + + [Theory] + [MemberData(nameof(Variants))] + public async Task GetAccessToken_ReturnsAToken(string variant) + { + // Arrange + await using var fixture = Fixture.Create(); + string[] scopes = ["scope1", "scope2"]; + string formattedScopes = MaskinportenClient.FormattedScopes(scopes); + var maskinportenTokenResponse = PrincipalUtil.GetMaskinportenToken( + scope: formattedScopes, + expiry: TimeSpan.FromMinutes(2), + fixture.FakeTime + ); + fixture + .HttpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) .Returns(() => { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); + var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(maskinportenTokenResponse); return new HttpClient(mockHandler.Object); }); // Act - var result = await _maskinportenClient.GetAccessToken(scopes); + var result = await fixture.Client(variant).GetAccessToken(scopes); // Assert - result.Should().BeEquivalentTo(tokenResponse, config => config.Excluding(x => x.ExpiresAt)); + result.Should().BeEquivalentTo(maskinportenTokenResponse.AccessToken); + result.Scope.Should().BeEquivalentTo(formattedScopes); } - [Fact] - public async Task GetAccessToken_ThrowsExceptionWhenTokenIsExpired() + [Theory] + [MemberData(nameof(Variants))] + public async Task GetAltinnExchangedToken_ReturnsAToken(string variant) { // Arrange - var scopes = new List { "scope1", "scope2" }; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "expired-access-token", - ExpiresIn = MaskinportenClient.TokenExpirationMargin - 1, - Scope = "-", - TokenType = "Bearer", - }; + await using var fixture = Fixture.Create(); + string[] scopes = ["scope1", "scope2"]; + var maskinportenTokenResponse = PrincipalUtil.GetMaskinportenToken( + scope: MaskinportenClient.FormattedScopes(scopes), + expiry: TimeSpan.FromMinutes(2), + fixture.FakeTime + ); + var expiresIn = TimeSpan.FromMinutes(30); + var altinnAccessToken = PrincipalUtil.GetOrgToken("ttd", "160694123", 3, expiresIn, fixture.FakeTime); + fixture + .HttpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) + .Returns(() => + { + var mockHandler = TestHelpers.MockHttpMessageHandlerFactory( + maskinportenTokenResponse, + altinnAccessToken + ); + return new HttpClient(mockHandler.Object); + }); + + // Act + var result = await fixture.Client(variant).GetAltinnExchangedToken(scopes); + + // Assert + result.Value.Should().NotBeNullOrWhiteSpace(); + result.ExpiresAt.Should().Be(fixture.FakeTime.GetUtcNow().Add(expiresIn).UtcDateTime); + } - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) + [Theory] + [MemberData(nameof(Variants))] + public async Task GetAccessToken_ThrowsExceptionWhenTokenIsExpired(string variant) + { + // Arrange + await using var fixture = Fixture.Create(); + var maskinportenTokenResponse = PrincipalUtil.GetMaskinportenToken( + scope: "-", + expiry: MaskinportenClient.TokenExpirationMargin - TimeSpan.FromSeconds(1), + fixture.FakeTime + ); + + fixture + .HttpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) .Returns(() => { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); + var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(maskinportenTokenResponse); return new HttpClient(mockHandler.Object); }); // Act - Func act = async () => + Func act1 = async () => + { + await fixture.Client(variant).GetAccessToken(["scope1", "scope2"]); + }; + Func act2 = async () => { - await _maskinportenClient.GetAccessToken(scopes); + await fixture.Client(variant).GetAltinnExchangedToken(["scope1", "scope2"]); }; // Assert - await act.Should().ThrowAsync(); + await act1.Should().ThrowAsync(); + await act2.Should().ThrowAsync(); } - [Fact] - public async Task GetAccessToken_UsesCachedTokenIfAvailable() + [Theory] + [MemberData(nameof(Variants))] + public async Task GetAccessToken_UsesCachedTokenIfAvailable(string variant) { // Arrange + await using var fixture = Fixture.Create(); string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "2 minute access token content", - ExpiresIn = 120, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer", - }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) + var maskinportenTokenResponse = () => + PrincipalUtil.GetMaskinportenToken( + scope: MaskinportenClient.FormattedScopes(scopes), + expiry: TimeSpan.FromMinutes(2), + fixture.FakeTime + ); + fixture + .HttpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) .Returns(() => { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); + var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(maskinportenTokenResponse.Invoke()); return new HttpClient(mockHandler.Object); }); // Act - var token1 = await _maskinportenClient.GetAccessToken(scopes); - _fakeTimeProvider.Advance(TimeSpan.FromMinutes(1)); - var token2 = await _maskinportenClient.GetAccessToken(scopes); + var token1 = await fixture.Client(variant).GetAccessToken(scopes); + fixture.FakeTime.Advance(TimeSpan.FromMinutes(1)); + var token2 = await fixture.Client(variant).GetAccessToken(scopes); // Assert - token1.Should().BeSameAs(token2); + token1.Should().BeEquivalentTo(token2); } - [Fact] - public async Task GetAccessToken_GeneratesNewTokenIfRequired() + [Theory] + [MemberData(nameof(Variants))] + public async Task GetAccessToken_GeneratesNewTokenIfRequired(string variant) { // Arrange + await using var fixture = Fixture.Create(); string[] scopes = ["scope1", "scope2"]; - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "Very short lived access token", - ExpiresIn = MaskinportenClient.TokenExpirationMargin + 1, - Scope = MaskinportenClient.FormattedScopes(scopes), - TokenType = "Bearer", - }; - _mockHttpClientFactory - .Setup(x => x.CreateClient(It.IsAny())) + var maskinportenTokenResponse = () => + PrincipalUtil.GetMaskinportenToken( + scope: MaskinportenClient.FormattedScopes(scopes), + expiry: MaskinportenClient.TokenExpirationMargin + TimeSpan.FromSeconds(1), + fixture.FakeTime + ); + fixture + .HttpClientFactoryMock.Setup(x => x.CreateClient(It.IsAny())) .Returns(() => { - var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(tokenResponse); + var mockHandler = TestHelpers.MockHttpMessageHandlerFactory(maskinportenTokenResponse.Invoke()); return new HttpClient(mockHandler.Object); }); // Act - var token1 = await _maskinportenClient.GetAccessToken(scopes); - _fakeTimeProvider.Advance(TimeSpan.FromSeconds(10)); - var token2 = await _maskinportenClient.GetAccessToken(scopes); + var token1 = await fixture.Client(variant).GetAccessToken(scopes); + fixture.FakeTime.Advance(TimeSpan.FromSeconds(10)); + var token2 = await fixture.Client(variant).GetAccessToken(scopes); // Assert token1.Should().NotBeSameAs(token2); @@ -264,17 +378,14 @@ await act.Should() public async Task ParseServerResponse_ThrowsOn_DisposedObject() { // Arrange - var tokenResponse = new MaskinportenTokenResponse - { - AccessToken = "access-token-content", - ExpiresIn = 120, - Scope = "scope1 scope2", - TokenType = "Bearer", - }; + var maskinportenTokenResponse = PrincipalUtil.GetMaskinportenToken( + scope: "a b", + expiry: MaskinportenClient.TokenExpirationMargin + TimeSpan.FromSeconds(1) + ); var validHttpResponse = new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonSerializer.Serialize(tokenResponse)), + Content = new StringContent(JsonSerializer.Serialize(maskinportenTokenResponse)), }; // Act diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs index 717c9660a..37d5a3e9c 100644 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs +++ b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenSettingsTest.cs @@ -12,7 +12,7 @@ public class MaskinportenSettingsTest /// /// This key definition is complete and valid /// - private string validJwk = """ + private static readonly string _validJwk = """ { "p": "5BRHaF0zpryULcbyTf02xZUXMb26Ait8XvU4NsAYCH4iLNkC_zYRJ0X_qb0sJ_WVYecB1-nCV1Qr15KnsaKp1qBOx21_ftHHwdBE12z9KYGe1xQ4ZIXEP0OiR044XQPphRFVjWOF7wQKdoXlTNXCg4B3lo5waBj8eYmMHCxyK6k", "kty": "RSA", @@ -30,15 +30,15 @@ public class MaskinportenSettingsTest """; /// - /// This is a Base64 encoded version of . - /// + /// This is a Base64 encoded version of . + /// /// - private string validJwk_Base64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(validJwk)); + private static string _validJwkBase64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(_validJwk)); /// /// This key definition is missing the `e` exponent and the `kid` identifier /// - private string invalidJwk = """ + private static readonly string _invalidJwk = """ { "p": "5BRHaF0zpryULcbyTf02xZUXMb26Ait8XvU4NsAYCH4iLNkC_zYRJ0X_qb0sJ_WVYecB1-nCV1Qr15KnsaKp1qBOx21_ftHHwdBE12z9KYGe1xQ4ZIXEP0OiR044XQPphRFVjWOF7wQKdoXlTNXCg4B3lo5waBj8eYmMHCxyK6k", "kty": "RSA", @@ -56,10 +56,10 @@ public class MaskinportenSettingsTest """; /// - /// This is a Base64 encoded version of . - /// + /// This is a Base64 encoded version of . + /// /// - private string invalidJwk_base64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(invalidJwk)); + private static string _invalidJwkBase64 => Convert.ToBase64String(Encoding.UTF8.GetBytes(_invalidJwk)); [Fact] public void ShouldDeserializeFromJsonCorrectly() @@ -69,7 +69,7 @@ public void ShouldDeserializeFromJsonCorrectly() { "authority": "https://maskinporten.dev/", "clientId": "test-client", - "jwk": {{validJwk}} + "jwk": {{_validJwk}} } """; @@ -91,7 +91,7 @@ public void ShouldDeserializeFromJsonCorrectly_Base64Encoded() { "authority": "https://maskinporten.dev/", "clientId": "test-client", - "jwkBase64": "{{validJwk_Base64}}" + "jwkBase64": "{{_validJwkBase64}}" } """; @@ -113,7 +113,7 @@ public void ShouldValidateJwkAfterDeserializing() { "authority": "https://maskinporten.dev/", "clientId": "test-client", - "jwk": {{invalidJwk}} + "jwk": {{_invalidJwk}} } """; @@ -139,7 +139,7 @@ public void ShouldValidateJwkAfterDeserializing_Base64() { "authority": "https://maskinporten.dev/", "clientId": "test-client", - "jwkBase64": "{{invalidJwk_base64}}" + "jwkBase64": "{{_invalidJwkBase64}}" } """; diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs index 0c49cf5c0..3237bac93 100644 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs +++ b/test/Altinn.App.Core.Tests/Features/Maskinporten/Models/MaskinportenTokenResponseTest.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; using FluentAssertions; namespace Altinn.App.Core.Tests.Features.Maskinporten.Models; @@ -10,9 +11,10 @@ public class MaskinportenTokenResponseTest public void ShouldDeserializeFromJsonCorrectly() { // Arrange - var json = """ + var encodedToken = TestHelpers.GetEncodedAccessToken(); + var json = $$""" { - "access_token": "jwt.content.here", + "access_token": "{{encodedToken.AccessToken}}", "token_type": "Bearer", "expires_in": 120, "scope": "anything" @@ -20,16 +22,34 @@ public void ShouldDeserializeFromJsonCorrectly() """; // Act - var beforeCreation = DateTime.UtcNow; - var token = JsonSerializer.Deserialize(json); - var afterCreation = DateTime.UtcNow; + var tokenResponse = JsonSerializer.Deserialize(json); // Assert - Assert.NotNull(token); - token.AccessToken.Should().Be("jwt.content.here"); - token.TokenType.Should().Be("Bearer"); - token.Scope.Should().Be("anything"); - token.ExpiresIn.Should().Be(120); - token.ExpiresAt.Should().BeBefore(afterCreation.AddSeconds(120)).And.BeAfter(beforeCreation.AddSeconds(120)); + Assert.NotNull(tokenResponse); + tokenResponse.AccessToken.Should().Be(JwtToken.Parse(encodedToken.AccessToken)); + tokenResponse.TokenType.Should().Be("Bearer"); + tokenResponse.Scope.Should().Be("anything"); + tokenResponse.ExpiresIn.Should().Be(120); + } + + [Fact] + public void ToString_ShouldMaskAccessToken() + { + // Arrange + var encodedToken = TestHelpers.GetEncodedAccessToken(); + + // Act + var tokenResponse = new MaskinportenTokenResponse + { + AccessToken = JwtToken.Parse(encodedToken.AccessToken), + Scope = "yep", + TokenType = "Bearer", + ExpiresIn = 120, + }; + + // Assert + tokenResponse.AccessToken.ToStringUnmasked().Should().Be(encodedToken.AccessToken); + tokenResponse.ToString().Should().NotContain(encodedToken.Components.Signature); + $"{tokenResponse}".Should().NotContain(encodedToken.Components.Signature); } } diff --git a/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs b/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs index c5fa1d892..edb33d29e 100644 --- a/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs +++ b/test/Altinn.App.Core.Tests/Features/Maskinporten/TestHelpers.cs @@ -1,11 +1,13 @@ +using System.Linq.Expressions; using System.Net; -using System.Security.Cryptography; using System.Text.Json; +using Altinn.App.Api.Tests.Utils; using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Features.Maskinporten.Constants; using Altinn.App.Core.Features.Maskinporten.Delegates; using Altinn.App.Core.Features.Maskinporten.Models; +using Altinn.App.Core.Models; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Tokens; using Moq; using Moq.Protected; @@ -13,14 +15,22 @@ namespace Altinn.App.Core.Tests.Features.Maskinporten; internal static class TestHelpers { - public static Mock MockHttpMessageHandlerFactory(MaskinportenTokenResponse tokenResponse) + private static readonly Expression> _isTokenRequest = req => + req.RequestUri!.PathAndQuery.Contains("token", StringComparison.OrdinalIgnoreCase); + private static readonly Expression> _isExchangeRequest = req => + req.RequestUri!.PathAndQuery.Contains("exchange/maskinporten", StringComparison.OrdinalIgnoreCase); + + public static Mock MockHttpMessageHandlerFactory( + MaskinportenTokenResponse maskinportenTokenResponse, + string? altinnAccessToken = null + ) { var handlerMock = new Mock(); - handlerMock - .Protected() + var protectedMock = handlerMock.Protected(); + protectedMock .Setup>( "SendAsync", - ItExpr.IsAny(), + ItExpr.Is(_isTokenRequest), ItExpr.IsAny() ) .ReturnsAsync( @@ -28,7 +38,23 @@ public static Mock MockHttpMessageHandlerFactory(Maskinporte new HttpResponseMessage { StatusCode = HttpStatusCode.OK, - Content = new StringContent(JsonSerializer.Serialize(tokenResponse)), + Content = new StringContent(JsonSerializer.Serialize(maskinportenTokenResponse)), + } + ); + + altinnAccessToken ??= PrincipalUtil.GetOrgToken("ttd", "160694123", 3); + protectedMock + .Setup>( + "SendAsync", + ItExpr.Is(_isExchangeRequest), + ItExpr.IsAny() + ) + .ReturnsAsync( + () => + new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(altinnAccessToken), } ); @@ -38,7 +64,11 @@ public static Mock MockHttpMessageHandlerFactory(Maskinporte public static ( Mock client, MaskinportenDelegatingHandler handler - ) MockMaskinportenDelegatingHandlerFactory(IEnumerable scopes, MaskinportenTokenResponse tokenResponse) + ) MockMaskinportenDelegatingHandlerFactory( + TokenAuthorities authorities, + IEnumerable scopes, + JwtToken accessToken + ) { var mockProvider = new Mock(); var innerHandlerMock = new Mock(); @@ -54,16 +84,21 @@ MaskinportenDelegatingHandler handler .Protected() .Setup>( "SendAsync", - ItExpr.IsAny(), + ItExpr.Is(_isTokenRequest), ItExpr.IsAny() ) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); mockMaskinportenClient .Setup(c => c.GetAccessToken(scopes, It.IsAny())) - .ReturnsAsync(tokenResponse); + .ReturnsAsync(accessToken); - var handler = new MaskinportenDelegatingHandler(scopes, mockMaskinportenClient.Object, mockLogger.Object) + var handler = new MaskinportenDelegatingHandler( + authorities, + scopes, + mockMaskinportenClient.Object, + mockLogger.Object + ) { InnerHandler = innerHandlerMock.Object, }; @@ -79,4 +114,17 @@ public static async Task> ParseFormUrlEncodedContent( .Select(pair => pair.Split('=')) .ToDictionary(split => Uri.UnescapeDataString(split[0]), split => Uri.UnescapeDataString(split[1])); } + + public static ( + string AccessToken, + (string Header, string Payload, string Signature) Components + ) GetEncodedAccessToken() + { + const string testTokenHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; + const string testTokenPayload = "eyJzdWIiOiJpdHMtYS1tZSJ9"; + const string testTokenSignature = "wLLw4Timcl9gnQvA93RgREz-6S5y1UfzI_GYVI_XVDA"; + const string testToken = testTokenHeader + "." + testTokenPayload + "." + testTokenSignature; + + return (testToken, (testTokenHeader, testTokenPayload, testTokenSignature)); + } } diff --git a/test/Altinn.App.Core.Tests/Models/JwtTokenTests.cs b/test/Altinn.App.Core.Tests/Models/JwtTokenTests.cs new file mode 100644 index 000000000..f14fbfdd7 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/JwtTokenTests.cs @@ -0,0 +1,147 @@ +using Altinn.App.Core.Models; +using Altinn.App.Core.Tests.Features.Maskinporten; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; + +namespace Altinn.App.Core.Tests.Models; + +public class AccessTokenTests +{ + private static readonly string[] _validTokens = + [ + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UifQ.DjwRE2jZhren2Wt37t5hlVru6Myq4AhpGLiiefF69u8", + ]; + private static readonly string _invalidToken = "is.not.base64token"; + + [Fact] + public void Parse_ValidToken_ShouldReturnAccessToken() + { + // Arrange + var encodedToken = _validTokens[0]; + + // Act + var accessToken = JwtToken.Parse(encodedToken); + + // Assert + accessToken.Value.Should().Be(encodedToken); + } + + [Fact] + public void Parse_InvalidToken_ShouldThrowFormatException() + { + Assert.Throws(() => JwtToken.Parse(_invalidToken)); + } + + [Fact] + public void Equals_SameToken_ShouldReturnTrue() + { + // Arrange + var token1 = JwtToken.Parse(_validTokens[0]); + var token2 = JwtToken.Parse(_validTokens[0]); + + // Act + bool result1 = token1.Equals(token2); + bool result2 = token1 == token2; + bool result3 = token1 != token2; + + // Assert + result1.Should().BeTrue(); + result2.Should().BeTrue(); + result3.Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentToken_ShouldReturnFalse() + { + // Arrange + var token1 = JwtToken.Parse(_validTokens[0]); + var token2 = JwtToken.Parse(_validTokens[1]); + + // Act + bool result1 = token1.Equals(token2); + bool result2 = token1 == token2; + bool result3 = token1 != token2; + + // Assert + result1.Should().BeFalse(); + result2.Should().BeFalse(); + result3.Should().BeTrue(); + } + + [Fact] + public void ToString_ShouldReturnMaskedToken() + { + // Arrange + var token = JwtToken.Parse(_validTokens[0]); + + // Act + var maskedToken1 = token.ToString(); + var maskedToken2 = $"{token}"; + + // Assert + maskedToken1.Should().Be(maskedToken2); + maskedToken1 + .Should() + .Be( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + ); + } + + [Fact] + public void ImplicitConversion_ShouldReturnFullTokenString() + { + // Arrange + var token = JwtToken.Parse(_validTokens[0]); + + // Act + string tokenString = token; + + // Assert + tokenString.Should().Be(_validTokens[0]); + } + + [Fact] + public void Value_Property_ShouldReturnFullTokenString() + { + // Arrange + var token = JwtToken.Parse(_validTokens[0]); + + // Act + string tokenString = token.Value; + + // Assert + tokenString.Should().Be(_validTokens[0]); + } + + // [Theory] + // [InlineData(true)] + // [InlineData(false)] + // public void ShouldIndicateExpiry(bool expired) + // { + // // Arrange + // var encodedToken = TestHelpers.GetEncodedAccessToken(); + // var jwtToken = JwtToken.Parse(encodedToken.AccessToken); + // var expiry = jwtToken.ExpiresAt; + // var fakeTimeProvider = new FakeTimeProvider(expiry.AddDays(expired ? -1 : 1)); + + // // Act + // var isExpired = jwtToken.IsExpired(fakeTimeProvider); + + // // Assert + // isExpired.Should().Be(expired); + // } + + [Fact] + public void ToString_ShouldMask_AccessToken() + { + // Arrange + var encodedToken = TestHelpers.GetEncodedAccessToken(); + var accessToken = JwtToken.Parse(encodedToken.AccessToken); + + // Act, Assert + accessToken.ToStringUnmasked().Should().Be(encodedToken.AccessToken); + accessToken.ToString().Should().NotContain(encodedToken.Components.Signature); + $"{accessToken}".Should().NotContain(encodedToken.Components.Signature); + } +} diff --git a/test/Altinn.App.Core.Tests/Models/LanguageCodeTests.cs b/test/Altinn.App.Core.Tests/Models/LanguageCodeTests.cs new file mode 100644 index 000000000..d9fbdcdf7 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/LanguageCodeTests.cs @@ -0,0 +1,57 @@ +#nullable disable +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Models; + +public class LanguageCodeTests +{ + static readonly string[] _validIso6391Codes = ["aa", "Bb", "CC", "zz"]; + + static readonly string[] _invalidIso6391Codes = ["a.", " b", "abc", "😎🤓"]; + + [Fact] + public void ValidIso6391Code_ParsesOk() + { + foreach (var validCode in _validIso6391Codes) + { + var langCode = LanguageCode.Parse(validCode); + langCode.Value.Should().Be(validCode.ToLowerInvariant()); + } + } + + [Fact] + public void InvalidIso6391Code_ThrowsException() + { + foreach (var invalidCode in _invalidIso6391Codes) + { + Action act = () => LanguageCode.Parse(invalidCode); + act.Should().Throw(); + } + } + + [Fact] + public void Equality_WorksAsExpected() + { + var langCode1A = LanguageCode.Parse(_validIso6391Codes[0]); + var langCode1B = LanguageCode.Parse(_validIso6391Codes[0]); + var langCode2 = LanguageCode.Parse(_validIso6391Codes[2]); + + Assert.True(langCode1A == langCode1B); + Assert.True(langCode1A != langCode2); + Assert.False(langCode1A == langCode2); + + langCode1A.Should().Be(langCode1B); + langCode1A.Should().NotBe(langCode2); + } + + [Fact] + public void ImplicitStringConversion_WorksAsExpected() + { + foreach (var validCode in _validIso6391Codes) + { + string langCodeString = LanguageCode.Parse(validCode); + langCodeString.Should().Be(validCode.ToLowerInvariant()); + } + } +} diff --git a/test/Altinn.App.Core.Tests/Models/NationalIdentityNumberTests.cs b/test/Altinn.App.Core.Tests/Models/NationalIdentityNumberTests.cs new file mode 100644 index 000000000..ae0e7f11e --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/NationalIdentityNumberTests.cs @@ -0,0 +1,174 @@ +#nullable disable +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Models; + +public class NationalIdentityNumberTests +{ + internal static readonly string[] ValidNationalIdentityNumbers = + [ + "13896396174", + "29896695590", + "21882448425", + "03917396654", + "61875300317", + "60896400498", + "65918300265", + "22869798367", + "02912447718", + "22909397689", + "26267892619", + "12318496828", + "20270310266", + "10084808933", + "09113920472", + "28044017069", + "18055606346", + "24063324295", + "16084521195", + ]; + + internal static readonly string[] InvalidNationalIdentityNumbers = + [ + "13816396174", + "29896795590", + "21883418425", + "03917506654", + "61175310317", + "60996410498", + "65918310265", + "22869898467", + "02912447719", + "22909397680", + "26270892619", + "12318696828", + "20289310266", + "11084808933", + "08113921472", + "28044417069", + "180556f6346", + "240633242951", + "1234", + ]; + + [Fact] + public void Parse_ValidNumber_ShouldReturnOrganisationNumber() + { + foreach (var validNumber in ValidNationalIdentityNumbers) + { + var number = NationalIdentityNumber.Parse(validNumber); + number.Value.Should().Be(validNumber); + } + } + + [Fact] + public void Parse_InvalidNumber_ShouldThrowFormatException() + { + foreach (var invalidNumber in InvalidNationalIdentityNumbers) + { + Action act = () => NationalIdentityNumber.Parse(invalidNumber); + act.Should().Throw(); + } + } + + // [Fact] + // public void TryParse_ValidNumber_ShouldReturnTrue() + // { + // foreach (var validOrgNumber in ValidNationalIdentityNumbers) + // { + // OrganisationNumber.TryParse(validOrgNumber, out var parsed).Should().BeTrue(); + // parsed.Get(OrganisationNumberFormat.Local).Should().Be(validOrgNumber); + // } + // } + // + // [Fact] + // public void TryParse_InvalidNumber_ShouldReturnFalse() + // { + // foreach (var invalidOrgNumber in InvalidNationalIdentityNumbers) + // { + // OrganisationNumber.TryParse(invalidOrgNumber, out _).Should().BeFalse(); + // } + // } + // + // [Fact] + // public void Equals_SameNumber_ShouldReturnTrue() + // { + // // Arrange + // var number1 = OrganisationNumber.Parse(ValidNationalIdentityNumbers[0]); + // var number2 = OrganisationNumber.Parse(ValidNationalIdentityNumbers[0]); + // + // // Act + // bool result1 = number1.Equals(number2); + // bool result2 = number1 == number2; + // bool result3 = number1 != number2; + // + // // Assert + // result1.Should().BeTrue(); + // result2.Should().BeTrue(); + // result3.Should().BeFalse(); + // } + // + // [Fact] + // public void Equals_DifferentNumber_ShouldReturnFalse() + // { + // // Arrange + // var number1 = OrganisationNumber.Parse(ValidNationalIdentityNumbers[0]); + // var number2 = OrganisationNumber.Parse(ValidNationalIdentityNumbers[1]); + // + // // Act + // bool result1 = number1.Equals(number2); + // bool result2 = number1 == number2; + // bool result3 = number1 != number2; + // + // // Assert + // result1.Should().BeFalse(); + // result2.Should().BeFalse(); + // result3.Should().BeTrue(); + // } + // + // [Fact] + // public void ToString_ShouldReturnLocalFormat() + // { + // // Arrange + // var rawLocal = ValidNationalIdentityNumbers[0]; + // var number = OrganisationNumber.Parse(rawLocal); + // + // // Act + // var stringified1 = number.ToString(); + // var stringified2 = $"{number}"; + // + // // Assert + // stringified1.Should().Be(rawLocal); + // stringified2.Should().Be(rawLocal); + // } + // + // [Fact] + // public void GetMethod_ShouldReturnCorrectFormats() + // { + // // Arrange + // var rawLocal = ValidNationalIdentityNumbers[0]; + // var number = OrganisationNumber.Parse(rawLocal); + // + // // Act + // var stringified1 = number.Get(OrganisationNumberFormat.Local); + // var stringified2 = number.Get(OrganisationNumberFormat.International); + // + // // Assert + // stringified1.Should().Be(rawLocal); + // stringified2.Should().Be($"0192:{rawLocal}"); + // } + // + // [Fact] + // public void ImplicitConversion_ShouldReturnFullTokenString() + // { + // // Arrange + // var token = AccessToken.Parse(_validTokens[0]); + // + // // Act + // string tokenString = token; + // + // // Assert + // tokenString.Should().Be(_validTokens[0]); + // } +} diff --git a/test/Altinn.App.Core.Tests/Models/OrganisationNumberTests.cs b/test/Altinn.App.Core.Tests/Models/OrganisationNumberTests.cs new file mode 100644 index 000000000..37d8c647c --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/OrganisationNumberTests.cs @@ -0,0 +1,151 @@ +#nullable disable +using Altinn.App.Core.Models; +using FluentAssertions; + +namespace Altinn.App.Core.Tests.Models; + +public class OrganisationNumberTests +{ + internal static readonly string[] ValidOrganisationNumbers = + [ + "474103390", + "593422461", + "331660698", + "904162426", + "316620612", + "452496593", + "591955012", + "343679238", + "874408522", + "857498941", + "084209694", + "545482657", + "713789208", + "149618953", + "014888918", + "184961733", + "825076719", + "544332597", + "579390867", + "930771813", + "207154156", + "601050765", + "085483285", + ]; + + internal static readonly string[] InvalidOrganisationNumbers = + [ + "474103392", + "593422460", + "331661698", + "904172426", + "316628612", + "452496592", + "591956012", + "343679338", + "874408520", + "857498949", + "084239694", + "545487657", + "623752180", + "177442146", + "262417258", + "897200890", + "509527177", + "956866735", + "760562895", + "516103886", + "192411646", + "486551298", + "370221387", + "569288067", + "322550165", + "773771810", + "862984904", + "548575390", + "183139014", + "181318036", + "843828242", + "668910901", + "123456789", + "987654321", + "12345", + "08548328f", + ]; + + [Fact] + public void Parse_ValidNumber_ShouldReturnOrganisationNumber() + { + foreach (var validOrgNumber in ValidOrganisationNumbers) + { + var orgNumber = OrganisationNumber.Parse(validOrgNumber); + var orgNumberLocal = orgNumber.Get(OrganisationNumberFormat.Local); + var orgNumberInternational = orgNumber.Get(OrganisationNumberFormat.International); + + orgNumberLocal.Should().Be(validOrgNumber); + orgNumberInternational.Should().Be($"0192:{validOrgNumber}"); + } + } + + [Fact] + public void Parse_InvalidNumber_ShouldThrowFormatException() + { + foreach (var invalidOrgNumber in InvalidOrganisationNumbers) + { + Action act = () => OrganisationNumber.Parse(invalidOrgNumber); + act.Should().Throw(); + } + } + + [Fact] + public void Equals_SameNumber_ShouldReturnTrue() + { + // Arrange + var number1 = OrganisationNumber.Parse(ValidOrganisationNumbers[0]); + var number2 = OrganisationNumber.Parse(ValidOrganisationNumbers[0]); + + // Act + bool result1 = number1.Equals(number2); + bool result2 = number1 == number2; + bool result3 = number1 != number2; + + // Assert + result1.Should().BeTrue(); + result2.Should().BeTrue(); + result3.Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentNumber_ShouldReturnFalse() + { + // Arrange + var number1 = OrganisationNumber.Parse(ValidOrganisationNumbers[0]); + var number2 = OrganisationNumber.Parse(ValidOrganisationNumbers[1]); + + // Act + bool result1 = number1.Equals(number2); + bool result2 = number1 == number2; + bool result3 = number1 != number2; + + // Assert + result1.Should().BeFalse(); + result2.Should().BeFalse(); + result3.Should().BeTrue(); + } + + [Fact] + public void ToString_ShouldReturnLocalFormat() + { + // Arrange + var rawLocal = ValidOrganisationNumbers[0]; + var number = OrganisationNumber.Parse(rawLocal); + + // Act + var stringified1 = number.ToString(); + var stringified2 = $"{number}"; + + // Assert + stringified1.Should().Be(rawLocal); + stringified2.Should().Be(rawLocal); + } +}