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);
+ }
+}