From d6e3573e870e70d9f6d6630a9515efc08aca87bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 14 Aug 2025 10:29:45 +0200 Subject: [PATCH 01/44] Add signature hash validator. --- .../Extensions/HttpClientExtension.cs | 30 ++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Default/SignatureHashValidator.cs | 162 ++++++++++++++++++ .../Models/Validation/ValidationIssueCodes.cs | 5 + 4 files changed, 198 insertions(+) create mode 100644 src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index 541ab9db2..b33864c79 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -103,6 +103,36 @@ public static async Task GetAsync( return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None); } + /// + /// Extension that add authorization header to request + /// + /// The HttpClient + /// the authorization token (jwt) + /// The request Uri + /// The platformAccess tokens + /// A HttpResponseMessage + public static async Task GetStreamingAsync( + this HttpClient httpClient, + string authorizationToken, + string requestUri, + string? platformAccessToken = null + ) + { + using HttpRequestMessage request = new(HttpMethod.Get, requestUri); + + 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, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None); + } + /// /// Extension that add authorization header to request /// diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 88686973b..693c1a62a 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -230,6 +230,7 @@ private static void AddValidationServices(IServiceCollection services, IConfigur services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); var appSettings = configuration.GetSection("AppSettings").Get(); if (appSettings?.RequiredValidation is true) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs new file mode 100644 index 000000000..b4d90dce0 --- /dev/null +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -0,0 +1,162 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using Altinn.App.Core.Features.Signing.Models; +using Altinn.App.Core.Features.Signing.Services; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Process; +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Result; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.Extensions.Logging; + +namespace Altinn.App.Core.Features.Validation.Default; + +/// +/// Validates that signature hashes are still valid. +/// +internal sealed class SignatureHashValidator( + ISigningService signingService, + IProcessReader processReader, + IAppMetadata appMetadata, + IDataClient dataClient, + ILogger logger +) : IValidator +{ + /// + /// We implement instead. + /// + public string TaskId => "*"; + + /// + /// Only runs for tasks that are of type "signing". + /// + public bool ShouldRunForTask(string taskId) + { + AltinnTaskExtension? taskConfig; + try + { + taskConfig = processReader.GetAltinnTaskExtension(taskId); + } + catch (Exception) + { + return false; + } + + return taskConfig?.TaskType is "signing"; //TODO: Do you agree that it's best to always run this validator for singing, even if they haven't turned on 'RunDefaultValidator'? Why would you want to finish the step with invalid hashes? + } + + public bool NoIncrementalValidation => true; + + /// + public Task HasRelevantChanges(IInstanceDataAccessor dataAccessor, string taskId, DataElementChanges changes) + { + throw new UnreachableException( + "HasRelevantChanges should not be called because NoIncrementalValidation is true." + ); + } + + public async Task> Validate( + IInstanceDataAccessor dataAccessor, + string taskId, + string? language + ) + { + Instance instance = dataAccessor.Instance; + var instanceIdentifier = new InstanceIdentifier(instance); + + AltinnSignatureConfiguration signingConfiguration = + (processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration) + ?? throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension"); + + ServiceResult appMetadataResult = await CatchError( + appMetadata.GetApplicationMetadata + ); + + if (!appMetadataResult.Success) + { + logger.LogError(appMetadataResult.Error, "Error while fetching application metadata"); + return []; + } + + ServiceResult, Exception> signeeContextsResults = await CatchError(() => + signingService.GetSigneeContexts(dataAccessor, signingConfiguration, CancellationToken.None) + ); + + if (!signeeContextsResults.Success) + { + logger.LogError(signeeContextsResults.Error, "Error while fetching signee contexts"); + return []; + } + + foreach (SigneeContext signeeContext in signeeContextsResults.Ok) + { + List dataElementSignatures = + signeeContext.SignDocument?.DataElementSignatures ?? []; + + foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures) + { + Stream dataStream = await dataClient.GetBinaryData( + instance.Org, + instance.AppId, + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + Guid.Parse(dataElementSignature.DataElementId) + ); + + string sha256Hash = await GenerateSha256Hash(dataStream); + + if (sha256Hash != dataElementSignature.Sha256Hash) + { + return + [ + new ValidationIssue + { + Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, + }, + ]; + } + } + } + + return []; + } + + private static async Task GenerateSha256Hash(Stream stream) + { + using var sha256 = SHA256.Create(); + byte[] digest = await sha256.ComputeHashAsync(stream); + return FormatShaDigest(digest); + } + + /// + /// Formats a SHA digest with common best best practice:
+ /// Lowercase hexadecimal representation without delimiters + ///
+ /// The hash code (digest) to format + /// String representation of the digest + private static string FormatShaDigest(byte[] digest) + { + return Convert.ToHexString(digest).ToLowerInvariant(); + } + + /// + /// Catch exceptions from an async function and return them as a ServiceResult record with the result. + /// + private static async Task> CatchError(Func> function) + { + try + { + var result = await function(); + return result; + } + catch (Exception ex) + { + return ex; + } + } +} diff --git a/src/Altinn.App.Core/Models/Validation/ValidationIssueCodes.cs b/src/Altinn.App.Core/Models/Validation/ValidationIssueCodes.cs index f20d1089a..15b9ae3e0 100644 --- a/src/Altinn.App.Core/Models/Validation/ValidationIssueCodes.cs +++ b/src/Altinn.App.Core/Models/Validation/ValidationIssueCodes.cs @@ -70,5 +70,10 @@ public static class DataElementCodes /// Gets a value that represents a validation issue where the data element does not contain all required signatures. /// public static string MissingSignatures => nameof(MissingSignatures); + + /// + /// Gets a value that represents a validation issue where a signature hash does not match the expected hash. + /// + public static string InvalidSignatureHash => nameof(InvalidSignatureHash); } } From eb88444d4ebdaf05a8420f4dcaa87545c01851a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 17 Sep 2025 10:53:11 +0200 Subject: [PATCH 02/44] Use service owner auth if the data type is restricted. --- .../Default/SignatureHashValidator.cs | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index b4d90dce0..c48f2f918 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -7,7 +7,6 @@ using Altinn.App.Core.Internal.Process; using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; using Altinn.App.Core.Models; -using Altinn.App.Core.Models.Result; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; using Microsoft.Extensions.Logging; @@ -20,8 +19,8 @@ namespace Altinn.App.Core.Features.Validation.Default; internal sealed class SignatureHashValidator( ISigningService signingService, IProcessReader processReader, - IAppMetadata appMetadata, IDataClient dataClient, + IAppMetadata appMetadata, ILogger logger ) : IValidator { @@ -45,7 +44,7 @@ public bool ShouldRunForTask(string taskId) return false; } - return taskConfig?.TaskType is "signing"; //TODO: Do you agree that it's best to always run this validator for singing, even if they haven't turned on 'RunDefaultValidator'? Why would you want to finish the step with invalid hashes? + return taskConfig?.TaskType is "signing"; } public bool NoIncrementalValidation => true; @@ -71,27 +70,15 @@ public async Task> Validate( (processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration) ?? throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension"); - ServiceResult appMetadataResult = await CatchError( - appMetadata.GetApplicationMetadata - ); - - if (!appMetadataResult.Success) - { - logger.LogError(appMetadataResult.Error, "Error while fetching application metadata"); - return []; - } + ApplicationMetadata applicationMetadata = await appMetadata.GetApplicationMetadata(); - ServiceResult, Exception> signeeContextsResults = await CatchError(() => - signingService.GetSigneeContexts(dataAccessor, signingConfiguration, CancellationToken.None) + List signeeContextsResults = await signingService.GetSigneeContexts( + dataAccessor, + signingConfiguration, + CancellationToken.None ); - if (!signeeContextsResults.Success) - { - logger.LogError(signeeContextsResults.Error, "Error while fetching signee contexts"); - return []; - } - - foreach (SigneeContext signeeContext in signeeContextsResults.Ok) + foreach (SigneeContext signeeContext in signeeContextsResults) { List dataElementSignatures = signeeContext.SignDocument?.DataElementSignatures ?? []; @@ -103,13 +90,20 @@ public async Task> Validate( instance.AppId, instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid, - Guid.Parse(dataElementSignature.DataElementId) + Guid.Parse(dataElementSignature.DataElementId), + HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId) + ? StorageAuthenticationMethod.ServiceOwner() + : null ); string sha256Hash = await GenerateSha256Hash(dataStream); if (sha256Hash != dataElementSignature.Sha256Hash) { + logger.LogError( + $"Found an invalid signature for data element {dataElementSignature.DataElementId} on instance {instance.Id}. Expected hash {dataElementSignature.Sha256Hash}, calculated hash {sha256Hash}." + ); + return [ new ValidationIssue @@ -123,9 +117,31 @@ public async Task> Validate( } } + logger.LogInformation("All signature hashes are valid for instance {InstanceId}", instance.Id); + return []; } + private static bool HasRestrictedRead( + ApplicationMetadata applicationMetadata, + Instance instance, + string dataElementId + ) + { + DataElement? dataElement = instance.Data.FirstOrDefault(de => de.Id == dataElementId); + string? dataTypeId = dataElement?.DataType; + DataType? dataType = applicationMetadata.DataTypes.FirstOrDefault(dt => dt.Id == dataTypeId); + + if (dataType == null) + { + throw new ApplicationConfigException( + $"Unable to find data type {dataTypeId} for data element {dataElementId} in applicationmetadata.json." + ); + } + + return !string.IsNullOrEmpty(dataType?.ActionRequiredToRead); + } + private static async Task GenerateSha256Hash(Stream stream) { using var sha256 = SHA256.Create(); @@ -143,20 +159,4 @@ private static string FormatShaDigest(byte[] digest) { return Convert.ToHexString(digest).ToLowerInvariant(); } - - /// - /// Catch exceptions from an async function and return them as a ServiceResult record with the result. - /// - private static async Task> CatchError(Func> function) - { - try - { - var result = await function(); - return result; - } - catch (Exception ex) - { - return ex; - } - } } From 9bd750df2fb74a275b95b007ca927077eb203f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 17 Sep 2025 10:53:45 +0200 Subject: [PATCH 03/44] Add claudes tests. --- .claude/settings.local.json | 11 + .../Default/SignatureHashValidatorTests.cs | 415 ++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..9707b6772 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(dotnet test:*)", + "Bash(dotnet build:*)", + "Bash(dotnet clean:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs new file mode 100644 index 000000000..f21d0100e --- /dev/null +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -0,0 +1,415 @@ +using System.Diagnostics; +using System.Text; +using Altinn.App.Core.Features; +using Altinn.App.Core.Features.Signing.Models; +using Altinn.App.Core.Features.Signing.Services; +using Altinn.App.Core.Features.Validation.Default; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Data; +using Altinn.App.Core.Internal.Process; +using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Moq; +using CoreSignee = Altinn.App.Core.Features.Signing.Models.Signee; +using SigneeState = Altinn.App.Core.Features.Signing.Models.SigneeContextState; + +namespace Altinn.App.Core.Tests.Features.Validators.Default; + +public class SignatureHashValidatorTests +{ + private readonly Mock _processReaderMock = new(); + private readonly Mock _signingServiceMock = new(); + private readonly Mock _dataClientMock = new(); + private readonly Mock _appMetadataMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly Mock _dataAccessorMock = new(); + private readonly SignatureHashValidator _validator; + + public SignatureHashValidatorTests() + { + _validator = new SignatureHashValidator( + _signingServiceMock.Object, + _processReaderMock.Object, + _dataClientMock.Object, + _appMetadataMock.Object, + _loggerMock.Object + ); + } + + [Fact] + public async Task Validate_WithValidSignatureHashes_ReturnsEmptyList() + { + var testData = "test data"; + var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], + }; + var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + + var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task Validate_WithInvalidSignatureHash_ReturnsValidationIssue() + { + var testData = "test data"; + var storedHash = "different-hash"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], + }; + var signeeContext = CreateSigneeContextWithValidHash(storedHash); + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + + var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + result.Should().HaveCount(1); + result[0].Code.Should().Be(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash); + result[0].Severity.Should().Be(ValidationIssueSeverity.Error); + result[0].Description.Should().Be(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash); + } + + [Fact] + public async Task Validate_WithMissingSignatureConfiguration_ThrowsApplicationConfigException() + { + var instance = CreateTestInstance(); + _dataAccessorMock.Setup(x => x.Instance).Returns(instance); + _processReaderMock + .Setup(x => x.GetAltinnTaskExtension("signing-task")) + .Returns(new AltinnTaskExtension { SignatureConfiguration = null }); + + var action = async () => await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + await action + .Should() + .ThrowAsync() + .WithMessage("Signing configuration not found in AltinnTaskExtension"); + } + + [Fact] + public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() + { + var testData = "test data"; + var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = "read" }], + }; + var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + + await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + _dataClientMock.Verify( + x => + x.GetBinaryData( + "testorg", + "testapp", + 12345, + It.IsAny(), + It.IsAny(), + It.Is(auth => auth != null), + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerAuth() + { + var testData = "test data"; + var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], + }; + var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + + await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + _dataClientMock.Verify( + x => + x.GetBinaryData( + "testorg", + "testapp", + 12345, + It.IsAny(), + It.IsAny(), + null, + It.IsAny() + ), + Times.Once + ); + } + + [Fact] + public async Task Validate_WithDataTypeNotFoundInApplicationMetadata_ThrowsApplicationConfigException() + { + var testData = "test data"; + var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") { DataTypes = [] }; + var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + + var action = async () => await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + await action + .Should() + .ThrowAsync() + .WithMessage( + "Unable to find data type form for data element 550e8400-e29b-41d4-a716-446655440001 in applicationmetadata.json." + ); + } + + [Fact] + public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() + { + var testData = "test data"; + var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], + }; + var signeeContexts = new List + { + CreateSigneeContextWithValidHash(expectedHash), + CreateSigneeContextWithValidHash(expectedHash), + }; + + SetupMocks(instance, signingConfiguration, applicationMetadata, signeeContexts, testData); + + var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + result.Should().BeEmpty(); + _dataClientMock.Verify( + x => + x.GetBinaryData( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Exactly(2) + ); + } + + [Fact] + public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation() + { + var instance = CreateTestInstance(); + var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; + var applicationMetadata = new ApplicationMetadata("testorg/testapp") + { + DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], + }; + var signeeContext = new SigneeContext + { + TaskId = "signing-task", + Signee = new CoreSignee.PersonSignee + { + SocialSecurityNumber = "12345678901", + FullName = "Test Person", + Party = new Altinn.Platform.Register.Models.Party(), + }, + SigneeState = new SigneeState { IsAccessDelegated = false }, + SignDocument = null, + }; + + SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], "test"); + + var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + + result.Should().BeEmpty(); + _dataClientMock.Verify( + x => + x.GetBinaryData( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public void TaskId_ShouldReturnAsterisk() + { + _validator.TaskId.Should().Be("*"); + } + + [Fact] + public void NoIncrementalValidation_ShouldReturnTrue() + { + _validator.NoIncrementalValidation.Should().BeTrue(); + } + + [Fact] + public void ShouldRunForTask_WithSigningTask_ReturnsTrue() + { + var taskConfig = new AltinnTaskExtension { TaskType = "signing" }; + _processReaderMock.Setup(x => x.GetAltinnTaskExtension("signing-task")).Returns(taskConfig); + + var result = _validator.ShouldRunForTask("signing-task"); + + result.Should().BeTrue(); + } + + [Fact] + public void ShouldRunForTask_WithNonSigningTask_ReturnsFalse() + { + var taskConfig = new AltinnTaskExtension { TaskType = "data" }; + _processReaderMock.Setup(x => x.GetAltinnTaskExtension("data-task")).Returns(taskConfig); + + var result = _validator.ShouldRunForTask("data-task"); + + result.Should().BeFalse(); + } + + [Fact] + public void ShouldRunForTask_WithNullTaskType_ReturnsFalse() + { + var taskConfig = new AltinnTaskExtension { TaskType = null }; + _processReaderMock.Setup(x => x.GetAltinnTaskExtension("task")).Returns(taskConfig); + + var result = _validator.ShouldRunForTask("task"); + + result.Should().BeFalse(); + } + + [Fact] + public void ShouldRunForTask_WithException_ReturnsFalse() + { + _processReaderMock + .Setup(x => x.GetAltinnTaskExtension("task")) + .Throws(new InvalidOperationException("Task not found")); + + var result = _validator.ShouldRunForTask("task"); + + result.Should().BeFalse(); + } + + [Fact] + public void HasRelevantChanges_ShouldThrowUnreachableException() + { + var changes = new DataElementChanges([]); + + var action = () => _validator.HasRelevantChanges(_dataAccessorMock.Object, "task", changes); + + action + .Should() + .ThrowAsync() + .WithMessage("HasRelevantChanges should not be called because NoIncrementalValidation is true."); + } + + private Instance CreateTestInstance() + { + return new Instance + { + Id = "12345/550e8400-e29b-41d4-a716-446655440000", + Org = "testorg", + AppId = "testapp", + Data = [new DataElement { Id = "550e8400-e29b-41d4-a716-446655440001", DataType = "form" }], + }; + } + + private SigneeContext CreateSigneeContextWithValidHash(string hash) + { + return new SigneeContext + { + TaskId = "signing-task", + Signee = new CoreSignee.PersonSignee + { + SocialSecurityNumber = "12345678901", + FullName = "Test Person", + Party = new Altinn.Platform.Register.Models.Party(), + }, + SigneeState = new SigneeState { IsAccessDelegated = false }, + SignDocument = new SignDocument + { + DataElementSignatures = + [ + new SignDocument.DataElementSignature + { + DataElementId = "550e8400-e29b-41d4-a716-446655440001", + Sha256Hash = hash, + }, + ], + }, + }; + } + + private void SetupMocks( + Instance instance, + AltinnSignatureConfiguration signingConfiguration, + ApplicationMetadata applicationMetadata, + List signeeContexts, + string testData + ) + { + _dataAccessorMock.Setup(x => x.Instance).Returns(instance); + + _processReaderMock + .Setup(x => x.GetAltinnTaskExtension("signing-task")) + .Returns(new AltinnTaskExtension { SignatureConfiguration = signingConfiguration }); + + _appMetadataMock.Setup(x => x.GetApplicationMetadata()).ReturnsAsync(applicationMetadata); + + _signingServiceMock + .Setup(x => + x.GetSigneeContexts( + It.Is(d => d == _dataAccessorMock.Object), + It.Is(c => c == signingConfiguration), + It.IsAny() + ) + ) + .ReturnsAsync(signeeContexts); + + _dataClientMock + .Setup(x => + x.GetBinaryData( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(() => new MemoryStream(Encoding.UTF8.GetBytes(testData))); + } +} From a07d326f1ed0b94d742d1a8d7806561454b02348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 17 Sep 2025 12:48:31 +0200 Subject: [PATCH 04/44] Test tweaks. --- .../Default/SignatureHashValidatorTests.cs | 203 +++++++++--------- 1 file changed, 96 insertions(+), 107 deletions(-) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index f21d0100e..59ed39d9d 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -10,12 +10,11 @@ using Altinn.App.Core.Internal.Process.Elements.AltinnExtensionProperties; using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; +using Altinn.Platform.Register.Models; using Altinn.Platform.Storage.Interface.Models; -using FluentAssertions; using Microsoft.Extensions.Logging; using Moq; using CoreSignee = Altinn.App.Core.Features.Signing.Models.Signee; -using SigneeState = Altinn.App.Core.Features.Signing.Models.SigneeContextState; namespace Altinn.App.Core.Tests.Features.Validators.Default; @@ -25,7 +24,6 @@ public class SignatureHashValidatorTests private readonly Mock _signingServiceMock = new(); private readonly Mock _dataClientMock = new(); private readonly Mock _appMetadataMock = new(); - private readonly Mock> _loggerMock = new(); private readonly Mock _dataAccessorMock = new(); private readonly SignatureHashValidator _validator; @@ -36,84 +34,80 @@ public SignatureHashValidatorTests() _processReaderMock.Object, _dataClientMock.Object, _appMetadataMock.Object, - _loggerMock.Object + new Mock>().Object ); + + _dataAccessorMock.Setup(x => x.Instance).Returns(CreateTestInstance()); } [Fact] public async Task Validate_WithValidSignatureHashes_ReturnsEmptyList() { - var testData = "test data"; - var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") + const string testData = "test data"; + const string expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], }; - var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + SigneeContext signeeContext = CreateSigneeContextWithValidHash(expectedHash); - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], testData); - var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + List result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); - result.Should().BeEmpty(); + Assert.Empty(result); } [Fact] public async Task Validate_WithInvalidSignatureHash_ReturnsValidationIssue() { - var testData = "test data"; - var storedHash = "different-hash"; - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") + const string testData = "test data"; + const string storedHash = "different-hash"; + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], }; - var signeeContext = CreateSigneeContextWithValidHash(storedHash); + SigneeContext signeeContext = CreateSigneeContextWithValidHash(storedHash); - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], testData); - var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + List result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); - result.Should().HaveCount(1); - result[0].Code.Should().Be(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash); - result[0].Severity.Should().Be(ValidationIssueSeverity.Error); - result[0].Description.Should().Be(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash); + Assert.Single(result); + Assert.Equal(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, result[0].Code); + Assert.Equal(ValidationIssueSeverity.Error, result[0].Severity); + Assert.Equal(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, result[0].Description); } [Fact] public async Task Validate_WithMissingSignatureConfiguration_ThrowsApplicationConfigException() { - var instance = CreateTestInstance(); - _dataAccessorMock.Setup(x => x.Instance).Returns(instance); _processReaderMock .Setup(x => x.GetAltinnTaskExtension("signing-task")) .Returns(new AltinnTaskExtension { SignatureConfiguration = null }); - var action = async () => await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + var exception = await Assert.ThrowsAsync(() => + _validator.Validate(_dataAccessorMock.Object, "signing-task", "en") + ); - await action - .Should() - .ThrowAsync() - .WithMessage("Signing configuration not found in AltinnTaskExtension"); + Assert.Equal("Signing configuration not found in AltinnTaskExtension", exception.Message); } [Fact] public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() { - var testData = "test data"; - var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") + const string testData = "test data"; + const string expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = "read" }], }; - var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + SigneeContext signeeContext = CreateSigneeContextWithValidHash(expectedHash); - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], testData); await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); @@ -135,17 +129,16 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() [Fact] public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerAuth() { - var testData = "test data"; - var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; - var instance = CreateTestInstance(); + const string testData = "test data"; + const string expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; var applicationMetadata = new ApplicationMetadata("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], }; - var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + SigneeContext signeeContext = CreateSigneeContextWithValidHash(expectedHash); - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], testData); await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); @@ -167,47 +160,45 @@ public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerA [Fact] public async Task Validate_WithDataTypeNotFoundInApplicationMetadata_ThrowsApplicationConfigException() { - var testData = "test data"; - var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") { DataTypes = [] }; - var signeeContext = CreateSigneeContextWithValidHash(expectedHash); + const string testData = "test data"; + const string expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [] }; + SigneeContext signeeContext = CreateSigneeContextWithValidHash(expectedHash); - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], testData); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], testData); - var action = async () => await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + var exception = await Assert.ThrowsAsync(() => + _validator.Validate(_dataAccessorMock.Object, "signing-task", "en") + ); - await action - .Should() - .ThrowAsync() - .WithMessage( - "Unable to find data type form for data element 550e8400-e29b-41d4-a716-446655440001 in applicationmetadata.json." - ); + Assert.Equal( + "Unable to find data type form for data element 550e8400-e29b-41d4-a716-446655440001 in applicationmetadata.json.", + exception.Message + ); } [Fact] public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() { - var testData = "test data"; - var expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") + const string testData = "test data"; + const string expectedHash = "916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9"; + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], }; - var signeeContexts = new List - { + List signeeContexts = + [ CreateSigneeContextWithValidHash(expectedHash), CreateSigneeContextWithValidHash(expectedHash), - }; + ]; - SetupMocks(instance, signingConfiguration, applicationMetadata, signeeContexts, testData); + SetupMocks(signingConfiguration, applicationMetadata, signeeContexts, testData); - var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + List result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); - result.Should().BeEmpty(); + Assert.Empty(result); _dataClientMock.Verify( x => x.GetBinaryData( @@ -226,30 +217,29 @@ public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() [Fact] public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation() { - var instance = CreateTestInstance(); - var signingConfiguration = new AltinnSignatureConfiguration { SignatureDataType = "signature" }; - var applicationMetadata = new ApplicationMetadata("testorg/testapp") + AltinnSignatureConfiguration signingConfiguration = new() { SignatureDataType = "signature" }; + ApplicationMetadata applicationMetadata = new("testorg/testapp") { DataTypes = [new DataType { Id = "form", ActionRequiredToRead = null }], }; - var signeeContext = new SigneeContext + SigneeContext signeeContext = new() { TaskId = "signing-task", Signee = new CoreSignee.PersonSignee { SocialSecurityNumber = "12345678901", FullName = "Test Person", - Party = new Altinn.Platform.Register.Models.Party(), + Party = new Party(), }, - SigneeState = new SigneeState { IsAccessDelegated = false }, + SigneeState = new SigneeContextState { IsAccessDelegated = false }, SignDocument = null, }; - SetupMocks(instance, signingConfiguration, applicationMetadata, [signeeContext], "test"); + SetupMocks(signingConfiguration, applicationMetadata, [signeeContext], "test"); - var result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); + List result = await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); - result.Should().BeEmpty(); + Assert.Empty(result); _dataClientMock.Verify( x => x.GetBinaryData( @@ -268,46 +258,46 @@ public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation( [Fact] public void TaskId_ShouldReturnAsterisk() { - _validator.TaskId.Should().Be("*"); + Assert.Equal("*", _validator.TaskId); } [Fact] public void NoIncrementalValidation_ShouldReturnTrue() { - _validator.NoIncrementalValidation.Should().BeTrue(); + Assert.True(_validator.NoIncrementalValidation); } [Fact] public void ShouldRunForTask_WithSigningTask_ReturnsTrue() { - var taskConfig = new AltinnTaskExtension { TaskType = "signing" }; + AltinnTaskExtension taskConfig = new() { TaskType = "signing" }; _processReaderMock.Setup(x => x.GetAltinnTaskExtension("signing-task")).Returns(taskConfig); - var result = _validator.ShouldRunForTask("signing-task"); + bool result = _validator.ShouldRunForTask("signing-task"); - result.Should().BeTrue(); + Assert.True(result); } [Fact] public void ShouldRunForTask_WithNonSigningTask_ReturnsFalse() { - var taskConfig = new AltinnTaskExtension { TaskType = "data" }; + AltinnTaskExtension taskConfig = new() { TaskType = "data" }; _processReaderMock.Setup(x => x.GetAltinnTaskExtension("data-task")).Returns(taskConfig); - var result = _validator.ShouldRunForTask("data-task"); + bool result = _validator.ShouldRunForTask("data-task"); - result.Should().BeFalse(); + Assert.False(result); } [Fact] public void ShouldRunForTask_WithNullTaskType_ReturnsFalse() { - var taskConfig = new AltinnTaskExtension { TaskType = null }; + AltinnTaskExtension taskConfig = new() { TaskType = null }; _processReaderMock.Setup(x => x.GetAltinnTaskExtension("task")).Returns(taskConfig); - var result = _validator.ShouldRunForTask("task"); + bool result = _validator.ShouldRunForTask("task"); - result.Should().BeFalse(); + Assert.False(result); } [Fact] @@ -317,25 +307,27 @@ public void ShouldRunForTask_WithException_ReturnsFalse() .Setup(x => x.GetAltinnTaskExtension("task")) .Throws(new InvalidOperationException("Task not found")); - var result = _validator.ShouldRunForTask("task"); + bool result = _validator.ShouldRunForTask("task"); - result.Should().BeFalse(); + Assert.False(result); } [Fact] - public void HasRelevantChanges_ShouldThrowUnreachableException() + public async Task HasRelevantChanges_ShouldThrowUnreachableException() { - var changes = new DataElementChanges([]); + DataElementChanges changes = new([]); - var action = () => _validator.HasRelevantChanges(_dataAccessorMock.Object, "task", changes); + var exception = await Assert.ThrowsAsync(() => + _validator.HasRelevantChanges(_dataAccessorMock.Object, "task", changes) + ); - action - .Should() - .ThrowAsync() - .WithMessage("HasRelevantChanges should not be called because NoIncrementalValidation is true."); + Assert.Equal( + "HasRelevantChanges should not be called because NoIncrementalValidation is true.", + exception.Message + ); } - private Instance CreateTestInstance() + private static Instance CreateTestInstance() { return new Instance { @@ -346,7 +338,7 @@ private Instance CreateTestInstance() }; } - private SigneeContext CreateSigneeContextWithValidHash(string hash) + private static SigneeContext CreateSigneeContextWithValidHash(string hash) { return new SigneeContext { @@ -355,9 +347,9 @@ private SigneeContext CreateSigneeContextWithValidHash(string hash) { SocialSecurityNumber = "12345678901", FullName = "Test Person", - Party = new Altinn.Platform.Register.Models.Party(), + Party = new Party(), }, - SigneeState = new SigneeState { IsAccessDelegated = false }, + SigneeState = new SigneeContextState { IsAccessDelegated = false }, SignDocument = new SignDocument { DataElementSignatures = @@ -373,15 +365,12 @@ private SigneeContext CreateSigneeContextWithValidHash(string hash) } private void SetupMocks( - Instance instance, AltinnSignatureConfiguration signingConfiguration, ApplicationMetadata applicationMetadata, List signeeContexts, - string testData + string dataElementStringContent ) { - _dataAccessorMock.Setup(x => x.Instance).Returns(instance); - _processReaderMock .Setup(x => x.GetAltinnTaskExtension("signing-task")) .Returns(new AltinnTaskExtension { SignatureConfiguration = signingConfiguration }); @@ -410,6 +399,6 @@ string testData It.IsAny() ) ) - .ReturnsAsync(() => new MemoryStream(Encoding.UTF8.GetBytes(testData))); + .ReturnsAsync(() => new MemoryStream(Encoding.UTF8.GetBytes(dataElementStringContent))); } } From 56f7d1dd77eaefcdc9480330b6c10068b8d9456c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 17 Sep 2025 12:51:53 +0200 Subject: [PATCH 05/44] Nitpick --- .../Features/Validation/Default/SignatureHashValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index c48f2f918..5dcf1ca35 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -67,7 +67,7 @@ public async Task> Validate( var instanceIdentifier = new InstanceIdentifier(instance); AltinnSignatureConfiguration signingConfiguration = - (processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration) + processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration ?? throw new ApplicationConfigException("Signing configuration not found in AltinnTaskExtension"); ApplicationMetadata applicationMetadata = await appMetadata.GetApplicationMetadata(); From bbe8741c03410c8bdded6e7e220b376df53e7ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 17 Sep 2025 12:55:26 +0200 Subject: [PATCH 06/44] Remove settings.local.json. --- .claude/settings.local.json | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 9707b6772..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet test:*)", - "Bash(dotnet build:*)", - "Bash(dotnet clean:*)" - ], - "deny": [], - "ask": [] - } -} \ No newline at end of file From 82c037e717e229a2a97728b00937309b9d423544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 11:37:24 +0200 Subject: [PATCH 07/44] Actually stream the data. --- .../Extensions/HttpClientExtension.cs | 4 +-- .../Default/SignatureHashValidator.cs | 2 +- .../Clients/Storage/DataClient.cs | 36 +++++++++++++++++++ .../Internal/Data/IDataClient.cs | 23 +++++++++++- ...dator_ReturnsValidationErrors.verified.txt | 10 ++++++ ...sNext_PdfFails_DataIsUnlocked.verified.txt | 10 ++++++ .../Mocks/DataClientMock.cs | 34 ++++++++++++++++++ .../Default/SignatureHashValidatorTests.cs | 10 +++--- ...ouldNotChange_Unintentionally.verified.txt | 4 +++ 9 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index b33864c79..d433d34d2 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -104,14 +104,14 @@ public static async Task GetAsync( } /// - /// Extension that add authorization header to request + /// Extension that adds authorization header to request and returns an unbuffered response /// /// The HttpClient /// the authorization token (jwt) /// The request Uri /// The platformAccess tokens /// A HttpResponseMessage - public static async Task GetStreamingAsync( + public static async Task GetUnbufferedAsync( this HttpClient httpClient, string authorizationToken, string requestUri, diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index 5dcf1ca35..bddc2597d 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -85,7 +85,7 @@ public async Task> Validate( foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures) { - Stream dataStream = await dataClient.GetBinaryData( + Stream dataStream = await dataClient.GetBinaryDataStream( instance.Org, instance.AppId, instanceIdentifier.InstanceOwnerPartyId, diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 80da2a45c..b61b63cd9 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -204,6 +204,42 @@ public async Task GetBinaryData( throw await PlatformHttpException.CreateAsync(response); } + /// + public async Task GetBinaryDataStream( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId, + StorageAuthenticationMethod? authenticationMethod = null, + CancellationToken cancellationToken = default + ) + { + using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); + string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; + string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; + + JwtToken token = await _authenticationTokenResolver.GetAccessToken( + authenticationMethod ?? _defaultAuthenticationMethod, + cancellationToken: cancellationToken + ); + + HttpResponseMessage response = await _client.GetUnbufferedAsync(token, apiUrl); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStreamAsync(cancellationToken); + } + else if (response.StatusCode == HttpStatusCode.NotFound) + { +#nullable disable + return null; +#nullable restore + } + + throw await PlatformHttpException.CreateAsync(response); + } + /// public async Task GetFormData( Guid instanceGuid, diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index 084018aad..8138cb626 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -107,7 +107,8 @@ Task GetFormData( ); /// - /// Gets the data as is. + /// Gets the data as is. Note: This method buffers the entire response in memory before returning the stream. + /// For memory-efficient processing of large files, use instead. /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. @@ -126,6 +127,26 @@ Task GetBinaryData( CancellationToken cancellationToken = default ); + /// + /// Gets the data as an unbuffered stream for memory-efficient processing of large files. + /// + /// Unique identifier of the organisation responsible for the app. + /// Application identifier which is unique within an organisation. + /// The instance owner id + /// The instance id + /// the data id + /// An optional specification of the authentication method to use for requests + /// An optional cancellation token + Task GetBinaryDataStream( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId, + StorageAuthenticationMethod? authenticationMethod = null, + CancellationToken cancellationToken = default + ); + /// /// Similar to GetBinaryData, but returns a HttpResponseMessage instead of a cached stream /// diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt index 429328b5d..2e8eab9ef 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_FailingValidator_ReturnsValidationErrors.verified.txt @@ -184,6 +184,16 @@ IdFormat: W3C, HasParent: true }, + { + Name: ProcessReader.GetAltinnTaskExtension, + IdFormat: W3C, + HasParent: true + }, + { + Name: ProcessReader.GetFlowElement, + IdFormat: W3C, + HasParent: true + }, { Name: ProcessReader.GetFlowElement, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt index 928aa0257..ea6e05a40 100644 --- a/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt +++ b/test/Altinn.App.Api.Tests/Controllers/ProcessControllerTests.RunProcessNext_PdfFails_DataIsUnlocked.verified.txt @@ -267,6 +267,11 @@ IdFormat: W3C, HasParent: true }, + { + Name: ProcessReader.GetAltinnTaskExtension, + IdFormat: W3C, + HasParent: true + }, { Name: ProcessReader.GetEndEventIds, IdFormat: W3C, @@ -292,6 +297,11 @@ IdFormat: W3C, HasParent: true }, + { + Name: ProcessReader.GetFlowElement, + IdFormat: W3C, + HasParent: true + }, { Name: ProcessReader.GetNextElements, IdFormat: W3C, diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index aaf837853..39184065e 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -131,6 +131,40 @@ await GetDataBytes( ); } + public Task GetBinaryDataStream( + string org, + string app, + int instanceOwnerPartyId, + Guid instanceGuid, + Guid dataId, + StorageAuthenticationMethod? authenticationMethod = null, + CancellationToken cancellationToken = default + ) + { + if (cancellationToken.IsCancellationRequested) + return Task.FromCanceled(cancellationToken); + + string path = TestData.GetDataBlobPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); + + if (!File.Exists(path)) + { +#nullable disable + return Task.FromResult(null); +#nullable restore + } + + var fs = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + options: FileOptions.Asynchronous | FileOptions.SequentialScan + ); + + return Task.FromResult(fs); + } + public async Task GetDataBytes( string org, string app, diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index 59ed39d9d..947a97bff 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -113,7 +113,7 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() _dataClientMock.Verify( x => - x.GetBinaryData( + x.GetBinaryDataStream( "testorg", "testapp", 12345, @@ -144,7 +144,7 @@ public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerA _dataClientMock.Verify( x => - x.GetBinaryData( + x.GetBinaryDataStream( "testorg", "testapp", 12345, @@ -201,7 +201,7 @@ public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() Assert.Empty(result); _dataClientMock.Verify( x => - x.GetBinaryData( + x.GetBinaryDataStream( It.IsAny(), It.IsAny(), It.IsAny(), @@ -242,7 +242,7 @@ public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation( Assert.Empty(result); _dataClientMock.Verify( x => - x.GetBinaryData( + x.GetBinaryDataStream( It.IsAny(), It.IsAny(), It.IsAny(), @@ -389,7 +389,7 @@ string dataElementStringContent _dataClientMock .Setup(x => - x.GetBinaryData( + x.GetBinaryDataStream( It.IsAny(), It.IsAny(), It.IsAny(), 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 8e71e4f24..881574055 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 @@ -232,6 +232,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) { } public static System.Threading.Tasks.Task GetAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } + public static System.Threading.Tasks.Task GetUnbufferedAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } 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) { } 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) { } } @@ -2429,6 +2430,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage public System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -2904,6 +2906,7 @@ namespace Altinn.App.Core.Internal.Data System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); @@ -4749,6 +4752,7 @@ namespace Altinn.App.Core.Models.Validation public static string DataElementTooLarge { get; } public static string DataElementValidatedAtWrongTask { get; } public static string InvalidFileNameFormat { get; } + public static string InvalidSignatureHash { get; } public static string MissingContentType { get; } public static string MissingFileName { get; } public static string MissingSignatures { get; } From de6ff8ad20345843133a30013ceb372926556342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 12:06:00 +0200 Subject: [PATCH 08/44] Dispose stream. --- .../Features/Validation/Default/SignatureHashValidator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index bddc2597d..11702d42d 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -85,7 +85,7 @@ public async Task> Validate( foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures) { - Stream dataStream = await dataClient.GetBinaryDataStream( + await using Stream dataStream = await dataClient.GetBinaryDataStream( instance.Org, instance.AppId, instanceIdentifier.InstanceOwnerPartyId, From 94b213918497039bb11e5631ec2e990c4392d871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 12:08:16 +0200 Subject: [PATCH 09/44] Adjust test. --- .../Features/Validators/Default/SignatureHashValidatorTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index 947a97bff..56be2755d 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -119,7 +119,7 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() 12345, It.IsAny(), It.IsAny(), - It.Is(auth => auth != null), + It.Is(auth => auth == StorageAuthenticationMethod.ServiceOwner()), It.IsAny() ), Times.Once From 7f6779c2628771fafe9b5dfac1de342a6e3c60a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 12:33:39 +0200 Subject: [PATCH 10/44] Some cleanup. Extract method for validation logic. Never return null from GetBinaryDataStream. Throw if element is not found. --- .../Default/SignatureHashValidator.cs | 93 +++++++++++-------- .../Clients/Storage/DataClient.cs | 6 -- .../Internal/Data/IDataClient.cs | 2 + .../Mocks/DataClientMock.cs | 4 +- 4 files changed, 57 insertions(+), 48 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index 11702d42d..ddce88a5f 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -24,6 +24,8 @@ internal sealed class SignatureHashValidator( ILogger logger ) : IValidator { + private const string SigningTaskType = "signing"; + /// /// We implement instead. /// @@ -34,17 +36,8 @@ ILogger logger /// public bool ShouldRunForTask(string taskId) { - AltinnTaskExtension? taskConfig; - try - { - taskConfig = processReader.GetAltinnTaskExtension(taskId); - } - catch (Exception) - { - return false; - } - - return taskConfig?.TaskType is "signing"; + AltinnTaskExtension? taskConfig = processReader.GetAltinnTaskExtension(taskId); + return taskConfig?.TaskType is SigningTaskType; } public bool NoIncrementalValidation => true; @@ -64,7 +57,6 @@ public async Task> Validate( ) { Instance instance = dataAccessor.Instance; - var instanceIdentifier = new InstanceIdentifier(instance); AltinnSignatureConfiguration signingConfiguration = processReader.GetAltinnTaskExtension(taskId)?.SignatureConfiguration @@ -85,34 +77,15 @@ public async Task> Validate( foreach (SignDocument.DataElementSignature dataElementSignature in dataElementSignatures) { - await using Stream dataStream = await dataClient.GetBinaryDataStream( - instance.Org, - instance.AppId, - instanceIdentifier.InstanceOwnerPartyId, - instanceIdentifier.InstanceGuid, - Guid.Parse(dataElementSignature.DataElementId), - HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId) - ? StorageAuthenticationMethod.ServiceOwner() - : null + ValidationIssue? validationIssue = await ValidateDataElementSignature( + dataElementSignature, + instance, + applicationMetadata ); - string sha256Hash = await GenerateSha256Hash(dataStream); - - if (sha256Hash != dataElementSignature.Sha256Hash) + if (validationIssue != null) { - logger.LogError( - $"Found an invalid signature for data element {dataElementSignature.DataElementId} on instance {instance.Id}. Expected hash {dataElementSignature.Sha256Hash}, calculated hash {sha256Hash}." - ); - - return - [ - new ValidationIssue - { - Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, - Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, - }, - ]; + return [validationIssue]; } } } @@ -122,6 +95,48 @@ public async Task> Validate( return []; } + private async Task ValidateDataElementSignature( + SignDocument.DataElementSignature dataElementSignature, + Instance instance, + ApplicationMetadata applicationMetadata + ) + { + var instanceIdentifier = new InstanceIdentifier(instance); + + await using Stream dataStream = await dataClient.GetBinaryDataStream( + instance.Org, + instance.AppId, + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + Guid.Parse(dataElementSignature.DataElementId), + HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId) + ? StorageAuthenticationMethod.ServiceOwner() + : null + ); + + string sha256Hash = await GenerateSha256Hash(dataStream); + + if (sha256Hash != dataElementSignature.Sha256Hash) + { + logger.LogError( + "Found an invalid signature for data element {DataElementId} on instance {InstanceId}. Expected hash {ExpectedHash}, calculated hash {CalculatedHash}", + dataElementSignature.DataElementId, + instance.Id, + dataElementSignature.Sha256Hash, + sha256Hash + ); + + return new ValidationIssue + { + Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, + Severity = ValidationIssueSeverity.Error, + Description = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, + }; + } + + return null; + } + private static bool HasRestrictedRead( ApplicationMetadata applicationMetadata, Instance instance, @@ -139,7 +154,7 @@ string dataElementId ); } - return !string.IsNullOrEmpty(dataType?.ActionRequiredToRead); + return !string.IsNullOrEmpty(dataType.ActionRequiredToRead); } private static async Task GenerateSha256Hash(Stream stream) @@ -150,7 +165,7 @@ private static async Task GenerateSha256Hash(Stream stream) } /// - /// Formats a SHA digest with common best best practice:
+ /// Formats a SHA digest with common best practice:
/// Lowercase hexadecimal representation without delimiters ///
/// The hash code (digest) to format diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index b61b63cd9..c32d30048 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -230,12 +230,6 @@ public async Task GetBinaryDataStream( { return await response.Content.ReadAsStreamAsync(cancellationToken); } - else if (response.StatusCode == HttpStatusCode.NotFound) - { -#nullable disable - return null; -#nullable restore - } throw await PlatformHttpException.CreateAsync(response); } diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index 8138cb626..36427cae1 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -129,6 +129,7 @@ Task GetBinaryData( /// /// Gets the data as an unbuffered stream for memory-efficient processing of large files. + /// Throws if the data element is not found or other HTTP errors occur. /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. @@ -137,6 +138,7 @@ Task GetBinaryData( /// the data id /// An optional specification of the authentication method to use for requests /// An optional cancellation token + /// Thrown when the data element is not found or other HTTP errors occur Task GetBinaryDataStream( string org, string app, diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index 39184065e..70c9c1961 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -148,9 +148,7 @@ public Task GetBinaryDataStream( if (!File.Exists(path)) { -#nullable disable - return Task.FromResult(null); -#nullable restore + throw new FileNotFoundException($"Data element not found at path: {path}"); } var fs = new FileStream( From eab540ded4a80e57d60c40c77eba06b1667a156b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 12:39:42 +0200 Subject: [PATCH 11/44] Add remark that the sha digest formatting needs to match Storage. --- .../Features/Validation/Default/SignatureHashValidator.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index ddce88a5f..3bdd4f427 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -170,6 +170,7 @@ private static async Task GenerateSha256Hash(Stream stream) /// /// The hash code (digest) to format /// String representation of the digest + /// This mirrors how the altinn-storage formats the Sha digest when creating the signature document, and it must stay in sync. private static string FormatShaDigest(byte[] digest) { return Convert.ToHexString(digest).ToLowerInvariant(); From 26ae23e2d273628f28503cd6d06f394bf147dbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 13:25:07 +0200 Subject: [PATCH 12/44] Test that exception from underlying code in ShouldRunForTask bubbles up. --- .../Validators/Default/SignatureHashValidatorTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index 56be2755d..328488232 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -301,15 +301,16 @@ public void ShouldRunForTask_WithNullTaskType_ReturnsFalse() } [Fact] - public void ShouldRunForTask_WithException_ReturnsFalse() + public void ShouldRunForTask_Exception_BubblesUp() { + var mockedException = new Exception("Exception bubbles up"); _processReaderMock .Setup(x => x.GetAltinnTaskExtension("task")) - .Throws(new InvalidOperationException("Task not found")); + .Throws(mockedException); - bool result = _validator.ShouldRunForTask("task"); + var thrownException = Assert.Throws(() => _validator.ShouldRunForTask("task")); - Assert.False(result); + Assert.True(thrownException.Message == mockedException.Message); } [Fact] From 967d18be6075c16595b3e8554ea579195e8843e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 13:32:10 +0200 Subject: [PATCH 13/44] Formatting that got past rider. --- .../Validators/Default/SignatureHashValidatorTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index 328488232..a5659fd31 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -304,9 +304,7 @@ public void ShouldRunForTask_WithNullTaskType_ReturnsFalse() public void ShouldRunForTask_Exception_BubblesUp() { var mockedException = new Exception("Exception bubbles up"); - _processReaderMock - .Setup(x => x.GetAltinnTaskExtension("task")) - .Throws(mockedException); + _processReaderMock.Setup(x => x.GetAltinnTaskExtension("task")).Throws(mockedException); var thrownException = Assert.Throws(() => _validator.ShouldRunForTask("task")); From 0e30e33724699332ce247a5b27964ed0f6a45852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 18 Sep 2025 13:56:38 +0200 Subject: [PATCH 14/44] Add tests for GetBinaryDataStream. --- .../Clients/Storage/DataClientTests.cs | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index 39c1d498b..ffbb55cf0 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -480,6 +480,127 @@ await fixture.DataClient.GetBinaryData( actual.Response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); } + [Theory] + [MemberData(nameof(AuthenticationTestCases))] + public async Task GetBinaryDataStream_returns_stream_of_binary_data_with_unbuffered_response( + AuthenticationTestCase? testCase + ) + { + var instanceIdentifier = new InstanceIdentifier("501337/d3f3250d-705c-4683-a215-e05ebcbe6071"); + var dataGuid = new Guid("67a5ef12-6e38-41f8-8b42-f91249ebcec0"); + HttpRequestMessage? platformRequest = null; + int invocations = 0; + + await using var fixture = Fixture.Create( + async (request, ct) => + { + invocations++; + platformRequest = request; + + await Task.CompletedTask; + return new HttpResponseMessage() { Content = new StringContent("hello streaming world") }; + } + ); + + var expectedUri = new Uri( + $"{ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}", + UriKind.RelativeOrAbsolute + ); + + await using var response = await fixture.DataClient.GetBinaryDataStream( + "ttd", + "app", + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + dataGuid, + authenticationMethod: testCase?.AuthenticationMethod + ); + + invocations.Should().Be(1); + platformRequest?.Should().NotBeNull(); + AssertHttpRequest(platformRequest!, expectedUri, HttpMethod.Get, expectedAuth: testCase?.ExpectedToken); + + using StreamReader streamReader = new StreamReader(response); + var responseString = await streamReader.ReadToEndAsync(); + responseString.Should().BeEquivalentTo("hello streaming world"); + } + + [Theory] + [MemberData(nameof(AuthenticationTestCases))] + public async Task GetBinaryDataStream_throws_PlatformHttpException_when_data_not_found( + AuthenticationTestCase? testCase + ) + { + var instanceIdentifier = new InstanceIdentifier("501337/d3f3250d-705c-4683-a215-e05ebcbe6071"); + var dataGuid = new Guid("67a5ef12-6e38-41f8-8b42-f91249ebcec0"); + HttpRequestMessage? platformRequest = null; + int invocations = 0; + + await using var fixture = Fixture.Create( + async (request, ct) => + { + invocations++; + platformRequest = request; + + await Task.CompletedTask; + return new HttpResponseMessage() { StatusCode = HttpStatusCode.NotFound }; + } + ); + + var expectedUri = new Uri( + $"{ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}", + UriKind.RelativeOrAbsolute + ); + + var actual = await Assert.ThrowsAsync(async () => + await fixture.DataClient.GetBinaryDataStream( + "ttd", + "app", + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + dataGuid, + authenticationMethod: testCase?.AuthenticationMethod + ) + ); + + invocations.Should().Be(1); + platformRequest?.Should().NotBeNull(); + AssertHttpRequest(platformRequest!, expectedUri, HttpMethod.Get, expectedAuth: testCase?.ExpectedToken); + actual.Should().NotBeNull(); + actual.Response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetBinaryDataStream_throws_PlatformHttpException_when_server_error_returned_from_storage() + { + var instanceIdentifier = new InstanceIdentifier("501337/d3f3250d-705c-4683-a215-e05ebcbe6071"); + var dataGuid = new Guid("67a5ef12-6e38-41f8-8b42-f91249ebcec0"); + int invocations = 0; + + await using var fixture = Fixture.Create( + async (request, ct) => + { + invocations++; + + await Task.CompletedTask; + return new HttpResponseMessage() { StatusCode = HttpStatusCode.InternalServerError }; + } + ); + + var actual = await Assert.ThrowsAsync(async () => + await fixture.DataClient.GetBinaryDataStream( + "ttd", + "app", + instanceIdentifier.InstanceOwnerPartyId, + instanceIdentifier.InstanceGuid, + dataGuid + ) + ); + invocations.Should().Be(1); + actual.Should().NotBeNull(); + actual.Response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + } + [Theory] [MemberData(nameof(AuthenticationTestCases))] public async Task GetBinaryDataList_returns_AttachemtList_when_DataElements_found(AuthenticationTestCase? testCase) From 933a50a00a499405463c3a86d94b4f2dc8c2ee4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 10:08:22 +0200 Subject: [PATCH 15/44] Rename GetUnbufferedAsync to GetStreamingAsync to avoid confusion. Add cancellation token support. --- .../Extensions/HttpClientExtension.cs | 17 +++++++++++++---- .../Clients/Storage/DataClient.cs | 13 +++++++++---- ...ShouldNotChange_Unintentionally.verified.txt | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index d433d34d2..015a91945 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -104,18 +104,23 @@ public static async Task GetAsync( } /// - /// Extension that adds authorization header to request and returns an unbuffered response + /// Extension that adds authorization header to request and returns response configured for streaming. + /// Response returns immediately after headers are received, allowing content to be streamed. /// /// The HttpClient /// the authorization token (jwt) /// The request Uri /// The platformAccess tokens + /// The cancellation token /// A HttpResponseMessage - public static async Task GetUnbufferedAsync( + /// When using GetStreamingAsync() for large file downloads, ensure your HttpClient + /// instance has an appropriate timeout configured. The default timeout may be too short for large files. + public static async Task GetStreamingAsync( this HttpClient httpClient, string authorizationToken, string requestUri, - string? platformAccessToken = null + string? platformAccessToken = null, + CancellationToken? cancellationToken = null ) { using HttpRequestMessage request = new(HttpMethod.Get, requestUri); @@ -130,7 +135,11 @@ public static async Task GetUnbufferedAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, CancellationToken.None); + return await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken ?? CancellationToken.None + ); } /// diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index c32d30048..627ae4951 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net; using System.Net.Http.Headers; using System.Net.Mime; @@ -215,16 +216,20 @@ public async Task GetBinaryDataStream( CancellationToken cancellationToken = default ) { - using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); - string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; - string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; + using Activity? activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); + var instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; + var apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.GetUnbufferedAsync(token, apiUrl); + HttpResponseMessage response = await _client.GetStreamingAsync( + token, + apiUrl, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { 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 881574055..9790e44d9 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 @@ -232,7 +232,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) { } public static System.Threading.Tasks.Task GetAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } - public static System.Threading.Tasks.Task GetUnbufferedAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } + 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 PostAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null) { } 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) { } } From 5be7cb88e51e0c152c33aaad7d9abca04f76a1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 11:09:52 +0200 Subject: [PATCH 16/44] Adjust cancellation token param. --- src/Altinn.App.Core/Extensions/HttpClientExtension.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index 015a91945..80153ffb6 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -120,7 +120,7 @@ public static async Task GetStreamingAsync( string authorizationToken, string requestUri, string? platformAccessToken = null, - CancellationToken? cancellationToken = null + CancellationToken cancellationToken = default ) { using HttpRequestMessage request = new(HttpMethod.Get, requestUri); @@ -135,11 +135,7 @@ public static async Task GetStreamingAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken ?? CancellationToken.None - ); + return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); } /// From a91dc2fdf6f7741bb05c3c3d3fe6eb9633eff431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 11:11:36 +0200 Subject: [PATCH 17/44] Add correct verified.txt. --- ...Tests.PublicApi_ShouldNotChange_Unintentionally.verified.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9790e44d9..3662b4520 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 @@ -232,7 +232,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) { } public static System.Threading.Tasks.Task GetAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } - 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 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 PostAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null) { } 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) { } } From 0b829ab9898f976bbba4a2ecfbdcfd1a967daf15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 13:46:20 +0200 Subject: [PATCH 18/44] Add ResponseWrapperStream to ensure that the http response is disposed when the stream is disposed. --- .../Helpers/ResponseWrapperStream.cs | 115 ++++++++++++++++++ .../Clients/Storage/DataClient.cs | 12 +- 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs diff --git a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs new file mode 100644 index 000000000..1e88d1bc9 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs @@ -0,0 +1,115 @@ +namespace Altinn.App.Core.Helpers; + +/// +/// A wrapper stream that ensures proper disposal of an HttpResponseMessage along with its content stream. +/// +internal sealed class ResponseWrapperStream : Stream +{ + private readonly HttpResponseMessage _response; + private readonly Stream _innerStream; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP response message to be disposed when the stream is disposed. + /// The inner stream to wrap and delegate operations to. + public ResponseWrapperStream(HttpResponseMessage response, Stream innerStream) + { + _response = response; + _innerStream = innerStream; + } + + /// + /// Releases the unmanaged resources used by the and optionally releases the managed resources. + /// + /// true to release both managed and unmanaged resources; false to release only unmanaged resources. + protected override void Dispose(bool disposing) + { + if (disposing) + { + _response.Dispose(); // This will also dispose the inner stream + } + + base.Dispose(disposing); + } + + // Delegate all Stream operations to _innerStream + + /// + /// Gets a value indicating whether the current stream supports reading. + /// + public override bool CanRead => _innerStream.CanRead; + + /// + /// Gets a value indicating whether the current stream supports seeking. + /// + public override bool CanSeek => _innerStream.CanSeek; + + /// + /// Gets a value indicating whether the current stream supports writing. + /// + public override bool CanWrite => _innerStream.CanWrite; + + /// + /// Gets the length in bytes of the stream. + /// + /// The stream does not support seeking. + public override long Length => _innerStream.Length; + + /// + /// Gets or sets the position within the current stream. + /// + /// The stream does not support seeking. + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + + /// + /// Clears all buffers for this stream and causes any buffered data to be written to the underlying device. + /// + public override void Flush() => _innerStream.Flush(); + + /// + /// Reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read. + /// + /// An array of bytes. When this method returns, the buffer contains the specified byte array with the values between offset and (offset + count - 1) replaced by the bytes read from the current source. + /// The zero-based byte offset in buffer at which to begin storing the data read from the current stream. + /// The maximum number of bytes to be read from the current stream. + /// The total number of bytes read into the buffer. This can be less than the number of bytes requested if that many bytes are not currently available, or zero (0) if the end of the stream has been reached. + /// buffer is null. + /// offset or count is negative. + /// The sum of offset and count is larger than the buffer length. + /// The stream does not support reading. + public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); + + /// + /// Sets the position within the current stream. + /// + /// A byte offset relative to the origin parameter. + /// A value of type indicating the reference point used to obtain the new position. + /// The new position within the current stream. + /// The stream does not support seeking. + public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); + + /// + /// Sets the length of the current stream. + /// + /// The desired length of the current stream in bytes. + /// The stream does not support both writing and seeking. + /// value is negative. + public override void SetLength(long value) => _innerStream.SetLength(value); + + /// + /// Writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written. + /// + /// An array of bytes. This method copies count bytes from buffer to the current stream. + /// The zero-based byte offset in buffer at which to begin copying bytes to the current stream. + /// The number of bytes to be written to the current stream. + /// buffer is null. + /// offset or count is negative. + /// The sum of offset and count is greater than the buffer length. + /// The stream does not support writing. + public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count); +} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 627ae4951..e69da76df 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -233,9 +233,19 @@ public async Task GetBinaryDataStream( if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsStreamAsync(cancellationToken); + try + { + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return new ResponseWrapperStream(response, stream); + } + catch (Exception) + { + response.Dispose(); + throw; + } } + response.Dispose(); throw await PlatformHttpException.CreateAsync(response); } From 7db9c25554fea8b1cc6d4678be3f4e8a7ba30a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 13:54:58 +0200 Subject: [PATCH 19/44] Add async overrides to ResponseWrapperStream. --- .../Helpers/ResponseWrapperStream.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs index 1e88d1bc9..11e524399 100644 --- a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs +++ b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs @@ -71,6 +71,13 @@ public override long Position /// public override void Flush() => _innerStream.Flush(); + /// + /// Asynchronously clears all buffers for this stream and causes any buffered data to be written to the underlying device. + /// + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous flush operation. + public override Task FlushAsync(CancellationToken cancellationToken) => _innerStream.FlushAsync(cancellationToken); + /// /// Reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read. /// @@ -84,6 +91,26 @@ public override long Position /// The stream does not support reading. public override int Read(byte[] buffer, int offset, int count) => _innerStream.Read(buffer, offset, count); + /// + /// Asynchronously reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read. + /// + /// The buffer to write the data into. + /// The byte offset in buffer at which to begin writing data from the stream. + /// The maximum number of bytes to read. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous read operation. The value contains the total number of bytes read into the buffer. + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _innerStream.ReadAsync(buffer, offset, count, cancellationToken); + + /// + /// Asynchronously reads a sequence of bytes from the current stream and advances the position within the stream by the number of bytes read. + /// + /// The region of memory to write the data into. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous read operation. The value contains the total number of bytes read into the buffer. + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _innerStream.ReadAsync(buffer, cancellationToken); + /// /// Sets the position within the current stream. /// @@ -112,4 +139,24 @@ public override long Position /// The sum of offset and count is greater than the buffer length. /// The stream does not support writing. public override void Write(byte[] buffer, int offset, int count) => _innerStream.Write(buffer, offset, count); + + /// + /// Asynchronously writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written. + /// + /// The buffer to write data from. + /// The zero-based byte offset in buffer from which to begin copying bytes to the stream. + /// The number of bytes to write. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous write operation. + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _innerStream.WriteAsync(buffer, offset, count, cancellationToken); + + /// + /// Asynchronously writes a sequence of bytes to the current stream and advances the current position within this stream by the number of bytes written. + /// + /// The region of memory to write data from. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous write operation. + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + _innerStream.WriteAsync(buffer, cancellationToken); } From a72fe6e70ad07cd4a4a0d7f2b4a4f19e8322815e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 13:58:22 +0200 Subject: [PATCH 20/44] Create platform exception before disposing response. --- .../Infrastructure/Clients/Storage/DataClient.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index e69da76df..298c20418 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -245,8 +245,9 @@ public async Task GetBinaryDataStream( } } + var exception = await PlatformHttpException.CreateAsync(response); response.Dispose(); - throw await PlatformHttpException.CreateAsync(response); + throw exception; } /// From c1c510cb8d1bbff4dad6f206ceae616b611fba76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 23 Sep 2025 14:04:35 +0200 Subject: [PATCH 21/44] Null checks in ResponseWrapperStream. --- src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs index 11e524399..7b07b72f6 100644 --- a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs +++ b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs @@ -15,6 +15,9 @@ internal sealed class ResponseWrapperStream : Stream /// The inner stream to wrap and delegate operations to. public ResponseWrapperStream(HttpResponseMessage response, Stream innerStream) { + ArgumentNullException.ThrowIfNull(response); + ArgumentNullException.ThrowIfNull(innerStream); + _response = response; _innerStream = innerStream; } From 24bebc9867466c7c70c51433a4e4e256a6cf60db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 24 Sep 2025 10:54:04 +0200 Subject: [PATCH 22/44] PlatformHttpResponseSnapshotException first edition. --- .../PlatformHttpResponseSnapshotException.cs | 154 ++++++++++++++++++ .../Helpers/ResponseWrapperStream.cs | 2 +- .../Clients/Storage/DataClient.cs | 6 +- 3 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs new file mode 100644 index 000000000..539232806 --- /dev/null +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -0,0 +1,154 @@ +using System.Net.Http.Headers; +using System.Text; +using Altinn.App.Core.Exceptions; + +namespace Altinn.App.Core.Helpers; + +/// +/// Exception that represents a failed HTTP call to the Altinn Platform, +/// containing an immutable snapshot of the HTTP response. +/// +/// Unlike , this class does not hold on to a +/// instance. Instead, it copies relevant +/// metadata and the response body into strings, making it safe to throw, +/// log, and persist without leaking disposable resources. +/// +/// +public sealed class PlatformHttpResponseSnapshotException : AltinnException +{ + private const int MaxContentCharacters = 16 * 1024; + + /// + /// Gets the numeric HTTP status code. + /// + public int StatusCode { get; } + + /// + /// Gets the reason phrase sent by the server, if any. + /// + public string? ReasonPhrase { get; } + + /// + /// Gets the HTTP version used by the response (e.g. "1.1", "2.0"). + /// + public string HttpVersion { get; } + + /// + /// Gets a flattened string representation of all response, content, and trailing headers. + /// + public string Headers { get; } + + /// + /// Gets the response body content as a string. + /// + public string Content { get; } + + /// + /// Gets a value indicating whether the content was truncated due to the configured maximum length. + /// + public bool ContentTruncated { get; } + + /// + /// Creates a new by snapshotting + /// the provided into immutable string values, + /// and then disposes the response. + /// + /// The HTTP response to snapshot and dispose. + /// A cancellation token to cancel reading the content. + public static async Task CreateAndDisposeHttpResponse( + HttpResponseMessage response, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(response); + + try + { + string content = await response.Content.ReadAsStringAsync(cancellationToken); + + bool truncated = content.Length > MaxContentCharacters; + if (truncated) + { + content = content[..MaxContentCharacters]; + } + + string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders); + string message = BuildMessage((int)response.StatusCode, response.ReasonPhrase, content, truncated); + + return new PlatformHttpResponseSnapshotException( + statusCode: (int)response.StatusCode, + reasonPhrase: response.ReasonPhrase, + httpVersion: response.Version?.ToString() ?? string.Empty, + headers: headers, + content: content, + contentTruncated: truncated, + message: message); + } + finally + { + try + { + response.Dispose(); + } + catch + { + /* ignore dispose failures */ + } + } + } + + private PlatformHttpResponseSnapshotException( + int statusCode, + string? reasonPhrase, + string httpVersion, + string headers, + string content, + bool contentTruncated, + string message) + : base(message) + { + StatusCode = statusCode; + ReasonPhrase = reasonPhrase; + HttpVersion = httpVersion; + Headers = headers; + Content = content; + ContentTruncated = contentTruncated; + } + + private static string BuildMessage(int statusCode, string? reason, string content, bool truncated) + { + StringBuilder sb = new StringBuilder().Append(statusCode).Append(' ').Append(reason ?? string.Empty); + if (string.IsNullOrEmpty(content)) + { + return sb.ToString(); + } + + sb.Append(" - ").Append(content); + if (truncated) sb.Append("… [truncated]"); + + return sb.ToString(); + } + + private static string FlattenHeaders( + HttpResponseHeaders? responseHeaders, + HttpContentHeaders? contentHeaders, + HttpResponseHeaders? trailingHeaders) + { + var sb = new StringBuilder(); + + Append("Headers", responseHeaders); + Append("Content-Headers", contentHeaders); + Append("Trailing-Headers", trailingHeaders); + + return sb.ToString(); + + void Append(string prefix, IEnumerable>>? headers) + { + if (headers is null) return; + foreach ((string key, IEnumerable values) in headers) + { + sb.Append(prefix).Append(": ").Append(key).Append(": ") + .AppendLine(string.Join(", ", values)); + } + } + } +} diff --git a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs index 7b07b72f6..25dbde368 100644 --- a/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs +++ b/src/Altinn.App.Core/Helpers/ResponseWrapperStream.cs @@ -17,7 +17,7 @@ public ResponseWrapperStream(HttpResponseMessage response, Stream innerStream) { ArgumentNullException.ThrowIfNull(response); ArgumentNullException.ThrowIfNull(innerStream); - + _response = response; _innerStream = innerStream; } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 298c20418..4f44121b8 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -245,9 +245,9 @@ public async Task GetBinaryDataStream( } } - var exception = await PlatformHttpException.CreateAsync(response); - response.Dispose(); - throw exception; + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse( + response, + cancellationToken: cancellationToken); } /// From 7ac2d12623f73f8cc44f39de9587007dcc333221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 24 Sep 2025 11:14:08 +0200 Subject: [PATCH 23/44] PlatformHttpResponseSnapshotException that inherits from PlatformHttpException. --- .../Controllers/ProcessController.cs | 13 ++ .../PlatformHttpResponseSnapshotException.cs | 113 +++++++++++------- .../Clients/Storage/DataClient.cs | 3 +- .../Clients/Storage/DataClientTests.cs | 4 +- ...ouldNotChange_Unintentionally.verified.txt | 10 ++ 5 files changed, 98 insertions(+), 45 deletions(-) diff --git a/src/Altinn.App.Api/Controllers/ProcessController.cs b/src/Altinn.App.Api/Controllers/ProcessController.cs index c6d2ac647..28b9156f0 100644 --- a/src/Altinn.App.Api/Controllers/ProcessController.cs +++ b/src/Altinn.App.Api/Controllers/ProcessController.cs @@ -680,6 +680,19 @@ private ObjectResult ExceptionResponse(Exception exception, string message) { _logger.LogError(exception, message); + if (exception is PlatformHttpResponseSnapshotException phse) + { + return StatusCode( + phse.StatusCode, + new ProblemDetails() + { + Detail = phse.Message, + Status = phse.StatusCode, + Title = message, + } + ); + } + if (exception is PlatformHttpException phe) { return StatusCode( diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 539232806..577f2acb6 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -1,87 +1,114 @@ using System.Net.Http.Headers; using System.Text; -using Altinn.App.Core.Exceptions; namespace Altinn.App.Core.Helpers; /// /// Exception that represents a failed HTTP call to the Altinn Platform, -/// containing an immutable snapshot of the HTTP response. +/// containing an immutable snapshot of the HTTP response, while remaining +/// backward compatible with . /// -/// Unlike , this class does not hold on to a -/// instance. Instead, it copies relevant -/// metadata and the response body into strings, making it safe to throw, -/// log, and persist without leaking disposable resources. +/// This class derives from so existing +/// catch blocks continue to work. It passes a sanitized, non-streaming +/// to the base class to avoid keeping any +/// live network resources, and it exposes string-based snapshot properties +/// for safe logging and persistence. /// /// -public sealed class PlatformHttpResponseSnapshotException : AltinnException +public sealed class PlatformHttpResponseSnapshotException : PlatformHttpException { - private const int MaxContentCharacters = 16 * 1024; - /// - /// Gets the numeric HTTP status code. + /// The maximum number of characters captured from the response content. /// + private const int MaxCapturedContentLength = 16 * 1024; // 16 KB + + /// Gets the numeric HTTP status code. public int StatusCode { get; } - /// - /// Gets the reason phrase sent by the server, if any. - /// + /// Gets the reason phrase sent by the server, if any. public string? ReasonPhrase { get; } - /// - /// Gets the HTTP version used by the response (e.g. "1.1", "2.0"). - /// + /// Gets the HTTP version used by the response (e.g. "1.1", "2.0"). public string HttpVersion { get; } - /// - /// Gets a flattened string representation of all response, content, and trailing headers. - /// + /// Gets a flattened string representation of all response, content, and trailing headers. public string Headers { get; } - /// - /// Gets the response body content as a string. - /// + /// Gets the response body content as a string. public string Content { get; } - /// - /// Gets a value indicating whether the content was truncated due to the configured maximum length. - /// + /// Gets a value indicating whether the content was truncated due to the configured maximum length. public bool ContentTruncated { get; } /// /// Creates a new by snapshotting /// the provided into immutable string values, - /// and then disposes the response. + /// constructing a sanitized clone for the base class, and then disposing the original response. /// /// The HTTP response to snapshot and dispose. /// A cancellation token to cancel reading the content. public static async Task CreateAndDisposeHttpResponse( HttpResponseMessage response, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default + ) { ArgumentNullException.ThrowIfNull(response); try { - string content = await response.Content.ReadAsStringAsync(cancellationToken); + string content = response.Content is null + ? string.Empty + : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - bool truncated = content.Length > MaxContentCharacters; + bool truncated = content.Length > MaxCapturedContentLength; if (truncated) { - content = content[..MaxContentCharacters]; + content = content[..MaxCapturedContentLength]; } 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) + { + ReasonPhrase = response.ReasonPhrase, + Version = response.Version, + }; + + // Copy normal headers + foreach (KeyValuePair> h in response.Headers) + { + safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + + // Attach an empty content so we can preserve content headers without any live stream + safeResponse.Content = new ByteArrayContent(Array.Empty()); + + if (response.Content is not null) + { + foreach (KeyValuePair> h in response.Content.Headers) + { + safeResponse.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); + } + } + + // Copy trailing headers if present (available on HTTP/2+) + foreach (KeyValuePair> h in response.TrailingHeaders) + { + 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: truncated, - message: message); + message: message + ); } finally { @@ -96,19 +123,22 @@ public static async Task CreateAndDispose } } + /// Initializes a new instance of the class. private PlatformHttpResponseSnapshotException( + HttpResponseMessage safeResponse, int statusCode, string? reasonPhrase, string httpVersion, string headers, string content, bool contentTruncated, - string message) - : base(message) + string message + ) + : base(safeResponse, message) { StatusCode = statusCode; ReasonPhrase = reasonPhrase; - HttpVersion = httpVersion; + HttpVersion = string.IsNullOrEmpty(httpVersion) ? string.Empty : httpVersion; Headers = headers; Content = content; ContentTruncated = contentTruncated; @@ -118,20 +148,19 @@ private static string BuildMessage(int statusCode, string? reason, string conten { StringBuilder sb = new StringBuilder().Append(statusCode).Append(' ').Append(reason ?? string.Empty); if (string.IsNullOrEmpty(content)) - { return sb.ToString(); - } sb.Append(" - ").Append(content); - if (truncated) sb.Append("… [truncated]"); - + if (truncated) + sb.Append("… [truncated]"); return sb.ToString(); } private static string FlattenHeaders( HttpResponseHeaders? responseHeaders, HttpContentHeaders? contentHeaders, - HttpResponseHeaders? trailingHeaders) + HttpResponseHeaders? trailingHeaders + ) { var sb = new StringBuilder(); @@ -143,11 +172,11 @@ private static string FlattenHeaders( void Append(string prefix, IEnumerable>>? headers) { - if (headers is null) return; + if (headers is null) + return; foreach ((string key, IEnumerable values) in headers) { - sb.Append(prefix).Append(": ").Append(key).Append(": ") - .AppendLine(string.Join(", ", values)); + sb.Append(prefix).Append(": ").Append(key).Append(": ").AppendLine(string.Join(", ", values)); } } } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 4f44121b8..e2997be33 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -247,7 +247,8 @@ public async Task GetBinaryDataStream( throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse( response, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken + ); } /// diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index ffbb55cf0..0501326d1 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -552,7 +552,7 @@ public async Task GetBinaryDataStream_throws_PlatformHttpException_when_data_not UriKind.RelativeOrAbsolute ); - var actual = await Assert.ThrowsAsync(async () => + var actual = await Assert.ThrowsAsync(async () => await fixture.DataClient.GetBinaryDataStream( "ttd", "app", @@ -587,7 +587,7 @@ public async Task GetBinaryDataStream_throws_PlatformHttpException_when_server_e } ); - var actual = await Assert.ThrowsAsync(async () => + var actual = await Assert.ThrowsAsync(async () => await fixture.DataClient.GetBinaryDataStream( "ttd", "app", 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 3662b4520..d3f88a336 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 @@ -2148,6 +2148,16 @@ namespace Altinn.App.Core.Helpers public System.Net.Http.HttpResponseMessage Response { get; } public static System.Threading.Tasks.Task CreateAsync(System.Net.Http.HttpResponseMessage response) { } } + public sealed class PlatformHttpResponseSnapshotException : Altinn.App.Core.Helpers.PlatformHttpException + { + public string Content { get; } + public bool ContentTruncated { get; } + public string Headers { get; } + public string HttpVersion { get; } + public string? ReasonPhrase { get; } + public int StatusCode { get; } + public static System.Threading.Tasks.Task CreateAndDisposeHttpResponse(System.Net.Http.HttpResponseMessage response, System.Threading.CancellationToken cancellationToken = default) { } + } public class ProcessError { public ProcessError() { } From 9f2b786eff5ab2ea3c6a605b64c4f42f2204cbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 24 Sep 2025 15:05:19 +0200 Subject: [PATCH 24/44] Tweaks to PlatformHttpResponseSnapshowException. --- .../PlatformHttpResponseSnapshotException.cs | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 577f2acb6..52cf276c2 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -22,22 +22,34 @@ public sealed class PlatformHttpResponseSnapshotException : PlatformHttpExceptio /// private const int MaxCapturedContentLength = 16 * 1024; // 16 KB - /// Gets the numeric HTTP status code. + /// + /// Gets the numeric HTTP status code. + /// public int StatusCode { get; } - /// Gets the reason phrase sent by the server, if any. + /// + /// Gets the reason phrase sent by the server, if any. + /// public string? ReasonPhrase { get; } - /// Gets the HTTP version used by the response (e.g. "1.1", "2.0"). + /// + /// Gets the HTTP version used by the response (e.g. "1.1", "2.0"). + /// public string HttpVersion { get; } - /// Gets a flattened string representation of all response, content, and trailing headers. + /// + /// Gets a flattened string representation of all response, content, and trailing headers. + /// public string Headers { get; } - /// Gets the response body content as a string. + /// + /// Gets the response body content as a string (possibly truncated). + /// public string Content { get; } - /// Gets a value indicating whether the content was truncated due to the configured maximum length. + /// + /// Gets a value indicating whether the content was truncated due to the configured maximum length. + /// public bool ContentTruncated { get; } /// @@ -47,6 +59,7 @@ public sealed class PlatformHttpResponseSnapshotException : PlatformHttpExceptio /// /// The HTTP response to snapshot and dispose. /// A cancellation token to cancel reading the content. + /// The constructed . public static async Task CreateAndDisposeHttpResponse( HttpResponseMessage response, CancellationToken cancellationToken = default @@ -56,9 +69,10 @@ public static async Task CreateAndDispose try { + // Snapshot content first (handle null) string content = response.Content is null ? string.Empty - : await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + : await response.Content.ReadAsStringAsync(cancellationToken); bool truncated = content.Length > MaxCapturedContentLength; if (truncated) @@ -82,18 +96,15 @@ public static async Task CreateAndDispose safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); } - // Attach an empty content so we can preserve content headers without any live stream - safeResponse.Content = new ByteArrayContent(Array.Empty()); + // Attach a diagnostic snapshot body for legacy consumers (text only, truncated) + string mediaType = response.Content?.Headers?.ContentType?.MediaType ?? "text/plain"; + var safeContent = new StringContent(content, Encoding.UTF8, mediaType); + safeResponse.Content = safeContent; - if (response.Content is not null) - { - foreach (KeyValuePair> h in response.Content.Headers) - { - safeResponse.Content.Headers.TryAddWithoutValidation(h.Key, h.Value); - } - } + // Important: do not copy content headers blindly (avoid Content-Length/Encoding mismatch). + // StringContent already sets Content-Type (with charset) appropriately. - // Copy trailing headers if present (available on HTTP/2+) + // Copy trailing headers if present (HTTP/2+) foreach (KeyValuePair> h in response.TrailingHeaders) { safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value); @@ -123,7 +134,17 @@ public static async Task CreateAndDispose } } - /// Initializes a new instance of the class. + /// + /// Initializes a new instance of the class. + /// + /// A sanitized, non-streaming suitable for legacy consumers. + /// The numeric HTTP status code. + /// The reason phrase sent by the server, if any. + /// The HTTP version used by the response. + /// A flattened string representation of response, content, and trailing headers. + /// The response body content as a string (possibly truncated). + /// Whether the content was truncated. + /// The exception message. private PlatformHttpResponseSnapshotException( HttpResponseMessage safeResponse, int statusCode, From 94199df20c2193eb449794f7a82963540f640c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Wed, 24 Sep 2025 15:39:55 +0200 Subject: [PATCH 25/44] Use a separate streaming http client with longer timeout when streaming binary data in DataClient. --- .../Extensions/ServiceCollectionExtensions.cs | 7 +++++++ .../Infrastructure/Clients/Storage/DataClient.cs | 15 ++++++++++++++- .../Clients/Storage/DataClientTests.cs | 5 +++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 693c1a62a..fb66c7cc0 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -101,6 +101,13 @@ IWebHostEnvironment env services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); + services.AddHttpClient( + "DataClient.Streaming", + client => + { + client.Timeout = TimeSpan.FromMinutes(30); // Longer timeout for streaming + } + ); services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index e2997be33..364195b5f 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -34,6 +34,7 @@ public class DataClient : IDataClient private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly HttpClient _client; + private readonly HttpClient _streamingClient; private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); @@ -56,6 +57,18 @@ public DataClient(HttpClient httpClient, IServiceProvider serviceProvider) httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); _client = httpClient; + + // Configure streaming client + var httpClientFactory = serviceProvider.GetRequiredService(); + _streamingClient = httpClientFactory.CreateClient("DataClient.Streaming"); + + _streamingClient.BaseAddress = new Uri(_platformSettings.ApiStorageEndpoint); + _streamingClient.DefaultRequestHeaders.Add( + General.SubscriptionKeyHeaderName, + _platformSettings.SubscriptionKey + ); + _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); } /// @@ -225,7 +238,7 @@ public async Task GetBinaryDataStream( cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.GetStreamingAsync( + HttpResponseMessage response = await _streamingClient.GetStreamingAsync( token, apiUrl, cancellationToken: cancellationToken diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index 0501326d1..e74ef34f6 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1167,6 +1167,11 @@ public static Fixture Create( ) .ReturnsAsync(_testTokens.ServiceOwnerToken); + // Setup HttpClientFactory mock to return a streaming client + DelegatingHandlerStub streamingDelegatingHandler = new(dataClientDelegatingHandler); + HttpClient streamingHttpClient = new(streamingDelegatingHandler) { Timeout = TimeSpan.FromMinutes(30) }; + mocks.HttpClientFactoryMock.Setup(x => x.CreateClient("DataClient.Streaming")).Returns(streamingHttpClient); + var services = new ServiceCollection(); services.Configure(options => options.ApiStorageEndpoint = ApiStorageEndpoint); services.Configure(options => options.HostName = "tt02.altinn.no"); From 98e62eaeb6cd5bfc31f599c71c8d70b8eb8a655b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 10 Oct 2025 08:04:35 +0200 Subject: [PATCH 26/44] Make PlatformHttpResponseSnapshotException internal. --- .../Helpers/PlatformHttpResponseSnapshotException.cs | 2 +- ...licApi_ShouldNotChange_Unintentionally.verified.txt | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 52cf276c2..b65cd7779 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -15,7 +15,7 @@ namespace Altinn.App.Core.Helpers; /// for safe logging and persistence. /// /// -public sealed class PlatformHttpResponseSnapshotException : PlatformHttpException +internal sealed class PlatformHttpResponseSnapshotException : PlatformHttpException { /// /// The maximum number of characters captured from the response content. 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 d3f88a336..3662b4520 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 @@ -2148,16 +2148,6 @@ namespace Altinn.App.Core.Helpers public System.Net.Http.HttpResponseMessage Response { get; } public static System.Threading.Tasks.Task CreateAsync(System.Net.Http.HttpResponseMessage response) { } } - public sealed class PlatformHttpResponseSnapshotException : Altinn.App.Core.Helpers.PlatformHttpException - { - public string Content { get; } - public bool ContentTruncated { get; } - public string Headers { get; } - public string HttpVersion { get; } - public string? ReasonPhrase { get; } - public int StatusCode { get; } - public static System.Threading.Tasks.Task CreateAndDisposeHttpResponse(System.Net.Http.HttpResponseMessage response, System.Threading.CancellationToken cancellationToken = default) { } - } public class ProcessError { public ProcessError() { } From 8413f594655558ff18fc3e0fe3bb2a7494d56a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 10 Oct 2025 08:15:51 +0200 Subject: [PATCH 27/44] Don't default to any content type in PlatformHttpResponseSnapshotException. --- .../Helpers/PlatformHttpResponseSnapshotException.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index b65cd7779..2c0e57a8b 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -96,14 +96,11 @@ public static async Task CreateAndDispose safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); } - // Attach a diagnostic snapshot body for legacy consumers (text only, truncated) - string mediaType = response.Content?.Headers?.ContentType?.MediaType ?? "text/plain"; - var safeContent = new StringContent(content, Encoding.UTF8, mediaType); + // 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; - // Important: do not copy content headers blindly (avoid Content-Length/Encoding mismatch). - // StringContent already sets Content-Type (with charset) appropriately. - // Copy trailing headers if present (HTTP/2+) foreach (KeyValuePair> h in response.TrailingHeaders) { From c8d6d21b078e6909d4dbc48e644c9534f77a8177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 10 Oct 2025 10:55:34 +0200 Subject: [PATCH 28/44] Redact sensitive headers from PlatformHttpResponseSnapshotException. --- .../PlatformHttpResponseSnapshotException.cs | 11 +- ...tformHttpResponseSnapshotExceptionTests.cs | 352 ++++++++++++++++++ 2 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 2c0e57a8b..a47e3d794 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -194,8 +194,17 @@ void Append(string prefix, IEnumerable> return; foreach ((string key, IEnumerable values) in headers) { - sb.Append(prefix).Append(": ").Append(key).Append(": ").AppendLine(string.Join(", ", values)); + string display = _redactedHeaders.Contains(key) ? "[REDACTED]" : string.Join(", ", values); + sb.Append(prefix).Append(": ").Append(key).Append(": ").AppendLine(display); } } } + + private static readonly HashSet _redactedHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Authorization", + "Proxy-Authorization", + "Cookie", + "Set-Cookie", + }; } diff --git a/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs new file mode 100644 index 000000000..9e2f35172 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs @@ -0,0 +1,352 @@ +#nullable disable +using System.Net; +using System.Text; +using Altinn.App.Core.Helpers; + +namespace Altinn.App.Core.Tests.Helpers; + +public class PlatformHttpResponseSnapshotExceptionTests +{ + [Fact] + public async Task CreateAndDisposeHttpResponse_CapturesBasicProperties() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + ReasonPhrase = "Internal Server Error", + Version = new Version(2, 0), + Content = new StringContent("Error details", Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Equal(500, exception.StatusCode); + Assert.Equal("Internal Server Error", exception.ReasonPhrase); + Assert.Equal("2.0", exception.HttpVersion); + Assert.Equal("Error details", exception.Content); + Assert.False(exception.ContentTruncated); + Assert.Contains("500", exception.Message); + Assert.Contains("Internal Server Error", exception.Message); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesNullContent() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.NoContent) { Content = null }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Empty(exception.Content); + Assert.False(exception.ContentTruncated); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesEmptyContent() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Empty(exception.Content); + Assert.False(exception.ContentTruncated); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_TruncatesLargeContent() + { + // Arrange + string largeContent = new string('x', 20 * 1024); // 20 KB + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(largeContent, Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.True(exception.ContentTruncated); + Assert.Equal(16 * 1024, exception.Content.Length); // Max is 16 KB + Assert.Contains("[truncated]", exception.Message); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_DoesNotTruncateSmallContent() + { + // Arrange + string content = new string('x', 10 * 1024); // 10 KB (under limit) + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content, Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.False(exception.ContentTruncated); + Assert.Equal(10 * 1024, exception.Content.Length); + Assert.DoesNotContain("[truncated]", exception.Message); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_RedactsSensitiveHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("Unauthorized", Encoding.UTF8, "text/plain"), + }; + + response.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token-12345"); + response.Headers.TryAddWithoutValidation("Cookie", "session=abc123; user=john"); + response.Headers.TryAddWithoutValidation("Set-Cookie", "session=newvalue; Secure; HttpOnly"); + response.Headers.TryAddWithoutValidation("Proxy-Authorization", "Basic encoded-credentials"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("Authorization: [REDACTED]", exception.Headers); + Assert.Contains("Cookie: [REDACTED]", exception.Headers); + Assert.Contains("Set-Cookie: [REDACTED]", exception.Headers); + Assert.Contains("Proxy-Authorization: [REDACTED]", exception.Headers); + + Assert.DoesNotContain("secret-token-12345", exception.Headers); + Assert.DoesNotContain("session=abc123", exception.Headers); + Assert.DoesNotContain("session=newvalue", exception.Headers); + Assert.DoesNotContain("encoded-credentials", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_RedactsHeaders_CaseInsensitive() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("Forbidden", Encoding.UTF8, "text/plain"), + }; + + response.Headers.TryAddWithoutValidation("authorization", "Bearer lowercase-token"); + response.Headers.TryAddWithoutValidation("COOKIE", "session=uppercase"); + response.Headers.TryAddWithoutValidation("sEt-CoOkIe", "session=mixedcase"); + response.Headers.TryAddWithoutValidation("PROXY-AUTHORIZATION", "Basic proxy-creds"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert - HttpHeaders normalizes header names, so we just check that values are redacted + Assert.Contains("[REDACTED]", exception.Headers); + Assert.DoesNotContain("lowercase-token", exception.Headers); + Assert.DoesNotContain("session=uppercase", exception.Headers); + Assert.DoesNotContain("session=mixedcase", exception.Headers); + Assert.DoesNotContain("proxy-creds", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_PreservesNonSensitiveHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Success", Encoding.UTF8, "application/json"), + }; + + response.Headers.TryAddWithoutValidation("X-Correlation-Id", "correlation-123"); + response.Headers.TryAddWithoutValidation("X-Rate-Limit", "100"); + response.Headers.TryAddWithoutValidation("Cache-Control", "no-cache"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("X-Correlation-Id: correlation-123", exception.Headers); + Assert.Contains("X-Rate-Limit: 100", exception.Headers); + Assert.Contains("Cache-Control: no-cache", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_CapturesContentHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Test content", Encoding.UTF8, "application/json"), + }; + response.Content.Headers.Add("X-Custom-Content-Header", "content-value"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("Content-Headers:", exception.Headers); + Assert.Contains("X-Custom-Content-Header: content-value", exception.Headers); + Assert.Contains("Content-Type: application/json; charset=utf-8", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_CapturesTrailingHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Test", Encoding.UTF8, "text/plain"), + Version = new Version(2, 0), + }; + response.TrailingHeaders.TryAddWithoutValidation("X-Trailing-Header", "trailing-value"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("Trailing-Headers:", exception.Headers); + Assert.Contains("X-Trailing-Header: trailing-value", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_RedactsSensitiveTrailingHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Test", Encoding.UTF8, "text/plain"), + Version = new Version(2, 0), + }; + response.TrailingHeaders.TryAddWithoutValidation("Set-Cookie", "trailing-session=secret"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("Trailing-Headers:", exception.Headers); + Assert.Contains("Set-Cookie: [REDACTED]", exception.Headers); + Assert.DoesNotContain("trailing-session=secret", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_DisposesOriginalResponse() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Test", Encoding.UTF8, "text/plain"), + }; + + // Act + await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert - accessing disposed response should throw + await Assert.ThrowsAsync(async () => await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_InheritsFromPlatformHttpException() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("Bad request", Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.IsAssignableFrom(exception); + Assert.NotNull(exception.Response); + Assert.Equal(HttpStatusCode.BadRequest, exception.Response.StatusCode); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_MessageIncludesContentWhenPresent() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.NotFound) + { + ReasonPhrase = "Not Found", + Content = new StringContent("Resource not found", Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("404", exception.Message); + Assert.Contains("Not Found", exception.Message); + Assert.Contains("Resource not found", exception.Message); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_MessageOmitsContentWhenEmpty() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.NoContent) + { + ReasonPhrase = "No Content", + Content = new StringContent(string.Empty, Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("204", exception.Message); + Assert.Contains("No Content", exception.Message); + Assert.DoesNotContain(" - ", exception.Message); // Content separator not present + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesMultiValueHeaders() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Test", Encoding.UTF8, "text/plain"), + }; + response.Headers.TryAddWithoutValidation("Accept-Encoding", new[] { "gzip", "deflate", "br" }); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("Accept-Encoding: gzip, deflate, br", exception.Headers); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_ThrowsOnNullResponse() + { + // Act & Assert + await Assert.ThrowsAsync(async () => + await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(null) + ); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesNullReasonPhrase() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + ReasonPhrase = null, + Content = new StringContent("OK", Encoding.UTF8, "text/plain"), + }; + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert - HttpResponseMessage provides default reason phrase when null is set + Assert.NotNull(exception.ReasonPhrase); + Assert.Contains("200", exception.Message); + } +} From 5f17178c0957587e88987fcdb4dbabf513c43f47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 10 Oct 2025 11:50:25 +0200 Subject: [PATCH 29/44] PlatformHttpResponseSnapshotException: Snapshot content with bounded streaming to avoid loading large responses into memory --- .../PlatformHttpResponseSnapshotException.cs | 94 +++++++++++++++++-- ...tformHttpResponseSnapshotExceptionTests.cs | 37 ++++++++ 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index a47e3d794..142389915 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -69,16 +69,12 @@ public static async Task CreateAndDispose try { - // Snapshot content first (handle null) - string content = response.Content is null - ? string.Empty - : await response.Content.ReadAsStringAsync(cancellationToken); - - bool truncated = content.Length > MaxCapturedContentLength; - if (truncated) - { - content = content[..MaxCapturedContentLength]; - } + // Snapshot content with bounded streaming to avoid loading large responses into memory + (string content, bool truncated) = await ReadContentSnapshotAsync( + response.Content, + MaxCapturedContentLength, + cancellationToken + ); string headers = FlattenHeaders(response.Headers, response.Content?.Headers, response.TrailingHeaders); string message = BuildMessage((int)response.StatusCode, response.ReasonPhrase, content, truncated); @@ -162,6 +158,84 @@ string message ContentTruncated = contentTruncated; } + /// + /// Reads and snapshots the HTTP content in a streaming fashion, up to a maximum number of characters. + /// For binary content, returns a summary. For textual content, reads only the required amount to avoid unbounded buffering. + /// + /// The HTTP content to read, or null. + /// The maximum number of characters to capture. + /// A cancellation token to cancel the read operation. + /// A tuple containing the content snapshot and a flag indicating whether it was truncated. + private static async Task<(string content, bool truncated)> ReadContentSnapshotAsync( + HttpContent? httpContent, + int maxChars, + CancellationToken cancellationToken + ) + { + if (httpContent is null) + { + return (string.Empty, false); + } + + // Check if content is textual based on Content-Type + // Default to textual if no media type is specified (common for error responses) + string? mediaType = httpContent.Headers?.ContentType?.MediaType; + bool isTextual = + mediaType is null + || mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) + || mediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) + || mediaType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) + || mediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) + || mediaType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase); + + if (!isTextual) + { + // For binary content, return a summary instead of trying to read it as text + long? contentLength = httpContent.Headers?.ContentLength; + string lengthStr = contentLength.HasValue ? $"{contentLength.Value} bytes" : "unknown size"; + return ($"<{mediaType ?? "application/octet-stream"}; {lengthStr}>", false); + } + + // For textual content, stream with bounded buffer to avoid loading entire response into memory + using Stream stream = await httpContent.ReadAsStreamAsync(cancellationToken); + Encoding encoding = Encoding.UTF8; + string? charset = httpContent.Headers?.ContentType?.CharSet; + if (!string.IsNullOrWhiteSpace(charset)) + { + try + { + encoding = Encoding.GetEncoding(charset); + } + catch + { + // Fallback to UTF8 if charset is invalid + } + } + + using StreamReader reader = new StreamReader( + stream, + encoding, + detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, + leaveOpen: false + ); + + // Allocate buffer for exactly maxChars characters and read in loop to ensure buffer is filled + char[] buffer = new char[maxChars]; + int read = 0; + while (read < maxChars) + { + int n = await reader.ReadAsync(buffer.AsMemory(read, maxChars - read), cancellationToken); + if (n == 0) + break; + read += n; + } + + // Check if there's more content to determine truncation + bool hasMore = reader.Peek() != -1; + return (new string(buffer, 0, read), hasMore); + } + private static string BuildMessage(int statusCode, string? reason, string content, bool truncated) { StringBuilder sb = new StringBuilder().Append(statusCode).Append(' ').Append(reason ?? string.Empty); diff --git a/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs index 9e2f35172..08bd7d440 100644 --- a/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs +++ b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs @@ -332,6 +332,43 @@ await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(null) ); } + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesBinaryContent() + { + // Arrange + byte[] binaryData = new byte[1024]; + Array.Fill(binaryData, (byte)0xFF); + var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(binaryData) }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("", exception.Content); + Assert.False(exception.ContentTruncated); + } + + [Fact] + public async Task CreateAndDisposeHttpResponse_HandlesBinaryContentWithStreamContent() + { + // Arrange + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new MemoryStream(new byte[512])), + }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf"); + + // Act + var exception = await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response); + + // Assert + Assert.Contains("", exception.Content); + Assert.False(exception.ContentTruncated); + } + [Fact] public async Task CreateAndDisposeHttpResponse_HandlesNullReasonPhrase() { From cb044edc13b4063705bce3f026c849619b8cbd9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Mon, 13 Oct 2025 11:01:21 +0200 Subject: [PATCH 30/44] Various ai suggestions. --- .../PlatformHttpResponseSnapshotException.cs | 22 ++++++++++++++++--- ...tformHttpResponseSnapshotExceptionTests.cs | 3 +-- .../Clients/Storage/DataClientTests.cs | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 142389915..7434b4ff5 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -52,6 +52,8 @@ internal sealed class PlatformHttpResponseSnapshotException : PlatformHttpExcept /// public bool ContentTruncated { get; } + private const string Redacted = "[REDACTED]"; + /// /// Creates a new by snapshotting /// the provided into immutable string values, @@ -89,7 +91,14 @@ public static async Task CreateAndDispose // Copy normal headers foreach (KeyValuePair> h in response.Headers) { - safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); + if (_redactedHeaders.Contains(h.Key)) + { + safeResponse.Headers.TryAddWithoutValidation(h.Key, [Redacted]); + } + else + { + safeResponse.Headers.TryAddWithoutValidation(h.Key, h.Value); + } } // Attach a diagnostic snapshot body for legacy consumers @@ -100,7 +109,14 @@ public static async Task CreateAndDispose // Copy trailing headers if present (HTTP/2+) foreach (KeyValuePair> h in response.TrailingHeaders) { - safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value); + if (_redactedHeaders.Contains(h.Key)) + { + safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, [Redacted]); + } + else + { + safeResponse.TrailingHeaders.TryAddWithoutValidation(h.Key, h.Value); + } } return new PlatformHttpResponseSnapshotException( @@ -268,7 +284,7 @@ void Append(string prefix, IEnumerable> return; foreach ((string key, IEnumerable values) in headers) { - string display = _redactedHeaders.Contains(key) ? "[REDACTED]" : string.Join(", ", values); + string display = _redactedHeaders.Contains(key) ? Redacted : string.Join(", ", values); sb.Append(prefix).Append(": ").Append(key).Append(": ").AppendLine(display); } } diff --git a/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs index 08bd7d440..e1bd9dc54 100644 --- a/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs +++ b/test/Altinn.App.Core.Tests/Helpers/PlatformHttpResponseSnapshotExceptionTests.cs @@ -1,4 +1,3 @@ -#nullable disable using System.Net; using System.Text; using Altinn.App.Core.Helpers; @@ -328,7 +327,7 @@ public async Task CreateAndDisposeHttpResponse_ThrowsOnNullResponse() { // Act & Assert await Assert.ThrowsAsync(async () => - await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(null) + await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(null!) ); } diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index e74ef34f6..d074afdd0 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1152,6 +1152,7 @@ private sealed record Fixture : IAsyncDisposable public required DataClient DataClient { get; init; } public required ServiceProvider ServiceProvider { get; init; } public required FixtureMocks Mocks { get; init; } + public required HttpClient StreamingHttpClient { get; init; } public static Fixture Create( Func> dataClientDelegatingHandler, @@ -1200,6 +1201,7 @@ public static Fixture Create( Mocks = mocks, ServiceProvider = serviceProvider, DataClient = new DataClient(httpClient, serviceProvider), + StreamingHttpClient = streamingHttpClient, }; } @@ -1215,6 +1217,7 @@ public sealed record FixtureMocks public async ValueTask DisposeAsync() { await ServiceProvider.DisposeAsync(); + StreamingHttpClient.Dispose(); } } From 5239d47e9b20789c6be774d1f001ee1bc56b1205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Mon, 13 Oct 2025 11:38:55 +0200 Subject: [PATCH 31/44] More ai suggested disposing in DataClienTests.cs. --- .../Infrastructure/Clients/Storage/DataClientTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index d074afdd0..b088804e4 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1152,6 +1152,7 @@ private sealed record Fixture : IAsyncDisposable public required DataClient DataClient { get; init; } public required ServiceProvider ServiceProvider { get; init; } public required FixtureMocks Mocks { get; init; } + public required HttpClient BaseHttpClient { get; init; } public required HttpClient StreamingHttpClient { get; init; } public static Fixture Create( @@ -1201,6 +1202,7 @@ public static Fixture Create( Mocks = mocks, ServiceProvider = serviceProvider, DataClient = new DataClient(httpClient, serviceProvider), + BaseHttpClient = httpClient, StreamingHttpClient = streamingHttpClient, }; } @@ -1217,6 +1219,7 @@ public sealed record FixtureMocks public async ValueTask DisposeAsync() { await ServiceProvider.DisposeAsync(); + BaseHttpClient.Dispose(); StreamingHttpClient.Dispose(); } } From cba78aef5da7408f32db36085898cea26fc67352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Mon, 13 Oct 2025 12:22:36 +0200 Subject: [PATCH 32/44] Remove this unnecessary check for null. --- .../Helpers/PlatformHttpResponseSnapshotException.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs index 7434b4ff5..21eadcf85 100644 --- a/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs +++ b/src/Altinn.App.Core/Helpers/PlatformHttpResponseSnapshotException.cs @@ -209,7 +209,7 @@ mediaType is null // For binary content, return a summary instead of trying to read it as text long? contentLength = httpContent.Headers?.ContentLength; string lengthStr = contentLength.HasValue ? $"{contentLength.Value} bytes" : "unknown size"; - return ($"<{mediaType ?? "application/octet-stream"}; {lengthStr}>", false); + return ($"<{mediaType}; {lengthStr}>", false); } // For textual content, stream with bounded buffer to avoid loading entire response into memory From 841451fdda86e0d8e967163d05d8dc88d19e1763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 16 Oct 2025 14:35:33 +0200 Subject: [PATCH 33/44] Base timeout in DataClient on cancellation token instead of HttpClient.Timeout. --- .../Clients/Storage/DataClient.cs | 132 +++++++++++------- .../Internal/Data/IDataClient.cs | 2 + .../Mocks/DataClientMock.cs | 1 + .../Default/SignatureHashValidatorTests.cs | 5 + ...ouldNotChange_Unintentionally.verified.txt | 4 +- 5 files changed, 95 insertions(+), 49 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 364195b5f..8477eba98 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -34,9 +34,9 @@ public class DataClient : IDataClient private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly HttpClient _client; - private readonly HttpClient _streamingClient; private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); + private static readonly TimeSpan _defaultHttpOperationTimeout = TimeSpan.FromSeconds(100); /// /// Initializes a new data of the class. @@ -56,19 +56,8 @@ public DataClient(HttpClient httpClient, IServiceProvider serviceProvider) httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, _platformSettings.SubscriptionKey); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + httpClient.Timeout = Timeout.InfiniteTimeSpan; _client = httpClient; - - // Configure streaming client - var httpClientFactory = serviceProvider.GetRequiredService(); - _streamingClient = httpClientFactory.CreateClient("DataClient.Streaming"); - - _streamingClient.BaseAddress = new Uri(_platformSettings.ApiStorageEndpoint); - _streamingClient.DefaultRequestHeaders.Add( - General.SubscriptionKeyHeaderName, - _platformSettings.SubscriptionKey - ); - _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); } /// @@ -85,9 +74,12 @@ public async Task InsertFormData( ) where T : notnull { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartInsertFormDataActivity(instanceGuid, instanceOwnerPartyId); Instance instance = new() { Id = $"{instanceOwnerPartyId}/{instanceGuid}" }; - return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, cancellationToken); + return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, timeoutCts.Token); } /// @@ -101,12 +93,15 @@ public async Task InsertFormData( ) where T : notnull { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartInsertFormDataActivity(instance); string apiUrl = $"instances/{instance.Id}/data?dataType={dataTypeString}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); var application = await _appMetadata.GetApplicationMetadata(); @@ -122,7 +117,7 @@ public async Task InsertFormData( if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); + string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release var dataElement = JsonConvert.DeserializeObject(instanceData)!; @@ -152,13 +147,16 @@ public async Task UpdateData( ) where T : notnull { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartUpdateDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); //TODO: this method does not get enough information to know the content type from the DataType @@ -173,7 +171,7 @@ public async Task UpdateData( if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); + string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instanceData)!; return dataElement; @@ -193,20 +191,23 @@ public async Task GetBinaryData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); HttpResponseMessage response = await _client.GetAsync(token, apiUrl); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsStreamAsync(cancellationToken); + return await response.Content.ReadAsStreamAsync(timeoutCts.Token); } else if (response.StatusCode == HttpStatusCode.NotFound) { @@ -226,29 +227,33 @@ public async Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout ?? _defaultHttpOperationTimeout); + using Activity? activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); var instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; var apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); - HttpResponseMessage response = await _streamingClient.GetStreamingAsync( + HttpResponseMessage response = await _client.GetStreamingAsync( token, apiUrl, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); if (response.IsSuccessStatusCode) { try { - Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); + Stream stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token); return new ResponseWrapperStream(response, stream); } catch (Exception) @@ -260,7 +265,7 @@ public async Task GetBinaryDataStream( throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse( response, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); } @@ -276,19 +281,22 @@ public async Task GetFormData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartGetFormDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); HttpResponseMessage response = await _client.GetAsync(token, apiUrl); if (response.IsSuccessStatusCode) { - var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var bytes = await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); try { @@ -317,20 +325,23 @@ public async Task GetDataBytes( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); HttpResponseMessage response = await _client.GetAsync(token, apiUrl); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsByteArrayAsync(cancellationToken); + return await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); } throw await PlatformHttpException.CreateAsync(response); @@ -346,13 +357,16 @@ public async Task> GetBinaryDataList( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartGetBinaryDataListActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/dataelements"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); DataElementList dataList; @@ -361,7 +375,7 @@ public async Task> GetBinaryDataList( HttpResponseMessage response = await _client.GetAsync(token, apiUrl); if (response.StatusCode == HttpStatusCode.OK) { - string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); + string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); dataList = JsonConvert.DeserializeObject(instanceData) ?? throw new JsonException("Could not deserialize DataElementList"); @@ -436,13 +450,16 @@ public async Task DeleteData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartDeleteDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataGuid}?delay={delay}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl); @@ -470,6 +487,9 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = @@ -477,7 +497,7 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); StreamContent content = request.CreateContentStream(); @@ -485,7 +505,7 @@ public async Task InsertBinaryData( if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); + string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -510,6 +530,9 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceId); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceId}/data?dataType={dataType}"; if (!string.IsNullOrEmpty(generatedFromTask)) @@ -519,7 +542,7 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); StreamContent content = new(stream); @@ -537,7 +560,7 @@ public async Task InsertBinaryData( if (response.IsSuccessStatusCode) { - string dataElementString = await response.Content.ReadAsStringAsync(cancellationToken); + string dataElementString = await response.Content.ReadAsStringAsync(timeoutCts.Token); var dataElement = JsonConvert.DeserializeObject(dataElementString); if (dataElement is not null) @@ -545,7 +568,7 @@ public async Task InsertBinaryData( } _logger.LogError( - $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(cancellationToken)}" + $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(timeoutCts.Token)}" ); throw await PlatformHttpException.CreateAsync(response); } @@ -562,13 +585,16 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); StreamContent content = request.CreateContentStream(); @@ -577,7 +603,7 @@ public async Task UpdateBinaryData( if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); + string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -601,12 +627,15 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceIdentifier.GetInstanceId()); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); StreamContent content = new(stream); @@ -625,7 +654,7 @@ public async Task UpdateBinaryData( _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); + string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -642,12 +671,15 @@ public async Task Update( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartUpdateDataActivity(instance); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instance.Id}/dataelements/{dataElement.Id}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); @@ -657,7 +689,7 @@ public async Task Update( { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cancellationToken) + await response.Content.ReadAsStringAsync(timeoutCts.Token) )!; return result; @@ -674,12 +706,15 @@ public async Task LockDataElement( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartLockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); _logger.LogDebug( @@ -693,7 +728,7 @@ public async Task LockDataElement( { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cancellationToken) + await response.Content.ReadAsStringAsync(timeoutCts.Token) )!; return result; } @@ -714,12 +749,15 @@ public async Task UnlockDataElement( CancellationToken cancellationToken = default ) { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + using var activity = _telemetry?.StartUnlockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cancellationToken + cancellationToken: timeoutCts.Token ); _logger.LogDebug( @@ -733,7 +771,7 @@ public async Task UnlockDataElement( { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cancellationToken) + await response.Content.ReadAsStringAsync(timeoutCts.Token) )!; return result; } diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index 36427cae1..e1ed4747b 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -137,6 +137,7 @@ Task GetBinaryData( /// The instance id /// the data id /// An optional specification of the authentication method to use for requests + /// Optional timeout for the operation. Defaults to 100 seconds if not specified. /// An optional cancellation token /// Thrown when the data element is not found or other HTTP errors occur Task GetBinaryDataStream( @@ -146,6 +147,7 @@ Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default ); diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index e1e1e9e69..c09c5e234 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -115,6 +115,7 @@ public Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, + TimeSpan? timeout = null, CancellationToken cancellationToken = default ) { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index a5659fd31..cf730a29f 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -120,6 +120,7 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() It.IsAny(), It.IsAny(), It.Is(auth => auth == StorageAuthenticationMethod.ServiceOwner()), + It.IsAny(), It.IsAny() ), Times.Once @@ -151,6 +152,7 @@ public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerA It.IsAny(), It.IsAny(), null, + It.IsAny(), It.IsAny() ), Times.Once @@ -208,6 +210,7 @@ public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny() ), Times.Exactly(2) @@ -249,6 +252,7 @@ public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation( It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny() ), Times.Never @@ -395,6 +399,7 @@ string dataElementStringContent It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny() ) ) 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 f58e15052..f00132076 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 @@ -2447,7 +2447,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage public System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } - public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.TimeSpan? timeout = default, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -2928,7 +2928,7 @@ namespace Altinn.App.Core.Internal.Data System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.TimeSpan? timeout = default, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); From c15edb517c073bd287d007bfb8c52ae0ec2132b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 16 Oct 2025 14:41:23 +0200 Subject: [PATCH 34/44] Remove unused streaming http client. --- .../Extensions/ServiceCollectionExtensions.cs | 7 ------- .../Infrastructure/Clients/Storage/DataClientTests.cs | 4 ---- 2 files changed, 11 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index fb66c7cc0..693c1a62a 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -101,13 +101,6 @@ IWebHostEnvironment env services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); - services.AddHttpClient( - "DataClient.Streaming", - client => - { - client.Timeout = TimeSpan.FromMinutes(30); // Longer timeout for streaming - } - ); services.AddHttpClient(); services.AddHttpClient(); services.AddHttpClient(); diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index b088804e4..b00c8736c 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1153,7 +1153,6 @@ private sealed record Fixture : IAsyncDisposable public required ServiceProvider ServiceProvider { get; init; } public required FixtureMocks Mocks { get; init; } public required HttpClient BaseHttpClient { get; init; } - public required HttpClient StreamingHttpClient { get; init; } public static Fixture Create( Func> dataClientDelegatingHandler, @@ -1172,7 +1171,6 @@ public static Fixture Create( // Setup HttpClientFactory mock to return a streaming client DelegatingHandlerStub streamingDelegatingHandler = new(dataClientDelegatingHandler); HttpClient streamingHttpClient = new(streamingDelegatingHandler) { Timeout = TimeSpan.FromMinutes(30) }; - mocks.HttpClientFactoryMock.Setup(x => x.CreateClient("DataClient.Streaming")).Returns(streamingHttpClient); var services = new ServiceCollection(); services.Configure(options => options.ApiStorageEndpoint = ApiStorageEndpoint); @@ -1203,7 +1201,6 @@ public static Fixture Create( ServiceProvider = serviceProvider, DataClient = new DataClient(httpClient, serviceProvider), BaseHttpClient = httpClient, - StreamingHttpClient = streamingHttpClient, }; } @@ -1220,7 +1217,6 @@ public async ValueTask DisposeAsync() { await ServiceProvider.DisposeAsync(); BaseHttpClient.Dispose(); - StreamingHttpClient.Dispose(); } } From 412ad8e847631acd27a37f25c43c7e75517c15d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Thu, 16 Oct 2025 14:46:30 +0200 Subject: [PATCH 35/44] remove more streaming client stuff --- .../Infrastructure/Clients/Storage/DataClientTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index b00c8736c..ee0da3d42 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1168,10 +1168,6 @@ public static Fixture Create( ) .ReturnsAsync(_testTokens.ServiceOwnerToken); - // Setup HttpClientFactory mock to return a streaming client - DelegatingHandlerStub streamingDelegatingHandler = new(dataClientDelegatingHandler); - HttpClient streamingHttpClient = new(streamingDelegatingHandler) { Timeout = TimeSpan.FromMinutes(30) }; - var services = new ServiceCollection(); services.Configure(options => options.ApiStorageEndpoint = ApiStorageEndpoint); services.Configure(options => options.HostName = "tt02.altinn.no"); From 6370f20ad7d7a7652f26f9625cd3035bce724d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 17 Oct 2025 08:54:07 +0200 Subject: [PATCH 36/44] Set hard coded max of 30 minute timeout on streaming. --- .../Clients/Storage/DataClient.cs | 36 +++++++++---------- .../Internal/Data/IDataClient.cs | 2 -- .../Mocks/DataClientMock.cs | 1 - .../Default/SignatureHashValidatorTests.cs | 5 --- ...ouldNotChange_Unintentionally.verified.txt | 4 +-- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 8477eba98..577f78fb3 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -36,7 +36,8 @@ public class DataClient : IDataClient private readonly HttpClient _client; private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); - private static readonly TimeSpan _defaultHttpOperationTimeout = TimeSpan.FromSeconds(100); + private static readonly TimeSpan _httpOperationTimeout = TimeSpan.FromSeconds(100); + private static readonly TimeSpan _streamingHttpOperationTimeout = TimeSpan.FromMinutes(30); /// /// Initializes a new data of the class. @@ -75,7 +76,7 @@ public async Task InsertFormData( where T : notnull { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartInsertFormDataActivity(instanceGuid, instanceOwnerPartyId); Instance instance = new() { Id = $"{instanceOwnerPartyId}/{instanceGuid}" }; @@ -94,7 +95,7 @@ public async Task InsertFormData( where T : notnull { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartInsertFormDataActivity(instance); string apiUrl = $"instances/{instance.Id}/data?dataType={dataTypeString}"; @@ -148,7 +149,7 @@ public async Task UpdateData( where T : notnull { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -192,7 +193,7 @@ public async Task GetBinaryData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -227,12 +228,11 @@ public async Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, - TimeSpan? timeout = null, CancellationToken cancellationToken = default ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(timeout ?? _defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_streamingHttpOperationTimeout); using Activity? activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); var instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -282,7 +282,7 @@ public async Task GetFormData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartGetFormDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -326,7 +326,7 @@ public async Task GetDataBytes( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -358,7 +358,7 @@ public async Task> GetBinaryDataList( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataListActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -451,7 +451,7 @@ public async Task DeleteData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartDeleteDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -488,7 +488,7 @@ public async Task InsertBinaryData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -531,7 +531,7 @@ public async Task InsertBinaryData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceId); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceId}/data?dataType={dataType}"; @@ -586,7 +586,7 @@ public async Task UpdateBinaryData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -628,7 +628,7 @@ public async Task UpdateBinaryData( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceIdentifier.GetInstanceId()); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; @@ -672,7 +672,7 @@ public async Task Update( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateDataActivity(instance); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instance.Id}/dataelements/{dataElement.Id}"; @@ -707,7 +707,7 @@ public async Task LockDataElement( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartLockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; @@ -750,7 +750,7 @@ public async Task UnlockDataElement( ) { using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_defaultHttpOperationTimeout); + timeoutCts.CancelAfter(_httpOperationTimeout); using var activity = _telemetry?.StartUnlockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index e1ed4747b..36427cae1 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -137,7 +137,6 @@ Task GetBinaryData( /// The instance id /// the data id /// An optional specification of the authentication method to use for requests - /// Optional timeout for the operation. Defaults to 100 seconds if not specified. /// An optional cancellation token /// Thrown when the data element is not found or other HTTP errors occur Task GetBinaryDataStream( @@ -147,7 +146,6 @@ Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, - TimeSpan? timeout = null, CancellationToken cancellationToken = default ); diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index c09c5e234..e1e1e9e69 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -115,7 +115,6 @@ public Task GetBinaryDataStream( Guid instanceGuid, Guid dataId, StorageAuthenticationMethod? authenticationMethod = null, - TimeSpan? timeout = null, CancellationToken cancellationToken = default ) { diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index cf730a29f..a5659fd31 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -120,7 +120,6 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() It.IsAny(), It.IsAny(), It.Is(auth => auth == StorageAuthenticationMethod.ServiceOwner()), - It.IsAny(), It.IsAny() ), Times.Once @@ -152,7 +151,6 @@ public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerA It.IsAny(), It.IsAny(), null, - It.IsAny(), It.IsAny() ), Times.Once @@ -210,7 +208,6 @@ public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny() ), Times.Exactly(2) @@ -252,7 +249,6 @@ public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny() ), Times.Never @@ -399,7 +395,6 @@ string dataElementStringContent It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny() ) ) 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 f00132076..f58e15052 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 @@ -2447,7 +2447,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage public System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } - public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.TimeSpan? timeout = default, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -2928,7 +2928,7 @@ namespace Altinn.App.Core.Internal.Data System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.TimeSpan? timeout = default, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); From 7018d575ff22c910b5ee647ec30119c64bfe8f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 17 Oct 2025 09:26:23 +0200 Subject: [PATCH 37/44] Add cancellation token to methods in HttpClientExtension. --- .../Extensions/HttpClientExtension.cs | 24 ++++--- .../Clients/Storage/DataClient.cs | 68 +++++++++++++++---- ...ouldNotChange_Unintentionally.verified.txt | 8 +-- 3 files changed, 74 insertions(+), 26 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs index 80153ffb6..d6ff3aeca 100644 --- a/src/Altinn.App.Core/Extensions/HttpClientExtension.cs +++ b/src/Altinn.App.Core/Extensions/HttpClientExtension.cs @@ -15,13 +15,15 @@ public static class HttpClientExtension /// The request Uri /// The http content /// The platformAccess tokens + /// The cancellation token /// A HttpResponseMessage public static async Task PostAsync( this HttpClient httpClient, string authorizationToken, string requestUri, HttpContent? content, - string? platformAccessToken = null + string? platformAccessToken = null, + CancellationToken cancellationToken = default ) { using HttpRequestMessage request = new(HttpMethod.Post, requestUri); @@ -37,7 +39,7 @@ public static async Task PostAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync(request, CancellationToken.None); + return await httpClient.SendAsync(request, cancellationToken); } /// @@ -48,13 +50,15 @@ public static async Task PostAsync( /// The request Uri /// The http content /// The platformAccess tokens + /// The cancellation token /// A HttpResponseMessage public static async Task PutAsync( this HttpClient httpClient, string authorizationToken, string requestUri, HttpContent? content, - string? platformAccessToken = null + string? platformAccessToken = null, + CancellationToken cancellationToken = default ) { using HttpRequestMessage request = new(HttpMethod.Put, requestUri); @@ -70,7 +74,7 @@ public static async Task PutAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync(request, CancellationToken.None); + return await httpClient.SendAsync(request, cancellationToken); } /// @@ -80,12 +84,14 @@ public static async Task PutAsync( /// the authorization token (jwt) /// The request Uri /// The platformAccess tokens + /// The cancellation token /// A HttpResponseMessage public static async Task GetAsync( this HttpClient httpClient, string authorizationToken, string requestUri, - string? platformAccessToken = null + string? platformAccessToken = null, + CancellationToken cancellationToken = default ) { using HttpRequestMessage request = new(HttpMethod.Get, requestUri); @@ -100,7 +106,7 @@ public static async Task GetAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None); + return await httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken); } /// @@ -145,12 +151,14 @@ public static async Task GetStreamingAsync( /// the authorization token (jwt) /// The request Uri /// The platformAccess tokens + /// The cancellation token /// A HttpResponseMessage public static async Task DeleteAsync( this HttpClient httpClient, string authorizationToken, string requestUri, - string? platformAccessToken = null + string? platformAccessToken = null, + CancellationToken cancellationToken = default ) { using HttpRequestMessage request = new(HttpMethod.Delete, requestUri); @@ -165,6 +173,6 @@ public static async Task DeleteAsync( request.Headers.Add(Constants.General.PlatformAccessTokenHeaderName, platformAccessToken); } - return await httpClient.SendAsync(request, CancellationToken.None); + return await httpClient.SendAsync(request, cancellationToken); } } diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 577f78fb3..c371baea0 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -114,7 +114,12 @@ public async Task InsertFormData( StreamContent streamContent = new(new MemoryAsStream(data)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, streamContent); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + streamContent, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -168,7 +173,12 @@ public async Task UpdateData( StreamContent streamContent = new(new MemoryAsStream(serializedBytes)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, streamContent); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + streamContent, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -204,7 +214,7 @@ public async Task GetBinaryData( cancellationToken: timeoutCts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.IsSuccessStatusCode) { @@ -293,7 +303,7 @@ public async Task GetFormData( cancellationToken: timeoutCts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.IsSuccessStatusCode) { var bytes = await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); @@ -337,7 +347,7 @@ public async Task GetDataBytes( cancellationToken: timeoutCts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.IsSuccessStatusCode) { @@ -372,7 +382,7 @@ public async Task> GetBinaryDataList( DataElementList dataList; List attachmentList = []; - HttpResponseMessage response = await _client.GetAsync(token, apiUrl); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.StatusCode == HttpStatusCode.OK) { string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); @@ -462,7 +472,7 @@ public async Task DeleteData( cancellationToken: timeoutCts.Token ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.IsSuccessStatusCode) { @@ -501,7 +511,12 @@ public async Task InsertBinaryData( ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -556,7 +571,12 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -599,7 +619,12 @@ public async Task UpdateBinaryData( StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -650,7 +675,12 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + cancellationToken: timeoutCts.Token + ); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { @@ -683,7 +713,12 @@ public async Task Update( ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + jsonString, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { @@ -723,7 +758,12 @@ public async Task LockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content: null); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content: null, + cancellationToken: timeoutCts.Token + ); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release @@ -766,7 +806,7 @@ public async Task UnlockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: timeoutCts.Token); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release 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 f58e15052..aed71b1da 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 @@ -230,11 +230,11 @@ namespace Altinn.App.Core.Extensions } public static class HttpClientExtension { - public static System.Threading.Tasks.Task DeleteAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } - public static System.Threading.Tasks.Task GetAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, string? platformAccessToken = null) { } + 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 PostAsync(this System.Net.Http.HttpClient httpClient, string authorizationToken, string requestUri, System.Net.Http.HttpContent? content, string? platformAccessToken = null) { } - 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) { } + 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) { } } public static class HttpContextExtensions { From ce1bceb0a35cbb18e1a2f7d0fd6bab64db056eca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Fri, 17 Oct 2025 11:59:02 +0200 Subject: [PATCH 38/44] Use the http context cancellation token in SignatureHashValidator. --- .../Validation/Default/SignatureHashValidator.cs | 15 +++++++++++---- .../Default/SignatureHashValidatorTests.cs | 2 ++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index 3bdd4f427..2d757ea01 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -9,6 +9,7 @@ using Altinn.App.Core.Models; using Altinn.App.Core.Models.Validation; using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; namespace Altinn.App.Core.Features.Validation.Default; @@ -21,6 +22,7 @@ internal sealed class SignatureHashValidator( IProcessReader processReader, IDataClient dataClient, IAppMetadata appMetadata, + IHttpContextAccessor httpContextAccessor, ILogger logger ) : IValidator { @@ -56,6 +58,8 @@ public async Task> Validate( string? language ) { + CancellationToken cancellationToken = httpContextAccessor.HttpContext?.RequestAborted ?? CancellationToken.None; + Instance instance = dataAccessor.Instance; AltinnSignatureConfiguration signingConfiguration = @@ -67,7 +71,7 @@ public async Task> Validate( List signeeContextsResults = await signingService.GetSigneeContexts( dataAccessor, signingConfiguration, - CancellationToken.None + cancellationToken ); foreach (SigneeContext signeeContext in signeeContextsResults) @@ -80,7 +84,8 @@ public async Task> Validate( ValidationIssue? validationIssue = await ValidateDataElementSignature( dataElementSignature, instance, - applicationMetadata + applicationMetadata, + cancellationToken ); if (validationIssue != null) @@ -98,7 +103,8 @@ public async Task> Validate( private async Task ValidateDataElementSignature( SignDocument.DataElementSignature dataElementSignature, Instance instance, - ApplicationMetadata applicationMetadata + ApplicationMetadata applicationMetadata, + CancellationToken cancellationToken ) { var instanceIdentifier = new InstanceIdentifier(instance); @@ -111,7 +117,8 @@ ApplicationMetadata applicationMetadata Guid.Parse(dataElementSignature.DataElementId), HasRestrictedRead(applicationMetadata, instance, dataElementSignature.DataElementId) ? StorageAuthenticationMethod.ServiceOwner() - : null + : null, + cancellationToken ); string sha256Hash = await GenerateSha256Hash(dataStream); diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index a5659fd31..adf1fb5d7 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -12,6 +12,7 @@ using Altinn.App.Core.Models.Validation; using Altinn.Platform.Register.Models; using Altinn.Platform.Storage.Interface.Models; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Moq; using CoreSignee = Altinn.App.Core.Features.Signing.Models.Signee; @@ -34,6 +35,7 @@ public SignatureHashValidatorTests() _processReaderMock.Object, _dataClientMock.Object, _appMetadataMock.Object, + new Mock().Object, new Mock>().Object ); From 9fa862454159cc88fd7a52a8074c949ec71ad7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Mon, 20 Oct 2025 16:00:27 +0200 Subject: [PATCH 39/44] Remove unused org and app params from IDataCient.GetBinaryDataStream. --- .../Default/SignatureHashValidator.cs | 2 -- .../Clients/Storage/DataClient.cs | 2 -- .../Internal/Data/IDataClient.cs | 4 ---- .../Mocks/DataClientMock.cs | 6 ++++-- .../Default/SignatureHashValidatorTests.cs | 19 +------------------ .../Clients/Storage/DataClientTests.cs | 6 ------ ...ouldNotChange_Unintentionally.verified.txt | 4 ++-- 7 files changed, 7 insertions(+), 36 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index 2d757ea01..145163a63 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -110,8 +110,6 @@ CancellationToken cancellationToken var instanceIdentifier = new InstanceIdentifier(instance); await using Stream dataStream = await dataClient.GetBinaryDataStream( - instance.Org, - instance.AppId, instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid, Guid.Parse(dataElementSignature.DataElementId), diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index c371baea0..9998b7b5e 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -232,8 +232,6 @@ public async Task GetBinaryData( /// public async Task GetBinaryDataStream( - string org, - string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId, diff --git a/src/Altinn.App.Core/Internal/Data/IDataClient.cs b/src/Altinn.App.Core/Internal/Data/IDataClient.cs index 36427cae1..8d6d9ad4d 100644 --- a/src/Altinn.App.Core/Internal/Data/IDataClient.cs +++ b/src/Altinn.App.Core/Internal/Data/IDataClient.cs @@ -131,8 +131,6 @@ Task GetBinaryData( /// Gets the data as an unbuffered stream for memory-efficient processing of large files. /// Throws if the data element is not found or other HTTP errors occur. /// - /// Unique identifier of the organisation responsible for the app. - /// Application identifier which is unique within an organisation. /// The instance owner id /// The instance id /// the data id @@ -140,8 +138,6 @@ Task GetBinaryData( /// An optional cancellation token /// Thrown when the data element is not found or other HTTP errors occur Task GetBinaryDataStream( - string org, - string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId, diff --git a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs index e1e1e9e69..595871839 100644 --- a/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs +++ b/test/Altinn.App.Api.Tests/Mocks/DataClientMock.cs @@ -109,8 +109,6 @@ await GetDataBytes( } public Task GetBinaryDataStream( - string org, - string app, int instanceOwnerPartyId, Guid instanceGuid, Guid dataId, @@ -121,6 +119,10 @@ public Task GetBinaryDataStream( if (cancellationToken.IsCancellationRequested) return Task.FromCanceled(cancellationToken); + (string org, string app) = TestData.GetInstanceOrgApp( + new InstanceIdentifier(instanceOwnerPartyId, instanceGuid) + ); + string path = TestData.GetDataBlobPath(org, app, instanceOwnerPartyId, instanceGuid, dataId); if (!File.Exists(path)) diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index adf1fb5d7..c1648b740 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -116,8 +116,6 @@ public async Task Validate_WithRestrictedReadDataType_UsesServiceOwnerAuth() _dataClientMock.Verify( x => x.GetBinaryDataStream( - "testorg", - "testapp", 12345, It.IsAny(), It.IsAny(), @@ -145,16 +143,7 @@ public async Task Validate_WithNonRestrictedReadDataType_DoesNotUseServiceOwnerA await _validator.Validate(_dataAccessorMock.Object, "signing-task", "en"); _dataClientMock.Verify( - x => - x.GetBinaryDataStream( - "testorg", - "testapp", - 12345, - It.IsAny(), - It.IsAny(), - null, - It.IsAny() - ), + x => x.GetBinaryDataStream(12345, It.IsAny(), It.IsAny(), null, It.IsAny()), Times.Once ); } @@ -204,8 +193,6 @@ public async Task Validate_WithMultipleSigneeContexts_ValidatesAllSignatures() _dataClientMock.Verify( x => x.GetBinaryDataStream( - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -245,8 +232,6 @@ public async Task Validate_WithSigneeContextWithoutSignDocument_SkipsValidation( _dataClientMock.Verify( x => x.GetBinaryDataStream( - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), @@ -391,8 +376,6 @@ string dataElementStringContent _dataClientMock .Setup(x => x.GetBinaryDataStream( - It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index ee0da3d42..71d7b9112 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -508,8 +508,6 @@ public async Task GetBinaryDataStream_returns_stream_of_binary_data_with_unbuffe ); await using var response = await fixture.DataClient.GetBinaryDataStream( - "ttd", - "app", instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid, dataGuid, @@ -554,8 +552,6 @@ public async Task GetBinaryDataStream_throws_PlatformHttpException_when_data_not var actual = await Assert.ThrowsAsync(async () => await fixture.DataClient.GetBinaryDataStream( - "ttd", - "app", instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid, dataGuid, @@ -589,8 +585,6 @@ public async Task GetBinaryDataStream_throws_PlatformHttpException_when_server_e var actual = await Assert.ThrowsAsync(async () => await fixture.DataClient.GetBinaryDataStream( - "ttd", - "app", instanceIdentifier.InstanceOwnerPartyId, instanceIdentifier.InstanceGuid, dataGuid 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 eb8310d17..ca6488f5b 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 @@ -2448,7 +2448,7 @@ namespace Altinn.App.Core.Infrastructure.Clients.Storage public System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } - public System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } + public System.Threading.Tasks.Task GetBinaryDataStream(int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } public System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default) { } @@ -2930,7 +2930,7 @@ namespace Altinn.App.Core.Internal.Data System.Threading.Tasks.Task DeleteData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataGuid, bool delay, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetBinaryData(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task> GetBinaryDataList(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); - System.Threading.Tasks.Task GetBinaryDataStream(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); + System.Threading.Tasks.Task GetBinaryDataStream(int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetDataBytes(string org, string app, int instanceOwnerPartyId, System.Guid instanceGuid, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task GetFormData(System.Guid instanceGuid, System.Type type, string org, string app, int instanceOwnerPartyId, System.Guid dataId, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); System.Threading.Tasks.Task InsertBinaryData(string instanceId, string dataType, string contentType, string? filename, System.IO.Stream stream, string? generatedFromTask = null, Altinn.App.Core.Features.StorageAuthenticationMethod? authenticationMethod = null, System.Threading.CancellationToken cancellationToken = default); From 9fa048dd70f96525bdd3f2259bb33efedab68c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 21 Oct 2025 12:49:35 +0200 Subject: [PATCH 40/44] Make cancellation token pattern in DataClient slightly less noisy by using an extension method. --- .../Extensions/CancellationTokenExtensions.cs | 23 +++ .../Clients/Storage/DataClient.cs | 171 +++++++----------- 2 files changed, 86 insertions(+), 108 deletions(-) create mode 100644 src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs diff --git a/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs b/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs new file mode 100644 index 000000000..10396600d --- /dev/null +++ b/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs @@ -0,0 +1,23 @@ +namespace Altinn.App.Core.Extensions; + +internal static class CancellationTokenExtensions +{ + /// + /// Creates a linked cancellation token source that will be canceled after the specified timeout or when the original token is canceled. + /// + /// The cancellation token to link with the timeout. + /// The timeout duration. + /// A CancellationTokenSource that must be disposed to clean up resources. + /// + /// + /// using var cts = cancellationToken.WithTimeout(TimeSpan.FromSeconds(30)); + /// await SomeOperationAsync(cts.Token); + /// + /// + public static CancellationTokenSource WithTimeout(this CancellationToken cancellationToken, TimeSpan timeout) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(timeout); + return cts; + } +} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 9998b7b5e..f04bc1660 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -75,12 +75,11 @@ public async Task InsertFormData( ) where T : notnull { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartInsertFormDataActivity(instanceGuid, instanceOwnerPartyId); Instance instance = new() { Id = $"{instanceOwnerPartyId}/{instanceGuid}" }; - return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, timeoutCts.Token); + return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, cts.Token); } /// @@ -94,15 +93,14 @@ public async Task InsertFormData( ) where T : notnull { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartInsertFormDataActivity(instance); string apiUrl = $"instances/{instance.Id}/data?dataType={dataTypeString}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); var application = await _appMetadata.GetApplicationMetadata(); @@ -118,12 +116,12 @@ public async Task InsertFormData( token, apiUrl, streamContent, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release var dataElement = JsonConvert.DeserializeObject(instanceData)!; @@ -153,8 +151,7 @@ public async Task UpdateData( ) where T : notnull { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -162,7 +159,7 @@ public async Task UpdateData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); //TODO: this method does not get enough information to know the content type from the DataType @@ -177,12 +174,12 @@ public async Task UpdateData( token, apiUrl, streamContent, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instanceData)!; return dataElement; @@ -202,8 +199,7 @@ public async Task GetBinaryData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -211,14 +207,14 @@ public async Task GetBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsStreamAsync(timeoutCts.Token); + return await response.Content.ReadAsStreamAsync(cts.Token); } else if (response.StatusCode == HttpStatusCode.NotFound) { @@ -239,8 +235,7 @@ public async Task GetBinaryDataStream( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_streamingHttpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_streamingHttpOperationTimeout); using Activity? activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); var instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -248,20 +243,16 @@ public async Task GetBinaryDataStream( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.GetStreamingAsync( - token, - apiUrl, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.GetStreamingAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { try { - Stream stream = await response.Content.ReadAsStreamAsync(timeoutCts.Token); + Stream stream = await response.Content.ReadAsStreamAsync(cts.Token); return new ResponseWrapperStream(response, stream); } catch (Exception) @@ -273,7 +264,7 @@ public async Task GetBinaryDataStream( throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse( response, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); } @@ -289,8 +280,7 @@ public async Task GetFormData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartGetFormDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -298,13 +288,13 @@ public async Task GetFormData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - var bytes = await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); + var bytes = await response.Content.ReadAsByteArrayAsync(cts.Token); try { @@ -333,8 +323,7 @@ public async Task GetDataBytes( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -342,14 +331,14 @@ public async Task GetDataBytes( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsByteArrayAsync(timeoutCts.Token); + return await response.Content.ReadAsByteArrayAsync(cts.Token); } throw await PlatformHttpException.CreateAsync(response); @@ -365,8 +354,7 @@ public async Task> GetBinaryDataList( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartGetBinaryDataListActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -374,16 +362,16 @@ public async Task> GetBinaryDataList( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); DataElementList dataList; List attachmentList = []; - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); if (response.StatusCode == HttpStatusCode.OK) { - string instanceData = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cts.Token); dataList = JsonConvert.DeserializeObject(instanceData) ?? throw new JsonException("Could not deserialize DataElementList"); @@ -458,8 +446,7 @@ public async Task DeleteData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartDeleteDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -467,10 +454,10 @@ public async Task DeleteData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { @@ -495,8 +482,7 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -505,20 +491,15 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PostAsync( - token, - apiUrl, - content, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -543,8 +524,7 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceId); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceId}/data?dataType={dataType}"; @@ -555,7 +535,7 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); StreamContent content = new(stream); @@ -569,16 +549,11 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = await _client.PostAsync( - token, - apiUrl, - content, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - string dataElementString = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string dataElementString = await response.Content.ReadAsStringAsync(cts.Token); var dataElement = JsonConvert.DeserializeObject(dataElementString); if (dataElement is not null) @@ -586,7 +561,7 @@ public async Task InsertBinaryData( } _logger.LogError( - $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(timeoutCts.Token)}" + $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(cts.Token)}" ); throw await PlatformHttpException.CreateAsync(response); } @@ -603,8 +578,7 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; @@ -612,21 +586,16 @@ public async Task UpdateBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PutAsync( - token, - apiUrl, - content, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -650,15 +619,14 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceIdentifier.GetInstanceId()); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); StreamContent content = new(stream); @@ -673,16 +641,11 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = await _client.PutAsync( - token, - apiUrl, - content, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(timeoutCts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cts.Token); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -699,30 +662,24 @@ public async Task Update( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartUpdateDataActivity(instance); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instance.Id}/dataelements/{dataElement.Id}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _client.PutAsync( - token, - apiUrl, - jsonString, - cancellationToken: timeoutCts.Token - ); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(timeoutCts.Token) + await response.Content.ReadAsStringAsync(cts.Token) )!; return result; @@ -739,15 +696,14 @@ public async Task LockDataElement( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartLockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); _logger.LogDebug( @@ -760,13 +716,13 @@ public async Task LockDataElement( token, apiUrl, content: null, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(timeoutCts.Token) + await response.Content.ReadAsStringAsync(cts.Token) )!; return result; } @@ -787,15 +743,14 @@ public async Task UnlockDataElement( CancellationToken cancellationToken = default ) { - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutCts.CancelAfter(_httpOperationTimeout); + using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); using var activity = _telemetry?.StartUnlockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: timeoutCts.Token + cancellationToken: cts.Token ); _logger.LogDebug( @@ -804,12 +759,12 @@ public async Task UnlockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: timeoutCts.Token); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(timeoutCts.Token) + await response.Content.ReadAsStringAsync(cts.Token) )!; return result; } From dbc85c4fadb52d3e5e8820619d2c452a08d6cac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 21 Oct 2025 17:13:56 +0200 Subject: [PATCH 41/44] Revert to having a dedicated streaming http client to avoid merge conflicts. Might consider using cts for cancellation later. --- .../Extensions/CancellationTokenExtensions.cs | 23 --- .../Clients/Storage/DataClient.cs | 155 +++++++----------- .../Clients/Storage/DataClientTests.cs | 8 + 3 files changed, 69 insertions(+), 117 deletions(-) delete mode 100644 src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs diff --git a/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs b/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs deleted file mode 100644 index 10396600d..000000000 --- a/src/Altinn.App.Core/Extensions/CancellationTokenExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Altinn.App.Core.Extensions; - -internal static class CancellationTokenExtensions -{ - /// - /// Creates a linked cancellation token source that will be canceled after the specified timeout or when the original token is canceled. - /// - /// The cancellation token to link with the timeout. - /// The timeout duration. - /// A CancellationTokenSource that must be disposed to clean up resources. - /// - /// - /// using var cts = cancellationToken.WithTimeout(TimeSpan.FromSeconds(30)); - /// await SomeOperationAsync(cts.Token); - /// - /// - public static CancellationTokenSource WithTimeout(this CancellationToken cancellationToken, TimeSpan timeout) - { - var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(timeout); - return cts; - } -} diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index f04bc1660..f09091faf 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -34,9 +34,9 @@ public class DataClient : IDataClient private readonly ModelSerializationService _modelSerializationService; private readonly Telemetry? _telemetry; private readonly HttpClient _client; + private readonly HttpClient _streamingClient; private readonly AuthenticationMethod _defaultAuthenticationMethod = StorageAuthenticationMethod.CurrentUser(); - private static readonly TimeSpan _httpOperationTimeout = TimeSpan.FromSeconds(100); private static readonly TimeSpan _streamingHttpOperationTimeout = TimeSpan.FromMinutes(30); /// @@ -52,13 +52,24 @@ public DataClient(HttpClient httpClient, IServiceProvider serviceProvider) _platformSettings = serviceProvider.GetRequiredService>().Value; _logger = serviceProvider.GetRequiredService>(); _telemetry = serviceProvider.GetService(); + var httpClientFactory = serviceProvider.GetRequiredService(); httpClient.BaseAddress = new Uri(_platformSettings.ApiStorageEndpoint); httpClient.DefaultRequestHeaders.Add(General.SubscriptionKeyHeaderName, _platformSettings.SubscriptionKey); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); - httpClient.Timeout = Timeout.InfiniteTimeSpan; _client = httpClient; + + // Create dedicated client for streaming large files with extended timeout + _streamingClient = httpClientFactory.CreateClient(); + _streamingClient.BaseAddress = new Uri(_platformSettings.ApiStorageEndpoint); + _streamingClient.DefaultRequestHeaders.Add( + General.SubscriptionKeyHeaderName, + _platformSettings.SubscriptionKey + ); + _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _streamingClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + _streamingClient.Timeout = _streamingHttpOperationTimeout; } /// @@ -75,11 +86,9 @@ public async Task InsertFormData( ) where T : notnull { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartInsertFormDataActivity(instanceGuid, instanceOwnerPartyId); Instance instance = new() { Id = $"{instanceOwnerPartyId}/{instanceGuid}" }; - return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, cts.Token); + return await InsertFormData(instance, dataType, dataToSerialize, type, authenticationMethod, cancellationToken); } /// @@ -93,14 +102,12 @@ public async Task InsertFormData( ) where T : notnull { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartInsertFormDataActivity(instance); string apiUrl = $"instances/{instance.Id}/data?dataType={dataTypeString}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); var application = await _appMetadata.GetApplicationMetadata(); @@ -112,16 +119,11 @@ public async Task InsertFormData( StreamContent streamContent = new(new MemoryAsStream(data)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PostAsync( - token, - apiUrl, - streamContent, - cancellationToken: cts.Token - ); + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, streamContent, null, cancellationToken); if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(cts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release var dataElement = JsonConvert.DeserializeObject(instanceData)!; @@ -151,15 +153,13 @@ public async Task UpdateData( ) where T : notnull { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartUpdateDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); //TODO: this method does not get enough information to know the content type from the DataType @@ -170,16 +170,11 @@ public async Task UpdateData( StreamContent streamContent = new(new MemoryAsStream(serializedBytes)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PutAsync( - token, - apiUrl, - streamContent, - cancellationToken: cts.Token - ); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, streamContent, null, cancellationToken); if (response.IsSuccessStatusCode) { - string instanceData = await response.Content.ReadAsStringAsync(cts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instanceData)!; return dataElement; @@ -199,22 +194,20 @@ public async Task GetBinaryData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsStreamAsync(cts.Token); + return await response.Content.ReadAsStreamAsync(cancellationToken); } else if (response.StatusCode == HttpStatusCode.NotFound) { @@ -235,24 +228,22 @@ public async Task GetBinaryDataStream( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_streamingHttpOperationTimeout); - using Activity? activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); var instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; var apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); - HttpResponseMessage response = await _client.GetStreamingAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _streamingClient.GetStreamingAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { try { - Stream stream = await response.Content.ReadAsStreamAsync(cts.Token); + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken); return new ResponseWrapperStream(response, stream); } catch (Exception) @@ -262,10 +253,7 @@ public async Task GetBinaryDataStream( } } - throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse( - response, - cancellationToken: cts.Token - ); + throw await PlatformHttpResponseSnapshotException.CreateAndDisposeHttpResponse(response, cancellationToken); } /// @@ -280,21 +268,19 @@ public async Task GetFormData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartGetFormDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { - var bytes = await response.Content.ReadAsByteArrayAsync(cts.Token); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); try { @@ -323,22 +309,20 @@ public async Task GetDataBytes( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartGetBinaryDataActivity(instanceGuid, dataId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataId}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { - return await response.Content.ReadAsByteArrayAsync(cts.Token); + return await response.Content.ReadAsByteArrayAsync(cancellationToken); } throw await PlatformHttpException.CreateAsync(response); @@ -354,24 +338,22 @@ public async Task> GetBinaryDataList( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartGetBinaryDataListActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/dataelements"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); DataElementList dataList; List attachmentList = []; - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); if (response.StatusCode == HttpStatusCode.OK) { - string instanceData = await response.Content.ReadAsStringAsync(cts.Token); + string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); dataList = JsonConvert.DeserializeObject(instanceData) ?? throw new JsonException("Could not deserialize DataElementList"); @@ -446,18 +428,16 @@ public async Task DeleteData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartDeleteDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"instances/{instanceIdentifier}/data/{dataGuid}?delay={delay}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { @@ -482,8 +462,6 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = @@ -491,15 +469,15 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, null, cancellationToken); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -524,8 +502,6 @@ public async Task InsertBinaryData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartInsertBinaryDataActivity(instanceId); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceId}/data?dataType={dataType}"; if (!string.IsNullOrEmpty(generatedFromTask)) @@ -535,7 +511,7 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); StreamContent content = new(stream); @@ -549,11 +525,11 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, null, cancellationToken); if (response.IsSuccessStatusCode) { - string dataElementString = await response.Content.ReadAsStringAsync(cts.Token); + string dataElementString = await response.Content.ReadAsStringAsync(cancellationToken); var dataElement = JsonConvert.DeserializeObject(dataElementString); if (dataElement is not null) @@ -561,7 +537,7 @@ public async Task InsertBinaryData( } _logger.LogError( - $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(cts.Token)}" + $"Storing attachment for instance {instanceId} failed with status code {response.StatusCode} - content {await response.Content.ReadAsStringAsync(cancellationToken)}" ); throw await PlatformHttpException.CreateAsync(response); } @@ -578,24 +554,22 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceGuid, instanceOwnerPartyId); string instanceIdentifier = $"{instanceOwnerPartyId}/{instanceGuid}"; string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, null, cancellationToken); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -619,14 +593,12 @@ public async Task UpdateBinaryData( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartUpdateBinaryDataActivity(instanceIdentifier.GetInstanceId()); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); StreamContent content = new(stream); @@ -641,11 +613,11 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, null, cancellationToken); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { - string instancedata = await response.Content.ReadAsStringAsync(cts.Token); + string instancedata = await response.Content.ReadAsStringAsync(cancellationToken); // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement dataElement = JsonConvert.DeserializeObject(instancedata)!; @@ -662,24 +634,22 @@ public async Task Update( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartUpdateDataActivity(instance); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instance.Id}/dataelements/{dataElement.Id}"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString, null, cancellationToken); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cts.Token) + await response.Content.ReadAsStringAsync(cancellationToken) )!; return result; @@ -696,14 +666,12 @@ public async Task LockDataElement( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartLockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); _logger.LogDebug( @@ -716,13 +684,14 @@ public async Task LockDataElement( token, apiUrl, content: null, - cancellationToken: cts.Token + platformAccessToken: null, + cancellationToken ); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cts.Token) + await response.Content.ReadAsStringAsync(cancellationToken) )!; return result; } @@ -743,14 +712,12 @@ public async Task UnlockDataElement( CancellationToken cancellationToken = default ) { - using var cts = cancellationToken.WithTimeout(_httpOperationTimeout); - using var activity = _telemetry?.StartUnlockDataElementActivity(instanceIdentifier.GetInstanceId(), dataGuid); string apiUrl = $"{_platformSettings.ApiStorageEndpoint}instances/{instanceIdentifier}/data/{dataGuid}/lock"; JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken: cts.Token + cancellationToken ); _logger.LogDebug( @@ -759,12 +726,12 @@ public async Task UnlockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cts.Token); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, null, cancellationToken); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release DataElement result = JsonConvert.DeserializeObject( - await response.Content.ReadAsStringAsync(cts.Token) + await response.Content.ReadAsStringAsync(cancellationToken) )!; return result; } diff --git a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs index 71d7b9112..f949f18b8 100644 --- a/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs +++ b/test/Altinn.App.Core.Tests/Infrastructure/Clients/Storage/DataClientTests.cs @@ -1147,6 +1147,7 @@ private sealed record Fixture : IAsyncDisposable public required ServiceProvider ServiceProvider { get; init; } public required FixtureMocks Mocks { get; init; } public required HttpClient BaseHttpClient { get; init; } + public required HttpClient StreamingHttpClient { get; init; } public static Fixture Create( Func> dataClientDelegatingHandler, @@ -1162,6 +1163,11 @@ public static Fixture Create( ) .ReturnsAsync(_testTokens.ServiceOwnerToken); + // Setup HttpClientFactory for streaming client creation + DelegatingHandlerStub streamingDelegatingHandler = new(dataClientDelegatingHandler); + HttpClient streamingClient = new(streamingDelegatingHandler); + mocks.HttpClientFactoryMock.Setup(x => x.CreateClient(string.Empty)).Returns(streamingClient); + var services = new ServiceCollection(); services.Configure(options => options.ApiStorageEndpoint = ApiStorageEndpoint); services.Configure(options => options.HostName = "tt02.altinn.no"); @@ -1191,6 +1197,7 @@ public static Fixture Create( ServiceProvider = serviceProvider, DataClient = new DataClient(httpClient, serviceProvider), BaseHttpClient = httpClient, + StreamingHttpClient = streamingClient, }; } @@ -1207,6 +1214,7 @@ public async ValueTask DisposeAsync() { await ServiceProvider.DisposeAsync(); BaseHttpClient.Dispose(); + StreamingHttpClient.Dispose(); } } From 8c2aa6c0d77329047cf3b975ce205d892ea0994e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 21 Oct 2025 17:26:17 +0200 Subject: [PATCH 42/44] Re-add named param for cancellation token. --- .../Clients/Storage/DataClient.cs | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index f09091faf..29c6f06e0 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -107,7 +107,7 @@ public async Task InsertFormData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); var application = await _appMetadata.GetApplicationMetadata(); @@ -119,7 +119,8 @@ public async Task InsertFormData( StreamContent streamContent = new(new MemoryAsStream(data)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, streamContent, null, cancellationToken); + HttpResponseMessage response = + await _client.PostAsync(token, apiUrl, streamContent, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -159,7 +160,7 @@ public async Task UpdateData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); //TODO: this method does not get enough information to know the content type from the DataType @@ -170,7 +171,8 @@ public async Task UpdateData( StreamContent streamContent = new(new MemoryAsStream(serializedBytes)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, streamContent, null, cancellationToken); + HttpResponseMessage response = + await _client.PutAsync(token, apiUrl, streamContent, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -200,10 +202,10 @@ public async Task GetBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -234,10 +236,11 @@ public async Task GetBinaryDataStream( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); - HttpResponseMessage response = await _streamingClient.GetStreamingAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = + await _streamingClient.GetStreamingAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -274,10 +277,10 @@ public async Task GetFormData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); @@ -315,10 +318,10 @@ public async Task GetDataBytes( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -344,13 +347,13 @@ public async Task> GetBinaryDataList( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); DataElementList dataList; List attachmentList = []; - HttpResponseMessage response = await _client.GetAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.GetAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.StatusCode == HttpStatusCode.OK) { string instanceData = await response.Content.ReadAsStringAsync(cancellationToken); @@ -434,10 +437,10 @@ public async Task DeleteData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -469,11 +472,12 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, null, cancellationToken); + HttpResponseMessage response = + await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -511,7 +515,7 @@ public async Task InsertBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); StreamContent content = new(stream); @@ -525,7 +529,8 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = await _client.PostAsync(token, apiUrl, content, null, cancellationToken); + HttpResponseMessage response = + await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -560,12 +565,13 @@ public async Task UpdateBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, null, cancellationToken); + HttpResponseMessage response = + await _client.PutAsync(token, apiUrl, content, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -598,7 +604,7 @@ public async Task UpdateBinaryData( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); StreamContent content = new(stream); @@ -613,7 +619,8 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, content, null, cancellationToken); + HttpResponseMessage response = + await _client.PutAsync(token, apiUrl, content, cancellationToken: cancellationToken); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { @@ -639,11 +646,12 @@ public async Task Update( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = await _client.PutAsync(token, apiUrl, jsonString, null, cancellationToken); + HttpResponseMessage response = + await _client.PutAsync(token, apiUrl, jsonString, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { @@ -671,7 +679,7 @@ public async Task LockDataElement( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); _logger.LogDebug( @@ -717,7 +725,7 @@ public async Task UnlockDataElement( JwtToken token = await _authenticationTokenResolver.GetAccessToken( authenticationMethod ?? _defaultAuthenticationMethod, - cancellationToken + cancellationToken: cancellationToken ); _logger.LogDebug( @@ -726,7 +734,7 @@ public async Task UnlockDataElement( instanceIdentifier, apiUrl ); - HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, null, cancellationToken); + HttpResponseMessage response = await _client.DeleteAsync(token, apiUrl, cancellationToken: cancellationToken); if (response.IsSuccessStatusCode) { // ! TODO: this null-forgiving operator should be fixed/removed for the next major release From c32f408bf1463813ee0ff63fa651928714a9b990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 21 Oct 2025 17:31:00 +0200 Subject: [PATCH 43/44] Formatting. --- .../Clients/Storage/DataClient.cs | 63 ++++++++++++++----- 1 file changed, 47 insertions(+), 16 deletions(-) diff --git a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs index 29c6f06e0..d08f61758 100644 --- a/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs +++ b/src/Altinn.App.Core/Infrastructure/Clients/Storage/DataClient.cs @@ -119,8 +119,12 @@ public async Task InsertFormData( StreamContent streamContent = new(new MemoryAsStream(data)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = - await _client.PostAsync(token, apiUrl, streamContent, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + streamContent, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -171,8 +175,12 @@ public async Task UpdateData( StreamContent streamContent = new(new MemoryAsStream(serializedBytes)); streamContent.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - HttpResponseMessage response = - await _client.PutAsync(token, apiUrl, streamContent, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + streamContent, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -239,8 +247,11 @@ public async Task GetBinaryDataStream( cancellationToken: cancellationToken ); - HttpResponseMessage response = - await _streamingClient.GetStreamingAsync(token, apiUrl, cancellationToken: cancellationToken); + HttpResponseMessage response = await _streamingClient.GetStreamingAsync( + token, + apiUrl, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -476,8 +487,12 @@ public async Task InsertBinaryData( ); StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = - await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -529,8 +544,12 @@ public async Task InsertBinaryData( }; } - HttpResponseMessage response = - await _client.PostAsync(token, apiUrl, content, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PostAsync( + token, + apiUrl, + content, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -570,8 +589,12 @@ public async Task UpdateBinaryData( StreamContent content = request.CreateContentStream(); - HttpResponseMessage response = - await _client.PutAsync(token, apiUrl, content, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { @@ -619,8 +642,12 @@ public async Task UpdateBinaryData( }; } - HttpResponseMessage response = - await _client.PutAsync(token, apiUrl, content, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + content, + cancellationToken: cancellationToken + ); _logger.LogInformation("Update binary data result: {ResultCode}", response.StatusCode); if (response.IsSuccessStatusCode) { @@ -650,8 +677,12 @@ public async Task Update( ); StringContent jsonString = new(JsonConvert.SerializeObject(dataElement), Encoding.UTF8, "application/json"); - HttpResponseMessage response = - await _client.PutAsync(token, apiUrl, jsonString, cancellationToken: cancellationToken); + HttpResponseMessage response = await _client.PutAsync( + token, + apiUrl, + jsonString, + cancellationToken: cancellationToken + ); if (response.IsSuccessStatusCode) { From 37c60825080de981b8d5b72232373b12588b5350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Tore=20Gjerde?= Date: Tue, 21 Oct 2025 19:15:01 +0200 Subject: [PATCH 44/44] Attempt to use CustomTextKey. --- .../Validation/Default/SignatureHashValidator.cs | 2 +- .../Internal/Texts/TranslationService.cs | 11 +++++++++++ .../Validators/Default/SignatureHashValidatorTests.cs | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs index 145163a63..2dc8b8faf 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/SignatureHashValidator.cs @@ -135,7 +135,7 @@ CancellationToken cancellationToken { Code = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, Severity = ValidationIssueSeverity.Error, - Description = ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, + CustomTextKey = "backend.validation_errors.invalid_signature_hash", }; } diff --git a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs index d1d51d4ac..2c1436fc5 100644 --- a/src/Altinn.App.Core/Internal/Texts/TranslationService.cs +++ b/src/Altinn.App.Core/Internal/Texts/TranslationService.cs @@ -199,6 +199,17 @@ await EvaluateTextVariable(resourceElement, variable, state, context, customText _ => "Field is required", }, }; + case "backend.validation_errors.invalid_signature_hash": + return new TextResourceElement() + { + Id = "backend.validation_errors.invalid_signature_hash", + Value = language switch + { + LanguageConst.Nb => "Signerte data er endret etter at signaturen ble utført.", + LanguageConst.Nn => "Signerte data er endra etter at signaturen vart utført.", + _ => "The signed data has been modified after the signature was made.", + }, + }; } return null; diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs index c1648b740..8ee062141 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/SignatureHashValidatorTests.cs @@ -80,7 +80,7 @@ public async Task Validate_WithInvalidSignatureHash_ReturnsValidationIssue() Assert.Single(result); Assert.Equal(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, result[0].Code); Assert.Equal(ValidationIssueSeverity.Error, result[0].Severity); - Assert.Equal(ValidationIssueCodes.DataElementCodes.InvalidSignatureHash, result[0].Description); + Assert.Equal("backend.validation_errors.invalid_signature_hash", result[0].CustomTextKey); } [Fact]