diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index d6ff3aeca1..16d5e8a1ef 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -144,6 +144,41 @@ public static async Task GetStreamingAsync( return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } + /// + /// Extension that add authorization header to request + /// + /// The HttpClient + /// the authorization token (jwt) + /// The request Uri + /// The http content + /// The platformAccess tokens + /// The cancellation token + /// A HttpResponseMessage + public static async Task PatchAsync( + this HttpClient httpClient, + string authorizationToken, + string requestUri, + HttpContent? content, + string? platformAccessToken = null, + CancellationToken cancellationToken = default + ) + { + using HttpRequestMessage request = new(HttpMethod.Patch, requestUri); + request.Content = content; + + request.Headers.Authorization = new AuthenticationHeaderValue( + Constants.AuthorizationSchemes.Bearer, + authorizationToken + ); + + if (!string.IsNullOrEmpty(platformAccessToken)) + { + request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); + } + + return await httpClient.SendAsync(request, cancellationToken); + } + /// /// Extension that add authorization header to request /// diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 0e4dc047f4..1d981eff2e 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Events; using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Internal.InstanceLocking; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Language; using Altinn.App.Core.Internal.Pdf; @@ -115,6 +116,7 @@ IWebHostEnvironment env services.AddHttpClient(); #pragma warning restore CS0618 // Type or member is obsolete services.AddHttpClient(); + services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); @@ -368,6 +370,8 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddScoped(); + // Process tasks services.AddTransient(); services.AddTransient(); diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs new file mode 100644 index 0000000000..e3c7ff606f --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + internal Activity? StartAcquireInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + { + var activity = ActivitySource.StartActivity("AcquireInstanceLock"); + activity?.SetInstanceId(instanceGuid); + activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); + + return activity; + } + + internal Activity? StartReleaseInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + { + var activity = ActivitySource.StartActivity("ReleaseInstanceLock"); + activity?.SetInstanceId(instanceGuid); + activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); + + return activity; + } +} diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 21eadcf85b..3f08ebacd3 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -78,69 +78,91 @@ public static async Task CreateAndDispose cancellationToken ); - string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders); string message = BuildMessage((int)response.StatusCode, response.ReasonPhrase, content, truncated); - // Build a sanitized, non-streaming HttpResponseMessage for the base class - var safeResponse = new HttpResponseMessage(response.StatusCode) + return Create(message, response, content, truncated); + } + finally + { + try { - ReasonPhrase = response.ReasonPhrase, - Version = response.Version, - }; - - // Copy normal headers - foreach (KeyValuePair> h in response.Headers) + response.Dispose(); + } + catch { - if (_redactedHeaders.Contains(h.Key)) - { - safeResponse.Headers.TryAddWithoutValidation(h.Key, [Redacted]); - } - else - { - safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); - } + /* ignore dispose failures */ } + } + } + + /// + /// Creates a new by snapshotting + /// the provided into immutable string values, + /// constructing a sanitized clone for the base class. + /// + /// The exception message. + /// The HTTP response to snapshot. + /// The response body content as a string (possibly truncated). + /// Whether the content was truncated. + /// The constructed . + public static PlatformHttpResponseSnapshotException Create( + string message, + HttpResponseMessage response, + string? content = null, + bool contentTruncated = false + ) + { + string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders); + content ??= string.Empty; - // Attach a diagnostic snapshot body for legacy consumers - StringContent safeContent = new StringContent(content, Encoding.UTF8); - safeContent.Headers.ContentType = response.Content?.Headers?.ContentType; - safeResponse.Content = safeContent; + // Build a sanitized, non-streaming HttpResponseMessage for the base class + var safeResponse = new HttpResponseMessage(response.StatusCode) + { + ReasonPhrase = response.ReasonPhrase, + Version = response.Version, + }; - // Copy trailing headers if present (HTTP/2+) - foreach (KeyValuePair> h in response.TrailingHeaders) + // Copy normal headers + foreach (KeyValuePair> h in response.Headers) + { + if (_redactedHeaders.Contains(h.Key)) { - if (_redactedHeaders.Contains(h.Key)) - { - safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, [Redacted]); - } - else - { - safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value); - } + safeResponse.Headers.TryAddWithoutValidation(h.Key, [Redacted]); + } + else + { + safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); } - - return new PlatformHttpResponseSnapshotException( - safeResponse, - statusCode: (int)response.StatusCode, - reasonPhrase: response.ReasonPhrase, - httpVersion: response.Version?.ToString() ?? string.Empty, - headers: headers, - content: content, - contentTruncated: truncated, - message: message - ); } - finally + + // Attach a diagnostic snapshot body for legacy consumers + StringContent safeContent = new StringContent(content, Encoding.UTF8); + safeContent.Headers.ContentType = response.Content?.Headers?.ContentType; + safeResponse.Content = safeContent; + + // Copy trailing headers if present (HTTP/2+) + foreach (KeyValuePair> h in response.TrailingHeaders) { - try + if (_redactedHeaders.Contains(h.Key)) { - response.Dispose(); + safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, [Redacted]); } - catch + else { - /* ignore dispose failures */ + safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value); } } + + return new PlatformHttpResponseSnapshotException( + safeResponse, + statusCode: (int)response.StatusCode, + reasonPhrase: response.ReasonPhrase, + httpVersion: response.Version?.ToString() ?? string.Empty, + headers: headers, + content: content, + contentTruncated: contentTruncated, + message: message + ); } /// diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs new file mode 100644 index 0000000000..127921e579 --- /dev/null +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -0,0 +1,123 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Constants; +using Altinn.App.Core.Extensions; +using Altinn.App.Core.Features; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Internal.Auth; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Infrastructure.Clients.Storage; + +internal sealed class InstanceLockClient +{ + private readonly ILogger _logger; + private readonly HttpClient _client; + private readonly Telemetry? _telemetry; + private readonly IAuthenticationTokenResolver _authenticationTokenResolver; + + private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); + + private const string LockTokenHeaderName = "Altinn-Storage-Lock-Token"; + + public InstanceLockClient( + IOptions platformSettings, + ILogger logger, + IAuthenticationTokenResolver authenticationTokenResolver, + HttpClient httpClient, + Telemetry? telemetry = null + ) + { + _logger = logger; + _authenticationTokenResolver = authenticationTokenResolver; + httpClient.BaseAddress = new Uri(platformSettings.Value.ApiStorageEndpoint); + httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _client = httpClient; + _telemetry = telemetry; + } + + public async Task AcquireInstanceLock( + Guid instanceGuid, + int instanceOwnerPartyId, + TimeSpan expiration, + StorageAuthenticationMethod? authenticationMethod = null, + CancellationToken cancellationToken = default + ) + { + using var activity = _telemetry?.StartAcquireInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; + + var token = await _authenticationTokenResolver.GetAccessToken( + authenticationMethod ?? _defaultAuthenticationMethod, + cancellationToken: cancellationToken + ); + + var request = new InstanceLockRequest { TtlSeconds = (int)expiration.TotalSeconds }; + var content = JsonContent.Create(request); + + using var response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken); + } + + string? lockToken = null; + try + { + var lockResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); + lockToken = lockResponse?.LockToken; + } + catch (Exception e) when (e is JsonException || e is InvalidOperationException) + { + _logger.LogError(e, "Error reading response from the lock acquisition endpoint."); + } + + if (string.IsNullOrEmpty(lockToken)) + { + throw PlatformHttpResponseSnapshotException.Create( + "The response from the lock acquisition endpoint was not expected.", + response + ); + } + + return lockToken; + } + + public async Task ReleaseInstanceLock( + Guid instanceGuid, + int instanceOwnerPartyId, + string lockToken, + StorageAuthenticationMethod? authenticationMethod = null, + CancellationToken cancellationToken = default + ) + { + using var activity = _telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; + var instanceLockRequest = new InstanceLockRequest { TtlSeconds = 0 }; + + var userToken = await _authenticationTokenResolver.GetAccessToken( + authenticationMethod ?? _defaultAuthenticationMethod, + cancellationToken: cancellationToken + ); + + using HttpRequestMessage request = new(HttpMethod.Patch, apiUrl); + request.Content = JsonContent.Create(instanceLockRequest); + request.Headers.Authorization = new AuthenticationHeaderValue(AuthorizationSchemes.Bearer, userToken); + request.Headers.Add(LockTokenHeaderName, lockToken); + + using var response = await _client.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken); + } + } +} diff --git a/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs new file mode 100644 index 0000000000..6c7c814bb9 --- /dev/null +++ b/src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs @@ -0,0 +1,8 @@ +namespace Altinn.App.Core.Internal.InstanceLocking; + +internal interface IInstanceLocker : IAsyncDisposable +{ + ValueTask LockAsync(); + + ValueTask LockAsync(TimeSpan ttl); +} diff --git a/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs new file mode 100644 index 0000000000..4be57623c9 --- /dev/null +++ b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs @@ -0,0 +1,89 @@ +using Altinn.App.Core.Infrastructure.Clients.Storage; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Altinn.App.Core.Internal.InstanceLocking; + +internal sealed partial class InstanceLocker( + InstanceLockClient client, + ILogger logger, + IHttpContextAccessor httpContextAccessor +) : IInstanceLocker +{ + private readonly HttpContext _httpContext = + httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); + + private InstanceLock? _lock; + + public ValueTask LockAsync() + { + return LockAsync(TimeSpan.FromMinutes(5)); + } + + public async ValueTask LockAsync(TimeSpan ttl) + { + if (_lock is not null) + { + return; + } + + var (instanceOwnerPartyId, instanceGuid) = + GetInstanceIdentifiers() ?? throw new InvalidOperationException("Unable to extract instance identifiers."); + + var lockToken = await client.AcquireInstanceLock(instanceGuid, instanceOwnerPartyId, ttl); + + LogLockAcquired(logger, instanceGuid); + + _lock = new InstanceLock(instanceGuid, instanceOwnerPartyId, lockToken); + } + + private (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers() + { + var routeValues = _httpContext.Request.RouteValues; + + if ( + routeValues.TryGetValue("instanceOwnerPartyId", out var partyIdObj) + && routeValues.TryGetValue("instanceGuid", out var guidObj) + && int.TryParse(partyIdObj?.ToString(), out var partyId) + && Guid.TryParse(guidObj?.ToString(), out var guid) + ) + { + return (partyId, guid); + } + + return null; + } + + public async ValueTask DisposeAsync() + { + if (_lock is null) + { + return; + } + + try + { + await client.ReleaseInstanceLock(_lock.InstanceGuid, _lock.InstanceOwnerPartyId, _lock.LockToken); + } + catch (Exception e) + { + LogLockReleaseFailed(logger, _lock.InstanceGuid, e); + return; + } + + LogLockReleased(logger, _lock.InstanceGuid); + + _lock = null; + } + + private sealed record InstanceLock(Guid InstanceGuid, int InstanceOwnerPartyId, string LockToken); + + [LoggerMessage(1, LogLevel.Debug, "Acquired lock for instance {InstanceGuid}.")] + private static partial void LogLockAcquired(ILogger logger, Guid instanceGuid); + + [LoggerMessage(2, LogLevel.Debug, "Released lock for instance {InstanceGuid}.")] + private static partial void LogLockReleased(ILogger logger, Guid instanceGuid); + + [LoggerMessage(3, LogLevel.Error, "Failed to release lock for instance {InstanceGuid}.")] + private static partial void LogLockReleaseFailed(ILogger logger, Guid instanceGuid, Exception e); +} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 3c84167766..375bde1c83 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -6,6 +6,7 @@ using Altinn.App.Core.Features.Auth; using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.InstanceLocking; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; @@ -42,6 +43,7 @@ public class ProcessEngine : IProcessEngine private readonly ILogger _logger; private readonly IValidationService _validationService; private readonly IInstanceClient _instanceClient; + private readonly IInstanceLocker _instanceLocker; /// /// Initializes a new instance of the class. @@ -74,6 +76,7 @@ public ProcessEngine( _logger = logger; _appImplementationFactory = serviceProvider.GetRequiredService(); _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); + _instanceLocker = serviceProvider.GetRequiredService(); } /// @@ -252,6 +255,8 @@ out ProcessChangeResult? invalidProcessStateError }; } + await _instanceLocker.LockAsync(); + _logger.LogDebug( "User successfully authorized to perform process next. Task ID: {CurrentTaskId}. Task type: {AltinnTaskType}. Action: {ProcessNextAction}.", LogSanitizer.Sanitize(currentTaskId), diff --git a/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs b/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs index 04d4bf5347..3eab426934 100644 --- a/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs +++ b/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs @@ -86,10 +86,13 @@ public class CustomConverterFactory : JsonConverterFactory { public override bool CanConvert(Type typeToConvert) => typeToConvert is not null; - public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + public override System.Text.Json.Serialization.JsonConverter? CreateConverter( + Type typeToConvert, + JsonSerializerOptions options + ) { var converterType = typeof(CustomConverter<>).MakeGenericType(typeToConvert); - return (JsonConverter)Activator.CreateInstance(converterType)!; + return (System.Text.Json.Serialization.JsonConverter)Activator.CreateInstance(converterType)!; } } diff --git a/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs b/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs new file mode 100644 index 0000000000..8748dc3a6a --- /dev/null +++ b/test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs @@ -0,0 +1,12 @@ +using Altinn.App.Core.Internal.InstanceLocking; + +namespace Altinn.App.Api.Tests.Mocks; + +internal sealed class InstanceLockerMock : IInstanceLocker +{ + public ValueTask LockAsync() => ValueTask.CompletedTask; + + public ValueTask LockAsync(TimeSpan ttl) => ValueTask.CompletedTask; + + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/test/Altinn.App.Api.Tests/Program.cs b/test/Altinn.App.Api.Tests/Program.cs index a2db05bc5d..e6e3de0daa 100644 --- a/test/Altinn.App.Api.Tests/Program.cs +++ b/test/Altinn.App.Api.Tests/Program.cs @@ -13,6 +13,7 @@ using Altinn.App.Core.Internal.Auth; using Altinn.App.Core.Internal.Data; using Altinn.App.Core.Internal.Events; +using Altinn.App.Core.Internal.InstanceLocking; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Profile; using Altinn.App.Core.Internal.Registers; @@ -115,6 +116,7 @@ void ConfigureMockServices(IServiceCollection services, ConfigurationManager con services.AddTransient>(); services.AddTransient(); services.AddTransient(); + services.AddScoped(); services.PostConfigureAll(options => { diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt new file mode 100644 index 0000000000..355d4cdb15 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt @@ -0,0 +1,3 @@ +{ + RequestBody: {"ttlSeconds":120} +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt new file mode 100644 index 0000000000..0cc7635a1e --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -0,0 +1,32 @@ +{ + Exception: { + $type: PlatformHttpResponseSnapshotException, + Type: PlatformHttpResponseSnapshotException, + StatusCode: 200, + ReasonPhrase: OK, + HttpVersion: 1.1, + Content: , + ContentTruncated: false, + Response: { + Status: 200 OK, + Headers: { + Date: DateTime_1, + Server: Kestrel, + Transfer-Encoding: chunked + }, + Content: { + Headers: { + Content-Type: application/json + }, + Value: + } + }, + Message: The response from the lock acquisition endpoint was not expected., + StackTrace: +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt new file mode 100644 index 0000000000..0b5490f66d --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt @@ -0,0 +1,12 @@ +{ + Exception: { + $type: InvalidOperationException, + Type: InvalidOperationException, + Message: Unable to extract instance identifiers., + StackTrace: +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt new file mode 100644 index 0000000000..0cc7635a1e --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -0,0 +1,32 @@ +{ + Exception: { + $type: PlatformHttpResponseSnapshotException, + Type: PlatformHttpResponseSnapshotException, + StatusCode: 200, + ReasonPhrase: OK, + HttpVersion: 1.1, + Content: , + ContentTruncated: false, + Response: { + Status: 200 OK, + Headers: { + Date: DateTime_1, + Server: Kestrel, + Transfer-Encoding: chunked + }, + Content: { + Headers: { + Content-Type: application/json + }, + Value: + } + }, + Message: The response from the lock acquisition endpoint was not expected., + StackTrace: +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt new file mode 100644 index 0000000000..a220293cfa --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -0,0 +1,29 @@ +{ + Exception: { + $type: PlatformHttpResponseSnapshotException, + Type: PlatformHttpResponseSnapshotException, + StatusCode: 409, + ReasonPhrase: Conflict, + HttpVersion: 1.1, + Content: , + ContentTruncated: false, + Response: { + Status: 409 Conflict, + Headers: { + Date: DateTime_1, + Server: Kestrel + }, + Content: { + Headers: {}, + Value: + } + }, + Message: 409 Conflict, + StackTrace: +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt new file mode 100644 index 0000000000..7793637439 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -0,0 +1,29 @@ +{ + Exception: { + $type: PlatformHttpResponseSnapshotException, + Type: PlatformHttpResponseSnapshotException, + StatusCode: 500, + ReasonPhrase: Internal Server Error, + HttpVersion: 1.1, + Content: , + ContentTruncated: false, + Response: { + Status: 500 Internal Server Error, + Headers: { + Date: DateTime_1, + Server: Kestrel + }, + Content: { + Headers: {}, + Value: + } + }, + Message: 500 Internal Server Error, + StackTrace: +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt new file mode 100644 index 0000000000..5931ac0c2d --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -0,0 +1,29 @@ +{ + Exception: { + $type: PlatformHttpResponseSnapshotException, + Type: PlatformHttpResponseSnapshotException, + StatusCode: 404, + ReasonPhrase: Not Found, + HttpVersion: 1.1, + Content: , + ContentTruncated: false, + Response: { + Status: 404 Not Found, + Headers: { + Date: DateTime_1, + Server: Kestrel + }, + Content: { + Headers: {}, + Value: + } + }, + Message: 404 Not Found, + StackTrace: +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration, StorageAuthenticationMethod authenticationMethod, CancellationToken cancellationToken) +at Altinn.App.Core.Internal.InstanceLocking.InstanceLocker.LockAsync(TimeSpan ttl) +--- End of stack trace from previous location --- +--- End of stack trace from previous location --- +at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs new file mode 100644 index 0000000000..8a5f370e86 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs @@ -0,0 +1,422 @@ +using System.Net; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Features.Auth; +using Altinn.App.Core.Features.Maskinporten; +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Infrastructure.Clients.Storage; +using Altinn.App.Core.Internal; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Auth; +using Altinn.App.Core.Internal.InstanceLocking; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using WireMock.Matchers.Request; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Altinn.App.Core.Tests.Internal.InstanceLocking; + +public sealed class InstanceLockTests +{ + private sealed record Fixture(WireMockServer Server, ServiceProvider ServiceProvider) : IDisposable + { + private static readonly Guid _instanceGuid = Guid.NewGuid(); + private const int InstanceOwnerPartyId = 12345; + + private static readonly Authenticated _defaultAuth = TestAuthentication.GetUserAuthentication(); + + public readonly string ServerUrl = Server.Url ?? throw new Exception("Missing server URL"); + + public static Fixture Create(Action? registerCustomServices = null) + { + var server = WireMockServer.Start(); + + var services = new ServiceCollection(); + + services.Configure(settings => + { + var testUrl = server.Url ?? throw new Exception("Missing server URL"); + settings.ApiStorageEndpoint = testUrl + new Uri(settings.ApiStorageEndpoint).PathAndQuery; + }); + + var mocks = new FixtureMocks(); + mocks.AuthenticationContextMock.Setup(x => x.Current).Returns(_defaultAuth); + + services.AddHttpClient(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues.Add("instanceOwnerPartyId", InstanceOwnerPartyId); + httpContext.Request.RouteValues.Add("instanceGuid", _instanceGuid); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + services.AddSingleton(httpContextAccessor); + + services.AddSingleton(); + services.AddSingleton(mocks.MaskinportenClientMock.Object); + services.AddSingleton(mocks.AppMetadataMock.Object); + services.AddSingleton(mocks.AuthenticationContextMock.Object); + + services.AddRuntimeEnvironment(); + + services.AddScoped(); + + registerCustomServices?.Invoke(services); + + var serviceProvider = services.BuildServiceProvider(); + + return new Fixture(server, serviceProvider); + } + + public sealed record FixtureMocks + { + public Mock AuthenticationContextMock { get; init; } = new(MockBehavior.Strict); + public Mock AppMetadataMock { get; init; } = new(MockBehavior.Strict); + public Mock MaskinportenClientMock { get; init; } = new(MockBehavior.Strict); + } + + public void Dispose() + { + Server.Stop(); + Server.Dispose(); + ServiceProvider.Dispose(); + } + + public IRequestBuilder GetAcquireLockRequestBuilder() + { + return Request + .Create() + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") + .UsingPost() + .WithHeader("Authorization", $"Bearer {_defaultAuth.Token}"); + } + + public IRequestBuilder GetReleaseLockRequestBuilder(string lockToken) + { + return Request + .Create() + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") + .UsingPatch() + .WithHeader("Authorization", $"Bearer {_defaultAuth.Token}") + .WithHeader("Altinn-Storage-Lock-Token", lockToken); + } + } + + [Fact] + public async Task HappyPath() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + var acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockToken); + + var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); + + fixture + .Server.Given(acquireLockRequestBuilder) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(releaseLockRequestBuilder) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + fixture.Server.Given(testRequestBuilder).RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); + + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); + response.EnsureSuccessStatusCode(); + } + + var requests = fixture.Server.LogEntries; + Assert.Equal(3, requests.Count); + + var acquireMatchResult = new RequestMatchResult(); + acquireLockRequestBuilder.GetMatchingScore(requests[0].RequestMessage, acquireMatchResult); + Assert.True(acquireMatchResult.IsPerfectMatch); + + var testMatchResult = new RequestMatchResult(); + testRequestBuilder.GetMatchingScore(requests[1].RequestMessage, testMatchResult); + Assert.True(testMatchResult.IsPerfectMatch); + + var releaseMatchResult = new RequestMatchResult(); + releaseLockRequestBuilder.GetMatchingScore(requests[2].RequestMessage, releaseMatchResult); + Assert.True(releaseMatchResult.IsPerfectMatch); + } + + [Fact] + public async Task HappyPath_MultipleLockCalls() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + var acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockToken); + + var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); + + fixture + .Server.Given(acquireLockRequestBuilder) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(releaseLockRequestBuilder) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + fixture.Server.Given(testRequestBuilder).RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); + + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + await instanceLocker.LockAsync(); + await instanceLocker.LockAsync(); + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); + response.EnsureSuccessStatusCode(); + } + + var requests = fixture.Server.LogEntries; + Assert.Equal(3, requests.Count); + + var acquireMatchResult = new RequestMatchResult(); + acquireLockRequestBuilder.GetMatchingScore(requests[0].RequestMessage, acquireMatchResult); + Assert.True(acquireMatchResult.IsPerfectMatch); + + var testMatchResult = new RequestMatchResult(); + testRequestBuilder.GetMatchingScore(requests[1].RequestMessage, testMatchResult); + Assert.True(testMatchResult.IsPerfectMatch); + + var releaseMatchResult = new RequestMatchResult(); + releaseLockRequestBuilder.GetMatchingScore(requests[2].RequestMessage, releaseMatchResult); + Assert.True(releaseMatchResult.IsPerfectMatch); + } + + [Fact] + public async Task LockReleasedOnException() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + await Assert.ThrowsAsync(async () => + { + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + throw new Exception(); + }); + + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockToken)); + Assert.Single(releaseRequests); + } + + [Fact] + public async Task CustomTtl_UsedInStorageApiCall() + { + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + var ttl = TimeSpan.FromSeconds(120); + + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(ttl); + } + + var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); + Assert.Single(acquireRequests); + var requestBody = acquireRequests[0].RequestMessage.Body; + + await Verify(new { RequestBody = requestBody }); + } + + [Fact] + public async Task LockReleaseFailure_DoesNotThrow() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); + + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + } + + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockToken)); + Assert.Single(releaseRequests); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task StorageApiError_ThrowsCorrectPlatformHttpException(HttpStatusCode storageStatusCode) + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith(Response.Create().WithStatusCode(storageStatusCode)); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + }); + + Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Exception = exception }) + .UseParameters(storageStatusCode) + .IgnoreMember(x => x.Headers); + } + + [Fact] + public async Task NullResponseBody_ThrowsPlatformHttpException() + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody("null") + ); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + }); + + Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); + } + + [Fact] + public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody("{}") + ); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + }); + + Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); + } + + [Fact] + public async Task InvalidInstanceId_ThrowsInvalidOperationException() + { + using var fixture = Fixture.Create(services => + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues.Add("instanceOwnerPartyId", "invalid"); + httpContext.Request.RouteValues.Add("instanceGuid", "format"); + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + services.AddSingleton(httpContextAccessor); + }); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); + }); + + Assert.Empty(fixture.Server.LogEntries); + await Verify(new { Exception = exception }); + } + + private string GenerateLockToken(Guid lockId) + { + return Convert.ToBase64String(lockId.ToByteArray()); + } +} diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs index 6978256137..0f35e4f9ad 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessEngineTest.cs @@ -9,6 +9,7 @@ using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.InstanceLocking; using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; @@ -47,7 +48,7 @@ public ProcessEngineTest(ITestOutputHelper output) [Fact] public async Task StartProcess_returns_unsuccessful_when_process_already_started() { - using var fixture = Fixture.Create(); + await using var fixture = Fixture.Create(); ProcessEngine processEngine = fixture.ProcessEngine; Instance instance = new Instance() { @@ -69,7 +70,7 @@ public async Task StartProcess_returns_unsuccessful_when_no_matching_startevent_ processReaderMock.Setup(r => r.GetStartEventIds()).Returns(new List() { "StartEvent_1" }); var services = new ServiceCollection(); services.AddSingleton(processReaderMock.Object); - using var fixture = Fixture.Create(services); + await using var fixture = Fixture.Create(services); ProcessEngine processEngine = fixture.ProcessEngine; Instance instance = new Instance() { Id = _instanceId, AppId = "org/app" }; ProcessStartRequest processStartRequest = new ProcessStartRequest() @@ -87,7 +88,7 @@ public async Task StartProcess_returns_unsuccessful_when_no_matching_startevent_ [Fact] public async Task StartProcess_starts_process_and_moves_to_first_task_without_event_dispatch_when_dryrun() { - using var fixture = Fixture.Create(); + await using var fixture = Fixture.Create(); ProcessEngine processEngine = fixture.ProcessEngine; Instance instance = new Instance() { @@ -121,7 +122,7 @@ public async Task StartProcess_starts_process_and_moves_to_first_task_without_ev [ClassData(typeof(TestAuthentication.AllTokens))] public async Task StartProcess_starts_process_and_moves_to_first_task(TestJwtToken token) { - using var fixture = Fixture.Create(withTelemetry: true, token: token); + await using var fixture = Fixture.Create(withTelemetry: true, token: token); var instanceOwnerPartyId = token.Auth switch { Authenticated.User auth => auth.SelectedPartyId, @@ -255,7 +256,7 @@ Authenticated.SystemUser auth when await auth.LoadDetails() is { } details => de [Fact] public async Task StartProcess_starts_process_and_moves_to_first_task_with_prefill() { - using var fixture = Fixture.Create(); + await using var fixture = Fixture.Create(); ProcessEngine processEngine = fixture.ProcessEngine; Instance instance = new Instance() { @@ -409,7 +410,7 @@ public async Task Next_returns_unsuccessful_for_invalid_process_states( string expectedErrorMessage ) { - using var fixture = Fixture.Create(); + await using var fixture = Fixture.Create(); ProcessEngine processEngine = fixture.ProcessEngine; var instance = new Instance() @@ -456,7 +457,10 @@ public async Task HandleUserAction_returns_successful_when_handler_succeeds() .Setup(u => u.HandleAction(It.IsAny())) .ReturnsAsync(UserActionResult.SuccessResult()); - using var fixture = Fixture.Create(updatedInstance: expectedInstance, userActions: [userActionMock.Object]); + await using var fixture = Fixture.Create( + updatedInstance: expectedInstance, + userActions: [userActionMock.Object] + ); fixture .Mock() .Setup(x => x.GetApplicationMetadata()) @@ -528,7 +532,10 @@ public async Task HandleUserAction_returns_unsuccessful_unauthorized_when_action ) ); - using var fixture = Fixture.Create(updatedInstance: expectedInstance, userActions: [userActionMock.Object]); + await using var fixture = Fixture.Create( + updatedInstance: expectedInstance, + userActions: [userActionMock.Object] + ); fixture .Mock() .Setup(x => x.GetApplicationMetadata()) @@ -595,7 +602,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_instanceevents() }, }; - using var fixture = Fixture.Create(updatedInstance: expectedInstance); + await using var fixture = Fixture.Create(updatedInstance: expectedInstance); fixture .Mock() .Setup(x => x.GetApplicationMetadata()) @@ -760,7 +767,7 @@ public async Task Next_moves_instance_to_next_task_and_produces_abandon_instance StartEvent = "StartEvent_1", }, }; - using var fixture = Fixture.Create(updatedInstance: expectedInstance); + await using var fixture = Fixture.Create(updatedInstance: expectedInstance); fixture .Mock() .Setup(x => x.GetApplicationMetadata()) @@ -918,7 +925,7 @@ public async Task Next_moves_instance_to_end_event_and_ends_process(bool registe EndEvent = "EndEvent_1", }, }; - using var fixture = Fixture.Create( + await using var fixture = Fixture.Create( updatedInstance: expectedInstance, registerProcessEnd: registerProcessEnd, withTelemetry: useTelemetry @@ -1160,7 +1167,7 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even }, }, }; - using var fixture = Fixture.Create(updatedInstance: updatedInstance); + await using var fixture = Fixture.Create(updatedInstance: updatedInstance); ProcessEngine processEngine = fixture.ProcessEngine; ProcessStartRequest processStartRequest = new ProcessStartRequest() { Instance = instance, Prefill = prefill }; Instance result = await processEngine.HandleEventsAndUpdateStorage( @@ -1191,7 +1198,7 @@ public async Task UpdateInstanceAndRerunEvents_sends_instance_and_events_to_even result.Should().Be(updatedInstance); } - private sealed record Fixture(IServiceProvider ServiceProvider) : IDisposable + private sealed record Fixture(IServiceProvider ServiceProvider) : IAsyncDisposable { public ProcessEngine ProcessEngine => (ProcessEngine)ServiceProvider.GetRequiredService(); @@ -1240,6 +1247,7 @@ public static Fixture Create( Mock appMetadataMock = new(MockBehavior.Strict); Mock appResourcesMock = new(MockBehavior.Strict); Mock translationServiceMock = new(MockBehavior.Strict); + Mock instanceLockerMock = new(MockBehavior.Strict); var appMetadata = new ApplicationMetadata("org/app"); appMetadataMock.Setup(x => x.GetApplicationMetadata()).ReturnsAsync(appMetadata); @@ -1314,6 +1322,9 @@ public static Fixture Create( .ReturnsAsync(() => updatedInstance); } + instanceLockerMock.Setup(x => x.DisposeAsync()).Returns(ValueTask.CompletedTask); + instanceLockerMock.Setup(x => x.LockAsync()).Returns(ValueTask.CompletedTask); + services.TryAddTransient(_ => authenticationContextMock.Object); services.TryAddTransient(_ => processNavigatorMock.Object); services.TryAddTransient(_ => processEngineAuthorizerMock.Object); @@ -1327,6 +1338,7 @@ public static Fixture Create( services.TryAddTransient(_ => translationServiceMock.Object); services.TryAddTransient(); services.TryAddTransient(_ => validationServiceMock.Object); + services.TryAddTransient(_ => instanceLockerMock.Object); if (registerProcessEnd) services.AddSingleton(_ => new Mock().Object); @@ -1339,7 +1351,20 @@ public static Fixture Create( return new Fixture(services.BuildStrictServiceProvider()); } - public void Dispose() => (ServiceProvider as IDisposable)?.Dispose(); + public ValueTask DisposeAsync() + { + if (ServiceProvider is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + if (ServiceProvider is IDisposable disposable) + { + disposable.Dispose(); + } + + return ValueTask.CompletedTask; + } } private bool CompareInstance(Instance expected, Instance actual) diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index ec33a03324..4343aab001 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -242,6 +242,7 @@ namespace Altinn.App.Core.Extensions public static System.Threading.Tasks.Task DeleteAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } public static System.Threading.Tasks.Task GetAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } public static System.Threading.Tasks.Task GetStreamingAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } + public static System.Threading.Tasks.Task PatchAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } public static System.Threading.Tasks.Task PostAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } public static System.Threading.Tasks.Task PutAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null, System.Threading.CancellationToken cancellationToken = default) { } } diff --git a/test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs b/test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs new file mode 100644 index 0000000000..573f8f1728 --- /dev/null +++ b/test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs @@ -0,0 +1,50 @@ +using Altinn.App.Api.Models; +using Altinn.Platform.Storage.Interface.Models; +using Xunit.Abstractions; + +namespace Altinn.App.Integration.Tests.InstanceLocking; + +[Trait("Category", "Integration")] +public sealed class InstanceLockTests(ITestOutputHelper _output, AppFixtureClassFixture _classFixture) + : IClassFixture +{ + [Fact] + public async Task ProcessNext_ConcurrentRequests_OneRequestGetsConflict() + { + await using var fixtureScope = await _classFixture.Get(_output, TestApps.Basic, "instance-lock"); + var fixture = fixtureScope.Fixture; + + var client = fixture.GetAppClient(); + + var resetResponse = await client.PostAsync("/test/instance-lock/reset", null); + resetResponse.EnsureSuccessStatusCode(); + + var token = await fixture.Auth.GetUserToken(userId: 1337); + + using var instantiationResponse = await fixture.Instances.PostSimplified( + token, + new InstansiationInstance { InstanceOwner = new InstanceOwner { PartyId = "501337" } } + ); + using var readInstantiationResponse = await instantiationResponse.Read(); + var instance = readInstantiationResponse.Data.Model; + Assert.NotNull(instance); + + Task[] processNextTasks = + [ + fixture.Instances.ProcessNext(token, readInstantiationResponse), + fixture.Instances.ProcessNext(token, readInstantiationResponse), + ]; + + var conflictResponseTask = await Task.WhenAny(processNextTasks); + using var conflictResponse = await conflictResponseTask; + + Assert.Equal(System.Net.HttpStatusCode.Conflict, conflictResponse.Response.StatusCode); + + var releaseResponse = await client.PostAsync("/test/instance-lock/release-wait", null); + releaseResponse.EnsureSuccessStatusCode(); + + using var successRequestResponse = await processNextTasks.First(x => x != conflictResponseTask); + + successRequestResponse.Response.EnsureSuccessStatusCode(); + } +} diff --git a/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs new file mode 100644 index 0000000000..1210d8e2bc --- /dev/null +++ b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Altinn.App.Core.Features; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using TestApp.Shared; + +namespace Altinn.App.Integration.Tests.Scenarios.InstanceLock; + +public sealed class WaitForReleaseProcessTaskEnd : IProcessTaskEnd +{ + private static TaskCompletionSource _signal = new(); + + public Task End(string taskId, Instance instance) + { + return _signal.Task; + } + + public static void Release() + { + _signal.TrySetResult(); + } + + public static void Reset() + { + _signal.TrySetResult(); + _signal = new TaskCompletionSource(); + } +} + +public sealed class WaitForReleaseProcessTaskEndEndpoints : IEndpointConfigurator +{ + public void ConfigureEndpoints(WebApplication app) + { + app.MapPost( + "/test/instance-lock/release-wait", + () => + { + WaitForReleaseProcessTaskEnd.Release(); + return Results.Ok(); + } + ); + + app.MapPost( + "/test/instance-lock/reset", + () => + { + WaitForReleaseProcessTaskEnd.Reset(); + return Results.Ok(); + } + ); + } +} + +public static class ServiceRegistration +{ + public static void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(); + } +}