From d618bdbadf10470deaec877f22b94cd3d4bdd0bd Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:15:06 +0000 Subject: [PATCH 1/7] docs: add changelog placeholder for #429 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index af4fc7ed..6a830751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Please ADD ALL Changes to the UNRELEASED SECTION and not a specific release - Dependencies - Updated Meziantou.Analyzer to 3.0.317 - BuildBot is published trimmed and as a native AOT binary with InvariantGlobalization enabled, and the Docker image uses the plain chiseled runtime-deps base; unused System.Reactive and System.Interactive.Async packages and Serilog.Enrichers.Demystifier are removed, and buildcheck configuration binding generator properties are fixed (#430) - Dependencies - Updated Microsoft.VisualStudio.Threading.Analyzers to 18.12.1 +- Moved startup configuration and endpoint wiring from the BuildBot host into testable feature libraries - #429 ### Deprecated ### Removed - Removed NuGet audit suppressions for Scriban 6.2.0 vulnerabilities now that the transitive dependency has been dropped From 0a5d207e432d6b218a113e1a9600db44acfc4e89 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:48:41 +0000 Subject: [PATCH 2/7] refactor(github): move configuration and endpoint wiring into BuildBot.GitHub (#429) Move the BuildBot.GitHub configuration binding, endpoint mapping and handlers out of the host into the library, with tests. The host keeps only the composition calls (MapGitHubEndpoints in Endpoints.cs and AddGitHub in ServerStartup.cs). Host Endpoints.GitHub.cs and EndpointsLoggingExtensions.cs are removed. --- ...GitHubEndpointsTestSerializationContext.cs | 19 ++ .../GitHubEndpointsTests.cs | 173 ++++++++++++++++++ .../GitHubSetupConfigurationTests.cs | 66 +++++++ src/BuildBot.GitHub/BuildBot.GitHub.csproj | 1 + src/BuildBot.GitHub/GitHubEndpoints.cs | 78 ++++++++ src/BuildBot.GitHub/GitHubSetup.cs | 8 + .../GitHubEndpointsLoggingExtensions.cs} | 4 +- src/BuildBot/Helpers/Endpoints.GitHub.cs | 66 ------- src/BuildBot/Helpers/Endpoints.cs | 3 +- src/BuildBot/Helpers/ServerStartup.cs | 9 +- 10 files changed, 350 insertions(+), 77 deletions(-) create mode 100644 src/BuildBot.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs create mode 100644 src/BuildBot.GitHub.Tests/GitHubEndpointsTests.cs create mode 100644 src/BuildBot.GitHub.Tests/GitHubSetupConfigurationTests.cs create mode 100644 src/BuildBot.GitHub/GitHubEndpoints.cs rename src/{BuildBot/Helpers/LoggingExtensions/EndpointsLoggingExtensions.cs => BuildBot.GitHub/LoggingExtensions/GitHubEndpointsLoggingExtensions.cs} (74%) delete mode 100644 src/BuildBot/Helpers/Endpoints.GitHub.cs diff --git a/src/BuildBot.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs b/src/BuildBot.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs new file mode 100644 index 00000000..d9b9e7c4 --- /dev/null +++ b/src/BuildBot.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BuildBot.ServiceModel.GitHub; + +namespace BuildBot.GitHub.Tests; + +[JsonSourceGenerationOptions( + GenerationMode = JsonSourceGenerationMode.Default, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + IncludeFields = false +)] +[JsonSerializable(typeof(PingModel))] +[JsonSerializable(typeof(Push))] +[JsonSerializable(typeof(Status))] +[JsonSerializable(typeof(JsonElement))] +internal sealed partial class GitHubEndpointsTestSerializationContext : JsonSerializerContext; diff --git a/src/BuildBot.GitHub.Tests/GitHubEndpointsTests.cs b/src/BuildBot.GitHub.Tests/GitHubEndpointsTests.cs new file mode 100644 index 00000000..f9872719 --- /dev/null +++ b/src/BuildBot.GitHub.Tests/GitHubEndpointsTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BuildBot.GitHub.Middleware; +using BuildBot.GitHub.Models; +using BuildBot.ServiceModel.GitHub; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.Core; +using Xunit; + +namespace BuildBot.GitHub.Tests; + +public sealed class GitHubEndpointsTests : TestBase +{ + private readonly IMediator _mediator; + + public GitHubEndpointsTests() + { + this._mediator = GetSubstitute(); + } + + [Fact] + public async Task PingPublishesGithubPingAndReturnsNoContentAsync() + { + PingModel model = new(zen: "Keep it logically awesome.", hookId: "12345"); + + IResult result = await GitHubEndpoints.PingAsync( + model: model, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + GithubPing published = this.PublishedNotification(); + Assert.Equal(expected: model, actual: published.Model); + } + + [Fact] + public async Task PushPublishesGithubPushAndReturnsNoContentAsync() + { + Push model = default; + + IResult result = await GitHubEndpoints.PushAsync( + model: model, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + GithubPush published = this.PublishedNotification(); + Assert.Equal(expected: model, actual: published.Model); + } + + [Fact] + public async Task StatusPublishesGithubStatusAndReturnsNoContentAsync() + { + Status model = default; + + IResult result = await GitHubEndpoints.StatusAsync( + model: model, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + GithubStatus published = this.PublishedNotification(); + Assert.Equal(expected: model, actual: published.Model); + } + + [Fact] + public void MissingWebhookSecretLogsWarningAndDoesNotAddMiddleware() + { + IApplicationBuilder app = GetSubstitute(); + ILogger logger = GetSubstitute(); + logger.IsEnabled(Arg.Any()).Returns(true); + + app.UseGitHubWebhookSignatureVerification(gitHubConfig: new(string.Empty), logger: logger); + + app.DidNotReceive().Use(Arg.Any>()); + ICall logCall = Assert.Single(logger.ReceivedCalls(), predicate: IsLogCall); + Assert.Equal(expected: LogLevel.Warning, actual: logCall.GetArguments()[0]); + } + + [Fact] + public void ConfiguredWebhookSecretAddsMiddlewareWithoutLogging() + { + using ServiceProvider serviceProvider = new ServiceCollection() + .AddLogging() + .AddMockedService() + .AddGitHub(new GitHubConfiguration(GitHubWebhookTestVectors.Secret)) + .BuildServiceProvider(); + + IApplicationBuilder app = GetSubstitute(); + app.ApplicationServices.Returns(serviceProvider); + ILogger logger = GetSubstitute(); + logger.IsEnabled(Arg.Any()).Returns(true); + + app.UseGitHubWebhookSignatureVerification( + gitHubConfig: serviceProvider.GetRequiredService(), + logger: logger + ); + + app.Received(1).Use(Arg.Any>()); + Assert.DoesNotContain(logger.ReceivedCalls(), filter: IsLogCall); + } + + [Fact] + public async Task MapGitHubEndpointsMapsWebhookRoutesAsync() + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert( + index: 0, + item: GitHubEndpointsTestSerializationContext.Default + ) + ); + builder.Services.AddMockedService().AddGitHub(new GitHubConfiguration(string.Empty)); + + await using WebApplication app = builder.Build(); + + WebApplication mapped = app.MapGitHubEndpoints(); + Assert.Same(expected: app, actual: mapped); + + IReadOnlyList endpoints = + [ + .. ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .OfType(), + ]; + + AssertWebhookPostRoute(endpoints: endpoints, route: "/github/ping"); + AssertWebhookPostRoute(endpoints: endpoints, route: "/github/push"); + AssertWebhookPostRoute(endpoints: endpoints, route: "/github/status"); + } + + private static void AssertWebhookPostRoute(IReadOnlyList endpoints, string route) + { + RouteEndpoint endpoint = Assert.Single( + endpoints, + predicate: e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: route) + ); + + HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methods); + Assert.Equal(expected: [HttpMethods.Post], actual: methods.HttpMethods); + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + + private static bool IsLogCall(ICall call) + { + return StringComparer.Ordinal.Equals(x: call.GetMethodInfo().Name, y: nameof(ILogger.Log)); + } + + private TNotification PublishedNotification() + where TNotification : INotification + { + return this + ._mediator.ReceivedCalls() + .Select(static call => call.GetArguments()[0]) + .OfType() + .Single(); + } +} diff --git a/src/BuildBot.GitHub.Tests/GitHubSetupConfigurationTests.cs b/src/BuildBot.GitHub.Tests/GitHubSetupConfigurationTests.cs new file mode 100644 index 00000000..e3484c4d --- /dev/null +++ b/src/BuildBot.GitHub.Tests/GitHubSetupConfigurationTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BuildBot.GitHub.Tests; + +public sealed class GitHubSetupConfigurationTests : TestBase +{ + private const string WEBHOOK_SECRET_KEY = "GitHub:WebhookSecret"; + + [Fact] + public async Task PresentWebhookSecretIsBoundAsync() + { + GitHubConfiguration gitHubConfig = await ResolveConfigurationAsync( + new Dictionary(StringComparer.Ordinal) + { + [WEBHOOK_SECRET_KEY] = GitHubWebhookTestVectors.Secret, + } + ); + + Assert.Equal(expected: GitHubWebhookTestVectors.Secret, actual: gitHubConfig.WebhookSecret); + Assert.True(gitHubConfig.HasWebhookSecret, userMessage: "Webhook secret should be configured"); + } + + [Fact] + public async Task MissingWebhookSecretBindsToEmptyAsync() + { + GitHubConfiguration gitHubConfig = await ResolveConfigurationAsync( + new Dictionary(StringComparer.Ordinal) + ); + + Assert.Equal(expected: string.Empty, actual: gitHubConfig.WebhookSecret); + Assert.False(gitHubConfig.HasWebhookSecret, userMessage: "Webhook secret should not be configured"); + } + + [Fact] + public async Task EmptyWebhookSecretBindsToEmptyAsync() + { + GitHubConfiguration gitHubConfig = await ResolveConfigurationAsync( + new Dictionary(StringComparer.Ordinal) { [WEBHOOK_SECRET_KEY] = string.Empty } + ); + + Assert.Equal(expected: string.Empty, actual: gitHubConfig.WebhookSecret); + Assert.False(gitHubConfig.HasWebhookSecret, userMessage: "Webhook secret should not be configured"); + } + + private static async ValueTask ResolveConfigurationAsync( + IReadOnlyDictionary settings + ) + { + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + await using ServiceProvider serviceProvider = new ServiceCollection() + .AddMockedService() + .AddGitHub(configuration) + .BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } +} diff --git a/src/BuildBot.GitHub/BuildBot.GitHub.csproj b/src/BuildBot.GitHub/BuildBot.GitHub.csproj index 63812cb1..6169c40a 100644 --- a/src/BuildBot.GitHub/BuildBot.GitHub.csproj +++ b/src/BuildBot.GitHub/BuildBot.GitHub.csproj @@ -6,6 +6,7 @@ true true true + true true true false diff --git a/src/BuildBot.GitHub/GitHubEndpoints.cs b/src/BuildBot.GitHub/GitHubEndpoints.cs new file mode 100644 index 00000000..8c777611 --- /dev/null +++ b/src/BuildBot.GitHub/GitHubEndpoints.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.GitHub.LoggingExtensions; +using BuildBot.GitHub.Middleware; +using BuildBot.GitHub.Models; +using BuildBot.ServiceModel.GitHub; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace BuildBot.GitHub; + +public static class GitHubEndpoints +{ + public static WebApplication MapGitHubEndpoints(this WebApplication app) + { + Console.WriteLine("Configuring Github Endpoint"); + + app.UseGitHubWebhookSignatureVerification( + gitHubConfig: app.Services.GetRequiredService(), + logger: app.Logger + ); + + RouteGroupBuilder group = app.MapGroup("/github").WithMetadata(new GitHubWebhookEndpointMetadata()); + + group.MapPost(pattern: "ping", handler: PingAsync); + group.MapPost(pattern: "push", handler: PushAsync); + group.MapPost(pattern: "status", handler: StatusAsync); + + return app; + } + + public static IApplicationBuilder UseGitHubWebhookSignatureVerification( + this IApplicationBuilder app, + GitHubConfiguration gitHubConfig, + ILogger logger + ) + { + if (gitHubConfig.HasWebhookSecret) + { + // Runs after routing and before the endpoint, so unsigned requests are rejected before model binding. + return app.Use(app.ApplicationServices.GetRequiredService().InvokeAsync); + } + + logger.GitHubWebhookSecretNotConfigured(); + + return app; + } + + public static async Task PingAsync( + PingModel model, + IMediator mediator, + CancellationToken cancellationToken + ) + { + await mediator.Publish(new GithubPing(model), cancellationToken: cancellationToken); + + return Results.NoContent(); + } + + public static async Task PushAsync(Push model, IMediator mediator, CancellationToken cancellationToken) + { + await mediator.Publish(new GithubPush(model), cancellationToken: cancellationToken); + + return Results.NoContent(); + } + + public static async Task StatusAsync(Status model, IMediator mediator, CancellationToken cancellationToken) + { + await mediator.Publish(new GithubStatus(model), cancellationToken: cancellationToken); + + return Results.NoContent(); + } +} diff --git a/src/BuildBot.GitHub/GitHubSetup.cs b/src/BuildBot.GitHub/GitHubSetup.cs index 3a4045e5..458767b4 100644 --- a/src/BuildBot.GitHub/GitHubSetup.cs +++ b/src/BuildBot.GitHub/GitHubSetup.cs @@ -2,12 +2,20 @@ using BuildBot.GitHub.Middleware; using BuildBot.GitHub.Publishers; using FunFair.Common.Web; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace BuildBot.GitHub; public static class GitHubSetup { + private const string WEBHOOK_SECRET_KEY = "GitHub:WebhookSecret"; + + public static IServiceCollection AddGitHub(this IServiceCollection services, IConfiguration configuration) + { + return services.AddGitHub(new GitHubConfiguration(configuration[WEBHOOK_SECRET_KEY] ?? string.Empty)); + } + public static IServiceCollection AddGitHub(this IServiceCollection services, GitHubConfiguration gitHubConfig) { return services diff --git a/src/BuildBot/Helpers/LoggingExtensions/EndpointsLoggingExtensions.cs b/src/BuildBot.GitHub/LoggingExtensions/GitHubEndpointsLoggingExtensions.cs similarity index 74% rename from src/BuildBot/Helpers/LoggingExtensions/EndpointsLoggingExtensions.cs rename to src/BuildBot.GitHub/LoggingExtensions/GitHubEndpointsLoggingExtensions.cs index 0f6b5cc4..845fa9a4 100644 --- a/src/BuildBot/Helpers/LoggingExtensions/EndpointsLoggingExtensions.cs +++ b/src/BuildBot.GitHub/LoggingExtensions/GitHubEndpointsLoggingExtensions.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; -namespace BuildBot.Helpers.LoggingExtensions; +namespace BuildBot.GitHub.LoggingExtensions; -internal static partial class EndpointsLoggingExtensions +internal static partial class GitHubEndpointsLoggingExtensions { [LoggerMessage( EventId = 1, diff --git a/src/BuildBot/Helpers/Endpoints.GitHub.cs b/src/BuildBot/Helpers/Endpoints.GitHub.cs deleted file mode 100644 index 5bbdb11f..00000000 --- a/src/BuildBot/Helpers/Endpoints.GitHub.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Threading; -using BuildBot.GitHub; -using BuildBot.GitHub.Middleware; -using BuildBot.GitHub.Models; -using BuildBot.Helpers.LoggingExtensions; -using BuildBot.ServiceModel.GitHub; -using Mediator; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; - -namespace BuildBot.Helpers; - -internal static partial class Endpoints -{ - private static WebApplication ConfigureGitHubEndpoints(this WebApplication app) - { - Console.WriteLine("Configuring Github Endpoint"); - - if (app.Services.GetRequiredService().HasWebhookSecret) - { - // Runs after routing and before the endpoint, so unsigned requests are rejected before model binding. - app.Use(app.Services.GetRequiredService().InvokeAsync); - } - else - { - app.Logger.GitHubWebhookSecretNotConfigured(); - } - - RouteGroupBuilder group = app.MapGroup("/github").WithMetadata(new GitHubWebhookEndpointMetadata()); - - group.MapPost( - pattern: "ping", - handler: static async (PingModel model, IMediator mediator, CancellationToken cancellationToken) => - { - await mediator.Publish(new GithubPing(model), cancellationToken: cancellationToken); - - return Results.NoContent(); - } - ); - - group.MapPost( - pattern: "push", - handler: static async (Push model, IMediator mediator, CancellationToken cancellationToken) => - { - await mediator.Publish(new GithubPush(model), cancellationToken: cancellationToken); - - return Results.NoContent(); - } - ); - - group.MapPost( - pattern: "status", - handler: static async (Status model, IMediator mediator, CancellationToken cancellationToken) => - { - await mediator.Publish(new GithubStatus(model), cancellationToken: cancellationToken); - - return Results.NoContent(); - } - ); - - return app; - } -} diff --git a/src/BuildBot/Helpers/Endpoints.cs b/src/BuildBot/Helpers/Endpoints.cs index fae010de..edcb3ec6 100644 --- a/src/BuildBot/Helpers/Endpoints.cs +++ b/src/BuildBot/Helpers/Endpoints.cs @@ -1,3 +1,4 @@ +using BuildBot.GitHub; using Microsoft.AspNetCore.Builder; namespace BuildBot.Helpers; @@ -8,7 +9,7 @@ public static WebApplication ConfigureEndpoints(this WebApplication app) { return app.ConfigureTestEndpoints() .ConfigureCloudformationEndpoints() - .ConfigureGitHubEndpoints() + .MapGitHubEndpoints() .ConfigureWatchtowerEndpoints(); } } diff --git a/src/BuildBot/Helpers/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index 1f6c485a..f5d767c7 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -66,8 +66,6 @@ public static WebApplication CreateApp(string[] args) SnsNotificationOptions snsConfiguration = LoadSnsNotificationConfig(config); - GitHubConfiguration gitHubConfig = LoadGitHubConfig(config); - builder.Host.UseWindowsService().UseSystemd(); builder .WebHost.UseKestrel(options: options => @@ -88,7 +86,7 @@ public static WebApplication CreateApp(string[] args) ) .AddDiscord(discordConfig) .AddCloudFormation(snsConfiguration) - .AddGitHub(gitHubConfig) + .AddGitHub(config) .AddWatchtower() .AddStatus() .AddMediator(); @@ -151,11 +149,6 @@ private static DiscordBotConfiguration LoadDiscordConfig(IConfigurationRoot conf return DiscordBotConfiguration.FromWebhookUrl(configuration["Discord:WebhookUrl"]); } - private static GitHubConfiguration LoadGitHubConfig(IConfigurationRoot configuration) - { - return new(configuration["GitHub:WebhookSecret"] ?? string.Empty); - } - private static void SetH1ListenOptions(ListenOptions listenOptions) { listenOptions.Protocols = HttpProtocols.Http1; From ddf0cc719e71721d68105f5a230822243e5385af Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:16:02 +0000 Subject: [PATCH 3/7] refactor(cloudformation): move configuration and endpoint wiring into BuildBot.CloudFormation (#429) Move the CloudFormation endpoints and configuration binding out of the src/BuildBot host into src/BuildBot.CloudFormation, so the host only wires it up. Adds tests for the endpoints and the setup configuration binding. Tests: 409/409 passing. Build clean. --- .../CloudFormationEndpointsTests.cs | 227 ++++++++++++++++++ .../CloudFormationSetupConfigurationTests.cs | 72 ++++++ .../BuildBot.CloudFormation.csproj | 4 + .../CloudFormationEndpoints.cs} | 85 +++---- .../CloudFormationSerializationContext.cs | 14 ++ .../CloudformationSetup.cs | 17 ++ src/BuildBot/Helpers/Endpoints.cs | 3 +- src/BuildBot/Helpers/ServerStartup.cs | 15 +- 8 files changed, 373 insertions(+), 64 deletions(-) create mode 100644 src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs create mode 100644 src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs rename src/{BuildBot/Helpers/Endpoints.CloudFormation.cs => BuildBot.CloudFormation/CloudFormationEndpoints.cs} (58%) create mode 100644 src/BuildBot.CloudFormation/CloudFormationSerializationContext.cs diff --git a/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs new file mode 100644 index 00000000..50344b2b --- /dev/null +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using BuildBot.CloudFormation.Configuration; +using BuildBot.CloudFormation.Models; +using BuildBot.ServiceModel.CloudFormation; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using NSubstitute; +using Xunit; + +namespace BuildBot.CloudFormation.Tests; + +public sealed class CloudFormationEndpointsTests : TestBase +{ + private const string TOPIC_ARN = "arn:aws:sns:eu-west-1:123456789012:test-topic"; + private const string MESSAGE_ID = "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324"; + private const string SUBSCRIBE_URL = "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription"; + + private static readonly DateTime Timestamp = new( + year: 2026, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + kind: DateTimeKind.Utc + ); + + private readonly IMediator _mediator; + private readonly ICloudFormationSnsPropertiesParser _parser; + + public CloudFormationEndpointsTests() + { + this._mediator = GetSubstitute(); + this._parser = GetSubstitute(); + } + + [Fact] + public async Task SubscriptionConfirmationPublishesConfirmationAndReturnsAcceptedAsync() + { + SnsMessage message = CreateMessage(type: "SubscriptionConfirmation", subscribeUrl: SUBSCRIBE_URL); + + IResult result = await CloudFormationEndpoints.HandleDeployMessageAsync( + message: message, + parser: this._parser, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + CloudFormationSubscriptionConfirmation published = + this.PublishedNotification(); + Assert.Equal(expected: TOPIC_ARN, actual: published.TopicArn); + Assert.Equal(expected: new(SUBSCRIBE_URL), actual: published.SubscribeUrl); + } + + [Fact] + public async Task SubscriptionConfirmationWithoutSubscribeUrlReturnsBadRequestAsync() + { + SnsMessage message = CreateMessage(type: "SubscriptionConfirmation", subscribeUrl: null); + + IResult result = await CloudFormationEndpoints.HandleDeployMessageAsync( + message: message, + parser: this._parser, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + Assert.Empty(this._mediator.ReceivedCalls()); + } + + [Fact] + public async Task NotificationPublishesMessageReceivedAndReturnsNoContentAsync() + { + SnsMessage message = CreateMessage(type: "Notification", subscribeUrl: null); + Dictionary properties = new(StringComparer.Ordinal) { ["StackName"] = "test-stack" }; + this._parser.SplitMessageToDictionary(message).Returns(properties); + + IResult result = await CloudFormationEndpoints.HandleDeployMessageAsync( + message: message, + parser: this._parser, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + CloudFormationMessageReceived published = this.PublishedNotification(); + Assert.Equal(expected: TOPIC_ARN, actual: published.TopicArn); + Assert.Equal(expected: MESSAGE_ID, actual: published.MessageId); + Assert.Same(expected: properties, actual: published.Properties); + Assert.Equal(expected: Timestamp, actual: published.Timestamp); + } + + [Fact] + public async Task UnknownMessageTypeReturnsNotFoundAsync() + { + SnsMessage message = CreateMessage(type: "UnsubscribeConfirmation", subscribeUrl: null); + + IResult result = await CloudFormationEndpoints.HandleDeployMessageAsync( + message: message, + parser: this._parser, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + Assert.Empty(this._mediator.ReceivedCalls()); + Assert.Empty(this._parser.ReceivedCalls()); + } + + [Fact] + public async Task DeployReadsSnsMessageFromBodyAsync() + { + const string body = + "{\"Type\":\"SubscriptionConfirmation\",\"MessageId\":\"" + + MESSAGE_ID + + "\",\"Token\":\"token\",\"TopicArn\":\"" + + TOPIC_ARN + + "\",\"Subject\":\"subject\",\"Message\":\"message\",\"Timestamp\":\"2026-01-01T00:00:00Z\"," + + "\"SignatureVersion\":\"1\",\"Signature\":\"signature\",\"SigningCertURL\":null,\"SubscribeURL\":\"" + + SUBSCRIBE_URL + + "\",\"UnsubscribeURL\":null}"; + + IResult result = await this.DeployAsync(body); + + Assert.IsType(result); + CloudFormationSubscriptionConfirmation published = + this.PublishedNotification(); + Assert.Equal(expected: TOPIC_ARN, actual: published.TopicArn); + Assert.Equal(expected: new(SUBSCRIBE_URL), actual: published.SubscribeUrl); + } + + [Fact] + public Task DeployWithNullBodyThrowsAsync() + { + return Assert.ThrowsAsync(() => this.DeployAsync("null")); + } + + [Fact] + public async Task MapCloudFormationEndpointsMapsDeployRouteAsync() + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder + .Services.AddMockedService() + .AddCloudFormation( + new SnsNotificationOptions( + TopicArn: TOPIC_ARN, + Region: "eu-west-1", + AccessKey: "AKIAIOSFODNN7EXAMPLE", + SecretKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + ) + ); + + await using WebApplication app = builder.Build(); + + WebApplication mapped = app.MapCloudFormationEndpoints(); + Assert.Same(expected: app, actual: mapped); + + RouteEndpoint endpoint = Assert.Single( + ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .OfType(), + predicate: static e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: "/cloudformation/deploy") + ); + + HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methods); + Assert.Equal(expected: [HttpMethods.Post], actual: methods.HttpMethods); + + IAcceptsMetadata? accepts = endpoint.Metadata.GetMetadata(); + Assert.NotNull(accepts); + Assert.Equal(expected: ["text/plain", "application/json"], actual: accepts.ContentTypes); + } + + private async Task DeployAsync(string body) + { + await using MemoryStream stream = new(Encoding.UTF8.GetBytes(body)); + DefaultHttpContext context = new() { Request = { Body = stream } }; + + return await CloudFormationEndpoints.DeployAsync( + request: context.Request, + parser: this._parser, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + } + + private static SnsMessage CreateMessage(string type, string? subscribeUrl) + { + return new( + type: type, + messageId: MESSAGE_ID, + token: null, + topicArn: TOPIC_ARN, + subject: "subject", + message: "message", + timestamp: Timestamp, + signatureVersion: "1", + signature: "signature", + signingCertUrl: null, + subscribeUrl: subscribeUrl, + unsubscribeUrl: null + ); + } + + private TNotification PublishedNotification() + where TNotification : INotification + { + return this + ._mediator.ReceivedCalls() + .Select(static call => call.GetArguments()[0]) + .OfType() + .Single(); + } +} diff --git a/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs b/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs new file mode 100644 index 00000000..feb176ad --- /dev/null +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BuildBot.CloudFormation.Configuration; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BuildBot.CloudFormation.Tests; + +public sealed class CloudFormationSetupConfigurationTests : TestBase +{ + private const string TOPIC_ARN_KEY = "CloudFormation:TopicArn"; + private const string REGION_KEY = "CloudFormation:Aws:Region"; + private const string ACCESS_KEY_ID_KEY = "CloudFormation:Aws:AccessKeyId"; + private const string SECRET_KEY_KEY = "CloudFormation:Aws:SecretKey"; + + private const string TOPIC_ARN = "arn:aws:sns:eu-west-1:123456789012:test-topic"; + private const string REGION = "eu-west-1"; + private const string ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"; + private const string SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + + [Fact] + public async Task PresentSettingsAreBoundAsync() + { + SnsNotificationOptions options = await ResolveOptionsAsync( + new Dictionary(StringComparer.Ordinal) + { + [TOPIC_ARN_KEY] = TOPIC_ARN, + [REGION_KEY] = REGION, + [ACCESS_KEY_ID_KEY] = ACCESS_KEY, + [SECRET_KEY_KEY] = SECRET_KEY, + } + ); + + Assert.Equal(expected: TOPIC_ARN, actual: options.TopicArn); + Assert.Equal(expected: REGION, actual: options.Region); + Assert.Equal(expected: ACCESS_KEY, actual: options.AccessKey); + Assert.Equal(expected: SECRET_KEY, actual: options.SecretKey); + } + + [Fact] + public async Task MissingSettingsBindToEmptyAsync() + { + // The region is always supplied because the AWS region endpoint is resolved when the services are registered. + SnsNotificationOptions options = await ResolveOptionsAsync( + new Dictionary(StringComparer.Ordinal) { [REGION_KEY] = REGION } + ); + + Assert.Equal(expected: string.Empty, actual: options.TopicArn); + Assert.Equal(expected: REGION, actual: options.Region); + Assert.Equal(expected: string.Empty, actual: options.AccessKey); + Assert.Equal(expected: string.Empty, actual: options.SecretKey); + } + + private static async ValueTask ResolveOptionsAsync( + IReadOnlyDictionary settings + ) + { + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + await using ServiceProvider serviceProvider = new ServiceCollection() + .AddMockedService() + .AddCloudFormation(configuration) + .BuildServiceProvider(); + + return serviceProvider.GetRequiredService(); + } +} diff --git a/src/BuildBot.CloudFormation/BuildBot.CloudFormation.csproj b/src/BuildBot.CloudFormation/BuildBot.CloudFormation.csproj index a400e156..682702bf 100644 --- a/src/BuildBot.CloudFormation/BuildBot.CloudFormation.csproj +++ b/src/BuildBot.CloudFormation/BuildBot.CloudFormation.csproj @@ -6,6 +6,7 @@ true true true + true true true false @@ -36,6 +37,9 @@ false + + + diff --git a/src/BuildBot/Helpers/Endpoints.CloudFormation.cs b/src/BuildBot.CloudFormation/CloudFormationEndpoints.cs similarity index 58% rename from src/BuildBot/Helpers/Endpoints.CloudFormation.cs rename to src/BuildBot.CloudFormation/CloudFormationEndpoints.cs index d5360426..bfa3e761 100644 --- a/src/BuildBot/Helpers/Endpoints.CloudFormation.cs +++ b/src/BuildBot.CloudFormation/CloudFormationEndpoints.cs @@ -2,56 +2,51 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; -using BuildBot.CloudFormation; using BuildBot.CloudFormation.Models; -using BuildBot.Json; using BuildBot.ServiceModel.CloudFormation; using Mediator; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -namespace BuildBot.Helpers; +namespace BuildBot.CloudFormation; -internal static partial class Endpoints +public static class CloudFormationEndpoints { - private static WebApplication ConfigureCloudformationEndpoints(this WebApplication app) + public static WebApplication MapCloudFormationEndpoints(this WebApplication app) { Console.WriteLine("Configuring Cloudformation Endpoint"); RouteGroupBuilder group = app.MapGroup("/cloudformation"); + + // SNS posts with a text/plain content type, so the body is read directly rather than model bound. group - .MapPost( - pattern: "deploy", - handler: static async ( - HttpRequest request, - ICloudFormationSnsPropertiesParser parser, - IMediator mediator, - CancellationToken cancellationToken - ) => - { - SnsMessage message = await ReadJsonAsync( - request: request, - cancellationToken: cancellationToken - ); - - return await HandleDeployMessageAsync( - message: message, - parser: parser, - mediator: mediator, - cancellationToken: cancellationToken - ); - } - ) + .MapPost(pattern: "deploy", handler: DeployAsync) .Accepts(contentType: "text/plain", "application/json"); return app; } - private static async ValueTask HandleDeployMessageAsync( + public static async Task DeployAsync( + HttpRequest request, + ICloudFormationSnsPropertiesParser parser, + IMediator mediator, + CancellationToken cancellationToken + ) + { + SnsMessage message = await ReadSnsMessageAsync(request: request, cancellationToken: cancellationToken); + + return await HandleDeployMessageAsync( + message: message, + parser: parser, + mediator: mediator, + cancellationToken: cancellationToken + ); + } + + public static async Task HandleDeployMessageAsync( SnsMessage message, ICloudFormationSnsPropertiesParser parser, IMediator mediator, @@ -60,7 +55,7 @@ CancellationToken cancellationToken { if (StringComparer.Ordinal.Equals(x: message.Type, y: "SubscriptionConfirmation")) { - return await HandleSnsSubscriptionMessagesAsync( + return await HandleSnsSubscriptionMessageAsync( message: message, mediator: mediator, cancellationToken: cancellationToken @@ -69,7 +64,7 @@ CancellationToken cancellationToken if (StringComparer.Ordinal.Equals(x: message.Type, y: "Notification")) { - return await HandleNotiifcationMessageAsync( + return await HandleNotificationMessageAsync( message: message, parser: parser, mediator: mediator, @@ -80,7 +75,7 @@ CancellationToken cancellationToken return Results.NotFound(); } - private static async ValueTask HandleNotiifcationMessageAsync( + private static async ValueTask HandleNotificationMessageAsync( SnsMessage message, ICloudFormationSnsPropertiesParser parser, IMediator mediator, @@ -102,7 +97,7 @@ await mediator.Publish( return Results.NoContent(); } - private static async ValueTask HandleSnsSubscriptionMessagesAsync( + private static async ValueTask HandleSnsSubscriptionMessageAsync( SnsMessage message, IMediator mediator, CancellationToken cancellationToken @@ -121,30 +116,22 @@ await mediator.Publish( return Results.Accepted(); } - private static async ValueTask ReadJsonAsync(HttpRequest request, CancellationToken cancellationToken) + private static async ValueTask ReadSnsMessageAsync( + HttpRequest request, + CancellationToken cancellationToken + ) { - if (AppSerializationContext.Default.GetTypeInfo(typeof(T)) is not JsonTypeInfo typeInfo) - { - return NoTypeInformation(); - } - - T? result = await JsonSerializer.DeserializeAsync( + SnsMessage? message = await JsonSerializer.DeserializeAsync( utf8Json: request.Body, - jsonTypeInfo: typeInfo, + jsonTypeInfo: CloudFormationSerializationContext.Default.SnsMessage, cancellationToken: cancellationToken ); - return result ?? InvalidJson(); - } - - [DoesNotReturn] - private static T NoTypeInformation() - { - throw new JsonException("No type handler found"); + return message ?? InvalidJson(); } [DoesNotReturn] - private static T InvalidJson() + private static SnsMessage InvalidJson() { throw new JsonException("Could not parse JSON"); } diff --git a/src/BuildBot.CloudFormation/CloudFormationSerializationContext.cs b/src/BuildBot.CloudFormation/CloudFormationSerializationContext.cs new file mode 100644 index 00000000..36e9ca9e --- /dev/null +++ b/src/BuildBot.CloudFormation/CloudFormationSerializationContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using BuildBot.ServiceModel.CloudFormation; + +namespace BuildBot.CloudFormation; + +[JsonSourceGenerationOptions( + GenerationMode = JsonSourceGenerationMode.Serialization | JsonSourceGenerationMode.Metadata, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + IncludeFields = false +)] +[JsonSerializable(typeof(SnsMessage))] +internal sealed partial class CloudFormationSerializationContext : JsonSerializerContext; diff --git a/src/BuildBot.CloudFormation/CloudformationSetup.cs b/src/BuildBot.CloudFormation/CloudformationSetup.cs index d6df9083..493932c1 100644 --- a/src/BuildBot.CloudFormation/CloudformationSetup.cs +++ b/src/BuildBot.CloudFormation/CloudformationSetup.cs @@ -10,6 +10,7 @@ using BuildBot.CloudFormation.Configuration; using BuildBot.CloudFormation.Publishers; using BuildBot.CloudFormation.Services; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Polly; @@ -18,6 +19,22 @@ namespace BuildBot.CloudFormation; public static class CloudformationSetup { private const int MAX_RETRIES = 3; + private const string TOPIC_ARN_KEY = "CloudFormation:TopicArn"; + private const string REGION_KEY = "CloudFormation:Aws:Region"; + private const string ACCESS_KEY_ID_KEY = "CloudFormation:Aws:AccessKeyId"; + private const string SECRET_KEY_KEY = "CloudFormation:Aws:SecretKey"; + + public static IServiceCollection AddCloudFormation(this IServiceCollection services, IConfiguration configuration) + { + return services.AddCloudFormation( + new SnsNotificationOptions( + TopicArn: configuration[TOPIC_ARN_KEY] ?? string.Empty, + Region: configuration[REGION_KEY] ?? string.Empty, + AccessKey: configuration[ACCESS_KEY_ID_KEY] ?? string.Empty, + SecretKey: configuration[SECRET_KEY_KEY] ?? string.Empty + ) + ); + } public static IServiceCollection AddCloudFormation( this IServiceCollection services, diff --git a/src/BuildBot/Helpers/Endpoints.cs b/src/BuildBot/Helpers/Endpoints.cs index edcb3ec6..1dd3980d 100644 --- a/src/BuildBot/Helpers/Endpoints.cs +++ b/src/BuildBot/Helpers/Endpoints.cs @@ -1,3 +1,4 @@ +using BuildBot.CloudFormation; using BuildBot.GitHub; using Microsoft.AspNetCore.Builder; @@ -8,7 +9,7 @@ internal static partial class Endpoints public static WebApplication ConfigureEndpoints(this WebApplication app) { return app.ConfigureTestEndpoints() - .ConfigureCloudformationEndpoints() + .MapCloudFormationEndpoints() .MapGitHubEndpoints() .ConfigureWatchtowerEndpoints(); } diff --git a/src/BuildBot/Helpers/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index f5d767c7..e9f7d24f 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -5,7 +5,6 @@ using System.Net; using System.Threading; using BuildBot.CloudFormation; -using BuildBot.CloudFormation.Configuration; using BuildBot.Discord; using BuildBot.GitHub; using BuildBot.Health; @@ -64,8 +63,6 @@ public static WebApplication CreateApp(string[] args) DiscordBotConfiguration discordConfig = LoadDiscordConfig(config); - SnsNotificationOptions snsConfiguration = LoadSnsNotificationConfig(config); - builder.Host.UseWindowsService().UseSystemd(); builder .WebHost.UseKestrel(options: options => @@ -85,7 +82,7 @@ public static WebApplication CreateApp(string[] args) options.SerializerOptions.TypeInfoResolverChain.Insert(index: 0, item: AppSerializationContext.Default) ) .AddDiscord(discordConfig) - .AddCloudFormation(snsConfiguration) + .AddCloudFormation(config) .AddGitHub(config) .AddWatchtower() .AddStatus() @@ -94,16 +91,6 @@ public static WebApplication CreateApp(string[] args) return builder.Build(); } - private static SnsNotificationOptions LoadSnsNotificationConfig(IConfigurationRoot configuration) - { - string topicArn = configuration["CloudFormation:TopicArn"] ?? string.Empty; - string region = configuration["CloudFormation:Aws:Region"] ?? string.Empty; - string accessKeyId = configuration["CloudFormation:Aws:AccessKeyId"] ?? string.Empty; - string secretKey = configuration["CloudFormation:Aws:SecretKey"] ?? string.Empty; - - return new(TopicArn: topicArn, Region: region, AccessKey: accessKeyId, SecretKey: secretKey); - } - [SuppressMessage( category: "Microsoft.Reliability", checkId: "CA2000:DisposeObjectsBeforeLosingScope", From e94a933486a44350c68f9f628d5b9aad114a7050 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:31:37 +0000 Subject: [PATCH 4/7] refactor(watchtower): move endpoint wiring into BuildBot.Watchtower (#429) --- ...htowerEndpointsTestSerializationContext.cs | 15 ++++ .../WatchtowerEndpointsTests.cs | 82 +++++++++++++++++++ .../BuildBot.Watchtower.csproj | 4 + .../WatchtowerEndpoints.cs | 35 ++++++++ src/BuildBot/Helpers/Endpoints.Watchtower.cs | 31 ------- src/BuildBot/Helpers/Endpoints.cs | 6 +- 6 files changed, 138 insertions(+), 35 deletions(-) create mode 100644 src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs create mode 100644 src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTests.cs create mode 100644 src/BuildBot.Watchtower/WatchtowerEndpoints.cs delete mode 100644 src/BuildBot/Helpers/Endpoints.Watchtower.cs diff --git a/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs b/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs new file mode 100644 index 00000000..df39270e --- /dev/null +++ b/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using BuildBot.ServiceModel.Watchtower; + +namespace BuildBot.Watchtower.Tests; + +[JsonSourceGenerationOptions( + GenerationMode = JsonSourceGenerationMode.Default, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + IncludeFields = false +)] +[JsonSerializable(typeof(WatchTowerMessage))] +internal sealed partial class WatchtowerEndpointsTestSerializationContext : JsonSerializerContext; diff --git a/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTests.cs b/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTests.cs new file mode 100644 index 00000000..c1d3feb5 --- /dev/null +++ b/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTests.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using BuildBot.ServiceModel.Watchtower; +using BuildBot.Watchtower.Models; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace BuildBot.Watchtower.Tests; + +public sealed class WatchtowerEndpointsTests : TestBase +{ + private readonly IMediator _mediator; + + public WatchtowerEndpointsTests() + { + this._mediator = GetSubstitute(); + } + + [Fact] + public async Task DeployPublishesWatchTowerPublishMessageAndReturnsNoContentAsync() + { + WatchTowerMessage model = new(Message: "Updated container", Title: "Watchtower"); + + IResult result = await WatchtowerEndpoints.DeployAsync( + model: model, + mediator: this._mediator, + cancellationToken: this.CancellationToken() + ); + + Assert.IsType(result); + WatchTowerPublishMessage published = this.PublishedNotification(); + Assert.Equal(expected: model, actual: published.Model); + } + + [Fact] + public async Task MapWatchtowerEndpointsMapsDeployRouteAsync() + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert( + index: 0, + item: WatchtowerEndpointsTestSerializationContext.Default + ) + ); + builder.Services.AddMockedService().AddWatchtower(); + + await using WebApplication app = builder.Build(); + + WebApplication mapped = app.MapWatchtowerEndpoints(); + Assert.Same(expected: app, actual: mapped); + + RouteEndpoint endpoint = Assert.Single( + ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .OfType(), + predicate: static e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: "/watchtower/deploy") + ); + + HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methods); + Assert.Equal(expected: [HttpMethods.Post], actual: methods.HttpMethods); + } + + private TNotification PublishedNotification() + where TNotification : INotification + { + return this + ._mediator.ReceivedCalls() + .Select(static call => call.GetArguments()[0]) + .OfType() + .Single(); + } +} diff --git a/src/BuildBot.Watchtower/BuildBot.Watchtower.csproj b/src/BuildBot.Watchtower/BuildBot.Watchtower.csproj index 8bd2f7fe..1ae18e93 100644 --- a/src/BuildBot.Watchtower/BuildBot.Watchtower.csproj +++ b/src/BuildBot.Watchtower/BuildBot.Watchtower.csproj @@ -6,6 +6,7 @@ true true true + true true true false @@ -36,6 +37,9 @@ false + + + diff --git a/src/BuildBot.Watchtower/WatchtowerEndpoints.cs b/src/BuildBot.Watchtower/WatchtowerEndpoints.cs new file mode 100644 index 00000000..07a5e026 --- /dev/null +++ b/src/BuildBot.Watchtower/WatchtowerEndpoints.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.ServiceModel.Watchtower; +using BuildBot.Watchtower.Models; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace BuildBot.Watchtower; + +public static class WatchtowerEndpoints +{ + public static WebApplication MapWatchtowerEndpoints(this WebApplication app) + { + Console.WriteLine("Configuring Watchtower Endpoint"); + + RouteGroupBuilder group = app.MapGroup("/watchtower"); + group.MapPost(pattern: "deploy", handler: DeployAsync); + + return app; + } + + public static async Task DeployAsync( + WatchTowerMessage model, + IMediator mediator, + CancellationToken cancellationToken + ) + { + await mediator.Publish(new WatchTowerPublishMessage(model), cancellationToken: cancellationToken); + + return Results.NoContent(); + } +} diff --git a/src/BuildBot/Helpers/Endpoints.Watchtower.cs b/src/BuildBot/Helpers/Endpoints.Watchtower.cs deleted file mode 100644 index 91be929e..00000000 --- a/src/BuildBot/Helpers/Endpoints.Watchtower.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Threading; -using BuildBot.ServiceModel.Watchtower; -using BuildBot.Watchtower.Models; -using Mediator; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; - -namespace BuildBot.Helpers; - -internal static partial class Endpoints -{ - private static WebApplication ConfigureWatchtowerEndpoints(this WebApplication app) - { - Console.WriteLine("Configuring Watchtower Endpoint"); - - RouteGroupBuilder group = app.MapGroup("/watchtower"); - group.MapPost( - pattern: "deploy", - handler: static async (WatchTowerMessage model, IMediator mediator, CancellationToken cancellationToken) => - { - await mediator.Publish(new WatchTowerPublishMessage(model), cancellationToken: cancellationToken); - - return Results.NoContent(); - } - ); - - return app; - } -} diff --git a/src/BuildBot/Helpers/Endpoints.cs b/src/BuildBot/Helpers/Endpoints.cs index 1dd3980d..295737d1 100644 --- a/src/BuildBot/Helpers/Endpoints.cs +++ b/src/BuildBot/Helpers/Endpoints.cs @@ -1,5 +1,6 @@ using BuildBot.CloudFormation; using BuildBot.GitHub; +using BuildBot.Watchtower; using Microsoft.AspNetCore.Builder; namespace BuildBot.Helpers; @@ -8,9 +9,6 @@ internal static partial class Endpoints { public static WebApplication ConfigureEndpoints(this WebApplication app) { - return app.ConfigureTestEndpoints() - .MapCloudFormationEndpoints() - .MapGitHubEndpoints() - .ConfigureWatchtowerEndpoints(); + return app.ConfigureTestEndpoints().MapCloudFormationEndpoints().MapGitHubEndpoints().MapWatchtowerEndpoints(); } } From 4b0f97b7995ed93a38fa7ef9061c738dadcbfff9 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:47:28 +0000 Subject: [PATCH 5/7] refactor(discord): move configuration wiring into BuildBot.Discord (#429) Add DiscordSetup.AddDiscord(IServiceCollection, IConfiguration) so the Discord webhook configuration is read inside BuildBot.Discord rather than in ServerStartup. Adds DiscordSetupConfigurationTests covering binding, service resolution, and missing or empty webhook URLs. --- .../DiscordSetupConfigurationTests.cs | 66 +++++++++++++++++++ src/BuildBot.Discord/DiscordSetup.cs | 7 ++ src/BuildBot/Helpers/ServerStartup.cs | 9 +-- 3 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/BuildBot.Discord.Tests/DiscordSetupConfigurationTests.cs diff --git a/src/BuildBot.Discord.Tests/DiscordSetupConfigurationTests.cs b/src/BuildBot.Discord.Tests/DiscordSetupConfigurationTests.cs new file mode 100644 index 00000000..7e28b036 --- /dev/null +++ b/src/BuildBot.Discord.Tests/DiscordSetupConfigurationTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BuildBot.Discord.Services; +using FunFair.Test.Common; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BuildBot.Discord.Tests; + +public sealed class DiscordSetupConfigurationTests : TestBase +{ + private const string WEBHOOK_URL_KEY = "Discord:WebhookUrl"; + private const string WEBHOOK_URL = "https://example.com/webhook"; + + [Fact] + public async Task PresentWebhookUrlIsBoundAsync() + { + await using ServiceProvider serviceProvider = BuildServiceProvider( + new Dictionary(StringComparer.Ordinal) { [WEBHOOK_URL_KEY] = WEBHOOK_URL } + ); + + DiscordBotConfiguration discordConfig = serviceProvider.GetRequiredService(); + + Assert.Equal(expected: new Uri(WEBHOOK_URL), actual: discordConfig.WebhookUrl); + } + + [Fact] + public async Task PresentWebhookUrlResolvesDiscordBotAsync() + { + await using ServiceProvider serviceProvider = BuildServiceProvider( + new Dictionary(StringComparer.Ordinal) { [WEBHOOK_URL_KEY] = WEBHOOK_URL } + ); + + Assert.NotNull(serviceProvider.GetRequiredService()); + } + + [Fact] + public void MissingWebhookUrlThrows() + { + IConfiguration configuration = BuildConfiguration(new Dictionary(StringComparer.Ordinal)); + + Assert.Throws(() => new ServiceCollection().AddDiscord(configuration)); + } + + [Fact] + public void EmptyWebhookUrlThrows() + { + IConfiguration configuration = BuildConfiguration( + new Dictionary(StringComparer.Ordinal) { [WEBHOOK_URL_KEY] = string.Empty } + ); + + Assert.Throws(() => new ServiceCollection().AddDiscord(configuration)); + } + + private static ServiceProvider BuildServiceProvider(IReadOnlyDictionary settings) + { + return new ServiceCollection().AddDiscord(BuildConfiguration(settings)).BuildServiceProvider(); + } + + private static IConfiguration BuildConfiguration(IReadOnlyDictionary settings) + { + return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + } +} diff --git a/src/BuildBot.Discord/DiscordSetup.cs b/src/BuildBot.Discord/DiscordSetup.cs index 77477673..fadbabdf 100644 --- a/src/BuildBot.Discord/DiscordSetup.cs +++ b/src/BuildBot.Discord/DiscordSetup.cs @@ -6,6 +6,7 @@ using BuildBot.Discord.Services; using BuildBot.Discord.Services.LoggingExtensions; using BuildBot.ServiceModel.ComponentStatus; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -16,6 +17,7 @@ namespace BuildBot.Discord; public static class DiscordSetup { + private const string WEBHOOK_URL_KEY = "Discord:WebhookUrl"; private const int MAX_RETRIES = 3; private static readonly TimeSpan MaximumRetryAfterDelay = TimeSpan.FromSeconds(60); private static readonly TimeSpan AttemptAllowance = TimeSpan.FromSeconds(30); @@ -31,6 +33,11 @@ public static class DiscordSetup public static readonly TimeSpan WebhookTimeout = MaximumRetryAfterDelay * MAX_RETRIES + AttemptAllowance * (MAX_RETRIES + 1) + WebhookTimeoutSlack; + public static IServiceCollection AddDiscord(this IServiceCollection services, IConfiguration configuration) + { + return services.AddDiscord(DiscordBotConfiguration.FromWebhookUrl(configuration[WEBHOOK_URL_KEY])); + } + public static IServiceCollection AddDiscord(this IServiceCollection services, DiscordBotConfiguration discordConfig) { services.TryAddSingleton(TimeProvider.System); diff --git a/src/BuildBot/Helpers/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index e9f7d24f..dec3339e 100644 --- a/src/BuildBot/Helpers/ServerStartup.cs +++ b/src/BuildBot/Helpers/ServerStartup.cs @@ -61,8 +61,6 @@ public static WebApplication CreateApp(string[] args) IConfigurationRoot config = LoadConfiguration(configPath); - DiscordBotConfiguration discordConfig = LoadDiscordConfig(config); - builder.Host.UseWindowsService().UseSystemd(); builder .WebHost.UseKestrel(options: options => @@ -81,7 +79,7 @@ public static WebApplication CreateApp(string[] args) .Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(index: 0, item: AppSerializationContext.Default) ) - .AddDiscord(discordConfig) + .AddDiscord(config) .AddCloudFormation(config) .AddGitHub(config) .AddWatchtower() @@ -131,11 +129,6 @@ private static IConfigurationRoot LoadConfiguration(string configPath) .Build(); } - private static DiscordBotConfiguration LoadDiscordConfig(IConfigurationRoot configuration) - { - return DiscordBotConfiguration.FromWebhookUrl(configuration["Discord:WebhookUrl"]); - } - private static void SetH1ListenOptions(ListenOptions listenOptions) { listenOptions.Protocols = HttpProtocols.Http1; From 19fdda02a801034f9053240d16544f50dbd2ef3b Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 03:03:21 +0000 Subject: [PATCH 6/7] refactor(health): move /ping endpoint wiring into BuildBot.Health (#429) --- ...StatusEndpointsTestSerializationContext.cs | 18 +++ .../StatusEndpointsTests.cs | 116 ++++++++++++++++++ src/BuildBot.Health/BuildBot.Health.csproj | 5 + .../StatusEndpointContextLoggingExtensions.cs | 12 ++ .../StatusEndpointContext.cs} | 4 +- src/BuildBot.Health/StatusEndpoints.cs | 55 +++++++++ src/BuildBot/Helpers/Endpoints.Test.cs | 53 -------- src/BuildBot/Helpers/Endpoints.cs | 3 +- .../TestEndpointContextLoggingExtensions.cs | 12 -- src/BuildBot/Helpers/PingPong.cs | 8 -- 10 files changed, 210 insertions(+), 76 deletions(-) create mode 100644 src/BuildBot.Health.Tests/StatusEndpointsTestSerializationContext.cs create mode 100644 src/BuildBot.Health.Tests/StatusEndpointsTests.cs create mode 100644 src/BuildBot.Health/LoggingExtensions/StatusEndpointContextLoggingExtensions.cs rename src/{BuildBot/Helpers/TestEndpointContext.cs => BuildBot.Health/StatusEndpointContext.cs} (72%) create mode 100644 src/BuildBot.Health/StatusEndpoints.cs delete mode 100644 src/BuildBot/Helpers/Endpoints.Test.cs delete mode 100644 src/BuildBot/Helpers/LoggingExtensions/TestEndpointContextLoggingExtensions.cs delete mode 100644 src/BuildBot/Helpers/PingPong.cs diff --git a/src/BuildBot.Health.Tests/StatusEndpointsTestSerializationContext.cs b/src/BuildBot.Health.Tests/StatusEndpointsTestSerializationContext.cs new file mode 100644 index 00000000..c190f33c --- /dev/null +++ b/src/BuildBot.Health.Tests/StatusEndpointsTestSerializationContext.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using BuildBot.Json.Models; +using BuildBot.ServiceModel.ComponentStatus; + +namespace BuildBot.Health.Tests; + +[JsonSourceGenerationOptions( + GenerationMode = JsonSourceGenerationMode.Default, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false, + IncludeFields = false +)] +[JsonSerializable(typeof(PongDto))] +[JsonSerializable(typeof(IReadOnlyList))] +internal sealed partial class StatusEndpointsTestSerializationContext : JsonSerializerContext; diff --git a/src/BuildBot.Health.Tests/StatusEndpointsTests.cs b/src/BuildBot.Health.Tests/StatusEndpointsTests.cs new file mode 100644 index 00000000..87b1dd90 --- /dev/null +++ b/src/BuildBot.Health.Tests/StatusEndpointsTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Json.Models; +using BuildBot.ServiceModel.ComponentStatus; +using FunFair.Test.Common; +using FunFair.Test.Infrastructure; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace BuildBot.Health.Tests; + +public sealed class StatusEndpointsTests : TestBase +{ + private readonly ILogger _logger; + private readonly IMediator _mediator; + + public StatusEndpointsTests() + { + this._mediator = GetSubstitute(); + this._logger = GetSubstitute>(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("test")] + public async Task PingReturnsPongWhenAllServicesAreOkAsync(string? source) + { + this.SetStatus(new ServiceStatus(Name: "One", Ok: true), new ServiceStatus(Name: "Two", Ok: true)); + + IResult result = await this.PingAsync(source); + + Ok ok = Assert.IsType>(result); + Assert.Equal(expected: "Pong!", actual: ok.Value.Value); + await this._mediator.Received(1).Send(new CheckStatus(source), this.CancellationToken()); + } + + [Fact] + public async Task PingReturnsConflictWhenAnyServiceIsNotOkAsync() + { + ServiceStatus[] statuses = [new(Name: "One", Ok: true), new(Name: "Two", Ok: false)]; + this.SetStatus(statuses); + + IResult result = await this.PingAsync("test"); + + Conflict> conflict = Assert.IsType>>(result); + Assert.Equal(expected: statuses, actual: conflict.Value); + } + + [Fact] + public async Task PingReturnsPongWhenNoServicesAreRegisteredAsync() + { + this.SetStatus(); + + IResult result = await this.PingAsync(null); + + Assert.IsType>(result); + } + + [Fact] + public async Task MapStatusEndpointsMapsPingRouteAsync() + { + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert( + index: 0, + item: StatusEndpointsTestSerializationContext.Default + ) + ); + builder.Services.AddMockedService(); + + await using WebApplication app = builder.Build(); + + WebApplication mapped = app.MapStatusEndpoints(); + Assert.Same(expected: app, actual: mapped); + + RouteEndpoint endpoint = Assert.Single( + ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .OfType(), + predicate: static e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: "/ping") + ); + + HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methods); + Assert.Equal(expected: [HttpMethods.Get], actual: methods.HttpMethods); + } + + private Task PingAsync(string? source) + { + return StatusEndpoints.PingAsync( + source: source, + mediator: this._mediator, + logger: this._logger, + cancellationToken: this.CancellationToken() + ); + } + + private void SetStatus(params ServiceStatus[] statuses) + { + IReadOnlyList result = statuses; + + this._mediator.Send(Arg.Any(), Arg.Any()).Returns(result); + } +} diff --git a/src/BuildBot.Health/BuildBot.Health.csproj b/src/BuildBot.Health/BuildBot.Health.csproj index b908959e..e14474d1 100644 --- a/src/BuildBot.Health/BuildBot.Health.csproj +++ b/src/BuildBot.Health/BuildBot.Health.csproj @@ -6,6 +6,7 @@ true true true + true true true false @@ -37,6 +38,10 @@ + + + + diff --git a/src/BuildBot.Health/LoggingExtensions/StatusEndpointContextLoggingExtensions.cs b/src/BuildBot.Health/LoggingExtensions/StatusEndpointContextLoggingExtensions.cs new file mode 100644 index 00000000..bbed750e --- /dev/null +++ b/src/BuildBot.Health/LoggingExtensions/StatusEndpointContextLoggingExtensions.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Logging; + +namespace BuildBot.Health.LoggingExtensions; + +internal static partial class StatusEndpointContextLoggingExtensions +{ + [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "PING: No Source")] + public static partial void LogPing(this ILogger logger); + + [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "PING: Source: {Source}")] + public static partial void LogPing(this ILogger logger, string source); +} diff --git a/src/BuildBot/Helpers/TestEndpointContext.cs b/src/BuildBot.Health/StatusEndpointContext.cs similarity index 72% rename from src/BuildBot/Helpers/TestEndpointContext.cs rename to src/BuildBot.Health/StatusEndpointContext.cs index c2388bc7..d33a29f3 100644 --- a/src/BuildBot/Helpers/TestEndpointContext.cs +++ b/src/BuildBot.Health/StatusEndpointContext.cs @@ -1,10 +1,10 @@ using System.Diagnostics.CodeAnalysis; -namespace BuildBot.Helpers; +namespace BuildBot.Health; [SuppressMessage( category: "SonarAnalyzer.CSharp", checkId: "S2094: Remove empty class", Justification = "Needed for logging" )] -public sealed class TestEndpointContext; +public sealed class StatusEndpointContext; diff --git a/src/BuildBot.Health/StatusEndpoints.cs b/src/BuildBot.Health/StatusEndpoints.cs new file mode 100644 index 00000000..a6deabd2 --- /dev/null +++ b/src/BuildBot.Health/StatusEndpoints.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BuildBot.Health.LoggingExtensions; +using BuildBot.Json.Models; +using BuildBot.ServiceModel.ComponentStatus; +using Mediator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace BuildBot.Health; + +public static class StatusEndpoints +{ + private static readonly PongDto Pong = new("Pong!"); + + public static WebApplication MapStatusEndpoints(this WebApplication app) + { + Console.WriteLine("Configuring Test/Ping Endpoint"); + + app.MapGet(pattern: "/ping", handler: PingAsync); + + return app; + } + + public static async Task PingAsync( + [FromQuery] string? source, + IMediator mediator, + ILogger logger, + CancellationToken cancellationToken + ) + { + LogPing(source: source, logger: logger); + + IReadOnlyList status = await mediator.Send(new CheckStatus(source), cancellationToken); + + return status.All(static s => s.Ok) ? Results.Ok(Pong) : Results.Conflict(status); + } + + private static void LogPing(string? source, ILogger logger) + { + if (!string.IsNullOrWhiteSpace(source)) + { + logger.LogPing(source); + } + else + { + logger.LogPing(); + } + } +} diff --git a/src/BuildBot/Helpers/Endpoints.Test.cs b/src/BuildBot/Helpers/Endpoints.Test.cs deleted file mode 100644 index 7b20ed37..00000000 --- a/src/BuildBot/Helpers/Endpoints.Test.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using BuildBot.Health; -using BuildBot.Helpers.LoggingExtensions; -using BuildBot.ServiceModel.ComponentStatus; -using Mediator; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace BuildBot.Helpers; - -internal static partial class Endpoints -{ - private static WebApplication ConfigureTestEndpoints(this WebApplication app) - { - Console.WriteLine("Configuring Test/Ping Endpoint"); - - app.MapGet( - pattern: "/ping", - handler: static async ( - [FromQuery] string? source, - IMediator mediator, - ILogger logger, - CancellationToken cancellationToken - ) => - { - LogPing(source: source, logger: logger); - - IReadOnlyList status = await mediator.Send(new CheckStatus(source), cancellationToken); - - return status.All(s => s.Ok) ? Results.Ok(PingPong.Model) : Results.Conflict(status); - } - ); - - return app; - } - - private static void LogPing(string? source, ILogger logger) - { - if (!string.IsNullOrWhiteSpace(source)) - { - logger.LogPing(source); - } - else - { - logger.LogPing(); - } - } -} diff --git a/src/BuildBot/Helpers/Endpoints.cs b/src/BuildBot/Helpers/Endpoints.cs index 295737d1..155bfc24 100644 --- a/src/BuildBot/Helpers/Endpoints.cs +++ b/src/BuildBot/Helpers/Endpoints.cs @@ -1,5 +1,6 @@ using BuildBot.CloudFormation; using BuildBot.GitHub; +using BuildBot.Health; using BuildBot.Watchtower; using Microsoft.AspNetCore.Builder; @@ -9,6 +10,6 @@ internal static partial class Endpoints { public static WebApplication ConfigureEndpoints(this WebApplication app) { - return app.ConfigureTestEndpoints().MapCloudFormationEndpoints().MapGitHubEndpoints().MapWatchtowerEndpoints(); + return app.MapStatusEndpoints().MapCloudFormationEndpoints().MapGitHubEndpoints().MapWatchtowerEndpoints(); } } diff --git a/src/BuildBot/Helpers/LoggingExtensions/TestEndpointContextLoggingExtensions.cs b/src/BuildBot/Helpers/LoggingExtensions/TestEndpointContextLoggingExtensions.cs deleted file mode 100644 index 6488236f..00000000 --- a/src/BuildBot/Helpers/LoggingExtensions/TestEndpointContextLoggingExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace BuildBot.Helpers.LoggingExtensions; - -internal static partial class TestEndpointContextLoggingExtensions -{ - [LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "PING: No Source")] - public static partial void LogPing(this ILogger logger); - - [LoggerMessage(EventId = 2, Level = LogLevel.Information, Message = "PING: Source: {Source}")] - public static partial void LogPing(this ILogger logger, string source); -} diff --git a/src/BuildBot/Helpers/PingPong.cs b/src/BuildBot/Helpers/PingPong.cs deleted file mode 100644 index 1de6fc89..00000000 --- a/src/BuildBot/Helpers/PingPong.cs +++ /dev/null @@ -1,8 +0,0 @@ -using BuildBot.Json.Models; - -namespace BuildBot.Helpers; - -internal static class PingPong -{ - public static PongDto Model { get; } = new("Pong!"); -} From 21b6da6fd5ecdcf6505ff6c7f749f47846c41a98 Mon Sep 17 00:00:00 2001 From: Mark Ridgwell <273118822+dnyw4l3n13@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:27:38 +0000 Subject: [PATCH 7/7] test: raise coverage of extracted endpoint wiring Add tests for the CloudFormation endpoint and setup configuration wiring and the Health status endpoints extracted under #429. --- .../CloudFormationEndpointsTests.cs | 40 +++++++++++++++--- .../CloudFormationSetupConfigurationTests.cs | 14 ++++++- .../StatusEndpointsTests.cs | 41 ++++++++++++++++--- 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs index 50344b2b..f2f8bc2a 100644 --- a/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using System.Threading.Tasks; using BuildBot.CloudFormation.Configuration; using BuildBot.CloudFormation.Models; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using NSubstitute; using Xunit; @@ -168,12 +170,7 @@ public async Task MapCloudFormationEndpointsMapsDeployRouteAsync() WebApplication mapped = app.MapCloudFormationEndpoints(); Assert.Same(expected: app, actual: mapped); - RouteEndpoint endpoint = Assert.Single( - ((IEndpointRouteBuilder)app) - .DataSources.SelectMany(static source => source.Endpoints) - .OfType(), - predicate: static e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: "/cloudformation/deploy") - ); + RouteEndpoint endpoint = FindEndpoint(app: app, pattern: "/cloudformation/deploy"); HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); Assert.NotNull(methods); @@ -182,6 +179,37 @@ public async Task MapCloudFormationEndpointsMapsDeployRouteAsync() IAcceptsMetadata? accepts = endpoint.Metadata.GetMetadata(); Assert.NotNull(accepts); Assert.Equal(expected: ["text/plain", "application/json"], actual: accepts.ContentTypes); + + Assert.Null(endpoint.Metadata.GetMetadata()); + } + + [Fact] + public async Task MapCloudFormationEndpointsWithoutRegisteredServicesTreatsThemAsJsonBodyAsync() + { + // Covers the generated request delegate's fallback for handler parameters that are not registered services; + // that fallback needs JSON metadata for the service interfaces, which only the reflection resolver can supply. + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(index: 0, item: new DefaultJsonTypeInfoResolver()) + ); + + await using WebApplication app = builder.Build(); + + Assert.Same(expected: app, actual: app.MapCloudFormationEndpoints()); + + RouteEndpoint endpoint = FindEndpoint(app: app, pattern: "/cloudformation/deploy"); + + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + + private static RouteEndpoint FindEndpoint(WebApplication app, string pattern) + { + return Assert.Single( + ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .OfType(), + predicate: e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: pattern) + ); } private async Task DeployAsync(string body) diff --git a/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs b/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs index feb176ad..60bb7da5 100644 --- a/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs @@ -45,7 +45,6 @@ public async Task PresentSettingsAreBoundAsync() [Fact] public async Task MissingSettingsBindToEmptyAsync() { - // The region is always supplied because the AWS region endpoint is resolved when the services are registered. SnsNotificationOptions options = await ResolveOptionsAsync( new Dictionary(StringComparer.Ordinal) { [REGION_KEY] = REGION } ); @@ -56,6 +55,19 @@ public async Task MissingSettingsBindToEmptyAsync() Assert.Equal(expected: string.Empty, actual: options.SecretKey); } + [Fact] + public async Task MissingRegionBindsToEmptyAsync() + { + SnsNotificationOptions options = await ResolveOptionsAsync( + new Dictionary(StringComparer.Ordinal) + ); + + Assert.Equal(expected: string.Empty, actual: options.TopicArn); + Assert.Equal(expected: string.Empty, actual: options.Region); + Assert.Equal(expected: string.Empty, actual: options.AccessKey); + Assert.Equal(expected: string.Empty, actual: options.SecretKey); + } + private static async ValueTask ResolveOptionsAsync( IReadOnlyDictionary settings ) diff --git a/src/BuildBot.Health.Tests/StatusEndpointsTests.cs b/src/BuildBot.Health.Tests/StatusEndpointsTests.cs index 87b1dd90..ff0958c7 100644 --- a/src/BuildBot.Health.Tests/StatusEndpointsTests.cs +++ b/src/BuildBot.Health.Tests/StatusEndpointsTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; using BuildBot.Json.Models; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -27,7 +29,8 @@ public sealed class StatusEndpointsTests : TestBase public StatusEndpointsTests() { this._mediator = GetSubstitute(); - this._logger = GetSubstitute>(); + this._logger = this.GetTypedLogger(); + this._logger.IsEnabled(Arg.Any()).Returns(true); } [Theory] @@ -44,6 +47,7 @@ public async Task PingReturnsPongWhenAllServicesAreOkAsync(string? source) Ok ok = Assert.IsType>(result); Assert.Equal(expected: "Pong!", actual: ok.Value.Value); await this._mediator.Received(1).Send(new CheckStatus(source), this.CancellationToken()); + this._logger.Received(1).IsEnabled(LogLevel.Information); } [Fact] @@ -85,16 +89,41 @@ public async Task MapStatusEndpointsMapsPingRouteAsync() WebApplication mapped = app.MapStatusEndpoints(); Assert.Same(expected: app, actual: mapped); - RouteEndpoint endpoint = Assert.Single( + RouteEndpoint endpoint = FindPingEndpoint(app); + + HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); + Assert.NotNull(methods); + Assert.Equal(expected: [HttpMethods.Get], actual: methods.HttpMethods); + Assert.Null(endpoint.Metadata.GetMetadata()); + } + + [Fact] + public async Task MapStatusEndpointsWithoutRegisteredMediatorTreatsItAsJsonBodyAsync() + { + // Covers the generated request delegate's fallback for handler parameters that are not registered services; + // that fallback needs JSON metadata for the service interfaces, which only the reflection resolver can supply. + WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(); + builder.Services.ConfigureHttpJsonOptions(static options => + options.SerializerOptions.TypeInfoResolverChain.Insert(index: 0, item: new DefaultJsonTypeInfoResolver()) + ); + + await using WebApplication app = builder.Build(); + + Assert.Same(expected: app, actual: app.MapStatusEndpoints()); + + RouteEndpoint endpoint = FindPingEndpoint(app); + + Assert.NotNull(endpoint.Metadata.GetMetadata()); + } + + private static RouteEndpoint FindPingEndpoint(WebApplication app) + { + return Assert.Single( ((IEndpointRouteBuilder)app) .DataSources.SelectMany(static source => source.Endpoints) .OfType(), predicate: static e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: "/ping") ); - - HttpMethodMetadata? methods = endpoint.Metadata.GetMetadata(); - Assert.NotNull(methods); - Assert.Equal(expected: [HttpMethods.Get], actual: methods.HttpMethods); } private Task PingAsync(string? source)