diff --git a/CHANGELOG.md b/CHANGELOG.md index af4fc7edf..6a830751c 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 diff --git a/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs new file mode 100644 index 000000000..f2f8bc2a1 --- /dev/null +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.IO; +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; +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 Microsoft.Extensions.DependencyInjection; +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 = FindEndpoint(app: app, pattern: "/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); + + 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) + { + 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 000000000..60bb7da5b --- /dev/null +++ b/src/BuildBot.CloudFormation.Tests/CloudFormationSetupConfigurationTests.cs @@ -0,0 +1,84 @@ +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() + { + 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); + } + + [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 + ) + { + 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 a400e1567..682702bf4 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 d53604263..bfa3e761e 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 000000000..36e9ca9ee --- /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 d6df9083b..493932c19 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.Discord.Tests/DiscordSetupConfigurationTests.cs b/src/BuildBot.Discord.Tests/DiscordSetupConfigurationTests.cs new file mode 100644 index 000000000..7e28b0369 --- /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 77477673d..fadbabdf3 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.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs b/src/BuildBot.GitHub.Tests/GitHubEndpointsTestSerializationContext.cs new file mode 100644 index 000000000..d9b9e7c4e --- /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 000000000..f9872719b --- /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 000000000..e3484c4de --- /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 63812cb15..6169c40ab 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 000000000..8c7776113 --- /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 3a4045e54..458767b46 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 0f6b5cc4e..845fa9a40 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.Health.Tests/StatusEndpointsTestSerializationContext.cs b/src/BuildBot.Health.Tests/StatusEndpointsTestSerializationContext.cs new file mode 100644 index 000000000..c190f33cc --- /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 000000000..ff0958c75 --- /dev/null +++ b/src/BuildBot.Health.Tests/StatusEndpointsTests.cs @@ -0,0 +1,145 @@ +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; +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.Http.Metadata; +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 = this.GetTypedLogger(); + this._logger.IsEnabled(Arg.Any()).Returns(true); + } + + [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()); + this._logger.Received(1).IsEnabled(LogLevel.Information); + } + + [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 = 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") + ); + } + + 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 b908959e5..e14474d17 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 000000000..bbed750e7 --- /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 c2388bc7f..d33a29f3f 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 000000000..a6deabd2c --- /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.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs b/src/BuildBot.Watchtower.Tests/WatchtowerEndpointsTestSerializationContext.cs new file mode 100644 index 000000000..df39270e4 --- /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 000000000..c1d3feb5f --- /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 8bd2f7fea..1ae18e932 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 000000000..07a5e0269 --- /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.GitHub.cs b/src/BuildBot/Helpers/Endpoints.GitHub.cs deleted file mode 100644 index 5bbdb11fd..000000000 --- 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.Test.cs b/src/BuildBot/Helpers/Endpoints.Test.cs deleted file mode 100644 index 7b20ed37b..000000000 --- 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.Watchtower.cs b/src/BuildBot/Helpers/Endpoints.Watchtower.cs deleted file mode 100644 index 91be929ec..000000000 --- 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 fae010def..155bfc24e 100644 --- a/src/BuildBot/Helpers/Endpoints.cs +++ b/src/BuildBot/Helpers/Endpoints.cs @@ -1,3 +1,7 @@ +using BuildBot.CloudFormation; +using BuildBot.GitHub; +using BuildBot.Health; +using BuildBot.Watchtower; using Microsoft.AspNetCore.Builder; namespace BuildBot.Helpers; @@ -6,9 +10,6 @@ internal static partial class Endpoints { public static WebApplication ConfigureEndpoints(this WebApplication app) { - return app.ConfigureTestEndpoints() - .ConfigureCloudformationEndpoints() - .ConfigureGitHubEndpoints() - .ConfigureWatchtowerEndpoints(); + 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 6488236ff..000000000 --- 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 1de6fc89e..000000000 --- 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!"); -} diff --git a/src/BuildBot/Helpers/ServerStartup.cs b/src/BuildBot/Helpers/ServerStartup.cs index 1f6c485ac..dec3339ea 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; @@ -62,12 +61,6 @@ public static WebApplication CreateApp(string[] args) IConfigurationRoot config = LoadConfiguration(configPath); - DiscordBotConfiguration discordConfig = LoadDiscordConfig(config); - - SnsNotificationOptions snsConfiguration = LoadSnsNotificationConfig(config); - - GitHubConfiguration gitHubConfig = LoadGitHubConfig(config); - builder.Host.UseWindowsService().UseSystemd(); builder .WebHost.UseKestrel(options: options => @@ -86,9 +79,9 @@ public static WebApplication CreateApp(string[] args) .Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Insert(index: 0, item: AppSerializationContext.Default) ) - .AddDiscord(discordConfig) - .AddCloudFormation(snsConfiguration) - .AddGitHub(gitHubConfig) + .AddDiscord(config) + .AddCloudFormation(config) + .AddGitHub(config) .AddWatchtower() .AddStatus() .AddMediator(); @@ -96,16 +89,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", @@ -146,16 +129,6 @@ private static IConfigurationRoot LoadConfiguration(string configPath) .Build(); } - private static DiscordBotConfiguration LoadDiscordConfig(IConfigurationRoot configuration) - { - 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;