Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
255 changes: 255 additions & 0 deletions src/BuildBot.CloudFormation.Tests/CloudFormationEndpointsTests.cs
Original file line number Diff line number Diff line change
@@ -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<IMediator>();
this._parser = GetSubstitute<ICloudFormationSnsPropertiesParser>();
}

[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<Accepted>(result);
CloudFormationSubscriptionConfirmation published =
this.PublishedNotification<CloudFormationSubscriptionConfirmation>();
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<BadRequest>(result);
Assert.Empty(this._mediator.ReceivedCalls());
}

[Fact]
public async Task NotificationPublishesMessageReceivedAndReturnsNoContentAsync()
{
SnsMessage message = CreateMessage(type: "Notification", subscribeUrl: null);
Dictionary<string, string> 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<NoContent>(result);
CloudFormationMessageReceived published = this.PublishedNotification<CloudFormationMessageReceived>();
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<NotFound>(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<Accepted>(result);
CloudFormationSubscriptionConfirmation published =
this.PublishedNotification<CloudFormationSubscriptionConfirmation>();
Assert.Equal(expected: TOPIC_ARN, actual: published.TopicArn);
Assert.Equal(expected: new(SUBSCRIBE_URL), actual: published.SubscribeUrl);
}

[Fact]
public Task DeployWithNullBodyThrowsAsync()
{
return Assert.ThrowsAsync<JsonException>(() => this.DeployAsync("null"));
}

[Fact]
public async Task MapCloudFormationEndpointsMapsDeployRouteAsync()
{
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder();
builder
.Services.AddMockedService<IMediator>()
.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<HttpMethodMetadata>();
Assert.NotNull(methods);
Assert.Equal(expected: [HttpMethods.Post], actual: methods.HttpMethods);

IAcceptsMetadata? accepts = endpoint.Metadata.GetMetadata<IAcceptsMetadata>();
Assert.NotNull(accepts);
Assert.Equal(expected: ["text/plain", "application/json"], actual: accepts.ContentTypes);

Assert.Null(endpoint.Metadata.GetMetadata<IDisableCookieRedirectMetadata>());
}

[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<IDisableCookieRedirectMetadata>());
}

private static RouteEndpoint FindEndpoint(WebApplication app, string pattern)
{
return Assert.Single(
((IEndpointRouteBuilder)app)
.DataSources.SelectMany(static source => source.Endpoints)
.OfType<RouteEndpoint>(),
predicate: e => StringComparer.Ordinal.Equals(x: e.RoutePattern.RawText, y: pattern)
);
}

private async Task<IResult> 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<TNotification>()
where TNotification : INotification
{
return this
._mediator.ReceivedCalls()
.Select(static call => call.GetArguments()[0])
.OfType<TNotification>()
.Single();
}
}
Original file line number Diff line number Diff line change
@@ -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<string, string?>(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<string, string?>(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<string, string?>(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<SnsNotificationOptions> ResolveOptionsAsync(
IReadOnlyDictionary<string, string?> settings
)
{
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();

await using ServiceProvider serviceProvider = new ServiceCollection()
.AddMockedService<IMediator>()
.AddCloudFormation(configuration)
.BuildServiceProvider();

return serviceProvider.GetRequiredService<SnsNotificationOptions>();
}
}
4 changes: 4 additions & 0 deletions src/BuildBot.CloudFormation/BuildBot.CloudFormation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<DebuggerSupport>true</DebuggerSupport>
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<EnforceExtendedAnalyzerRules>false</EnforceExtendedAnalyzerRules>
Expand Down Expand Up @@ -36,6 +37,9 @@
<WarnOnPackingNonPackableProject>false</WarnOnPackingNonPackableProject>
<WarningsAsErrors />
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../BuildBot.Discord/BuildBot.Discord.csproj" />
</ItemGroup>
Expand Down
Loading
Loading