From 0042f40d971b3b14895fbde7e5616cb2cb64f654 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 23 Dec 2024 13:25:39 +0100 Subject: [PATCH 01/14] Check localtest version --- .../Extensions/ServiceCollectionExtensions.cs | 3 + .../Internal/LocaltestClient.cs | 186 ++++++++++++++++++ .../Internal/LocaltestClientTests.cs | 92 +++++++++ 3 files changed, 281 insertions(+) create mode 100644 src/Altinn.App.Core/Internal/LocaltestClient.cs create mode 100644 test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 11bba1458..f44221811 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ using Altinn.App.Core.Infrastructure.Clients.Profile; using Altinn.App.Core.Infrastructure.Clients.Register; using Altinn.App.Core.Infrastructure.Clients.Storage; +using Altinn.App.Core.Internal; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Auth; @@ -176,6 +177,8 @@ IWebHostEnvironment env services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); + services.AddLocaltestClient(); + AddValidationServices(services, configuration); AddAppOptions(services); AddExternalApis(services); diff --git a/src/Altinn.App.Core/Internal/LocaltestClient.cs b/src/Altinn.App.Core/Internal/LocaltestClient.cs new file mode 100644 index 000000000..b66ff1fc7 --- /dev/null +++ b/src/Altinn.App.Core/Internal/LocaltestClient.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using System.Net; +using Altinn.App.Core.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Core.Internal; + +internal static class LocaltestClientDI +{ + public static IServiceCollection AddLocaltestClient(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + return services; + } +} + +internal sealed class LocaltestClient : BackgroundService +{ + private const string ExpectedHostname = "local.altinn.cloud"; + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IOptionsMonitor _generalSettings; + private readonly IHostApplicationLifetime _lifetime; + private readonly TimeProvider _timeProvider; + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal Task FirstResult => _tcs.Task; + + internal VersionResult? Result; + + public LocaltestClient( + ILogger logger, + IHttpClientFactory httpClientFactory, + IOptionsMonitor generalSettings, + IHostApplicationLifetime lifetime, + TimeProvider? timeProvider = null + ) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _generalSettings = generalSettings; + _lifetime = lifetime; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var configuredHostname = _generalSettings.CurrentValue.HostName; + if (configuredHostname != ExpectedHostname) + return; + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var result = await Version(); + Result = result; + _tcs.TrySetResult(result); + switch (result) + { + case VersionResult.Ok { Version: var version }: + { + _logger.LogInformation("Localtest version: {Version}", version); + if (version >= 1) + return; + _logger.LogError( + "Localtest version is not supported for this version of the app backend. Update your local copy of localtest." + + " Version found: '{Version}'. Shutting down..", + version + ); + _lifetime.StopApplication(); + return; + } + case VersionResult.ApiNotFound: + { + _logger.LogError( + "Localtest version may be outdated, as we failed to probe {HostName} API for version information." + + "Is localtest running on {HostName}? Do you have a recent copy of localtest? Shutting down..", + ExpectedHostname, + ExpectedHostname + ); + _lifetime.StopApplication(); + return; + } + case VersionResult.ApiNotAvailable { Error: var error }: + _logger.LogWarning( + "Localtest API could not be reached, is it running? Trying again soon.. Error: {Error}", + error + ); + break; + case VersionResult.UnhandledStatusCode { StatusCode: var statusCode }: + _logger.LogError( + "Localtest version endpoint returned unexpected status code: {StatusCode}", + statusCode + ); + break; + case VersionResult.UnknownError { Exception: var ex }: + _logger.LogError(ex, "Error while trying fetching localtest version"); + break; + case VersionResult.AppShuttingDown: + return; + } + await Task.Delay(TimeSpan.FromSeconds(5), _timeProvider, stoppingToken); + } + } + catch (OperationCanceledException) { } + } + + internal abstract record VersionResult + { + // Localtest is running, and we got a version number, which means this is a version of localtest that has + // the new version endpoint. + public sealed record Ok(int Version) : VersionResult; + + public sealed record InvalidVersionResponse(string Repsonse) : VersionResult; + + // Whatever listened on "local.altinn.cloud:80" responded with a 4044 + public sealed record ApiNotFound() : VersionResult; + + // The request timed out. Note that there may be multiple variants of timeouts. + public sealed record Timeout() : VersionResult; + + // Could not connect to "local.altinn.cloud:80", a server might not be listening on that address + // or it might be a network issue + public sealed record ApiNotAvailable(HttpRequestError Error) : VersionResult; + + // Request was cancelled because the application is shutting down + public sealed record AppShuttingDown() : VersionResult; + + // The localtest endpoint returned an unexpected statuscode + public sealed record UnhandledStatusCode(HttpStatusCode StatusCode) : VersionResult; + + // Unhandled error + public sealed record UnknownError(Exception Exception) : VersionResult; + } + + private async Task Version() + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5), _timeProvider); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, _lifetime.ApplicationStopping); + var cancellationToken = linkedCts.Token; + try + { + using var client = _httpClientFactory.CreateClient(); + + var url = "http://local.altinn.cloud/Home/Localtest/Version"; + + using var response = await client.GetAsync(url, cancellationToken); + switch (response.StatusCode) + { + case HttpStatusCode.OK: + var versionStr = await response.Content.ReadAsStringAsync(cancellationToken); + if (!int.TryParse(versionStr, CultureInfo.InvariantCulture, out var version)) + return new VersionResult.InvalidVersionResponse(versionStr); + return new VersionResult.Ok(version); + case HttpStatusCode.NotFound: + return new VersionResult.ApiNotFound(); + default: + return new VersionResult.UnhandledStatusCode(response.StatusCode); + } + } + catch (OperationCanceledException) + { + if (_lifetime.ApplicationStopping.IsCancellationRequested) + return new VersionResult.AppShuttingDown(); + + return new VersionResult.Timeout(); + } + catch (HttpRequestException ex) + { + if (_lifetime.ApplicationStopping.IsCancellationRequested) + return new VersionResult.AppShuttingDown(); + + return new VersionResult.ApiNotAvailable(ex.HttpRequestError); + } + catch (Exception ex) + { + return new VersionResult.UnknownError(ex); + } + } +} diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs new file mode 100644 index 000000000..402f0f4ab --- /dev/null +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs @@ -0,0 +1,92 @@ +using System.Net; +using Altinn.App.Core.Internal; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Time.Testing; +using Moq; + +namespace Altinn.App.Core.Tests.Internal; + +public class LocaltestclientTests +{ + private sealed record Fixture(WebApplication App) : IAsyncDisposable + { + public Mock HttpClientFactoryMock => + Mock.Get(App.Services.GetRequiredService()); + + public Mock HttpClientMock => Mock.Get(App.Services.GetRequiredService()); + + public static Fixture Create(bool realTest = false) + { + Mock? mockHttpClientFactory = null; + if (!realTest) + { + mockHttpClientFactory = new Mock(); + var mockHttpClient = new Mock(); + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); + } + + var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => + { + if (!realTest) + { + var fakeTimeProvider = new FakeTimeProvider( + new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero) + ); + services.AddSingleton(fakeTimeProvider); + services.AddSingleton(fakeTimeProvider); + } + + if (mockHttpClientFactory is not null) + services.AddSingleton(mockHttpClientFactory.Object); + }); + + return new Fixture(app); + } + + public async ValueTask DisposeAsync() => await App.DisposeAsync(); + } + + [Fact] + public async Task Test_Init() + { + await using var fixture = Fixture.Create(); + + var client = fixture.App.Services.GetRequiredService(); + + Assert.NotNull(client); + } + + [Fact] + public async Task Test_Ok() + { + await using var fixture = Fixture.Create(); + + var httpClient = fixture.HttpClientMock; + httpClient + .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"version\": 1}") } + ); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + Assert.NotNull(client); + } + + [Fact] + public async Task Test_Real() // Runs against localtest, add skip to avoid running this test on main + { + await using var fixture = Fixture.Create(realTest: true); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + } +} From 729b2ef8f28bc0b0b0c0d16d903de414caef7e3b Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 6 Jan 2025 10:42:20 +0100 Subject: [PATCH 02/14] More/better testing --- .../Configuration/GeneralSettings.cs | 3 + .../Internal/LocaltestClient.cs | 5 +- .../Altinn.App.Core.Tests.csproj | 1 + .../Internal/LocaltestClientTests.cs | 151 ++++++++++++++---- 4 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/Altinn.App.Core/Configuration/GeneralSettings.cs b/src/Altinn.App.Core/Configuration/GeneralSettings.cs index f2c2fcbb3..d021f3500 100644 --- a/src/Altinn.App.Core/Configuration/GeneralSettings.cs +++ b/src/Altinn.App.Core/Configuration/GeneralSettings.cs @@ -34,6 +34,9 @@ public class GeneralSettings /// public string HostName { get; set; } = "local.altinn.cloud"; + // Only here to be overridden from tests + internal string LocaltestUrl { get; set; } = "http://local.altinn.cloud"; + /// /// The externally accesible base url for the app with trailing / /// diff --git a/src/Altinn.App.Core/Internal/LocaltestClient.cs b/src/Altinn.App.Core/Internal/LocaltestClient.cs index b66ff1fc7..f4f598c92 100644 --- a/src/Altinn.App.Core/Internal/LocaltestClient.cs +++ b/src/Altinn.App.Core/Internal/LocaltestClient.cs @@ -119,7 +119,7 @@ public sealed record Ok(int Version) : VersionResult; public sealed record InvalidVersionResponse(string Repsonse) : VersionResult; - // Whatever listened on "local.altinn.cloud:80" responded with a 4044 + // Whatever listened on "local.altinn.cloud:80" responded with a 404 public sealed record ApiNotFound() : VersionResult; // The request timed out. Note that there may be multiple variants of timeouts. @@ -148,7 +148,8 @@ private async Task Version() { using var client = _httpClientFactory.CreateClient(); - var url = "http://local.altinn.cloud/Home/Localtest/Version"; + var baseUrl = _generalSettings.CurrentValue.LocaltestUrl; + var url = $"{baseUrl}/Home/Localtest/Version"; using var response = await client.GetAsync(url, cancellationToken); switch (response.StatusCode) diff --git a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj index 15f1579f1..4b5a12f02 100644 --- a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj +++ b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj @@ -57,6 +57,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs index 402f0f4ab..ffae14e68 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs @@ -1,10 +1,14 @@ -using System.Net; +using Altinn.App.Core.Configuration; using Altinn.App.Core.Internal; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Time.Testing; using Moq; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using static Altinn.App.Core.Internal.LocaltestClient; namespace Altinn.App.Core.Tests.Internal; @@ -12,34 +16,37 @@ public class LocaltestclientTests { private sealed record Fixture(WebApplication App) : IAsyncDisposable { + internal const string ApiPath = "/Home/Localtest/Version"; + public Mock HttpClientFactoryMock => Mock.Get(App.Services.GetRequiredService()); - public Mock HttpClientMock => Mock.Get(App.Services.GetRequiredService()); + public WireMockServer Server => App.Services.GetRequiredService(); + + public FakeTimeProvider TimeProvider => App.Services.GetRequiredService(); - public static Fixture Create(bool realTest = false) + public static Fixture Create() { - Mock? mockHttpClientFactory = null; - if (!realTest) - { - mockHttpClientFactory = new Mock(); - var mockHttpClient = new Mock(); - mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(mockHttpClient.Object); - } + var server = WireMockServer.Start(); + + var mockHttpClientFactory = new Mock(); + var mockHttpClient = new Mock(); + mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => server.CreateClient()); var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => { - if (!realTest) + services.AddSingleton(_ => server); + + services.Configure(settings => { - var fakeTimeProvider = new FakeTimeProvider( - new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero) - ); - services.AddSingleton(fakeTimeProvider); - services.AddSingleton(fakeTimeProvider); - } - - if (mockHttpClientFactory is not null) - services.AddSingleton(mockHttpClientFactory.Object); + settings.LocaltestUrl = server.Url ?? throw new Exception("Missing server URL"); + }); + + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero)); + services.AddSingleton(fakeTimeProvider); + services.AddSingleton(fakeTimeProvider); + + services.AddSingleton(mockHttpClientFactory.Object); }); return new Fixture(app); @@ -59,34 +66,120 @@ public async Task Test_Init() } [Fact] - public async Task Test_Ok() + public async Task Test_Recent_Version() { await using var fixture = Fixture.Create(); - var httpClient = fixture.HttpClientMock; - httpClient - .Setup(c => c.SendAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync( - new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"version\": 1}") } + var expectedVersion = 1; + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") ); var client = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); await client.StartAsync(lifetime.ApplicationStopping); - Assert.NotNull(client); + var result = await client.FirstResult; + Assert.NotNull(result); + var ok = Assert.IsType(result); + Assert.Equal(expectedVersion, ok.Version); + + var reqs = server.FindLogEntries(Request.Create().WithPath(Fixture.ApiPath).UsingGet()); + Assert.Single(reqs); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); } [Fact] - public async Task Test_Real() // Runs against localtest, add skip to avoid running this test on main + public async Task Test_Old_Version() { - await using var fixture = Fixture.Create(realTest: true); + await using var fixture = Fixture.Create(); + + var expectedVersion = 0; + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") + ); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + var ok = Assert.IsType(result); + Assert.Equal(expectedVersion, ok.Version); + + var reqs = server.FindLogEntries(Request.Create().WithPath(Fixture.ApiPath).UsingGet()); + Assert.Single(reqs); + + Assert.True(lifetime.ApplicationStopping.IsCancellationRequested); + } + + [Fact] + public async Task Test_Invalid_Version() + { + await using var fixture = Fixture.Create(); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody("blah") + ); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public async Task Test_Timeout() + { + await using var fixture = Fixture.Create(); + + var expectedVersion = 1; + var delay = TimeSpan.FromSeconds(6); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") + .WithDelay(delay) + ); var client = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); await client.StartAsync(lifetime.ApplicationStopping); + await Task.Delay(10); + fixture.TimeProvider.Advance(delay); var result = await client.FirstResult; Assert.NotNull(result); + Assert.IsType(result); } } From a5fa4177a0bf16cf80e7d27db3b70b675d8b3b57 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 6 Jan 2025 13:13:54 +0100 Subject: [PATCH 03/14] Rest of the tests --- .../Internal/LocaltestClientTests.cs | 168 +++++++++++++++++- 1 file changed, 166 insertions(+), 2 deletions(-) diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs index ffae14e68..a7d8e4e7c 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs @@ -1,3 +1,4 @@ +using System.Net; using Altinn.App.Core.Configuration; using Altinn.App.Core.Internal; using Microsoft.AspNetCore.Builder; @@ -25,13 +26,30 @@ private sealed record Fixture(WebApplication App) : IAsyncDisposable public FakeTimeProvider TimeProvider => App.Services.GetRequiredService(); - public static Fixture Create() + private sealed class ReqHandler(Action? onRequest = null) : DelegatingHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + onRequest?.Invoke(); + return base.SendAsync(request, cancellationToken); + } + } + + public static Fixture Create( + Action? registerCustomAppServices = default, + Action? onRequest = null + ) { var server = WireMockServer.Start(); var mockHttpClientFactory = new Mock(); var mockHttpClient = new Mock(); - mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(() => server.CreateClient()); + mockHttpClientFactory + .Setup(f => f.CreateClient(It.IsAny())) + .Returns(() => server.CreateClient(new ReqHandler(onRequest))); var app = Api.Tests.TestUtils.AppBuilder.Build(registerCustomAppServices: services => { @@ -47,6 +65,8 @@ public static Fixture Create() services.AddSingleton(fakeTimeProvider); services.AddSingleton(mockHttpClientFactory.Object); + + registerCustomAppServices?.Invoke(services); }); return new Fixture(app); @@ -131,6 +151,30 @@ public async Task Test_Old_Version() Assert.True(lifetime.ApplicationStopping.IsCancellationRequested); } + [Fact] + public async Task Test_Api_Not_Found() + { + await using var fixture = Fixture.Create(); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(404)); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + Assert.IsType(result); + + var reqs = server.FindLogEntries(Request.Create().WithPath(Fixture.ApiPath).UsingGet()); + Assert.Single(reqs); + + Assert.True(lifetime.ApplicationStopping.IsCancellationRequested); + } + [Fact] public async Task Test_Invalid_Version() { @@ -150,6 +194,8 @@ public async Task Test_Invalid_Version() var result = await client.FirstResult; Assert.NotNull(result); Assert.IsType(result); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); } [Fact] @@ -181,5 +227,123 @@ public async Task Test_Timeout() var result = await client.FirstResult; Assert.NotNull(result); Assert.IsType(result); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); + } + + [Fact] + public async Task Test_App_Shutdown() + { + await using var fixture = Fixture.Create(); + + var expectedVersion = 1; + var delay = TimeSpan.FromSeconds(6); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") + .WithDelay(delay) + ); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + await Task.Delay(10); + fixture.TimeProvider.Advance(delay.Subtract(TimeSpan.FromSeconds(4))); + lifetime.StopApplication(); + + var result = await client.FirstResult; + Assert.NotNull(result); + Assert.IsType(result); + + Assert.True(lifetime.ApplicationStopping.IsCancellationRequested); + } + + [Fact] + public async Task Test_Dns_Failure() + { + await using var fixture = Fixture.Create(registerCustomAppServices: services => + services.Configure(settings => + settings.LocaltestUrl = "http://provoke-dns-fail.local.altinn.cloud" + ) + ); + + var expectedVersion = 1; + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") + ); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + var notAvailable = Assert.IsType(result); + Assert.Equal(HttpRequestError.NameResolutionError, notAvailable.Error); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); + } + + [Fact] + public async Task Test_Unhandled_Status() + { + await using var fixture = Fixture.Create(); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(201)); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + var status = Assert.IsType(result); + Assert.Equal(HttpStatusCode.Created, status.StatusCode); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); + } + + [Fact] + public async Task Test_Unhandled_Error() + { + var errorMessage = "Unhandled error"; + await using var fixture = Fixture.Create(onRequest: () => + { + throw new Exception(errorMessage); + }); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody($"1")); + + var client = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await client.StartAsync(lifetime.ApplicationStopping); + + var result = await client.FirstResult; + Assert.NotNull(result); + var error = Assert.IsType(result); + Assert.Equal(errorMessage, error.Exception.Message); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); } } From d0f3f4bc17a98495379c639aec6d74bc190c4d11 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 6 Jan 2025 13:29:54 +0100 Subject: [PATCH 04/14] REname --- .../Extensions/ServiceCollectionExtensions.cs | 2 +- ...altestClient.cs => LocaltestValidation.cs} | 16 ++--- ...ntTests.cs => LocaltestValidationTests.cs} | 62 +++++++++---------- 3 files changed, 40 insertions(+), 40 deletions(-) rename src/Altinn.App.Core/Internal/{LocaltestClient.cs => LocaltestValidation.cs} (94%) rename test/Altinn.App.Core.Tests/Internal/{LocaltestClientTests.cs => LocaltestValidationTests.cs} (84%) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index f44221811..60ad29a07 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -177,7 +177,7 @@ IWebHostEnvironment env services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); - services.AddLocaltestClient(); + services.AddLocaltestValidation(); AddValidationServices(services, configuration); AddAppOptions(services); diff --git a/src/Altinn.App.Core/Internal/LocaltestClient.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs similarity index 94% rename from src/Altinn.App.Core/Internal/LocaltestClient.cs rename to src/Altinn.App.Core/Internal/LocaltestValidation.cs index f4f598c92..d346b7306 100644 --- a/src/Altinn.App.Core/Internal/LocaltestClient.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -8,21 +8,21 @@ namespace Altinn.App.Core.Internal; -internal static class LocaltestClientDI +internal static class LocaltestValidationDI { - public static IServiceCollection AddLocaltestClient(this IServiceCollection services) + public static IServiceCollection AddLocaltestValidation(this IServiceCollection services) { - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); return services; } } -internal sealed class LocaltestClient : BackgroundService +internal sealed class LocaltestValidation : BackgroundService { private const string ExpectedHostname = "local.altinn.cloud"; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsMonitor _generalSettings; private readonly IHostApplicationLifetime _lifetime; @@ -33,8 +33,8 @@ internal sealed class LocaltestClient : BackgroundService internal VersionResult? Result; - public LocaltestClient( - ILogger logger, + public LocaltestValidation( + ILogger logger, IHttpClientFactory httpClientFactory, IOptionsMonitor generalSettings, IHostApplicationLifetime lifetime, diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs similarity index 84% rename from test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs rename to test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index a7d8e4e7c..48a04374e 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestClientTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -9,11 +9,11 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; -using static Altinn.App.Core.Internal.LocaltestClient; +using static Altinn.App.Core.Internal.LocaltestValidation; namespace Altinn.App.Core.Tests.Internal; -public class LocaltestclientTests +public class LocaltestValidationTests { private sealed record Fixture(WebApplication App) : IAsyncDisposable { @@ -80,9 +80,9 @@ public async Task Test_Init() { await using var fixture = Fixture.Create(); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); - Assert.NotNull(client); + Assert.NotNull(service); } [Fact] @@ -103,11 +103,11 @@ public async Task Test_Recent_Version() .WithBody($"{expectedVersion}") ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); var ok = Assert.IsType(result); Assert.Equal(expectedVersion, ok.Version); @@ -136,11 +136,11 @@ public async Task Test_Old_Version() .WithBody($"{expectedVersion}") ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); var ok = Assert.IsType(result); Assert.Equal(expectedVersion, ok.Version); @@ -161,11 +161,11 @@ public async Task Test_Api_Not_Found() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(404)); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); Assert.IsType(result); @@ -187,11 +187,11 @@ public async Task Test_Invalid_Version() Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody("blah") ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); Assert.IsType(result); @@ -218,13 +218,13 @@ public async Task Test_Timeout() .WithDelay(delay) ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); await Task.Delay(10); fixture.TimeProvider.Advance(delay); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); Assert.IsType(result); @@ -251,14 +251,14 @@ public async Task Test_App_Shutdown() .WithDelay(delay) ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); await Task.Delay(10); fixture.TimeProvider.Advance(delay.Subtract(TimeSpan.FromSeconds(4))); lifetime.StopApplication(); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); Assert.IsType(result); @@ -287,11 +287,11 @@ public async Task Test_Dns_Failure() .WithBody($"{expectedVersion}") ); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); var notAvailable = Assert.IsType(result); Assert.Equal(HttpRequestError.NameResolutionError, notAvailable.Error); @@ -309,11 +309,11 @@ public async Task Test_Unhandled_Status() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(201)); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); var status = Assert.IsType(result); Assert.Equal(HttpStatusCode.Created, status.StatusCode); @@ -335,11 +335,11 @@ public async Task Test_Unhandled_Error() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody($"1")); - var client = fixture.App.Services.GetRequiredService(); + var service = fixture.App.Services.GetRequiredService(); var lifetime = fixture.App.Services.GetRequiredService(); - await client.StartAsync(lifetime.ApplicationStopping); + await service.StartAsync(lifetime.ApplicationStopping); - var result = await client.FirstResult; + var result = await service.FirstResult; Assert.NotNull(result); var error = Assert.IsType(result); Assert.Equal(errorMessage, error.Exception.Message); From cb806f7c035c49aa7923e556bdf3f333fa497902 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 6 Jan 2025 13:58:30 +0100 Subject: [PATCH 05/14] Better logging --- .../Internal/LocaltestValidation.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index d346b7306..16660db8c 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -48,6 +48,11 @@ public LocaltestValidation( _timeProvider = timeProvider ?? TimeProvider.System; } + private void Exit() + { + _lifetime.StopApplication(); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var configuredHostname = _generalSettings.CurrentValue.HostName; @@ -73,34 +78,34 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) + " Version found: '{Version}'. Shutting down..", version ); - _lifetime.StopApplication(); + Exit(); return; } case VersionResult.ApiNotFound: { _logger.LogError( "Localtest version may be outdated, as we failed to probe {HostName} API for version information." - + "Is localtest running on {HostName}? Do you have a recent copy of localtest? Shutting down..", + + " Is localtest running on {HostName}? Do you have a recent copy of localtest? Shutting down..", ExpectedHostname, ExpectedHostname ); - _lifetime.StopApplication(); + Exit(); return; } case VersionResult.ApiNotAvailable { Error: var error }: _logger.LogWarning( - "Localtest API could not be reached, is it running? Trying again soon.. Error: {Error}", + "Localtest API could not be reached, is it running? Trying again soon.. Error: '{Error}'. Trying again soon..", error ); break; case VersionResult.UnhandledStatusCode { StatusCode: var statusCode }: _logger.LogError( - "Localtest version endpoint returned unexpected status code: {StatusCode}", + "Localtest version endpoint returned unexpected status code: '{StatusCode}'. Trying again soon..", statusCode ); break; case VersionResult.UnknownError { Exception: var ex }: - _logger.LogError(ex, "Error while trying fetching localtest version"); + _logger.LogError(ex, "Error while trying to fetch localtest version. Trying again soon.."); break; case VersionResult.AppShuttingDown: return; From 0e840ccc081dd0df83b0c96806640a04f1cb6e2b Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Tue, 7 Jan 2025 00:08:54 +0100 Subject: [PATCH 06/14] One more test --- .../Internal/LocaltestValidation.cs | 29 +++++--- .../Altinn.App.Core.Tests.csproj | 1 + .../Internal/LocaltestValidationTests.cs | 70 ++++++++++++++++--- 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index 16660db8c..26949b44d 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Net; +using System.Threading.Channels; using Altinn.App.Core.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -27,11 +28,9 @@ internal sealed class LocaltestValidation : BackgroundService private readonly IOptionsMonitor _generalSettings; private readonly IHostApplicationLifetime _lifetime; private readonly TimeProvider _timeProvider; - private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Channel _resultChannel; - internal Task FirstResult => _tcs.Task; - - internal VersionResult? Result; + internal IAsyncEnumerable Results => _resultChannel.Reader.ReadAllAsync(); public LocaltestValidation( ILogger logger, @@ -46,6 +45,9 @@ public LocaltestValidation( _generalSettings = generalSettings; _lifetime = lifetime; _timeProvider = timeProvider ?? TimeProvider.System; + _resultChannel = Channel.CreateBounded( + new BoundedChannelOptions(10) { FullMode = BoundedChannelFullMode.DropWrite } + ); } private void Exit() @@ -55,17 +57,19 @@ private void Exit() protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var configuredHostname = _generalSettings.CurrentValue.HostName; - if (configuredHostname != ExpectedHostname) - return; - try { + var configuredHostname = _generalSettings.CurrentValue.HostName; + if (configuredHostname != ExpectedHostname) + return; + while (!stoppingToken.IsCancellationRequested) { var result = await Version(); - Result = result; - _tcs.TrySetResult(result); + + if (!_resultChannel.Writer.TryWrite(result)) + _logger.LogWarning("Couldn't log result to channel"); + switch (result) { case VersionResult.Ok { Version: var version }: @@ -114,6 +118,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } catch (OperationCanceledException) { } + finally + { + if (!_resultChannel.Writer.TryComplete()) + _logger.LogWarning("Couldn't close result channel"); + } } internal abstract record VersionResult diff --git a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj index 4b5a12f02..a49a6a32d 100644 --- a/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj +++ b/test/Altinn.App.Core.Tests/Altinn.App.Core.Tests.csproj @@ -58,6 +58,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index 48a04374e..5c0ff5461 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -107,7 +107,7 @@ public async Task Test_Recent_Version() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); var ok = Assert.IsType(result); Assert.Equal(expectedVersion, ok.Version); @@ -140,7 +140,7 @@ public async Task Test_Old_Version() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); var ok = Assert.IsType(result); Assert.Equal(expectedVersion, ok.Version); @@ -165,7 +165,7 @@ public async Task Test_Api_Not_Found() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); Assert.IsType(result); @@ -191,7 +191,7 @@ public async Task Test_Invalid_Version() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); Assert.IsType(result); @@ -224,7 +224,7 @@ public async Task Test_Timeout() await Task.Delay(10); fixture.TimeProvider.Advance(delay); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); Assert.IsType(result); @@ -258,7 +258,7 @@ public async Task Test_App_Shutdown() fixture.TimeProvider.Advance(delay.Subtract(TimeSpan.FromSeconds(4))); lifetime.StopApplication(); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); Assert.IsType(result); @@ -291,7 +291,7 @@ public async Task Test_Dns_Failure() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); var notAvailable = Assert.IsType(result); Assert.Equal(HttpRequestError.NameResolutionError, notAvailable.Error); @@ -313,7 +313,7 @@ public async Task Test_Unhandled_Status() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); var status = Assert.IsType(result); Assert.Equal(HttpStatusCode.Created, status.StatusCode); @@ -339,11 +339,63 @@ public async Task Test_Unhandled_Error() var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); - var result = await service.FirstResult; + var result = await service.Results.FirstAsync(); Assert.NotNull(result); var error = Assert.IsType(result); Assert.Equal(errorMessage, error.Exception.Message); Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); } + + [Fact] + public async Task Test_Unhandled_Error_But_Continue_To_Try() + { + var errorMessage = "Unhandled error"; + var failCount = 0; + var expectedVersion = 1; + await using var fixture = Fixture.Create(onRequest: () => + { + if (failCount++ < 3) + throw new Exception(errorMessage); + }); + + var server = fixture.Server; + server + .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) + .RespondWith( + Response + .Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "text/plain") + .WithBody($"{expectedVersion}") + ); + + var service = fixture.App.Services.GetRequiredService(); + var lifetime = fixture.App.Services.GetRequiredService(); + await service.StartAsync(lifetime.ApplicationStopping); + + List results = []; + await foreach (var result in service.Results) + { + fixture.TimeProvider.Advance(TimeSpan.FromSeconds(5)); + results.Add(result); + } + + Assert.Equal(4, results.Count); + Assert.All( + results.Take(3), + result => + { + Assert.NotNull(result); + var error = Assert.IsType(result); + Assert.Equal(errorMessage, error.Exception.Message); + + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); + } + ); + + var ok = Assert.IsType(results.Last()); + Assert.Equal(expectedVersion, ok.Version); + Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); + } } From 21208f97c516b79680f66deab4053c6b01a754e8 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Tue, 7 Jan 2025 09:52:57 +0100 Subject: [PATCH 07/14] Disable hosted service for api test --- .../Configuration/GeneralSettings.cs | 2 ++ .../Extensions/ServiceCollectionExtensions.cs | 2 +- .../Internal/LocaltestValidation.cs | 14 ++++++++++++-- test/Altinn.App.Api.Tests/Program.cs | 1 + 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Altinn.App.Core/Configuration/GeneralSettings.cs b/src/Altinn.App.Core/Configuration/GeneralSettings.cs index d021f3500..9c515a441 100644 --- a/src/Altinn.App.Core/Configuration/GeneralSettings.cs +++ b/src/Altinn.App.Core/Configuration/GeneralSettings.cs @@ -37,6 +37,8 @@ public class GeneralSettings // Only here to be overridden from tests internal string LocaltestUrl { get; set; } = "http://local.altinn.cloud"; + internal bool DisableLocaltestValidation { get; set; } + /// /// The externally accesible base url for the app with trailing / /// diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 60ad29a07..5b3561cb7 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -177,7 +177,7 @@ IWebHostEnvironment env services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); - services.AddLocaltestValidation(); + services.AddLocaltestValidation(configuration); AddValidationServices(services, configuration); AddAppOptions(services); diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index 26949b44d..fcdd58a4b 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -2,6 +2,7 @@ using System.Net; using System.Threading.Channels; using Altinn.App.Core.Configuration; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -11,9 +12,14 @@ namespace Altinn.App.Core.Internal; internal static class LocaltestValidationDI { - public static IServiceCollection AddLocaltestValidation(this IServiceCollection services) + public static IServiceCollection AddLocaltestValidation( + this IServiceCollection services, + IConfiguration configuration + ) { services.AddSingleton(); + if (configuration.GetValue("GeneralSettings:DisableLocaltestValidation")) + return services; services.AddSingleton(provider => provider.GetRequiredService()); return services; } @@ -59,7 +65,11 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - var configuredHostname = _generalSettings.CurrentValue.HostName; + var settings = _generalSettings.CurrentValue; + if (settings.DisableLocaltestValidation) + return; + + var configuredHostname = settings.HostName; if (configuredHostname != ExpectedHostname) return; diff --git a/test/Altinn.App.Api.Tests/Program.cs b/test/Altinn.App.Api.Tests/Program.cs index ba6777499..7a6982368 100644 --- a/test/Altinn.App.Api.Tests/Program.cs +++ b/test/Altinn.App.Api.Tests/Program.cs @@ -57,6 +57,7 @@ ); builder.Configuration.GetSection("MetricsSettings:Enabled").Value = "false"; builder.Configuration.GetSection("AppSettings:UseOpenTelemetry").Value = "true"; +builder.Configuration.GetSection("GeneralSettings:DisableLocaltestValidation").Value = "true"; ConfigureServices(builder.Services, builder.Configuration); ConfigureMockServices(builder.Services, builder.Configuration); From 2acf91ef505800c0b7d702ae83e2f82805a97c78 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Tue, 7 Jan 2025 10:25:45 +0100 Subject: [PATCH 08/14] Simplify log message --- src/Altinn.App.Core/Internal/LocaltestValidation.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index fcdd58a4b..cf6c17ce4 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -99,8 +99,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogError( "Localtest version may be outdated, as we failed to probe {HostName} API for version information." - + " Is localtest running on {HostName}? Do you have a recent copy of localtest? Shutting down..", - ExpectedHostname, + + " Is localtest running? Do you have a recent copy of localtest? Shutting down..", ExpectedHostname ); Exit(); From 5228e4e1b99bcb84fdd2b23036fc22a7d4b39b29 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Tue, 7 Jan 2025 10:31:55 +0100 Subject: [PATCH 09/14] Remove unused client --- test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index 5c0ff5461..ef625ef95 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -46,7 +46,6 @@ public static Fixture Create( var server = WireMockServer.Start(); var mockHttpClientFactory = new Mock(); - var mockHttpClient = new Mock(); mockHttpClientFactory .Setup(f => f.CreateClient(It.IsAny())) .Returns(() => server.CreateClient(new ReqHandler(onRequest))); From de2bd3a41bf595d1bbadad7e8febec628535491d Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 8 Jan 2025 12:43:55 +0100 Subject: [PATCH 10/14] Use PlatformSettings storage URL --- .../Configuration/GeneralSettings.cs | 8 ++++---- .../Internal/LocaltestValidation.cs | 5 ++++- .../Internal/LocaltestValidationTests.cs | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Altinn.App.Core/Configuration/GeneralSettings.cs b/src/Altinn.App.Core/Configuration/GeneralSettings.cs index 9c515a441..9b9bd1152 100644 --- a/src/Altinn.App.Core/Configuration/GeneralSettings.cs +++ b/src/Altinn.App.Core/Configuration/GeneralSettings.cs @@ -34,10 +34,10 @@ public class GeneralSettings /// public string HostName { get; set; } = "local.altinn.cloud"; - // Only here to be overridden from tests - internal string LocaltestUrl { get; set; } = "http://local.altinn.cloud"; - - internal bool DisableLocaltestValidation { get; set; } + /// + /// Gets or sets a value indicating whether to disable localtest validation on startup. + /// + public bool DisableLocaltestValidation { get; set; } /// /// The externally accesible base url for the app with trailing / diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index cf6c17ce4..5f3860470 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -32,6 +32,7 @@ internal sealed class LocaltestValidation : BackgroundService private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsMonitor _generalSettings; + private readonly IOptionsMonitor _platformSettings; private readonly IHostApplicationLifetime _lifetime; private readonly TimeProvider _timeProvider; private readonly Channel _resultChannel; @@ -42,6 +43,7 @@ public LocaltestValidation( ILogger logger, IHttpClientFactory httpClientFactory, IOptionsMonitor generalSettings, + IOptionsMonitor platformSettings, IHostApplicationLifetime lifetime, TimeProvider? timeProvider = null ) @@ -49,6 +51,7 @@ public LocaltestValidation( _logger = logger; _httpClientFactory = httpClientFactory; _generalSettings = generalSettings; + _platformSettings = platformSettings; _lifetime = lifetime; _timeProvider = timeProvider ?? TimeProvider.System; _resultChannel = Channel.CreateBounded( @@ -171,7 +174,7 @@ private async Task Version() { using var client = _httpClientFactory.CreateClient(); - var baseUrl = _generalSettings.CurrentValue.LocaltestUrl; + var baseUrl = new Uri(_platformSettings.CurrentValue.ApiStorageEndpoint).GetLeftPart(UriPartial.Authority); var url = $"{baseUrl}/Home/Localtest/Version"; using var response = await client.GetAsync(url, cancellationToken); diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index ef625ef95..990da00b9 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -54,9 +54,10 @@ public static Fixture Create( { services.AddSingleton(_ => server); - services.Configure(settings => + services.Configure(settings => { - settings.LocaltestUrl = server.Url ?? throw new Exception("Missing server URL"); + var testUrl = server.Url ?? throw new Exception("Missing server URL"); + settings.ApiStorageEndpoint = $"{testUrl}{new Uri(settings.ApiStorageEndpoint).PathAndQuery}"; }); var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 1, 10, 0, 0, TimeSpan.Zero)); @@ -268,8 +269,11 @@ public async Task Test_App_Shutdown() public async Task Test_Dns_Failure() { await using var fixture = Fixture.Create(registerCustomAppServices: services => - services.Configure(settings => - settings.LocaltestUrl = "http://provoke-dns-fail.local.altinn.cloud" + services.Configure(settings => + settings.ApiStorageEndpoint = ReplaceHost( + settings.ApiStorageEndpoint, + "provoke-dns-fail.local.altinn.cloud" + ) ) ); @@ -397,4 +401,11 @@ public async Task Test_Unhandled_Error_But_Continue_To_Try() Assert.Equal(expectedVersion, ok.Version); Assert.False(lifetime.ApplicationStopping.IsCancellationRequested); } + + private static string ReplaceHost(string original, string newHostName) + { + var builder = new UriBuilder(original); + builder.Host = newHostName; + return builder.Uri.ToString(); + } } From 80f64025d6c5397d224ec041f55a653c90b22b8a Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 8 Jan 2025 12:49:43 +0100 Subject: [PATCH 11/14] Also gate service registration by environment --- src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs | 3 ++- src/Altinn.App.Core/Internal/LocaltestValidation.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs index 5b3561cb7..d574b97e5 100644 --- a/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Core/Extensions/ServiceCollectionExtensions.cs @@ -177,7 +177,8 @@ IWebHostEnvironment env services.Configure(configuration.GetSection(nameof(FrontEndSettings))); services.Configure(configuration.GetSection(nameof(PdfGeneratorSettings))); - services.AddLocaltestValidation(configuration); + if (env.IsDevelopment()) + services.AddLocaltestValidation(configuration); AddValidationServices(services, configuration); AddAppOptions(services); diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index 5f3860470..d9becae2b 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -17,9 +17,9 @@ public static IServiceCollection AddLocaltestValidation( IConfiguration configuration ) { - services.AddSingleton(); if (configuration.GetValue("GeneralSettings:DisableLocaltestValidation")) return services; + services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); return services; } From ea61334a99dc0991cd84e8209f36962d30b33b26 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 8 Jan 2025 12:58:13 +0100 Subject: [PATCH 12/14] Cleanup service registration --- .../Internal/LocaltestValidation.cs | 3 +-- .../Internal/LocaltestValidationTests.cs | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index d9becae2b..126ab087f 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -19,8 +19,7 @@ IConfiguration configuration { if (configuration.GetValue("GeneralSettings:DisableLocaltestValidation")) return services; - services.AddSingleton(); - services.AddSingleton(provider => provider.GetRequiredService()); + services.AddHostedService(); return services; } } diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index 990da00b9..9c6b5296d 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -26,6 +26,9 @@ private sealed record Fixture(WebApplication App) : IAsyncDisposable public FakeTimeProvider TimeProvider => App.Services.GetRequiredService(); + public LocaltestValidation Validator => + App.Services.GetServices().OfType().Single(); + private sealed class ReqHandler(Action? onRequest = null) : DelegatingHandler { protected override Task SendAsync( @@ -80,7 +83,7 @@ public async Task Test_Init() { await using var fixture = Fixture.Create(); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; Assert.NotNull(service); } @@ -103,7 +106,7 @@ public async Task Test_Recent_Version() .WithBody($"{expectedVersion}") ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -136,7 +139,7 @@ public async Task Test_Old_Version() .WithBody($"{expectedVersion}") ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -161,7 +164,7 @@ public async Task Test_Api_Not_Found() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(404)); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -187,7 +190,7 @@ public async Task Test_Invalid_Version() Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody("blah") ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -218,7 +221,7 @@ public async Task Test_Timeout() .WithDelay(delay) ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); await Task.Delay(10); @@ -251,7 +254,7 @@ public async Task Test_App_Shutdown() .WithDelay(delay) ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); await Task.Delay(10); @@ -290,7 +293,7 @@ public async Task Test_Dns_Failure() .WithBody($"{expectedVersion}") ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -312,7 +315,7 @@ public async Task Test_Unhandled_Status() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(201)); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -338,7 +341,7 @@ public async Task Test_Unhandled_Error() .Given(Request.Create().WithPath(Fixture.ApiPath).UsingGet()) .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "text/plain").WithBody($"1")); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); @@ -373,7 +376,7 @@ public async Task Test_Unhandled_Error_But_Continue_To_Try() .WithBody($"{expectedVersion}") ); - var service = fixture.App.Services.GetRequiredService(); + var service = fixture.Validator; var lifetime = fixture.App.Services.GetRequiredService(); await service.StartAsync(lifetime.ApplicationStopping); From 015d821f786416d50ecc93ba6edfe20fce5c8700 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 8 Jan 2025 14:08:13 +0100 Subject: [PATCH 13/14] More random dns name --- test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs index 9c6b5296d..cd216091c 100644 --- a/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs +++ b/test/Altinn.App.Core.Tests/Internal/LocaltestValidationTests.cs @@ -275,7 +275,7 @@ public async Task Test_Dns_Failure() services.Configure(settings => settings.ApiStorageEndpoint = ReplaceHost( settings.ApiStorageEndpoint, - "provoke-dns-fail.local.altinn.cloud" + "provoke-dns-fail.local.not-altinn-at-all.cloud" ) ) ); From 8ac6c77d17a9e83deb5349ddad8076eb795baee7 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 8 Jan 2025 14:20:25 +0100 Subject: [PATCH 14/14] was there a race? --- .../Internal/LocaltestValidation.cs | 88 ++++++++++--------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/src/Altinn.App.Core/Internal/LocaltestValidation.cs b/src/Altinn.App.Core/Internal/LocaltestValidation.cs index 126ab087f..f4255c6ee 100644 --- a/src/Altinn.App.Core/Internal/LocaltestValidation.cs +++ b/src/Altinn.App.Core/Internal/LocaltestValidation.cs @@ -78,52 +78,56 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { var result = await Version(); - - if (!_resultChannel.Writer.TryWrite(result)) - _logger.LogWarning("Couldn't log result to channel"); - - switch (result) + try { - case VersionResult.Ok { Version: var version }: + switch (result) { - _logger.LogInformation("Localtest version: {Version}", version); - if (version >= 1) + case VersionResult.Ok { Version: var version }: + { + _logger.LogInformation("Localtest version: {Version}", version); + if (version >= 1) + return; + _logger.LogError( + "Localtest version is not supported for this version of the app backend. Update your local copy of localtest." + + " Version found: '{Version}'. Shutting down..", + version + ); + Exit(); + return; + } + case VersionResult.ApiNotFound: + { + _logger.LogError( + "Localtest version may be outdated, as we failed to probe {HostName} API for version information." + + " Is localtest running? Do you have a recent copy of localtest? Shutting down..", + ExpectedHostname + ); + Exit(); + return; + } + case VersionResult.ApiNotAvailable { Error: var error }: + _logger.LogWarning( + "Localtest API could not be reached, is it running? Trying again soon.. Error: '{Error}'. Trying again soon..", + error + ); + break; + case VersionResult.UnhandledStatusCode { StatusCode: var statusCode }: + _logger.LogError( + "Localtest version endpoint returned unexpected status code: '{StatusCode}'. Trying again soon..", + statusCode + ); + break; + case VersionResult.UnknownError { Exception: var ex }: + _logger.LogError(ex, "Error while trying to fetch localtest version. Trying again soon.."); + break; + case VersionResult.AppShuttingDown: return; - _logger.LogError( - "Localtest version is not supported for this version of the app backend. Update your local copy of localtest." - + " Version found: '{Version}'. Shutting down..", - version - ); - Exit(); - return; - } - case VersionResult.ApiNotFound: - { - _logger.LogError( - "Localtest version may be outdated, as we failed to probe {HostName} API for version information." - + " Is localtest running? Do you have a recent copy of localtest? Shutting down..", - ExpectedHostname - ); - Exit(); - return; } - case VersionResult.ApiNotAvailable { Error: var error }: - _logger.LogWarning( - "Localtest API could not be reached, is it running? Trying again soon.. Error: '{Error}'. Trying again soon..", - error - ); - break; - case VersionResult.UnhandledStatusCode { StatusCode: var statusCode }: - _logger.LogError( - "Localtest version endpoint returned unexpected status code: '{StatusCode}'. Trying again soon..", - statusCode - ); - break; - case VersionResult.UnknownError { Exception: var ex }: - _logger.LogError(ex, "Error while trying to fetch localtest version. Trying again soon.."); - break; - case VersionResult.AppShuttingDown: - return; + } + finally + { + if (!_resultChannel.Writer.TryWrite(result)) + _logger.LogWarning("Couldn't log result to channel"); } await Task.Delay(TimeSpan.FromSeconds(5), _timeProvider, stoppingToken); }