From fa818168fa97f8f98ed22c35e08699dea3698a53 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:37:01 +0100 Subject: [PATCH 01/17] Add process lock middleware --- .../Middleware/EnableProcessLockAttribute.cs | 4 + .../Middleware/ProcessLockMiddleware.cs | 120 ++++++ .../Middleware/ProcessLockOptions.cs | 6 + .../Extensions/HttpClientExtension.cs | 35 ++ .../Telemetry/Telemetry.ProcessLockClient.cs | 26 ++ .../Clients/Storage/ProcessLockClient.cs | 99 +++++ .../Process/ProcessLock/ProcessLockRequest.cs | 6 + .../ProcessLock/ProcessLockResponse.cs | 6 + .../Altinn.App.Api.Tests.csproj | 1 + .../Conventions/EnumSerializationTests.cs | 7 +- .../Middleware/ProcessLockMiddlewareTests.cs | 388 ++++++++++++++++++ 11 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs create mode 100644 src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs create mode 100644 src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs create mode 100644 src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs create mode 100644 src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs create mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs create mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs b/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs new file mode 100644 index 0000000000..669148f47d --- /dev/null +++ b/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs @@ -0,0 +1,4 @@ +namespace Altinn.App.Api.Infrastructure.Middleware; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +internal sealed class EnableProcessLockAttribute : Attribute; diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs b/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs new file mode 100644 index 0000000000..a5bc61b81e --- /dev/null +++ b/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs @@ -0,0 +1,120 @@ +using Altinn.App.Core.Helpers; +using Altinn.App.Core.Infrastructure.Clients.Storage; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Api.Infrastructure.Middleware; + +/// +/// Middleware that ensures only one request can proceed at a time by acquiring a lock. +/// Only applies to endpoints decorated with . +/// +internal sealed partial class ProcessLockMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly ProcessLockClient _processClient; + private readonly ProcessLockOptions _options; + + public ProcessLockMiddleware( + RequestDelegate next, + ILogger logger, + IOptions options, + ProcessLockClient processClient + ) + { + _next = next; + _logger = logger; + _processClient = processClient; + _options = options.Value; + } + + public async Task Invoke(HttpContext context) + { + var endpoint = context.GetEndpoint(); + + if (endpoint?.Metadata.GetMetadata() is null) + { + await _next(context); + return; + } + + var (instanceOwnerPartyId, instanceGuid) = + GetInstanceIdentifiers(context) + ?? throw new InvalidOperationException("Unable to extract instance identifiers."); + Guid? lockId = null; + + try + { + try + { + lockId = await _processClient.AcquireProcessLock( + instanceGuid, + instanceOwnerPartyId, + _options.Expiration + ); + + LogLockAcquired(_logger, lockId.Value); + } + catch (PlatformHttpException e) + { + LogLockAcquisitionFailed(_logger); + var problem = TypedResults.Problem( + detail: e.Message, + statusCode: e.Response.IsSuccessStatusCode ? 500 : (int)e.Response.StatusCode, + title: "Failed to acquire lock." + ); + + await problem.ExecuteAsync(context); + + return; + } + + await _next(context); + } + finally + { + if (lockId is not null) + { + try + { + await _processClient.ReleaseProcessLock(instanceGuid, instanceOwnerPartyId, lockId.Value); + + LogLockReleased(_logger, lockId.Value); + } + catch (Exception e) + { + LogLockReleaseFailed(_logger, lockId.Value, e); + } + } + } + } + + private static (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers(HttpContext context) + { + var routeData = context.GetRouteData(); + + if ( + routeData.Values.TryGetValue("instanceOwnerPartyId", out var partyIdObj) + && routeData.Values.TryGetValue("instanceGuid", out var guidObj) + && int.TryParse(partyIdObj?.ToString(), out var partyId) + && Guid.TryParse(guidObj?.ToString(), out var guid) + ) + { + return (partyId, guid); + } + + return null; + } + + [LoggerMessage(1, LogLevel.Debug, "Failed to acquire process lock.")] + private static partial void LogLockAcquisitionFailed(ILogger logger); + + [LoggerMessage(2, LogLevel.Debug, "Acquired process lock with id: {LockId}")] + private static partial void LogLockAcquired(ILogger logger, Guid lockId); + + [LoggerMessage(3, LogLevel.Debug, "Released process lock with id: {LockId}")] + private static partial void LogLockReleased(ILogger logger, Guid lockId); + + [LoggerMessage(4, LogLevel.Error, "Failed to release process lock with id: {LockId}")] + private static partial void LogLockReleaseFailed(ILogger logger, Guid lockId, Exception e); +} diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs b/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs new file mode 100644 index 0000000000..94b9d3a218 --- /dev/null +++ b/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs @@ -0,0 +1,6 @@ +namespace Altinn.App.Api.Infrastructure.Middleware; + +internal sealed class ProcessLockOptions +{ + public TimeSpan Expiration { get; set; } = TimeSpan.FromMinutes(5); +} 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/Features/Telemetry/Telemetry.ProcessLockClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs new file mode 100644 index 0000000000..eda8e56812 --- /dev/null +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; + +namespace Altinn.App.Core.Features; + +partial class Telemetry +{ + internal Activity? StartAqzActivity() => ActivitySource.StartActivity("ProcessClient.GetProcessDefinition"); + + internal Activity? StartAcquireProcessLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + { + var activity = ActivitySource.StartActivity("AcquireProcessLock"); + activity?.SetInstanceId(instanceGuid); + activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); + + return activity; + } + + internal Activity? StartReleaseProcessLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + { + var activity = ActivitySource.StartActivity("ReleaseProcessLock"); + activity?.SetInstanceId(instanceGuid); + activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); + + return activity; + } +} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs new file mode 100644 index 0000000000..c3a21b2ed2 --- /dev/null +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs @@ -0,0 +1,99 @@ +using System.Net.Http.Headers; +using System.Net.Http.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.Process.ProcessLock; +using AltinnCore.Authentication.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Infrastructure.Clients.Storage; + +internal sealed class ProcessLockClient +{ + private readonly AppSettings _appSettings; + private readonly HttpClient _client; + private readonly Telemetry? _telemetry; + private readonly IHttpContextAccessor _httpContextAccessor; + + public ProcessLockClient( + IOptions platformSettings, + IOptions appSettings, + IHttpContextAccessor httpContextAccessor, + HttpClient httpClient, + Telemetry? telemetry = null + ) + { + _appSettings = appSettings.Value; + _httpContextAccessor = httpContextAccessor; + 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 AcquireProcessLock(Guid instanceGuid, int instanceOwnerPartyId, TimeSpan expiration) + { + using var activity = _telemetry?.StartAcquireProcessLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/process/lock"; + string token = JwtTokenUtil.GetTokenFromContext( + _httpContextAccessor.HttpContext, + _appSettings.RuntimeCookieName + ); + + var request = new ProcessLockRequest { Expiration = (int)expiration.TotalSeconds }; + var content = JsonContent.Create(request); + + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content); + + if (!response.IsSuccessStatusCode) + { + throw await PlatformHttpException.CreateAsync(response); + } + + Guid? lockId = null; + try + { + var lockResponse = await response.Content.ReadFromJsonAsync(); + lockId = lockResponse?.LockId; + } + catch + { + // Throw exception below + } + + if (lockId is null || lockId.Value == Guid.Empty) + { + throw new PlatformHttpException( + response, + "The response from the lock acquisition endpoint was not expected." + ); + } + + return lockId.Value; + } + + public async Task ReleaseProcessLock(Guid instanceGuid, int instanceOwnerPartyId, Guid lockId) + { + using var activity = _telemetry?.StartReleaseProcessLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/process/lock/{lockId}"; + string token = JwtTokenUtil.GetTokenFromContext( + _httpContextAccessor.HttpContext, + _appSettings.RuntimeCookieName + ); + + var request = new ProcessLockRequest { Expiration = 0 }; + var content = JsonContent.Create(request); + + HttpResponseMessage response = await _client.PatchAsync(token, apiUrl, content); + + if (!response.IsSuccessStatusCode) + { + throw await PlatformHttpException.CreateAsync(response); + } + } +} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs new file mode 100644 index 0000000000..c8e816eeba --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs @@ -0,0 +1,6 @@ +namespace Altinn.App.Core.Internal.Process.ProcessLock; + +internal sealed class ProcessLockRequest +{ + public int Expiration { get; set; } +} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs new file mode 100644 index 0000000000..7f7f2d40cc --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs @@ -0,0 +1,6 @@ +namespace Altinn.App.Core.Internal.Process.ProcessLock; + +internal sealed class ProcessLockResponse +{ + public Guid LockId { get; set; } +} 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 9b0ae996d7..098b999643 100644 --- a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj +++ b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj @@ -18,6 +18,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive 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/Middleware/ProcessLockMiddlewareTests.cs b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs new file mode 100644 index 0000000000..f8c3bd9217 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs @@ -0,0 +1,388 @@ +using System.Net; +using System.Net.Http.Json; +using Altinn.App.Api.Infrastructure.Middleware; +using Altinn.App.Core.Configuration; +using Altinn.App.Core.Infrastructure.Clients.Storage; +using Altinn.App.Core.Internal.Process.ProcessLock; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using WireMock.Matchers.Request; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace Altinn.App.Api.Tests.Middleware; + +public sealed class ProcessLockMiddlewareTests +{ + private sealed record Fixture(IHost Host, WireMockServer Server) : IDisposable + { + public readonly Guid InstanceGuid = Guid.NewGuid(); + public readonly int InstanceOwnerPartyId = 12345; + private const string RuntimeCookieName = "test-cookie"; + private const string BearerToken = "test-token"; + + public static Fixture Create(Action? registerCustomAppServices = null) + { + var server = WireMockServer.Start(); + + var host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + + services.Configure(settings => + { + var testUrl = server.Url ?? throw new Exception("Missing server URL"); + settings.ApiStorageEndpoint = + testUrl + new Uri(settings.ApiStorageEndpoint).PathAndQuery; + }); + + services.Configure(settings => settings.RuntimeCookieName = RuntimeCookieName); + + services.AddHttpClient(); + + services.AddHttpContextAccessor(); + + registerCustomAppServices?.Invoke(services); + }) + .Configure(app => + { + app.UseRouting(); + app.UseMiddleware(); + app.UseEndpoints(endpoints => + { + // Endpoint with lock + endpoints + .MapGet( + "/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/test", + () => "Success" + ) + .WithMetadata(new EnableProcessLockAttribute()); + + // Endpoint without lock + endpoints.MapGet("/without-lock", () => "No lock required"); + + // Endpoint with lock that throws exception + endpoints + .MapGet( + "/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/test-exception", + _ => throw new InvalidOperationException("Test exception") + ) + .WithMetadata(new EnableProcessLockAttribute()); + + // Endpoint with lock but no route parameters + endpoints + .MapGet("/invalid-route", () => Results.Ok()) + .WithMetadata(new EnableProcessLockAttribute()); + }); + }); + }) + .Start(); + + return new Fixture(host, server); + } + + public void Dispose() + { + Server.Stop(); + Server.Dispose(); + Host.Dispose(); + } + + public HttpClient GetTestClient() + { + var httpClient = Host.GetTestClient(); + httpClient.DefaultRequestHeaders.Add("cookie", $"{RuntimeCookieName}={BearerToken}"); + + return httpClient; + } + + public IRequestBuilder GetAcquireLockRequestBuilder() + { + return Request + .Create() + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{InstanceGuid}/process/lock") + .UsingPost() + .WithHeader("Authorization", $"Bearer {BearerToken}"); + } + + public IRequestBuilder GetReleaseLockRequestBuilder(Guid lockId) + { + return Request + .Create() + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{InstanceGuid}/process/lock/{lockId}") + .UsingPatch() + .WithHeader("Authorization", $"Bearer {BearerToken}"); + } + } + + [Fact] + public async Task HappyPath() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + + var acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockId); + + fixture + .Server.Given(acquireLockRequestBuilder) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + ); + + fixture + .Server.Given(releaseLockRequestBuilder) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Success", content); + + var requests = fixture.Server.LogEntries; + Assert.Equal(2, requests.Count); + + var acquireMatchResult = new RequestMatchResult(); + acquireLockRequestBuilder.GetMatchingScore(requests[0].RequestMessage, acquireMatchResult); + Assert.True(acquireMatchResult.IsPerfectMatch); + + var releaseMatchResult = new RequestMatchResult(); + releaseLockRequestBuilder.GetMatchingScore(requests[1].RequestMessage, releaseMatchResult); + Assert.True(releaseMatchResult.IsPerfectMatch); + } + + [Fact] + public async Task EndpointWithoutAttribute_SkipsMiddleware() + { + using var fixture = Fixture.Create(); + + var response = await fixture.GetTestClient().GetAsync("/without-lock"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("No lock required", content); + + Assert.Empty(fixture.Server.LogEntries); + } + + [Fact] + public async Task LockReleasedOnException() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + await Assert.ThrowsAsync(async () => + await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test-exception") + ); + + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); + Assert.Single(releaseRequests); + } + + [Fact] + public async Task CustomExpirationConfiguration_UsedInStorageApiCall() + { + var lockId = Guid.NewGuid(); + const int customExpirationSeconds = 120; + + using var fixture = Fixture.Create(services => + services.Configure(options => + options.Expiration = TimeSpan.FromSeconds(customExpirationSeconds) + ) + ); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); + Assert.Single(acquireRequests); + var requestBody = acquireRequests[0].RequestMessage.Body; + Assert.Contains($"\"expiration\":{customExpirationSeconds}", requestBody); + } + + [Fact] + public async Task MissingRouteParameters_ThrowsException() + { + using var fixture = Fixture.Create(); + + var exception = await Assert.ThrowsAsync(async () => + await fixture.GetTestClient().GetAsync("/invalid-route") + ); + + Assert.Contains("Unable to extract instance identifiers.", exception.Message); + + Assert.Empty(fixture.Server.LogEntries); + } + + [Fact] + public async Task LockReleaseFailure_DoesNotAffectResponse() + { + using var fixture = Fixture.Create(); + + var lockId = Guid.NewGuid(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + ); + + fixture + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Equal("Success", content); + + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); + Assert.Single(releaseRequests); + } + + [Theory] + [InlineData(HttpStatusCode.Conflict)] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task StorageApiError_ReturnsCorrectStatusCode(HttpStatusCode storageStatusCode) + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith(Response.Create().WithStatusCode(storageStatusCode)); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(storageStatusCode, response.StatusCode); + + var problemDetails = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(problemDetails); + Assert.Equal("Failed to acquire lock.", problemDetails.Title); + Assert.Equal((int)storageStatusCode, problemDetails.Status); + + Assert.Single(fixture.Server.LogEntries); + } + + [Fact] + public async Task NullResponseBody_ReturnsProblemDetails() + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody("null") + ); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + + var problemDetails = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(problemDetails); + Assert.Equal("Failed to acquire lock.", problemDetails.Title); + Assert.Equal("The response from the lock acquisition endpoint was not expected.", problemDetails.Detail); + Assert.Equal((int)HttpStatusCode.InternalServerError, problemDetails.Status); + + Assert.Single(fixture.Server.LogEntries); + } + + [Fact] + public async Task EmptyJsonResponseBody_ReturnsProblemDetails() + { + using var fixture = Fixture.Create(); + + fixture + .Server.Given(fixture.GetAcquireLockRequestBuilder()) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithHeader("Content-Type", "application/json") + .WithBody("{}") + ); + + var response = await fixture + .GetTestClient() + .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + + var problemDetails = await response.Content.ReadFromJsonAsync(); + + Assert.NotNull(problemDetails); + Assert.Equal("Failed to acquire lock.", problemDetails.Title); + Assert.Equal("The response from the lock acquisition endpoint was not expected.", problemDetails.Detail); + Assert.Equal((int)HttpStatusCode.InternalServerError, problemDetails.Status); + + Assert.Single(fixture.Server.LogEntries); + } +} From 8d50972b57550dfd7deb066adefc788e30b888a0 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:53:15 +0100 Subject: [PATCH 02/17] Use snapshot testing --- ...guration_UsedInStorageApiCall.verified.txt | 3 ++ ...nseBody_ReturnsProblemDetails.verified.txt | 16 ++++++++ ...uteParameters_ThrowsException.verified.txt | 14 +++++++ ...nseBody_ReturnsProblemDetails.verified.txt | 16 ++++++++ ...de_storageStatusCode=Conflict.verified.txt | 16 ++++++++ ...tatusCode=InternalServerError.verified.txt | 16 ++++++++ ...de_storageStatusCode=NotFound.verified.txt | 16 ++++++++ .../Middleware/ProcessLockMiddlewareTests.cs | 40 +++++-------------- 8 files changed, 106 insertions(+), 31 deletions(-) create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt create mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt new file mode 100644 index 0000000000..6c474e41df --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt @@ -0,0 +1,3 @@ +{ + RequestBody: {"expiration":120} +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt new file mode 100644 index 0000000000..a44cbff789 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt @@ -0,0 +1,16 @@ +{ + Response: { + Status: 500 Internal Server Error, + Content: { + Headers: { + Content-Type: application/problem+json + }, + Value: { + type: https://tools.ietf.org/html/rfc9110#section-15.6.1, + title: Failed to acquire lock., + status: 500, + detail: The response from the lock acquisition endpoint was not expected. + } + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt new file mode 100644 index 0000000000..ccd8a701a9 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt @@ -0,0 +1,14 @@ +{ + Exception: { + $type: InvalidOperationException, + Type: InvalidOperationException, + Message: Unable to extract instance identifiers., + StackTrace: +at Altinn.App.Api.Infrastructure.Middleware.ProcessLockMiddleware.Invoke(HttpContext context) +--- End of stack trace from previous location --- +at Microsoft.AspNetCore.TestHost.ClientHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) +at System.Net.Http.HttpClient.g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken) +--- 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.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt new file mode 100644 index 0000000000..a44cbff789 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt @@ -0,0 +1,16 @@ +{ + Response: { + Status: 500 Internal Server Error, + Content: { + Headers: { + Content-Type: application/problem+json + }, + Value: { + type: https://tools.ietf.org/html/rfc9110#section-15.6.1, + title: Failed to acquire lock., + status: 500, + detail: The response from the lock acquisition endpoint was not expected. + } + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt new file mode 100644 index 0000000000..cbdb7d4bce --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt @@ -0,0 +1,16 @@ +{ + Response: { + Status: 409 Conflict, + Content: { + Headers: { + Content-Type: application/problem+json + }, + Value: { + type: https://tools.ietf.org/html/rfc9110#section-15.5.10, + title: Failed to acquire lock., + status: 409, + detail: 409 - Conflict - + } + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt new file mode 100644 index 0000000000..a7f5494031 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt @@ -0,0 +1,16 @@ +{ + Response: { + Status: 500 Internal Server Error, + Content: { + Headers: { + Content-Type: application/problem+json + }, + Value: { + type: https://tools.ietf.org/html/rfc9110#section-15.6.1, + title: Failed to acquire lock., + status: 500, + detail: 500 - Internal Server Error - + } + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt new file mode 100644 index 0000000000..3845a2f396 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt @@ -0,0 +1,16 @@ +{ + Response: { + Status: 404 Not Found, + Content: { + Headers: { + Content-Type: application/problem+json + }, + Value: { + type: https://tools.ietf.org/html/rfc9110#section-15.5.5, + title: Failed to acquire lock., + status: 404, + detail: 404 - Not Found - + } + } + } +} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs index f8c3bd9217..a17988bc61 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Net.Http.Json; using Altinn.App.Api.Infrastructure.Middleware; using Altinn.App.Core.Configuration; using Altinn.App.Core.Infrastructure.Clients.Storage; @@ -7,7 +6,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -248,7 +246,8 @@ public async Task CustomExpirationConfiguration_UsedInStorageApiCall() var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); Assert.Single(acquireRequests); var requestBody = acquireRequests[0].RequestMessage.Body; - Assert.Contains($"\"expiration\":{customExpirationSeconds}", requestBody); + + await Verify(new { RequestBody = requestBody }); } [Fact] @@ -260,9 +259,8 @@ public async Task MissingRouteParameters_ThrowsException() await fixture.GetTestClient().GetAsync("/invalid-route") ); - Assert.Contains("Unable to extract instance identifiers.", exception.Message); - Assert.Empty(fixture.Server.LogEntries); + await Verify(new { Exception = exception }); } [Fact] @@ -313,15 +311,9 @@ public async Task StorageApiError_ReturnsCorrectStatusCode(HttpStatusCode storag .GetTestClient() .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); - Assert.Equal(storageStatusCode, response.StatusCode); - - var problemDetails = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(problemDetails); - Assert.Equal("Failed to acquire lock.", problemDetails.Title); - Assert.Equal((int)storageStatusCode, problemDetails.Status); - Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Response = response }).UseParameters(storageStatusCode); } [Fact] @@ -343,16 +335,9 @@ public async Task NullResponseBody_ReturnsProblemDetails() .GetTestClient() .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - - var problemDetails = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(problemDetails); - Assert.Equal("Failed to acquire lock.", problemDetails.Title); - Assert.Equal("The response from the lock acquisition endpoint was not expected.", problemDetails.Detail); - Assert.Equal((int)HttpStatusCode.InternalServerError, problemDetails.Status); - Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Response = response }); } [Fact] @@ -374,15 +359,8 @@ public async Task EmptyJsonResponseBody_ReturnsProblemDetails() .GetTestClient() .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - - var problemDetails = await response.Content.ReadFromJsonAsync(); - - Assert.NotNull(problemDetails); - Assert.Equal("Failed to acquire lock.", problemDetails.Title); - Assert.Equal("The response from the lock acquisition endpoint was not expected.", problemDetails.Detail); - Assert.Equal((int)HttpStatusCode.InternalServerError, problemDetails.Status); - Assert.Single(fixture.Server.LogEntries); + + await Verify(new { Response = response }); } } From afa3dd715bc9fadd66ca105223ff17584522aeb3 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:14:49 +0100 Subject: [PATCH 03/17] Remove unused method --- .../Features/Telemetry/Telemetry.ProcessLockClient.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs index eda8e56812..6131a40c29 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs @@ -4,8 +4,6 @@ namespace Altinn.App.Core.Features; partial class Telemetry { - internal Activity? StartAqzActivity() => ActivitySource.StartActivity("ProcessClient.GetProcessDefinition"); - internal Activity? StartAcquireProcessLockActivity(Guid instanceGuid, int instanceOwnerPartyId) { var activity = ActivitySource.StartActivity("AcquireProcessLock"); From d84fe59f84dcfcffe9125bd02ac5b0ef05bbe8a4 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:53:56 +0100 Subject: [PATCH 04/17] Log exception --- .../Clients/Storage/ProcessLockClient.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs index c3a21b2ed2..63958b6675 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs @@ -8,6 +8,7 @@ using Altinn.App.Core.Internal.Process.ProcessLock; using AltinnCore.Authentication.Utils; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Altinn.App.Core.Infrastructure.Clients.Storage; @@ -15,6 +16,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage; internal sealed class ProcessLockClient { private readonly AppSettings _appSettings; + private readonly ILogger _logger; private readonly HttpClient _client; private readonly Telemetry? _telemetry; private readonly IHttpContextAccessor _httpContextAccessor; @@ -22,12 +24,14 @@ internal sealed class ProcessLockClient public ProcessLockClient( IOptions platformSettings, IOptions appSettings, + ILogger logger, IHttpContextAccessor httpContextAccessor, HttpClient httpClient, Telemetry? telemetry = null ) { _appSettings = appSettings.Value; + _logger = logger; _httpContextAccessor = httpContextAccessor; httpClient.BaseAddress = new Uri(platformSettings.Value.ApiStorageEndpoint); httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, platformSettings.Value.SubscriptionKey); @@ -48,7 +52,7 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP var request = new ProcessLockRequest { Expiration = (int)expiration.TotalSeconds }; var content = JsonContent.Create(request); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content); + var response = await _client.PostAsync(token, apiUrl, content); if (!response.IsSuccessStatusCode) { @@ -61,9 +65,9 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP var lockResponse = await response.Content.ReadFromJsonAsync(); lockId = lockResponse?.LockId; } - catch + catch (Exception e) { - // Throw exception below + _logger.LogError(e, "Error reading response from the lock acquisition endpoint."); } if (lockId is null || lockId.Value == Guid.Empty) @@ -89,7 +93,7 @@ public async Task ReleaseProcessLock(Guid instanceGuid, int instanceOwnerPartyId var request = new ProcessLockRequest { Expiration = 0 }; var content = JsonContent.Create(request); - HttpResponseMessage response = await _client.PatchAsync(token, apiUrl, content); + var response = await _client.PatchAsync(token, apiUrl, content); if (!response.IsSuccessStatusCode) { From c7d71b6e3060244d139043dcdab29349e9c4308b Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:49:34 +0100 Subject: [PATCH 05/17] Update public api test --- ...iTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt | 1 + 1 file changed, 1 insertion(+) 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 f62c1bff3a..466319ba09 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 @@ -241,6 +241,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) { } } From 6d3a10e158cd3137ee13d0a64b5dd425a11d42e6 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 26 Nov 2025 19:38:58 +0100 Subject: [PATCH 06/17] Use PlatformHttpResponseSnapshotException --- .../PlatformHttpResponseSnapshotException.cs | 116 +++++++++++------- .../Clients/Storage/ProcessLockClient.cs | 14 +-- ...de_storageStatusCode=Conflict.verified.txt | 2 +- ...tatusCode=InternalServerError.verified.txt | 2 +- ...de_storageStatusCode=NotFound.verified.txt | 2 +- 5 files changed, 79 insertions(+), 57 deletions(-) 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/ProcessLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs index 63958b6675..2d95c69b9a 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs @@ -52,11 +52,11 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP var request = new ProcessLockRequest { Expiration = (int)expiration.TotalSeconds }; var content = JsonContent.Create(request); - var response = await _client.PostAsync(token, apiUrl, content); + using var response = await _client.PostAsync(token, apiUrl, content); if (!response.IsSuccessStatusCode) { - throw await PlatformHttpException.CreateAsync(response); + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); } Guid? lockId = null; @@ -72,9 +72,9 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP if (lockId is null || lockId.Value == Guid.Empty) { - throw new PlatformHttpException( - response, - "The response from the lock acquisition endpoint was not expected." + throw PlatformHttpResponseSnapshotException.Create( + "The response from the lock acquisition endpoint was not expected.", + response ); } @@ -93,11 +93,11 @@ public async Task ReleaseProcessLock(Guid instanceGuid, int instanceOwnerPartyId var request = new ProcessLockRequest { Expiration = 0 }; var content = JsonContent.Create(request); - var response = await _client.PatchAsync(token, apiUrl, content); + using var response = await _client.PatchAsync(token, apiUrl, content); if (!response.IsSuccessStatusCode) { - throw await PlatformHttpException.CreateAsync(response); + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); } } } diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt index cbdb7d4bce..a6b96dea8f 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt @@ -9,7 +9,7 @@ type: https://tools.ietf.org/html/rfc9110#section-15.5.10, title: Failed to acquire lock., status: 409, - detail: 409 - Conflict - + detail: 409 Conflict } } } diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt index a7f5494031..6dfddf8227 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt @@ -9,7 +9,7 @@ type: https://tools.ietf.org/html/rfc9110#section-15.6.1, title: Failed to acquire lock., status: 500, - detail: 500 - Internal Server Error - + detail: 500 Internal Server Error } } } diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt index 3845a2f396..417802ed99 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt @@ -9,7 +9,7 @@ type: https://tools.ietf.org/html/rfc9110#section-15.5.5, title: Failed to acquire lock., status: 404, - detail: 404 - Not Found - + detail: 404 Not Found } } } From 480012660def29a45875009edf3c0a5064dd316a Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Sun, 30 Nov 2025 18:58:48 +0100 Subject: [PATCH 07/17] Change Expiration to TtlSeconds --- .../Infrastructure/Clients/Storage/ProcessLockClient.cs | 4 ++-- .../Internal/Process/ProcessLock/ProcessLockRequest.cs | 2 +- ...mExpirationConfiguration_UsedInStorageApiCall.verified.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs index 2d95c69b9a..dc46850ce2 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs @@ -49,7 +49,7 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP _appSettings.RuntimeCookieName ); - var request = new ProcessLockRequest { Expiration = (int)expiration.TotalSeconds }; + var request = new ProcessLockRequest { TtlSeconds = (int)expiration.TotalSeconds }; var content = JsonContent.Create(request); using var response = await _client.PostAsync(token, apiUrl, content); @@ -90,7 +90,7 @@ public async Task ReleaseProcessLock(Guid instanceGuid, int instanceOwnerPartyId _appSettings.RuntimeCookieName ); - var request = new ProcessLockRequest { Expiration = 0 }; + var request = new ProcessLockRequest { TtlSeconds = 0 }; var content = JsonContent.Create(request); using var response = await _client.PatchAsync(token, apiUrl, content); diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs index c8e816eeba..3d55e85753 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs @@ -2,5 +2,5 @@ namespace Altinn.App.Core.Internal.Process.ProcessLock; internal sealed class ProcessLockRequest { - public int Expiration { get; set; } + public int TtlSeconds { get; set; } } diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt index 6c474e41df..355d4cdb15 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt +++ b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt @@ -1,3 +1,3 @@ { - RequestBody: {"expiration":120} + RequestBody: {"ttlSeconds":120} } \ No newline at end of file From 3c685f4c208c28bfad945e2567a097ed1f198e01 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Sun, 30 Nov 2025 19:41:10 +0100 Subject: [PATCH 08/17] Enable lock for /process/next and add integration test --- .../Controllers/ProcessController.cs | 2 + .../WebApplicationBuilderExtensions.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 1 + .../ProcessLock/ProcessLockTests.cs | 50 ++++++++++++++ .../services/WaitForReleaseProcessTaskEnd.cs | 65 +++++++++++++++++++ 5 files changed, 119 insertions(+) create mode 100644 test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs create mode 100644 test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-lock/services/WaitForReleaseProcessTaskEnd.cs diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index af08623f2b..1ba428de9d 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Net; using Altinn.App.Api.Infrastructure.Filters; +using Altinn.App.Api.Infrastructure.Middleware; using Altinn.App.Api.Models; using Altinn.App.Core.Constants; using Altinn.App.Core.Helpers; @@ -253,6 +254,7 @@ [FromRoute] Guid instanceGuid [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] + [EnableProcessLock] public async Task> NextElement( [FromRoute] string org, [FromRoute] string app, diff --git a/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs index c3e5ed5819..be3d2fac61 100644 --- a/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -28,6 +28,7 @@ public static IApplicationBuilder UseAltinnAppCommonConfiguration(this IApplicat app.UseAuthorization(); app.UseTelemetryEnricher(); app.UseScopeAuthorization(); + app.UseMiddleware(); app.UseEndpoints(endpoints => { diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index e463ded3a9..20ad641c19 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -114,6 +114,7 @@ IWebHostEnvironment env services.AddHttpClient(); #pragma warning restore CS0618 // Type or member is obsolete services.AddHttpClient(); + services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); diff --git a/test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs b/test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs new file mode 100644 index 0000000000..c20f410f82 --- /dev/null +++ b/test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs @@ -0,0 +1,50 @@ +using Altinn.App.Api.Models; +using Altinn.Platform.Storage.Interface.Models; +using Xunit.Abstractions; + +namespace Altinn.App.Integration.Tests.ProcessLock; + +[Trait("Category", "Integration")] +public sealed class ProcessLockTests(ITestOutputHelper _output, AppFixtureClassFixture _classFixture) + : IClassFixture +{ + [Fact] + public async Task ProcessNext_ConcurrentRequests_OneRequestGetsConflict() + { + await using var fixtureScope = await _classFixture.Get(_output, TestApps.Basic, "process-lock"); + var fixture = fixtureScope.Fixture; + + var client = fixture.GetAppClient(); + + var resetResponse = await client.PostAsync("/test/process-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/process-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/process-lock/services/WaitForReleaseProcessTaskEnd.cs b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-lock/services/WaitForReleaseProcessTaskEnd.cs new file mode 100644 index 0000000000..1d6605269f --- /dev/null +++ b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-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.ProcessLock; + +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/process-lock/release-wait", + () => + { + WaitForReleaseProcessTaskEnd.Release(); + return Results.Ok(); + } + ); + + app.MapPost( + "/test/process-lock/reset", + () => + { + WaitForReleaseProcessTaskEnd.Reset(); + return Results.Ok(); + } + ); + } +} + +public static class ServiceRegistration +{ + public static void RegisterServices(IServiceCollection services) + { + services.AddTransient(); + services.AddSingleton(); + } +} From 9debb7854d5795ffdaeb0ab8bfd3a262f9fe8b8a Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Mon, 8 Dec 2025 15:40:41 +0100 Subject: [PATCH 09/17] Replace middleware by async disposable --- .../Controllers/ProcessController.cs | 6 +- .../WebApplicationBuilderExtensions.cs | 1 - .../Middleware/EnableProcessLockAttribute.cs | 4 - .../Middleware/ProcessLockMiddleware.cs | 120 -------- .../Extensions/ServiceCollectionExtensions.cs | 3 + .../ProcessLock}/ProcessLockOptions.cs | 2 +- .../Process/ProcessLock/ProcessLocker.cs | 69 +++++ .../Altinn.App.Api.Tests.csproj | 1 - ...nseBody_ReturnsProblemDetails.verified.txt | 16 -- ...uteParameters_ThrowsException.verified.txt | 14 - ...nseBody_ReturnsProblemDetails.verified.txt | 16 -- ...de_storageStatusCode=Conflict.verified.txt | 16 -- ...tatusCode=InternalServerError.verified.txt | 16 -- ...de_storageStatusCode=NotFound.verified.txt | 16 -- ...uration_UsedInStorageApiCall.verified.txt} | 0 ...y_ThrowsPlatformHttpException.verified.txt | 31 +++ ...nceId_ThrowsArgumentException.verified.txt | 11 + ...y_ThrowsPlatformHttpException.verified.txt | 31 +++ ...on_storageStatusCode=Conflict.verified.txt | 28 ++ ...tatusCode=InternalServerError.verified.txt | 28 ++ ...on_storageStatusCode=NotFound.verified.txt | 28 ++ .../Internal/Process/ProcessLockTests.cs} | 258 ++++++++---------- 22 files changed, 353 insertions(+), 362 deletions(-) delete mode 100644 src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs delete mode 100644 src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs rename src/{Altinn.App.Api/Infrastructure/Middleware => Altinn.App.Core/Internal/Process/ProcessLock}/ProcessLockOptions.cs (67%) create mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt delete mode 100644 test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt rename test/{Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt => Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt} (100%) create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt create mode 100644 test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt rename test/{Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs => Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs} (51%) diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 1ba428de9d..7684d58fcb 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Net; using Altinn.App.Api.Infrastructure.Filters; -using Altinn.App.Api.Infrastructure.Middleware; using Altinn.App.Api.Models; using Altinn.App.Core.Constants; using Altinn.App.Core.Helpers; @@ -10,6 +9,7 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Models.Validation; @@ -41,6 +41,7 @@ public class ProcessController : ControllerBase private readonly IProcessEngineAuthorizer _processEngineAuthorizer; private readonly IValidationService _validationService; private readonly InstanceDataUnitOfWorkInitializer _instanceDataUnitOfWorkInitializer; + private readonly ProcessLocker _processLocker; /// /// Initializes a new instance of the @@ -66,6 +67,7 @@ IProcessEngineAuthorizer processEngineAuthorizer _processEngineAuthorizer = processEngineAuthorizer; _validationService = validationService; _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); + _processLocker = serviceProvider.GetRequiredService(); } /// @@ -254,7 +256,6 @@ [FromRoute] Guid instanceGuid [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status409Conflict)] - [EnableProcessLock] public async Task> NextElement( [FromRoute] string org, [FromRoute] string app, @@ -269,6 +270,7 @@ public async Task> NextElement( try { Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); + await using var _ = await _processLocker.AcquireAsync(instance); var processNextRequest = new ProcessNextRequest { diff --git a/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs index be3d2fac61..c3e5ed5819 100644 --- a/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Altinn.App.Api/Extensions/WebApplicationBuilderExtensions.cs @@ -28,7 +28,6 @@ public static IApplicationBuilder UseAltinnAppCommonConfiguration(this IApplicat app.UseAuthorization(); app.UseTelemetryEnricher(); app.UseScopeAuthorization(); - app.UseMiddleware(); app.UseEndpoints(endpoints => { diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs b/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs deleted file mode 100644 index 669148f47d..0000000000 --- a/src/Altinn.App.Api/Infrastructure/Middleware/EnableProcessLockAttribute.cs +++ /dev/null @@ -1,4 +0,0 @@ -namespace Altinn.App.Api.Infrastructure.Middleware; - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] -internal sealed class EnableProcessLockAttribute : Attribute; diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs b/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs deleted file mode 100644 index a5bc61b81e..0000000000 --- a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockMiddleware.cs +++ /dev/null @@ -1,120 +0,0 @@ -using Altinn.App.Core.Helpers; -using Altinn.App.Core.Infrastructure.Clients.Storage; -using Microsoft.Extensions.Options; - -namespace Altinn.App.Api.Infrastructure.Middleware; - -/// -/// Middleware that ensures only one request can proceed at a time by acquiring a lock. -/// Only applies to endpoints decorated with . -/// -internal sealed partial class ProcessLockMiddleware -{ - private readonly RequestDelegate _next; - private readonly ILogger _logger; - private readonly ProcessLockClient _processClient; - private readonly ProcessLockOptions _options; - - public ProcessLockMiddleware( - RequestDelegate next, - ILogger logger, - IOptions options, - ProcessLockClient processClient - ) - { - _next = next; - _logger = logger; - _processClient = processClient; - _options = options.Value; - } - - public async Task Invoke(HttpContext context) - { - var endpoint = context.GetEndpoint(); - - if (endpoint?.Metadata.GetMetadata() is null) - { - await _next(context); - return; - } - - var (instanceOwnerPartyId, instanceGuid) = - GetInstanceIdentifiers(context) - ?? throw new InvalidOperationException("Unable to extract instance identifiers."); - Guid? lockId = null; - - try - { - try - { - lockId = await _processClient.AcquireProcessLock( - instanceGuid, - instanceOwnerPartyId, - _options.Expiration - ); - - LogLockAcquired(_logger, lockId.Value); - } - catch (PlatformHttpException e) - { - LogLockAcquisitionFailed(_logger); - var problem = TypedResults.Problem( - detail: e.Message, - statusCode: e.Response.IsSuccessStatusCode ? 500 : (int)e.Response.StatusCode, - title: "Failed to acquire lock." - ); - - await problem.ExecuteAsync(context); - - return; - } - - await _next(context); - } - finally - { - if (lockId is not null) - { - try - { - await _processClient.ReleaseProcessLock(instanceGuid, instanceOwnerPartyId, lockId.Value); - - LogLockReleased(_logger, lockId.Value); - } - catch (Exception e) - { - LogLockReleaseFailed(_logger, lockId.Value, e); - } - } - } - } - - private static (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers(HttpContext context) - { - var routeData = context.GetRouteData(); - - if ( - routeData.Values.TryGetValue("instanceOwnerPartyId", out var partyIdObj) - && routeData.Values.TryGetValue("instanceGuid", out var guidObj) - && int.TryParse(partyIdObj?.ToString(), out var partyId) - && Guid.TryParse(guidObj?.ToString(), out var guid) - ) - { - return (partyId, guid); - } - - return null; - } - - [LoggerMessage(1, LogLevel.Debug, "Failed to acquire process lock.")] - private static partial void LogLockAcquisitionFailed(ILogger logger); - - [LoggerMessage(2, LogLevel.Debug, "Acquired process lock with id: {LockId}")] - private static partial void LogLockAcquired(ILogger logger, Guid lockId); - - [LoggerMessage(3, LogLevel.Debug, "Released process lock with id: {LockId}")] - private static partial void LogLockReleased(ILogger logger, Guid lockId); - - [LoggerMessage(4, LogLevel.Error, "Failed to release process lock with id: {LockId}")] - private static partial void LogLockReleaseFailed(ILogger logger, Guid lockId, Exception e); -} diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 20ad641c19..a67934b076 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -46,6 +46,7 @@ using Altinn.App.Core.Internal.Process.Authorization; using Altinn.App.Core.Internal.Process.EventHandlers; using Altinn.App.Core.Internal.Process.EventHandlers.ProcessTask; +using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Process.ProcessTasks; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks.Legacy; @@ -365,6 +366,8 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + services.AddTransient(); + // Process tasks services.AddTransient(); services.AddTransient(); diff --git a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs similarity index 67% rename from src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs rename to src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs index 94b9d3a218..f3a0d839ed 100644 --- a/src/Altinn.App.Api/Infrastructure/Middleware/ProcessLockOptions.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs @@ -1,4 +1,4 @@ -namespace Altinn.App.Api.Infrastructure.Middleware; +namespace Altinn.App.Core.Internal.Process.ProcessLock; internal sealed class ProcessLockOptions { diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs new file mode 100644 index 0000000000..1225a96914 --- /dev/null +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs @@ -0,0 +1,69 @@ +using Altinn.App.Core.Infrastructure.Clients.Storage; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Internal.Process.ProcessLock; + +internal sealed partial class ProcessLocker( + ProcessLockClient client, + IOptions options, + ILogger logger +) +{ + public async Task AcquireAsync(Instance instance) + { + var instanceIdParts = instance.Id.Split('/'); + if ( + instanceIdParts.Length != 2 + || !int.TryParse(instanceIdParts[0], out var instanceOwnerPartyId) + || !Guid.TryParse(instanceIdParts[1], out var instanceGuid) + ) + { + throw new ArgumentException("Instance ID was not in the expected format."); + } + + var lockId = await client.AcquireProcessLock(instanceGuid, instanceOwnerPartyId, options.Value.Expiration); + + LogLockAcquired(logger, lockId); + + return new ProcessLock(instanceGuid, instanceOwnerPartyId, lockId, client, logger); + } + + private sealed partial class ProcessLock( + Guid instanceGuid, + int instanceOwnerPartyId, + Guid lockId, + ProcessLockClient client, + ILogger logger + ) : IAsyncDisposable + { + public async ValueTask DisposeAsync() + { + try + { + await client.ReleaseProcessLock(instanceGuid, instanceOwnerPartyId, lockId); + + LogLockReleased(logger, lockId); + + return; + } + catch (Exception e) + { + LogLockReleaseFailed(logger, lockId, e); + } + } + } + + [LoggerMessage(1, LogLevel.Debug, "Failed to acquire process lock.")] + private static partial void LogLockAcquisitionFailed(ILogger logger); + + [LoggerMessage(2, LogLevel.Debug, "Acquired process lock with id: {LockId}")] + private static partial void LogLockAcquired(ILogger logger, Guid lockId); + + [LoggerMessage(3, LogLevel.Debug, "Released process lock with id: {LockId}")] + private static partial void LogLockReleased(ILogger logger, Guid lockId); + + [LoggerMessage(4, LogLevel.Error, "Failed to release process lock with id: {LockId}")] + private static partial void LogLockReleaseFailed(ILogger logger, Guid lockId, Exception e); +} 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 098b999643..9b0ae996d7 100644 --- a/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj +++ b/test/Altinn.App.Api.Tests/Altinn.App.Api.Tests.csproj @@ -18,7 +18,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt deleted file mode 100644 index a44cbff789..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.EmptyJsonResponseBody_ReturnsProblemDetails.verified.txt +++ /dev/null @@ -1,16 +0,0 @@ -{ - Response: { - Status: 500 Internal Server Error, - Content: { - Headers: { - Content-Type: application/problem+json - }, - Value: { - type: https://tools.ietf.org/html/rfc9110#section-15.6.1, - title: Failed to acquire lock., - status: 500, - detail: The response from the lock acquisition endpoint was not expected. - } - } - } -} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt deleted file mode 100644 index ccd8a701a9..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.MissingRouteParameters_ThrowsException.verified.txt +++ /dev/null @@ -1,14 +0,0 @@ -{ - Exception: { - $type: InvalidOperationException, - Type: InvalidOperationException, - Message: Unable to extract instance identifiers., - StackTrace: -at Altinn.App.Api.Infrastructure.Middleware.ProcessLockMiddleware.Invoke(HttpContext context) ---- End of stack trace from previous location --- -at Microsoft.AspNetCore.TestHost.ClientHandler.SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) -at System.Net.Http.HttpClient.g__Core|83_0(HttpRequestMessage request, HttpCompletionOption completionOption, CancellationTokenSource cts, Boolean disposeCts, CancellationTokenSource pendingRequestsCts, CancellationToken originalCancellationToken) ---- 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.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt deleted file mode 100644 index a44cbff789..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.NullResponseBody_ReturnsProblemDetails.verified.txt +++ /dev/null @@ -1,16 +0,0 @@ -{ - Response: { - Status: 500 Internal Server Error, - Content: { - Headers: { - Content-Type: application/problem+json - }, - Value: { - type: https://tools.ietf.org/html/rfc9110#section-15.6.1, - title: Failed to acquire lock., - status: 500, - detail: The response from the lock acquisition endpoint was not expected. - } - } - } -} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt deleted file mode 100644 index a6b96dea8f..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=Conflict.verified.txt +++ /dev/null @@ -1,16 +0,0 @@ -{ - Response: { - Status: 409 Conflict, - Content: { - Headers: { - Content-Type: application/problem+json - }, - Value: { - type: https://tools.ietf.org/html/rfc9110#section-15.5.10, - title: Failed to acquire lock., - status: 409, - detail: 409 Conflict - } - } - } -} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt deleted file mode 100644 index 6dfddf8227..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=InternalServerError.verified.txt +++ /dev/null @@ -1,16 +0,0 @@ -{ - Response: { - Status: 500 Internal Server Error, - Content: { - Headers: { - Content-Type: application/problem+json - }, - Value: { - type: https://tools.ietf.org/html/rfc9110#section-15.6.1, - title: Failed to acquire lock., - status: 500, - detail: 500 Internal Server Error - } - } - } -} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt deleted file mode 100644 index 417802ed99..0000000000 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.StorageApiError_ReturnsCorrectStatusCode_storageStatusCode=NotFound.verified.txt +++ /dev/null @@ -1,16 +0,0 @@ -{ - Response: { - Status: 404 Not Found, - Content: { - Headers: { - Content-Type: application/problem+json - }, - Value: { - type: https://tools.ietf.org/html/rfc9110#section-15.5.5, - title: Failed to acquire lock., - status: 404, - detail: 404 Not Found - } - } - } -} \ No newline at end of file diff --git a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt similarity index 100% rename from test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt new file mode 100644 index 0000000000..a50062a95c --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -0,0 +1,31 @@ +{ + 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.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt new file mode 100644 index 0000000000..3660ab9708 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt @@ -0,0 +1,11 @@ +{ + Exception: { + $type: ArgumentException, + Type: ArgumentException, + Message: Instance ID was not in the expected format., + StackTrace: +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt new file mode 100644 index 0000000000..a50062a95c --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -0,0 +1,31 @@ +{ + 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.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt new file mode 100644 index 0000000000..4f16fd3971 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -0,0 +1,28 @@ +{ + 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.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt new file mode 100644 index 0000000000..4a64528477 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -0,0 +1,28 @@ +{ + 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.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt new file mode 100644 index 0000000000..f10a9626ba --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -0,0 +1,28 @@ +{ + 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.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +--- 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.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs similarity index 51% rename from test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs rename to test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs index a17988bc61..ef25e3c8f0 100644 --- a/test/Altinn.App.Api.Tests/Middleware/ProcessLockMiddlewareTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs @@ -1,108 +1,75 @@ using System.Net; -using Altinn.App.Api.Infrastructure.Middleware; using Altinn.App.Core.Configuration; +using Altinn.App.Core.Helpers; using Altinn.App.Core.Infrastructure.Clients.Storage; using Altinn.App.Core.Internal.Process.ProcessLock; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; +using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using WireMock.Matchers.Request; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; -namespace Altinn.App.Api.Tests.Middleware; +namespace Altinn.App.Core.Tests.Internal.Process; -public sealed class ProcessLockMiddlewareTests +public sealed class ProcessLockTests { - private sealed record Fixture(IHost Host, WireMockServer Server) : IDisposable + private sealed record Fixture(WireMockServer Server, ServiceProvider ServiceProvider) : IDisposable { public readonly Guid InstanceGuid = Guid.NewGuid(); public readonly int InstanceOwnerPartyId = 12345; private const string RuntimeCookieName = "test-cookie"; private const string BearerToken = "test-token"; - public static Fixture Create(Action? registerCustomAppServices = null) + public readonly string ServerUrl = Server.Url ?? throw new Exception("Missing server URL"); + + public static Fixture Create(Action? registerCustomServices = null) { var server = WireMockServer.Start(); - var host = new HostBuilder() - .ConfigureWebHost(webBuilder => - { - webBuilder - .UseTestServer() - .ConfigureServices(services => - { - services.AddRouting(); - - services.Configure(settings => - { - var testUrl = server.Url ?? throw new Exception("Missing server URL"); - settings.ApiStorageEndpoint = - testUrl + new Uri(settings.ApiStorageEndpoint).PathAndQuery; - }); - - services.Configure(settings => settings.RuntimeCookieName = RuntimeCookieName); - - services.AddHttpClient(); - - services.AddHttpContextAccessor(); - - registerCustomAppServices?.Invoke(services); - }) - .Configure(app => - { - app.UseRouting(); - app.UseMiddleware(); - app.UseEndpoints(endpoints => - { - // Endpoint with lock - endpoints - .MapGet( - "/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/test", - () => "Success" - ) - .WithMetadata(new EnableProcessLockAttribute()); - - // Endpoint without lock - endpoints.MapGet("/without-lock", () => "No lock required"); - - // Endpoint with lock that throws exception - endpoints - .MapGet( - "/instances/{instanceOwnerPartyId:int}/{instanceGuid:guid}/test-exception", - _ => throw new InvalidOperationException("Test exception") - ) - .WithMetadata(new EnableProcessLockAttribute()); - - // Endpoint with lock but no route parameters - endpoints - .MapGet("/invalid-route", () => Results.Ok()) - .WithMetadata(new EnableProcessLockAttribute()); - }); - }); - }) - .Start(); - - return new Fixture(host, server); + 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; + }); + + services.Configure(settings => settings.RuntimeCookieName = RuntimeCookieName); + + services.AddHttpClient(); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Cookie = $"{RuntimeCookieName}={BearerToken}"; + + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + services.AddSingleton(httpContextAccessor); + + services.AddTransient(); + + registerCustomServices?.Invoke(services); + + var serviceProvider = services.BuildServiceProvider(); + + return new Fixture(server, serviceProvider); } public void Dispose() { Server.Stop(); Server.Dispose(); - Host.Dispose(); + ServiceProvider.Dispose(); } - public HttpClient GetTestClient() + public ProcessLocker GetProcessLocker() { - var httpClient = Host.GetTestClient(); - httpClient.DefaultRequestHeaders.Add("cookie", $"{RuntimeCookieName}={BearerToken}"); + return ServiceProvider.GetRequiredService(); + } - return httpClient; + public Instance CreateInstance() + { + return new Instance { Id = $"{InstanceOwnerPartyId}/{InstanceGuid}" }; } public IRequestBuilder GetAcquireLockRequestBuilder() @@ -134,6 +101,8 @@ public async Task HappyPath() var acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockId); + var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); + fixture .Server.Given(acquireLockRequestBuilder) .RespondWith( @@ -147,41 +116,35 @@ public async Task HappyPath() .Server.Given(releaseLockRequestBuilder) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + fixture.Server.Given(testRequestBuilder).RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("Success", content); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); + + await using (var _ = await processLocker.AcquireAsync(instance)) + { + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); + response.EnsureSuccessStatusCode(); + } var requests = fixture.Server.LogEntries; - Assert.Equal(2, requests.Count); + 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[1].RequestMessage, releaseMatchResult); + releaseLockRequestBuilder.GetMatchingScore(requests[2].RequestMessage, releaseMatchResult); Assert.True(releaseMatchResult.IsPerfectMatch); } - [Fact] - public async Task EndpointWithoutAttribute_SkipsMiddleware() - { - using var fixture = Fixture.Create(); - - var response = await fixture.GetTestClient().GetAsync("/without-lock"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("No lock required", content); - - Assert.Empty(fixture.Server.LogEntries); - } - [Fact] public async Task LockReleasedOnException() { @@ -202,11 +165,14 @@ public async Task LockReleasedOnException() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - await Assert.ThrowsAsync(async () => - await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test-exception") - ); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); + + await Assert.ThrowsAsync(async () => + { + await using var _ = await processLocker.AcquireAsync(instance); + throw new Exception(); + }); var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); Assert.Single(releaseRequests); @@ -237,11 +203,10 @@ public async Task CustomExpirationConfiguration_UsedInStorageApiCall() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + await using (var _ = await processLocker.AcquireAsync(instance)) { } var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); Assert.Single(acquireRequests); @@ -251,20 +216,7 @@ public async Task CustomExpirationConfiguration_UsedInStorageApiCall() } [Fact] - public async Task MissingRouteParameters_ThrowsException() - { - using var fixture = Fixture.Create(); - - var exception = await Assert.ThrowsAsync(async () => - await fixture.GetTestClient().GetAsync("/invalid-route") - ); - - Assert.Empty(fixture.Server.LogEntries); - await Verify(new { Exception = exception }); - } - - [Fact] - public async Task LockReleaseFailure_DoesNotAffectResponse() + public async Task LockReleaseFailure_DoesNotThrow() { using var fixture = Fixture.Create(); @@ -283,13 +235,10 @@ public async Task LockReleaseFailure_DoesNotAffectResponse() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var content = await response.Content.ReadAsStringAsync(); - Assert.Equal("Success", content); + await using (var _ = await processLocker.AcquireAsync(instance)) { } var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); Assert.Single(releaseRequests); @@ -299,7 +248,7 @@ public async Task LockReleaseFailure_DoesNotAffectResponse() [InlineData(HttpStatusCode.Conflict)] [InlineData(HttpStatusCode.NotFound)] [InlineData(HttpStatusCode.InternalServerError)] - public async Task StorageApiError_ReturnsCorrectStatusCode(HttpStatusCode storageStatusCode) + public async Task StorageApiError_ThrowsCorrectPlatformHttpException(HttpStatusCode storageStatusCode) { using var fixture = Fixture.Create(); @@ -307,17 +256,23 @@ public async Task StorageApiError_ReturnsCorrectStatusCode(HttpStatusCode storag .Server.Given(fixture.GetAcquireLockRequestBuilder()) .RespondWith(Response.Create().WithStatusCode(storageStatusCode)); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var _ = await processLocker.AcquireAsync(instance); + }); Assert.Single(fixture.Server.LogEntries); - await Verify(new { Response = response }).UseParameters(storageStatusCode); + await Verify(new { Exception = exception }) + .UseParameters(storageStatusCode) + .IgnoreMember(x => x.Headers); } [Fact] - public async Task NullResponseBody_ReturnsProblemDetails() + public async Task NullResponseBody_ThrowsPlatformHttpException() { using var fixture = Fixture.Create(); @@ -331,17 +286,21 @@ public async Task NullResponseBody_ReturnsProblemDetails() .WithBody("null") ); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var _ = await processLocker.AcquireAsync(instance); + }); Assert.Single(fixture.Server.LogEntries); - await Verify(new { Response = response }); + await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); } [Fact] - public async Task EmptyJsonResponseBody_ReturnsProblemDetails() + public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() { using var fixture = Fixture.Create(); @@ -355,12 +314,33 @@ public async Task EmptyJsonResponseBody_ReturnsProblemDetails() .WithBody("{}") ); - var response = await fixture - .GetTestClient() - .GetAsync($"/instances/{fixture.InstanceOwnerPartyId}/{fixture.InstanceGuid}/test"); + var processLocker = fixture.GetProcessLocker(); + var instance = fixture.CreateInstance(); + + var exception = await Assert.ThrowsAsync(async () => + { + await using var _ = await processLocker.AcquireAsync(instance); + }); Assert.Single(fixture.Server.LogEntries); - await Verify(new { Response = response }); + await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); + } + + [Fact] + public async Task InvalidInstanceId_ThrowsArgumentException() + { + using var fixture = Fixture.Create(); + + var processLocker = fixture.GetProcessLocker(); + var instance = new Instance { Id = "invalid-format" }; + + var exception = await Assert.ThrowsAsync(async () => + { + await using var _ = await processLocker.AcquireAsync(instance); + }); + + Assert.Empty(fixture.Server.LogEntries); + await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); } } From a9263b59be633b201a097ba3307373c6e3dd0885 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 10 Dec 2025 18:53:32 +0100 Subject: [PATCH 10/17] Add scoped service --- .../Controllers/ProcessController.cs | 3 - .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Internal/Process/ProcessEngine.cs | 5 + .../Process/ProcessLock/ProcessLocker.cs | 84 ++++++---- ...y_ThrowsPlatformHttpException.verified.txt | 3 +- ...nceId_ThrowsArgumentException.verified.txt | 9 +- ...y_ThrowsPlatformHttpException.verified.txt | 3 +- ...on_storageStatusCode=Conflict.verified.txt | 3 +- ...tatusCode=InternalServerError.verified.txt | 3 +- ...on_storageStatusCode=NotFound.verified.txt | 3 +- .../Internal/Process/ProcessLockTests.cs | 147 ++++++++++++------ 11 files changed, 171 insertions(+), 94 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index 7684d58fcb..fd072491fb 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -41,7 +41,6 @@ public class ProcessController : ControllerBase private readonly IProcessEngineAuthorizer _processEngineAuthorizer; private readonly IValidationService _validationService; private readonly InstanceDataUnitOfWorkInitializer _instanceDataUnitOfWorkInitializer; - private readonly ProcessLocker _processLocker; /// /// Initializes a new instance of the @@ -67,7 +66,6 @@ IProcessEngineAuthorizer processEngineAuthorizer _processEngineAuthorizer = processEngineAuthorizer; _validationService = validationService; _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); - _processLocker = serviceProvider.GetRequiredService(); } /// @@ -270,7 +268,6 @@ public async Task> NextElement( try { Instance instance = await _instanceClient.GetInstance(app, org, instanceOwnerPartyId, instanceGuid); - await using var _ = await _processLocker.AcquireAsync(instance); var processNextRequest = new ProcessNextRequest { diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index a67934b076..ea4469d621 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -366,7 +366,7 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddScoped(); // Process tasks services.AddTransient(); diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 3c84167766..4bc09f7093 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -9,6 +9,7 @@ using Altinn.App.Core.Internal.Instances; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.Base; +using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -42,6 +43,7 @@ public class ProcessEngine : IProcessEngine private readonly ILogger _logger; private readonly IValidationService _validationService; private readonly IInstanceClient _instanceClient; + private readonly ProcessLocker _processLocker; /// /// Initializes a new instance of the class. @@ -74,6 +76,7 @@ public ProcessEngine( _logger = logger; _appImplementationFactory = serviceProvider.GetRequiredService(); _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); + _processLocker = serviceProvider.GetRequiredService(); } /// @@ -252,6 +255,8 @@ out ProcessChangeResult? invalidProcessStateError }; } + await _processLocker.LockAsync(); + _logger.LogDebug( "User successfully authorized to perform process next. Task ID: {CurrentTaskId}. Task type: {AltinnTaskType}. Action: {ProcessNextAction}.", LogSanitizer.Sanitize(currentTaskId), diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs index 1225a96914..786036a453 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs @@ -1,5 +1,5 @@ using Altinn.App.Core.Infrastructure.Clients.Storage; -using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -8,53 +8,73 @@ namespace Altinn.App.Core.Internal.Process.ProcessLock; internal sealed partial class ProcessLocker( ProcessLockClient client, IOptions options, - ILogger logger -) + ILogger logger, + IHttpContextAccessor httpContextAccessor +) : IAsyncDisposable { - public async Task AcquireAsync(Instance instance) + private readonly HttpContext _httpContext = + httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); + + private ProcessLock? _lock; + + public async ValueTask LockAsync() { - var instanceIdParts = instance.Id.Split('/'); - if ( - instanceIdParts.Length != 2 - || !int.TryParse(instanceIdParts[0], out var instanceOwnerPartyId) - || !Guid.TryParse(instanceIdParts[1], out var instanceGuid) - ) + if (_lock is not null) { - throw new ArgumentException("Instance ID was not in the expected format."); + return; } + var (instanceOwnerPartyId, instanceGuid) = + GetInstanceIdentifiers() ?? throw new InvalidOperationException("Unable to extract instance identifiers."); + var lockId = await client.AcquireProcessLock(instanceGuid, instanceOwnerPartyId, options.Value.Expiration); LogLockAcquired(logger, lockId); - return new ProcessLock(instanceGuid, instanceOwnerPartyId, lockId, client, logger); + _lock = new ProcessLock(instanceGuid, instanceOwnerPartyId, lockId); + } + + 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; } - private sealed partial class ProcessLock( - Guid instanceGuid, - int instanceOwnerPartyId, - Guid lockId, - ProcessLockClient client, - ILogger logger - ) : IAsyncDisposable + public async ValueTask DisposeAsync() { - public async ValueTask DisposeAsync() + if (_lock is null) + { + return; + } + + try { - try - { - await client.ReleaseProcessLock(instanceGuid, instanceOwnerPartyId, lockId); - - LogLockReleased(logger, lockId); - - return; - } - catch (Exception e) - { - LogLockReleaseFailed(logger, lockId, e); - } + await client.ReleaseProcessLock(_lock.InstanceGuid, _lock.InstanceOwnerPartyId, _lock.LockId); } + catch (Exception e) + { + LogLockReleaseFailed(logger, _lock.LockId, e); + return; + } + + LogLockReleased(logger, _lock.LockId); + + _lock = null; } + private sealed record ProcessLock(Guid InstanceGuid, int InstanceOwnerPartyId, Guid LockId); + [LoggerMessage(1, LogLevel.Debug, "Failed to acquire process lock.")] private static partial void LogLockAcquisitionFailed(ILogger logger); diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt index a50062a95c..c600555b06 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -24,7 +24,8 @@ Message: The response from the lock acquisition endpoint was not expected., StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt index 3660ab9708..7c21f3ce0d 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt @@ -1,10 +1,11 @@ { Exception: { - $type: ArgumentException, - Type: ArgumentException, - Message: Instance ID was not in the expected format., + $type: InvalidOperationException, + Type: InvalidOperationException, + Message: Unable to extract instance identifiers., StackTrace: -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt index a50062a95c..c600555b06 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -24,7 +24,8 @@ Message: The response from the lock acquisition endpoint was not expected., StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt index 4f16fd3971..29380de14b 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -21,7 +21,8 @@ Message: 409 Conflict, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt index 4a64528477..38d3db457d 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -21,7 +21,8 @@ Message: 500 Internal Server Error, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt index f10a9626ba..8bc705b034 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -21,7 +21,8 @@ Message: 404 Not Found, StackTrace: at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.AcquireAsync(Instance instance) +at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +--- End of stack trace from previous location --- --- End of stack trace from previous location --- at Xunit.Assert.RecordExceptionAsync(Func`1 testCode) } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs index ef25e3c8f0..3389c885d0 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs @@ -3,7 +3,6 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Infrastructure.Clients.Storage; using Altinn.App.Core.Internal.Process.ProcessLock; -using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using WireMock.Matchers.Request; @@ -17,8 +16,8 @@ public sealed class ProcessLockTests { private sealed record Fixture(WireMockServer Server, ServiceProvider ServiceProvider) : IDisposable { - public readonly Guid InstanceGuid = Guid.NewGuid(); - public readonly int InstanceOwnerPartyId = 12345; + private static readonly Guid _instanceGuid = Guid.NewGuid(); + private const int InstanceOwnerPartyId = 12345; private const string RuntimeCookieName = "test-cookie"; private const string BearerToken = "test-token"; @@ -42,6 +41,8 @@ public static Fixture Create(Action? registerCustomServices var httpContext = new DefaultHttpContext(); httpContext.Request.Headers.Cookie = $"{RuntimeCookieName}={BearerToken}"; + httpContext.Request.RouteValues.Add("instanceOwnerPartyId", InstanceOwnerPartyId); + httpContext.Request.RouteValues.Add("instanceGuid", _instanceGuid); var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; services.AddSingleton(httpContextAccessor); @@ -62,21 +63,11 @@ public void Dispose() ServiceProvider.Dispose(); } - public ProcessLocker GetProcessLocker() - { - return ServiceProvider.GetRequiredService(); - } - - public Instance CreateInstance() - { - return new Instance { Id = $"{InstanceOwnerPartyId}/{InstanceGuid}" }; - } - public IRequestBuilder GetAcquireLockRequestBuilder() { return Request .Create() - .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{InstanceGuid}/process/lock") + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/process/lock") .UsingPost() .WithHeader("Authorization", $"Bearer {BearerToken}"); } @@ -85,7 +76,7 @@ public IRequestBuilder GetReleaseLockRequestBuilder(Guid lockId) { return Request .Create() - .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{InstanceGuid}/process/lock/{lockId}") + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/process/lock/{lockId}") .UsingPatch() .WithHeader("Authorization", $"Bearer {BearerToken}"); } @@ -120,11 +111,65 @@ public async Task HappyPath() var httpClient = fixture.ServiceProvider.GetRequiredService().CreateClient(); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.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 acquireLockRequestBuilder = fixture.GetAcquireLockRequestBuilder(); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockId); + + var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); + + fixture + .Server.Given(acquireLockRequestBuilder) + .RespondWith( + Response + .Create() + .WithStatusCode(HttpStatusCode.OK) + .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + ); + + 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 _ = await processLocker.AcquireAsync(instance)) + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) { + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); + await processLocker.LockAsync(); + await processLocker.LockAsync(); using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); response.EnsureSuccessStatusCode(); } @@ -165,12 +210,11 @@ public async Task LockReleasedOnException() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - await Assert.ThrowsAsync(async () => { - await using var _ = await processLocker.AcquireAsync(instance); + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); throw new Exception(); }); @@ -203,10 +247,11 @@ public async Task CustomExpirationConfiguration_UsedInStorageApiCall() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - - await using (var _ = await processLocker.AcquireAsync(instance)) { } + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); + } var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); Assert.Single(acquireRequests); @@ -235,10 +280,11 @@ public async Task LockReleaseFailure_DoesNotThrow() .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - - await using (var _ = await processLocker.AcquireAsync(instance)) { } + await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) + { + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); + } var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); Assert.Single(releaseRequests); @@ -256,12 +302,11 @@ public async Task StorageApiError_ThrowsCorrectPlatformHttpException(HttpStatusC .Server.Given(fixture.GetAcquireLockRequestBuilder()) .RespondWith(Response.Create().WithStatusCode(storageStatusCode)); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - var exception = await Assert.ThrowsAsync(async () => { - await using var _ = await processLocker.AcquireAsync(instance); + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -286,12 +331,11 @@ public async Task NullResponseBody_ThrowsPlatformHttpException() .WithBody("null") ); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - var exception = await Assert.ThrowsAsync(async () => { - await using var _ = await processLocker.AcquireAsync(instance); + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -314,12 +358,11 @@ public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() .WithBody("{}") ); - var processLocker = fixture.GetProcessLocker(); - var instance = fixture.CreateInstance(); - var exception = await Assert.ThrowsAsync(async () => { - await using var _ = await processLocker.AcquireAsync(instance); + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -330,17 +373,23 @@ public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() [Fact] public async Task InvalidInstanceId_ThrowsArgumentException() { - using var fixture = Fixture.Create(); - - var processLocker = fixture.GetProcessLocker(); - var instance = new Instance { Id = "invalid-format" }; + 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 = new DefaultHttpContext() }; + services.AddSingleton(httpContextAccessor); + }); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => { - await using var _ = await processLocker.AcquireAsync(instance); + await using var scope = fixture.ServiceProvider.CreateAsyncScope(); + var processLocker = scope.ServiceProvider.GetRequiredService(); + await processLocker.LockAsync(); }); Assert.Empty(fixture.Server.LogEntries); - await Verify(new { Exception = exception }).IgnoreMember(x => x.Headers); + await Verify(new { Exception = exception }); } } From 53e358a9931eb64ccfa6d5137bc9d0d038272449 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:09:53 +0100 Subject: [PATCH 11/17] Adapt to changes in storage --- Directory.Packages.props | 2 +- .../Controllers/ProcessController.cs | 1 - .../Extensions/ServiceCollectionExtensions.cs | 6 +- ...ent.cs => Telemetry.InstanceLockClient.cs} | 8 +- ...essLockClient.cs => InstanceLockClient.cs} | 43 ++++---- .../InstanceLocker.cs} | 48 ++++----- .../Internal/Process/ProcessEngine.cs | 8 +- .../Process/ProcessLock/ProcessLockOptions.cs | 6 -- .../Process/ProcessLock/ProcessLockRequest.cs | 6 -- .../ProcessLock/ProcessLockResponse.cs | 6 -- ...stomTtl_UsedInStorageApiCall.verified.txt} | 0 ..._ThrowsPlatformHttpException.verified.txt} | 4 +- ...ceId_ThrowsArgumentException.verified.txt} | 2 +- ..._ThrowsPlatformHttpException.verified.txt} | 4 +- ...n_storageStatusCode=Conflict.verified.txt} | 4 +- ...atusCode=InternalServerError.verified.txt} | 4 +- ...n_storageStatusCode=NotFound.verified.txt} | 4 +- ...ocessLockTests.cs => InstanceLockTests.cs} | 101 ++++++++++-------- .../InstanceLockTests.cs} | 10 +- .../services/WaitForReleaseProcessTaskEnd.cs | 6 +- 20 files changed, 128 insertions(+), 145 deletions(-) rename src/Altinn.App.Core/Features/Telemetry/{Telemetry.ProcessLockClient.cs => Telemetry.InstanceLockClient.cs} (52%) rename src/Altinn.App.Core/Infrastructure/Clients/Storage/{ProcessLockClient.cs => InstanceLockClient.cs} (67%) rename src/Altinn.App.Core/Internal/{Process/ProcessLock/ProcessLocker.cs => InstanceLocking/InstanceLocker.cs} (54%) delete mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs delete mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs delete mode 100644 src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt => InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt} (100%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt => InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt} (75%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt => InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt} (79%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt => InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt} (75%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt => InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt} (72%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt => InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt} (73%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt => InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt} (72%) rename test/Altinn.App.Core.Tests/Internal/Process/{ProcessLockTests.cs => InstanceLockTests.cs} (80%) rename test/Altinn.App.Integration.Tests/{ProcessLock/ProcessLockTests.cs => InstanceLocking/InstanceLockTests.cs} (81%) rename test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/{process-lock => instance-lock}/services/WaitForReleaseProcessTaskEnd.cs (90%) diff --git a/Directory.Packages.props b/Directory.Packages.props index bd5ac7974b..fa576ba756 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,7 +9,7 @@ - + diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index fd072491fb..af08623f2b 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -9,7 +9,6 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; -using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models.Process; using Altinn.App.Core.Models.Validation; diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index ea4469d621..fac3615e04 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -38,6 +38,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; @@ -46,7 +47,6 @@ using Altinn.App.Core.Internal.Process.Authorization; using Altinn.App.Core.Internal.Process.EventHandlers; using Altinn.App.Core.Internal.Process.EventHandlers.ProcessTask; -using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Process.ProcessTasks; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks.Legacy; @@ -115,7 +115,7 @@ IWebHostEnvironment env services.AddHttpClient(); #pragma warning restore CS0618 // Type or member is obsolete services.AddHttpClient(); - services.AddHttpClient(); + services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); @@ -366,7 +366,7 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); - services.AddScoped(); + services.AddScoped(); // Process tasks services.AddTransient(); diff --git a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs similarity index 52% rename from src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs rename to src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs index 6131a40c29..e3c7ff606f 100644 --- a/src/Altinn.App.Core/Features/Telemetry/Telemetry.ProcessLockClient.cs +++ b/src/Altinn.App.Core/Features/Telemetry/Telemetry.InstanceLockClient.cs @@ -4,18 +4,18 @@ namespace Altinn.App.Core.Features; partial class Telemetry { - internal Activity? StartAcquireProcessLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + internal Activity? StartAcquireInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId) { - var activity = ActivitySource.StartActivity("AcquireProcessLock"); + var activity = ActivitySource.StartActivity("AcquireInstanceLock"); activity?.SetInstanceId(instanceGuid); activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); return activity; } - internal Activity? StartReleaseProcessLockActivity(Guid instanceGuid, int instanceOwnerPartyId) + internal Activity? StartReleaseInstanceLockActivity(Guid instanceGuid, int instanceOwnerPartyId) { - var activity = ActivitySource.StartActivity("ReleaseProcessLock"); + var activity = ActivitySource.StartActivity("ReleaseInstanceLock"); activity?.SetInstanceId(instanceGuid); activity?.SetInstanceOwnerPartyId(instanceOwnerPartyId); diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs similarity index 67% rename from src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs rename to src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index dc46850ce2..8adb46e138 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/ProcessLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -5,7 +5,7 @@ using Altinn.App.Core.Extensions; using Altinn.App.Core.Features; using Altinn.App.Core.Helpers; -using Altinn.App.Core.Internal.Process.ProcessLock; +using Altinn.Platform.Storage.Interface.Models; using AltinnCore.Authentication.Utils; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -13,18 +13,18 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage; -internal sealed class ProcessLockClient +internal sealed class InstanceLockClient { private readonly AppSettings _appSettings; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly HttpClient _client; private readonly Telemetry? _telemetry; private readonly IHttpContextAccessor _httpContextAccessor; - public ProcessLockClient( + public InstanceLockClient( IOptions platformSettings, IOptions appSettings, - ILogger logger, + ILogger logger, IHttpContextAccessor httpContextAccessor, HttpClient httpClient, Telemetry? telemetry = null @@ -40,16 +40,16 @@ public ProcessLockClient( _telemetry = telemetry; } - public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerPartyId, TimeSpan expiration) + public async Task AcquireInstanceLock(Guid instanceGuid, int instanceOwnerPartyId, TimeSpan expiration) { - using var activity = _telemetry?.StartAcquireProcessLockActivity(instanceGuid, instanceOwnerPartyId); - string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/process/lock"; + using var activity = _telemetry?.StartAcquireInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; string token = JwtTokenUtil.GetTokenFromContext( _httpContextAccessor.HttpContext, _appSettings.RuntimeCookieName ); - var request = new ProcessLockRequest { TtlSeconds = (int)expiration.TotalSeconds }; + var request = new InstanceLockRequest { TtlSeconds = (int)expiration.TotalSeconds }; var content = JsonContent.Create(request); using var response = await _client.PostAsync(token, apiUrl, content); @@ -59,18 +59,18 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); } - Guid? lockId = null; + string? lockToken = null; try { - var lockResponse = await response.Content.ReadFromJsonAsync(); - lockId = lockResponse?.LockId; + var lockResponse = await response.Content.ReadFromJsonAsync(); + lockToken = lockResponse?.LockToken; } catch (Exception e) { _logger.LogError(e, "Error reading response from the lock acquisition endpoint."); } - if (lockId is null || lockId.Value == Guid.Empty) + if (string.IsNullOrEmpty(lockToken)) { throw PlatformHttpResponseSnapshotException.Create( "The response from the lock acquisition endpoint was not expected.", @@ -78,22 +78,17 @@ public async Task AcquireProcessLock(Guid instanceGuid, int instanceOwnerP ); } - return lockId.Value; + return lockToken; } - public async Task ReleaseProcessLock(Guid instanceGuid, int instanceOwnerPartyId, Guid lockId) + public async Task ReleaseInstanceLock(Guid instanceGuid, int instanceOwnerPartyId, string lockToken) { - using var activity = _telemetry?.StartReleaseProcessLockActivity(instanceGuid, instanceOwnerPartyId); - string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/process/lock/{lockId}"; - string token = JwtTokenUtil.GetTokenFromContext( - _httpContextAccessor.HttpContext, - _appSettings.RuntimeCookieName - ); - - var request = new ProcessLockRequest { TtlSeconds = 0 }; + using var activity = _telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId); + string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; + var request = new InstanceLockRequest { TtlSeconds = 0 }; var content = JsonContent.Create(request); - using var response = await _client.PatchAsync(token, apiUrl, content); + using var response = await _client.PatchAsync(lockToken, apiUrl, content); if (!response.IsSuccessStatusCode) { diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs similarity index 54% rename from src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs rename to src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs index 786036a453..b7f553b564 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLocker.cs +++ b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs @@ -1,23 +1,26 @@ using Altinn.App.Core.Infrastructure.Clients.Storage; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -namespace Altinn.App.Core.Internal.Process.ProcessLock; +namespace Altinn.App.Core.Internal.InstanceLocking; -internal sealed partial class ProcessLocker( - ProcessLockClient client, - IOptions options, - ILogger logger, +internal sealed partial class InstanceLocker( + InstanceLockClient client, + ILogger logger, IHttpContextAccessor httpContextAccessor ) : IAsyncDisposable { private readonly HttpContext _httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); - private ProcessLock? _lock; + private InstanceLock? _lock; - public async ValueTask LockAsync() + public ValueTask LockAsync() + { + return LockAsync(TimeSpan.FromMinutes(5)); + } + + public async ValueTask LockAsync(TimeSpan ttl) { if (_lock is not null) { @@ -27,11 +30,11 @@ public async ValueTask LockAsync() var (instanceOwnerPartyId, instanceGuid) = GetInstanceIdentifiers() ?? throw new InvalidOperationException("Unable to extract instance identifiers."); - var lockId = await client.AcquireProcessLock(instanceGuid, instanceOwnerPartyId, options.Value.Expiration); + var lockToken = await client.AcquireInstanceLock(instanceGuid, instanceOwnerPartyId, ttl); - LogLockAcquired(logger, lockId); + LogLockAcquired(logger, instanceGuid); - _lock = new ProcessLock(instanceGuid, instanceOwnerPartyId, lockId); + _lock = new InstanceLock(instanceGuid, instanceOwnerPartyId, lockToken); } private (int instanceOwnerPartyId, Guid instanceGuid)? GetInstanceIdentifiers() @@ -60,30 +63,27 @@ public async ValueTask DisposeAsync() try { - await client.ReleaseProcessLock(_lock.InstanceGuid, _lock.InstanceOwnerPartyId, _lock.LockId); + await client.ReleaseInstanceLock(_lock.InstanceGuid, _lock.InstanceOwnerPartyId, _lock.LockToken); } catch (Exception e) { - LogLockReleaseFailed(logger, _lock.LockId, e); + LogLockReleaseFailed(logger, _lock.InstanceGuid, e); return; } - LogLockReleased(logger, _lock.LockId); + LogLockReleased(logger, _lock.InstanceGuid); _lock = null; } - private sealed record ProcessLock(Guid InstanceGuid, int InstanceOwnerPartyId, Guid LockId); - - [LoggerMessage(1, LogLevel.Debug, "Failed to acquire process lock.")] - private static partial void LogLockAcquisitionFailed(ILogger logger); + private sealed record InstanceLock(Guid InstanceGuid, int InstanceOwnerPartyId, string LockToken); - [LoggerMessage(2, LogLevel.Debug, "Acquired process lock with id: {LockId}")] - private static partial void LogLockAcquired(ILogger logger, Guid lockId); + [LoggerMessage(1, LogLevel.Debug, "Acquired lock for instance {InstanceGuid}.")] + private static partial void LogLockAcquired(ILogger logger, Guid instanceGuid); - [LoggerMessage(3, LogLevel.Debug, "Released process lock with id: {LockId}")] - private static partial void LogLockReleased(ILogger logger, Guid lockId); + [LoggerMessage(2, LogLevel.Debug, "Released lock for instance {InstanceGuid}.")] + private static partial void LogLockReleased(ILogger logger, Guid instanceGuid); - [LoggerMessage(4, LogLevel.Error, "Failed to release process lock with id: {LockId}")] - private static partial void LogLockReleaseFailed(ILogger logger, Guid lockId, Exception e); + [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 4bc09f7093..3610ba7bb8 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -6,10 +6,10 @@ 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; -using Altinn.App.Core.Internal.Process.ProcessLock; using Altinn.App.Core.Internal.Process.ProcessTasks.ServiceTasks; using Altinn.App.Core.Internal.Validation; using Altinn.App.Core.Models; @@ -43,7 +43,7 @@ public class ProcessEngine : IProcessEngine private readonly ILogger _logger; private readonly IValidationService _validationService; private readonly IInstanceClient _instanceClient; - private readonly ProcessLocker _processLocker; + private readonly InstanceLocker _instanceLocker; /// /// Initializes a new instance of the class. @@ -76,7 +76,7 @@ public ProcessEngine( _logger = logger; _appImplementationFactory = serviceProvider.GetRequiredService(); _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); - _processLocker = serviceProvider.GetRequiredService(); + _instanceLocker = serviceProvider.GetRequiredService(); } /// @@ -255,7 +255,7 @@ out ProcessChangeResult? invalidProcessStateError }; } - await _processLocker.LockAsync(); + await _instanceLocker.LockAsync(); _logger.LogDebug( "User successfully authorized to perform process next. Task ID: {CurrentTaskId}. Task type: {AltinnTaskType}. Action: {ProcessNextAction}.", diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs deleted file mode 100644 index f3a0d839ed..0000000000 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockOptions.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Altinn.App.Core.Internal.Process.ProcessLock; - -internal sealed class ProcessLockOptions -{ - public TimeSpan Expiration { get; set; } = TimeSpan.FromMinutes(5); -} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs deleted file mode 100644 index 3d55e85753..0000000000 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockRequest.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Altinn.App.Core.Internal.Process.ProcessLock; - -internal sealed class ProcessLockRequest -{ - public int TtlSeconds { get; set; } -} diff --git a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs b/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs deleted file mode 100644 index 7f7f2d40cc..0000000000 --- a/src/Altinn.App.Core/Internal/Process/ProcessLock/ProcessLockResponse.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Altinn.App.Core.Internal.Process.ProcessLock; - -internal sealed class ProcessLockResponse -{ - public Guid LockId { get; set; } -} diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.CustomExpirationConfiguration_UsedInStorageApiCall.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt similarity index 75% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt index c600555b06..a41c118c29 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -23,8 +23,8 @@ }, Message: The response from the lock acquisition endpoint was not expected., StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt similarity index 79% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt index 7c21f3ce0d..0b5490f66d 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt @@ -4,7 +4,7 @@ Type: InvalidOperationException, Message: Unable to extract instance identifiers., StackTrace: -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt similarity index 75% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt index c600555b06..a41c118c29 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -23,8 +23,8 @@ }, Message: The response from the lock acquisition endpoint was not expected., StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt similarity index 72% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt index 29380de14b..586bf06af6 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -20,8 +20,8 @@ }, Message: 409 Conflict, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt similarity index 73% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt index 38d3db457d..2d59753cb5 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -20,8 +20,8 @@ }, Message: 500 Internal Server Error, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt similarity index 72% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt index 8bc705b034..eff961bd02 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -20,8 +20,8 @@ }, Message: 404 Not Found, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.ProcessLockClient.AcquireProcessLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) -at Altinn.App.Core.Internal.Process.ProcessLock.ProcessLocker.LockAsync() +at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs similarity index 80% rename from test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs index 3389c885d0..f3a1871261 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/ProcessLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs @@ -2,7 +2,8 @@ using Altinn.App.Core.Configuration; using Altinn.App.Core.Helpers; using Altinn.App.Core.Infrastructure.Clients.Storage; -using Altinn.App.Core.Internal.Process.ProcessLock; +using Altinn.App.Core.Internal.InstanceLocking; +using Altinn.Platform.Storage.Interface.Models; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using WireMock.Matchers.Request; @@ -12,7 +13,7 @@ namespace Altinn.App.Core.Tests.Internal.Process; -public sealed class ProcessLockTests +public sealed class InstanceLockTests { private sealed record Fixture(WireMockServer Server, ServiceProvider ServiceProvider) : IDisposable { @@ -37,7 +38,7 @@ public static Fixture Create(Action? registerCustomServices services.Configure(settings => settings.RuntimeCookieName = RuntimeCookieName); - services.AddHttpClient(); + services.AddHttpClient(); var httpContext = new DefaultHttpContext(); httpContext.Request.Headers.Cookie = $"{RuntimeCookieName}={BearerToken}"; @@ -47,7 +48,7 @@ public static Fixture Create(Action? registerCustomServices var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; services.AddSingleton(httpContextAccessor); - services.AddTransient(); + services.AddTransient(); registerCustomServices?.Invoke(services); @@ -67,18 +68,18 @@ public IRequestBuilder GetAcquireLockRequestBuilder() { return Request .Create() - .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/process/lock") + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") .UsingPost() .WithHeader("Authorization", $"Bearer {BearerToken}"); } - public IRequestBuilder GetReleaseLockRequestBuilder(Guid lockId) + public IRequestBuilder GetReleaseLockRequestBuilder(string lockToken) { return Request .Create() - .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/process/lock/{lockId}") + .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") .UsingPatch() - .WithHeader("Authorization", $"Bearer {BearerToken}"); + .WithHeader("Authorization", $"Bearer {lockToken}"); } } @@ -88,9 +89,10 @@ 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(lockId); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockToken); var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); @@ -100,7 +102,7 @@ public async Task HappyPath() Response .Create() .WithStatusCode(HttpStatusCode.OK) - .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) ); fixture @@ -113,8 +115,8 @@ public async Task HappyPath() await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) { - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/test"); response.EnsureSuccessStatusCode(); } @@ -141,9 +143,10 @@ 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(lockId); + var releaseLockRequestBuilder = fixture.GetReleaseLockRequestBuilder(lockToken); var testRequestBuilder = Request.Create().WithPath($"/test").UsingGet(); @@ -153,7 +156,7 @@ public async Task HappyPath_MultipleLockCalls() Response .Create() .WithStatusCode(HttpStatusCode.OK) - .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) ); fixture @@ -166,10 +169,10 @@ public async Task HappyPath_MultipleLockCalls() await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) { - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); - await processLocker.LockAsync(); - await processLocker.LockAsync(); + 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(); } @@ -196,6 +199,7 @@ public async Task LockReleasedOnException() using var fixture = Fixture.Create(); var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); fixture .Server.Given(fixture.GetAcquireLockRequestBuilder()) @@ -203,36 +207,33 @@ public async Task LockReleasedOnException() Response .Create() .WithStatusCode(HttpStatusCode.OK) - .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) ); fixture - .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); await Assert.ThrowsAsync(async () => { await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); throw new Exception(); }); - var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockToken)); Assert.Single(releaseRequests); } [Fact] - public async Task CustomExpirationConfiguration_UsedInStorageApiCall() + public async Task CustomTtl_UsedInStorageApiCall() { var lockId = Guid.NewGuid(); - const int customExpirationSeconds = 120; + var lockToken = GenerateLockToken(lockId); + var ttl = TimeSpan.FromSeconds(120); - using var fixture = Fixture.Create(services => - services.Configure(options => - options.Expiration = TimeSpan.FromSeconds(customExpirationSeconds) - ) - ); + using var fixture = Fixture.Create(); fixture .Server.Given(fixture.GetAcquireLockRequestBuilder()) @@ -240,17 +241,17 @@ public async Task CustomExpirationConfiguration_UsedInStorageApiCall() Response .Create() .WithStatusCode(HttpStatusCode.OK) - .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) ); fixture - .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.OK)); await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) { - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(ttl); } var acquireRequests = fixture.Server.FindLogEntries(fixture.GetAcquireLockRequestBuilder()); @@ -266,6 +267,7 @@ public async Task LockReleaseFailure_DoesNotThrow() using var fixture = Fixture.Create(); var lockId = Guid.NewGuid(); + var lockToken = GenerateLockToken(lockId); fixture .Server.Given(fixture.GetAcquireLockRequestBuilder()) @@ -273,20 +275,20 @@ public async Task LockReleaseFailure_DoesNotThrow() Response .Create() .WithStatusCode(HttpStatusCode.OK) - .WithBodyAsJson(new ProcessLockResponse { LockId = lockId }) + .WithBodyAsJson(new InstanceLockResponse { LockToken = lockToken }) ); fixture - .Server.Given(fixture.GetReleaseLockRequestBuilder(lockId)) + .Server.Given(fixture.GetReleaseLockRequestBuilder(lockToken)) .RespondWith(Response.Create().WithStatusCode(HttpStatusCode.InternalServerError)); await using (var scope = fixture.ServiceProvider.CreateAsyncScope()) { - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); } - var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockId)); + var releaseRequests = fixture.Server.FindLogEntries(fixture.GetReleaseLockRequestBuilder(lockToken)); Assert.Single(releaseRequests); } @@ -305,8 +307,8 @@ public async Task StorageApiError_ThrowsCorrectPlatformHttpException(HttpStatusC var exception = await Assert.ThrowsAsync(async () => { await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -334,8 +336,8 @@ public async Task NullResponseBody_ThrowsPlatformHttpException() var exception = await Assert.ThrowsAsync(async () => { await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -361,8 +363,8 @@ public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() var exception = await Assert.ThrowsAsync(async () => { await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + var instanceLocker = scope.ServiceProvider.GetRequiredService(); + await instanceLocker.LockAsync(); }); Assert.Single(fixture.Server.LogEntries); @@ -385,11 +387,16 @@ public async Task InvalidInstanceId_ThrowsArgumentException() var exception = await Assert.ThrowsAsync(async () => { await using var scope = fixture.ServiceProvider.CreateAsyncScope(); - var processLocker = scope.ServiceProvider.GetRequiredService(); - await processLocker.LockAsync(); + 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.Integration.Tests/ProcessLock/ProcessLockTests.cs b/test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs similarity index 81% rename from test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs rename to test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs index c20f410f82..573f8f1728 100644 --- a/test/Altinn.App.Integration.Tests/ProcessLock/ProcessLockTests.cs +++ b/test/Altinn.App.Integration.Tests/InstanceLocking/InstanceLockTests.cs @@ -2,21 +2,21 @@ using Altinn.Platform.Storage.Interface.Models; using Xunit.Abstractions; -namespace Altinn.App.Integration.Tests.ProcessLock; +namespace Altinn.App.Integration.Tests.InstanceLocking; [Trait("Category", "Integration")] -public sealed class ProcessLockTests(ITestOutputHelper _output, AppFixtureClassFixture _classFixture) +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, "process-lock"); + 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/process-lock/reset", null); + var resetResponse = await client.PostAsync("/test/instance-lock/reset", null); resetResponse.EnsureSuccessStatusCode(); var token = await fixture.Auth.GetUserToken(userId: 1337); @@ -40,7 +40,7 @@ public async Task ProcessNext_ConcurrentRequests_OneRequestGetsConflict() Assert.Equal(System.Net.HttpStatusCode.Conflict, conflictResponse.Response.StatusCode); - var releaseResponse = await client.PostAsync("/test/process-lock/release-wait", null); + var releaseResponse = await client.PostAsync("/test/instance-lock/release-wait", null); releaseResponse.EnsureSuccessStatusCode(); using var successRequestResponse = await processNextTasks.First(x => x != conflictResponseTask); diff --git a/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-lock/services/WaitForReleaseProcessTaskEnd.cs b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs similarity index 90% rename from test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-lock/services/WaitForReleaseProcessTaskEnd.cs rename to test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs index 1d6605269f..1210d8e2bc 100644 --- a/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/process-lock/services/WaitForReleaseProcessTaskEnd.cs +++ b/test/Altinn.App.Integration.Tests/_testapps/basic/_scenarios/instance-lock/services/WaitForReleaseProcessTaskEnd.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using TestApp.Shared; -namespace Altinn.App.Integration.Tests.Scenarios.ProcessLock; +namespace Altinn.App.Integration.Tests.Scenarios.InstanceLock; public sealed class WaitForReleaseProcessTaskEnd : IProcessTaskEnd { @@ -36,7 +36,7 @@ public sealed class WaitForReleaseProcessTaskEndEndpoints : IEndpointConfigurato public void ConfigureEndpoints(WebApplication app) { app.MapPost( - "/test/process-lock/release-wait", + "/test/instance-lock/release-wait", () => { WaitForReleaseProcessTaskEnd.Release(); @@ -45,7 +45,7 @@ public void ConfigureEndpoints(WebApplication app) ); app.MapPost( - "/test/process-lock/reset", + "/test/instance-lock/reset", () => { WaitForReleaseProcessTaskEnd.Reset(); From f96ff3af32eef16a2bd4bc9dd79e6bad7e2e6114 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:39:47 +0100 Subject: [PATCH 12/17] Correct test --- ...idInstanceId_ThrowsInvalidOperationException.verified.txt} | 0 .../Internal/Process/InstanceLockTests.cs | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) rename test/Altinn.App.Core.Tests/Internal/Process/{InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt => InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt} (100%) diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsArgumentException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs index f3a1871261..ddd1c02767 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs @@ -373,14 +373,14 @@ public async Task EmptyJsonResponseBody_ThrowsPlatformHttpException() } [Fact] - public async Task InvalidInstanceId_ThrowsArgumentException() + 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 = new DefaultHttpContext() }; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; services.AddSingleton(httpContextAccessor); }); From 12eabe082b86c0cda8f0be833622644406b78541 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:31:58 +0100 Subject: [PATCH 13/17] Use IAuthenticationTokenResolver --- .../Clients/Storage/InstanceLockClient.cs | 50 ++++++++++++------- ...ustomTtl_UsedInStorageApiCall.verified.txt | 0 ...y_ThrowsPlatformHttpException.verified.txt | 2 +- ...rowsInvalidOperationException.verified.txt | 0 ...y_ThrowsPlatformHttpException.verified.txt | 2 +- ...on_storageStatusCode=Conflict.verified.txt | 2 +- ...tatusCode=InternalServerError.verified.txt | 2 +- ...on_storageStatusCode=NotFound.verified.txt | 2 +- .../InstanceLockTests.cs | 33 +++++++++--- 9 files changed, 63 insertions(+), 30 deletions(-) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt (100%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt (89%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt (100%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt (89%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt (87%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt (88%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt (87%) rename test/Altinn.App.Core.Tests/Internal/{Process => InstanceLocking}/InstanceLockTests.cs (91%) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index 8adb46e138..d7ea77721e 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -1,12 +1,13 @@ 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 AltinnCore.Authentication.Utils; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,24 +16,23 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage; internal sealed class InstanceLockClient { - private readonly AppSettings _appSettings; private readonly ILogger _logger; private readonly HttpClient _client; private readonly Telemetry? _telemetry; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthenticationTokenResolver _authenticationTokenResolver; + + private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); public InstanceLockClient( IOptions platformSettings, - IOptions appSettings, ILogger logger, - IHttpContextAccessor httpContextAccessor, + IAuthenticationTokenResolver authenticationTokenResolver, HttpClient httpClient, Telemetry? telemetry = null ) { - _appSettings = appSettings.Value; _logger = logger; - _httpContextAccessor = httpContextAccessor; + _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")); @@ -40,32 +40,41 @@ public InstanceLockClient( _telemetry = telemetry; } - public async Task AcquireInstanceLock(Guid instanceGuid, int instanceOwnerPartyId, TimeSpan expiration) + 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"; - string token = JwtTokenUtil.GetTokenFromContext( - _httpContextAccessor.HttpContext, - _appSettings.RuntimeCookieName + + 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); + using var response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { - throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken); } string? lockToken = null; try { - var lockResponse = await response.Content.ReadFromJsonAsync(); + var lockResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken + ); lockToken = lockResponse?.LockToken; } - catch (Exception e) + catch (Exception e) when (e is JsonException || e is InvalidOperationException) { _logger.LogError(e, "Error reading response from the lock acquisition endpoint."); } @@ -81,18 +90,23 @@ public async Task AcquireInstanceLock(Guid instanceGuid, int instanceOwn return lockToken; } - public async Task ReleaseInstanceLock(Guid instanceGuid, int instanceOwnerPartyId, string lockToken) + public async Task ReleaseInstanceLock( + Guid instanceGuid, + int instanceOwnerPartyId, + string lockToken, + CancellationToken cancellationToken = default + ) { using var activity = _telemetry?.StartReleaseInstanceLockActivity(instanceGuid, instanceOwnerPartyId); string apiUrl = $"instances/{instanceOwnerPartyId}/{instanceGuid}/lock"; var request = new InstanceLockRequest { TtlSeconds = 0 }; var content = JsonContent.Create(request); - using var response = await _client.PatchAsync(lockToken, apiUrl, content); + using var response = await _client.PatchAsync(lockToken, apiUrl, content, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { - throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken); } } } diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.CustomTtl_UsedInStorageApiCall.verified.txt diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt similarity index 89% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt index a41c118c29..0cc7635a1e 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.EmptyJsonResponseBody_ThrowsPlatformHttpException.verified.txt @@ -23,7 +23,7 @@ }, 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) +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 --- diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt similarity index 100% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.InvalidInstanceId_ThrowsInvalidOperationException.verified.txt diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt similarity index 89% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt index a41c118c29..0cc7635a1e 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.NullResponseBody_ThrowsPlatformHttpException.verified.txt @@ -23,7 +23,7 @@ }, 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) +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 --- diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt similarity index 87% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt index 586bf06af6..a220293cfa 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=Conflict.verified.txt @@ -20,7 +20,7 @@ }, Message: 409 Conflict, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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 --- diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt similarity index 88% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt index 2d59753cb5..7793637439 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=InternalServerError.verified.txt @@ -20,7 +20,7 @@ }, Message: 500 Internal Server Error, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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 --- diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt similarity index 87% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt index eff961bd02..5931ac0c2d 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.StorageApiError_ThrowsCorrectPlatformHttpException_storageStatusCode=NotFound.verified.txt @@ -20,7 +20,7 @@ }, Message: 404 Not Found, StackTrace: -at Altinn.App.Core.Infrastructure.Clients.Storage.InstanceLockClient.AcquireInstanceLock(Guid instanceGuid, Int32 instanceOwnerPartyId, TimeSpan expiration) +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 --- diff --git a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs similarity index 91% rename from test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs rename to test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs index ddd1c02767..fae1b4a232 100644 --- a/test/Altinn.App.Core.Tests/Internal/Process/InstanceLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs @@ -1,17 +1,23 @@ 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.Process; +namespace Altinn.App.Core.Tests.Internal.InstanceLocking; public sealed class InstanceLockTests { @@ -19,8 +25,8 @@ private sealed record Fixture(WireMockServer Server, ServiceProvider ServiceProv { private static readonly Guid _instanceGuid = Guid.NewGuid(); private const int InstanceOwnerPartyId = 12345; - private const string RuntimeCookieName = "test-cookie"; - private const string BearerToken = "test-token"; + + private static readonly Authenticated _defaultAuth = TestAuthentication.GetUserAuthentication(); public readonly string ServerUrl = Server.Url ?? throw new Exception("Missing server URL"); @@ -36,18 +42,24 @@ public static Fixture Create(Action? registerCustomServices settings.ApiStorageEndpoint = testUrl + new Uri(settings.ApiStorageEndpoint).PathAndQuery; }); - services.Configure(settings => settings.RuntimeCookieName = RuntimeCookieName); + var mocks = new FixtureMocks(); + mocks.AuthenticationContextMock.Setup(x => x.Current).Returns(_defaultAuth); services.AddHttpClient(); var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers.Cookie = $"{RuntimeCookieName}={BearerToken}"; 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.AddTransient(); registerCustomServices?.Invoke(services); @@ -57,6 +69,13 @@ public static Fixture Create(Action? registerCustomServices 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(); @@ -70,7 +89,7 @@ public IRequestBuilder GetAcquireLockRequestBuilder() .Create() .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") .UsingPost() - .WithHeader("Authorization", $"Bearer {BearerToken}"); + .WithHeader("Authorization", $"Bearer {_defaultAuth.Token}"); } public IRequestBuilder GetReleaseLockRequestBuilder(string lockToken) From 78d35f53d36d828ef0abae244a7c19240914d6be Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 31 Dec 2025 11:40:43 +0100 Subject: [PATCH 14/17] Use scoped service --- .../Infrastructure/Clients/Storage/InstanceLockClient.cs | 1 - .../Internal/InstanceLocking/InstanceLockTests.cs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index d7ea77721e..c7ceab87ed 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -8,7 +8,6 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.Auth; using Altinn.Platform.Storage.Interface.Models; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs index fae1b4a232..69b947e0b4 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs @@ -60,7 +60,7 @@ public static Fixture Create(Action? registerCustomServices services.AddRuntimeEnvironment(); - services.AddTransient(); + services.AddScoped(); registerCustomServices?.Invoke(services); From f36e20f4029f7729f8b70c2f64aa6dbf58b8c022 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Sun, 18 Jan 2026 11:54:45 +0100 Subject: [PATCH 15/17] Use custom header for lock token --- .../Clients/Storage/InstanceLockClient.cs | 18 +++++++++++++++--- .../InstanceLocking/InstanceLockTests.cs | 3 ++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs index c7ceab87ed..127921e579 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/InstanceLockClient.cs @@ -22,6 +22,8 @@ internal sealed class InstanceLockClient private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); + private const string LockTokenHeaderName = "Altinn-Storage-Lock-Token"; + public InstanceLockClient( IOptions platformSettings, ILogger logger, @@ -93,15 +95,25 @@ 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 request = new InstanceLockRequest { TtlSeconds = 0 }; - var content = JsonContent.Create(request); + 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.PatchAsync(lockToken, apiUrl, content, cancellationToken: cancellationToken); + using var response = await _client.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { diff --git a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs index 69b947e0b4..8a5f370e86 100644 --- a/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/InstanceLocking/InstanceLockTests.cs @@ -98,7 +98,8 @@ public IRequestBuilder GetReleaseLockRequestBuilder(string lockToken) .Create() .WithPath($"/storage/api/v1/instances/{InstanceOwnerPartyId}/{_instanceGuid}/lock") .UsingPatch() - .WithHeader("Authorization", $"Bearer {lockToken}"); + .WithHeader("Authorization", $"Bearer {_defaultAuth.Token}") + .WithHeader("Altinn-Storage-Lock-Token", lockToken); } } From 9125ddbe9eaaea91a170c336ff65fb0352800d10 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 28 Jan 2026 12:28:29 +0100 Subject: [PATCH 16/17] Mock InstanceLocker in tests --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Internal/InstanceLocking/IInstanceLocker.cs | 8 ++++++++ .../Internal/InstanceLocking/InstanceLocker.cs | 2 +- .../Internal/Process/ProcessEngine.cs | 4 ++-- .../Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs | 12 ++++++++++++ test/Altinn.App.Api.Tests/Program.cs | 2 ++ 6 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 src/Altinn.App.Core/Internal/InstanceLocking/IInstanceLocker.cs create mode 100644 test/Altinn.App.Api.Tests/Mocks/InstanceLockerMock.cs diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 005d06b56e..1d981eff2e 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -370,7 +370,7 @@ private static void AddProcessServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); - services.AddScoped(); + services.AddScoped(); // Process tasks services.AddTransient(); 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 index b7f553b564..4be57623c9 100644 --- a/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs +++ b/src/Altinn.App.Core/Internal/InstanceLocking/InstanceLocker.cs @@ -8,7 +8,7 @@ internal sealed partial class InstanceLocker( InstanceLockClient client, ILogger logger, IHttpContextAccessor httpContextAccessor -) : IAsyncDisposable +) : IInstanceLocker { private readonly HttpContext _httpContext = httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext cannot be null."); diff --git a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs index 3610ba7bb8..375bde1c83 100644 --- a/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs +++ b/src/Altinn.App.Core/Internal/Process/ProcessEngine.cs @@ -43,7 +43,7 @@ public class ProcessEngine : IProcessEngine private readonly ILogger _logger; private readonly IValidationService _validationService; private readonly IInstanceClient _instanceClient; - private readonly InstanceLocker _instanceLocker; + private readonly IInstanceLocker _instanceLocker; /// /// Initializes a new instance of the class. @@ -76,7 +76,7 @@ public ProcessEngine( _logger = logger; _appImplementationFactory = serviceProvider.GetRequiredService(); _instanceDataUnitOfWorkInitializer = serviceProvider.GetRequiredService(); - _instanceLocker = serviceProvider.GetRequiredService(); + _instanceLocker = serviceProvider.GetRequiredService(); } /// 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 => { From 312166d99f0e3701825b251b2158c8d6a561d366 Mon Sep 17 00:00:00 2001 From: Tobias Netskar <51908451+vxkc@users.noreply.github.com> Date: Wed, 28 Jan 2026 13:51:20 +0100 Subject: [PATCH 17/17] Mock InstanceLocker in Altinn.App.Core tests --- .../Internal/Process/ProcessEngineTest.cs | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) 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)