Skip to content

Commit

Permalink
Merge pull request #2033 from ThreeMammals/release/23.2
Browse files Browse the repository at this point in the history
Release 23.2.2 with #2032 hotfix
  • Loading branch information
raman-m committed Apr 5, 2024
2 parents 7cc6f9b + 2ded872 commit 7c26c07
Show file tree
Hide file tree
Showing 15 changed files with 139 additions and 90 deletions.
4 changes: 2 additions & 2 deletions .circleci/config.yml
Expand Up @@ -4,13 +4,13 @@ orbs:
jobs:
build:
docker:
- image: ocelot2/circleci-build:8.21.0
- image: ocelot2/circleci-build:latest
steps:
- checkout
- run: dotnet tool restore && dotnet cake
release:
docker:
- image: ocelot2/circleci-build:8.21.0
- image: ocelot2/circleci-build:latest
steps:
- checkout
- run: dotnet tool restore && dotnet cake --target=Release
Expand Down
33 changes: 30 additions & 3 deletions ReleaseNotes.md
@@ -1,4 +1,31 @@
## Documentation patch (version {0}) for [{1}](https://github.com/ThreeMammals/Ocelot/releases/tag/{1}) release
> Read the Docs: [Ocelot 23.2](https://ocelot.readthedocs.io/en/{0}/)
## Hotfix release (version {0}) for #2031 issue
> Route path template placeholders and their validation rules
This is a technical release: no other information.
Special thanks to **[Guillaume Gnaegi](https://github.com/ggnaegi)** and [Fabrizio Mancin](https://github.com/Fabman08)!

### About
The bug is related to the [Placeholders](https://ocelot.readthedocs.io/en/latest/features/routing.html#placeholders) feature in [Configuration](https://ocelot.readthedocs.io/en/latest/features/configuration.html) and [Routing](https://ocelot.readthedocs.io/en/latest/features/routing.html).
The bug was introduced in version [23.2.0](https://github.com/ThreeMammals/Ocelot/releases/tag/23.2.0) as a part of PR #1927.

### Breaking Change
The new [validation rules](https://github.com/ThreeMammals/Ocelot/blob/23.2.0/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs#L45-L50) of the `FileConfigurationFluentValidator` class do not allow the Ocelot app to start when implicit [placeholders](https://ocelot.readthedocs.io/en/latest/features/routing.html#placeholders) are defined in custom implementations, such as middlewares, delegating handlers, and replaced services in the dependency injection (DI) container.
These new rules are capable of validating explicit [placeholders](https://ocelot.readthedocs.io/en/latest/features/routing.html#placeholders) only within the `UpstreamPathTemplate` and `DownstreamPathTemplate` properties. Unfortunately, they cannot oversee implicit placeholders in custom implementations, and they do not validate early during the Ocelot app startup process.

Ensure that you avoid using version [23.2.0](https://github.com/ThreeMammals/Ocelot/releases/tag/23.2.0). If you are currently on that version, upgrade to version [{0}](https://github.com/ThreeMammals/Ocelot/releases/tag/{0}) by applying this hotfix patch.

### Technical info
With version [23.2.0](https://github.com/ThreeMammals/Ocelot/releases/tag/23.2.0), particularly if you have overridden certain service classes or implemented custom logic that manipulates placeholders, you may encounter Ocelot app crashes accompanied by the following errors in the log:
```
One or more errors occurred. (Unable to start Ocelot, errors are: XXX)
```
where `XXX` are the following validation error messages:
- `UpstreamPathTemplate 'UUU' doesn't contain the same placeholders in DownstreamPathTemplate 'DDD'`
- `DownstreamPathTemplate 'DDD' doesn't contain the same placeholders in UpstreamPathTemplate 'UUU'`

**Finally**, the [validation rules](https://github.com/ThreeMammals/Ocelot/blob/23.2.0/src/Ocelot/Configuration/Validator/FileConfigurationFluentValidator.cs#L45-L50) resulted from the incorrect assumption that placeholders are always explicit and can be validated early. Therefore, custom implementations and feature services in the dependency injection (DI) container, which rely on or manipulate placeholders, should validate the configuration JSON and appropriate options later, directly within their service implementations.

### Bug Artifacts
- Released in version: [23.2.0](https://github.com/ThreeMammals/Ocelot/releases/tag/23.2.0)
- Introduced in: PR #1927
- Reported bug: #2031 by @ggnaegi and tested by @Fabman08
- Hotfix PR: #2032 by @raman-m
31 changes: 13 additions & 18 deletions build.cake
Expand Up @@ -56,7 +56,7 @@ var nugetFeedStableSymbolsUploadUrl = "https://www.nuget.org/api/v2/package";
string committedVersion = "0.0.0-dev";
GitVersion versioning = null;
int releaseId = 0;
bool IsTechnicalRelease = true;
bool IsTechnicalRelease = false;
string gitHubUsername = "TomPallister";
string gitHubPassword = Environment.GetEnvironmentVariable("OCELOT_GITHUB_API_KEY");

Expand Down Expand Up @@ -84,7 +84,7 @@ Task("RunTests")
.IsDependentOn("RunIntegrationTests");

Task("Release")
//.IsDependentOn("Build")
.IsDependentOn("Build")
.IsDependentOn("CreateReleaseNotes")
.IsDependentOn("CreateArtifacts")
.IsDependentOn("PublishGitHubRelease")
Expand Down Expand Up @@ -306,11 +306,11 @@ Task("CreateReleaseNotes")
}
} // END of Top 3

releaseNotes.Add("### Honoring :medal_sports: aka Top Contributors :clap:");
releaseNotes.AddRange(topContributors);
releaseNotes.Add("");
releaseNotes.Add("### Starring :star: aka Release Influencers :bowtie:");
releaseNotes.AddRange(starring);
// releaseNotes.Add("### Honoring :medal_sports: aka Top Contributors :clap:");
// releaseNotes.AddRange(topContributors);
// releaseNotes.Add("");
// releaseNotes.Add("### Starring :star: aka Release Influencers :bowtie:");
// releaseNotes.AddRange(starring);
releaseNotes.Add("");
releaseNotes.Add($"### Features in Release {releaseVersion}");
var commitsHistory = GitHelper($"log --no-merges --date=format:\"%A, %B %d at %H:%M\" --pretty=format:\"<sub>%h by **%aN** on %ad &rarr;</sub>%n%s\" {lastRelease}..HEAD");
Expand Down Expand Up @@ -423,7 +423,7 @@ Task("RunIntegrationTests")

Task("CreateArtifacts")
.IsDependentOn("CreateReleaseNotes")
//.IsDependentOn("Compile")
.IsDependentOn("Compile")
.Does(() =>
{
WriteReleaseNotes();
Expand Down Expand Up @@ -530,7 +530,6 @@ Task("PublishToNuget")
if (IsRunningOnCircleCI())
{
Information("Publish to NuGet...");
PublishPackages(packagesDir, artifactsFile, nugetFeedStableKey, nugetFeedStableUploadUrl, nugetFeedStableSymbolsUploadUrl);
}
});
Expand Down Expand Up @@ -588,7 +587,7 @@ private void PersistVersion(string committedVersion, string newVersion)
/// Publishes code and symbols packages to nuget feed, based on contents of artifacts file
private void PublishPackages(ConvertableDirectoryPath packagesDir, ConvertableFilePath artifactsFile, string feedApiKey, string codeFeedUrl, string symbolFeedUrl)
{
Information("PublishPackages");
Information("Publishing to NuGet...");
var artifacts = System.IO.File
.ReadAllLines(artifactsFile)
.Distinct();
Expand All @@ -601,17 +600,13 @@ private void PublishPackages(ConvertableDirectoryPath packagesDir, ConvertableFi
}

var codePackage = packagesDir + File(artifact);
Information("Pushing package " + codePackage + "...");

Information("Pushing package " + codePackage);

Information("Calling NuGetPush");

Information("Calling DotNetNuGetPush");
DotNetNuGetPush(
codePackage,
new DotNetNuGetPushSettings {
ApiKey = feedApiKey,
Source = codeFeedUrl
});
new DotNetNuGetPushSettings { ApiKey = feedApiKey, Source = codeFeedUrl }
);
}
}

Expand Down
16 changes: 16 additions & 0 deletions docker/8.21.0/Dockerfile.base
@@ -0,0 +1,16 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine

RUN apk add bash icu-libs krb5-libs libgcc libintl libssl1.1 libstdc++ zlib git openssh-client

RUN curl -L --output ./dotnet-install.sh https://dot.net/v1/dotnet-install.sh

RUN chmod u+x ./dotnet-install.sh

# Install .NET 8 SDK (already included in the base image, but listed for consistency)
RUN ./dotnet-install.sh -c 8.0 -i /usr/share/dotnet

# Install .NET 7 SDK
RUN ./dotnet-install.sh -c 7.0 -i /usr/share/dotnet

# Install .NET 6 SDK
RUN ./dotnet-install.sh -c 6.0 -i /usr/share/dotnet
17 changes: 17 additions & 0 deletions docker/8.21.0/Dockerfile.build
@@ -0,0 +1,17 @@
# call from ocelot repo root with
# docker build --platform linux/arm64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .
# docker build --platform linux/amd64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .

FROM ocelot2/circleci-build:8.21.0

ARG OCELOT_COVERALLS_TOKEN

ENV OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN

WORKDIR /build

COPY ./. .

RUN dotnet tool restore

RUN dotnet cake
21 changes: 21 additions & 0 deletions docker/8.21.0/Dockerfile.release
@@ -0,0 +1,21 @@
# call from ocelot repo root with
# docker build --platform linux/arm64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN --build-arg OCELOT_GITHUB_API_KEY=$OCELOT_GITHUB_API_KEY --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .
# docker build --platform linux/amd64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN --build-arg OCELOT_GITHUB_API_KEY=$OCELOT_GITHUB_API_KEY --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .

FROM ocelot2/circleci-build:8.21.0

ARG OCELOT_COVERALLS_TOKEN
ARG OCELOT_NUTGET_API_KEY
ARG OCELOT_GITHUB_API_KEY

ENV OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN
ENV OCELOT_NUTGET_API_KEY=$OCELOT_NUTGET_API_KEY
ENV OCELOT_GITHUB_API_KEY=$OCELOT_GITHUB_API_KEY

WORKDIR /build

COPY ./. .

RUN dotnet tool restore

RUN dotnet cake
11 changes: 11 additions & 0 deletions docker/8.21.0/build.sh
@@ -0,0 +1,11 @@
# This script builds the Ocelot Docker file

# {DotNetSdkVer}.{OcelotVer} -> {.NET8}.{21.0} -> 8.21.0
version=8.21.0
docker build --platform linux/amd64 -t ocelot2/circleci-build -f Dockerfile.base .

echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin

docker tag ocelot2/circleci-build ocelot2/circleci-build:$version
docker push ocelot2/circleci-build:latest
docker push ocelot2/circleci-build:$version
11 changes: 10 additions & 1 deletion docker/Dockerfile.base
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine

RUN apk add bash icu-libs krb5-libs libgcc libintl libssl1.1 libstdc++ zlib git openssh-client
RUN apk add bash icu-libs krb5-libs libgcc libintl libssl3 libstdc++ zlib git openssh-client

RUN curl -L --output ./dotnet-install.sh https://dot.net/v1/dotnet-install.sh

Expand All @@ -14,3 +14,12 @@ RUN ./dotnet-install.sh -c 7.0 -i /usr/share/dotnet

# Install .NET 6 SDK
RUN ./dotnet-install.sh -c 6.0 -i /usr/share/dotnet

# Generate and export the development certificate
RUN dotnet dev-certs https -ep /certs/cert.pem -p '' && \
chmod 644 /certs/cert.pem

ENV ASPNETCORE_URLS="https://+;http://+"
ENV ASPNETCORE_HTTPS_PORT=443
ENV ASPNETCORE_Kestrel__Certificates__Default__Password=""
ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/certs/cert.pem
2 changes: 1 addition & 1 deletion docker/Dockerfile.build
Expand Up @@ -2,7 +2,7 @@
# docker build --platform linux/arm64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .
# docker build --platform linux/amd64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .

FROM ocelot2/circleci-build:8.21.0
FROM ocelot2/circleci-build:latest

ARG OCELOT_COVERALLS_TOKEN

Expand Down
2 changes: 1 addition & 1 deletion docker/Dockerfile.release
Expand Up @@ -2,7 +2,7 @@
# docker build --platform linux/arm64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN --build-arg OCELOT_GITHUB_API_KEY=$OCELOT_GITHUB_API_KEY --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .
# docker build --platform linux/amd64 --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN --build-arg OCELOT_GITHUB_API_KEY=$OCELOT_GITHUB_API_KEY --build-arg OCELOT_COVERALLS_TOKEN=$OCELOT_COVERALLS_TOKEN -f ./docker/Dockerfile.build .

FROM ocelot2/circleci-build:8.21.0
FROM ocelot2/circleci-build:latest

ARG OCELOT_COVERALLS_TOKEN
ARG OCELOT_NUTGET_API_KEY
Expand Down
11 changes: 11 additions & 0 deletions docker/README.md
@@ -1,3 +1,14 @@
# docker build

This folder contains the `Dockerfile.*` and `build.sh` script to create the Ocelot build image & container.

## Account
- [Ocelot Gateway Profile | Docker Hub](https://hub.docker.com/u/ocelot2)

## Repositories
- [circleci-build](https://hub.docker.com/r/ocelot2/circleci-build)

## ocelot2/circleci-build Tags
- [latest](https://hub.docker.com/layers/ocelot2/circleci-build/latest/images/sha256-981d6f9e6e5ba54f6e044bca6fcf8b5197a8f3e6ce2b3cdfa9e6704ecd2ca969?context=explore) is version 8.23.2, uploaded on Apr 3, 2024.
- [8.23.2](https://hub.docker.com/layers/ocelot2/circleci-build/8.23.2/images/sha256-981d6f9e6e5ba54f6e044bca6fcf8b5197a8f3e6ce2b3cdfa9e6704ecd2ca969?context=explore), uploaded on Apr 3, 2024. It supports development SSL certificates by the command `dotnet dev-certs https`.
- [8.21.0](https://hub.docker.com/layers/ocelot2/circleci-build/8.21.0/images/sha256-edb46d37ab52d39a5b27dc63895e5944d4d491d1788744ed144ecb4303b94532?context=explore), uploaded on Nov 20, 2023. It contains .NET 8, 7 and 6 SDKs. It supports builds for `net6.0`, `net7.0` and `net8.0` frameworks.
4 changes: 2 additions & 2 deletions docker/build.sh
@@ -1,7 +1,7 @@
# This script builds the Ocelot Docker file

# {DotNetSdkVer}.{OcelotVer} -> {.NET8}.{21.0} -> 8.21.0
version=8.21.0
# {DotNetSdkVer}.{OcelotVer} -> {.NET8}.{23.2} -> 8.23.2
version=8.23.2
docker build --platform linux/amd64 -t ocelot2/circleci-build -f Dockerfile.base .

echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin
Expand Down
4 changes: 2 additions & 2 deletions docs/features/configuration.rst
Expand Up @@ -189,10 +189,10 @@ For example:

.. code-block:: csharp
ConfigureAppConfiguration((hostingContext, config) =>
ConfigureAppConfiguration((context, config) =>
{
config.AddJsonFile(ConfigurationBuilderExtensions.PrimaryConfigFile, optional: false, reloadOnChange: true); // old approach
var env = hostingContext.HostingEnvironment;
var env = context.HostingEnvironment;
var mergeTo = MergeOcelotJson.ToFile; // ToMemory
var folder = "/My/folder";
FileConfiguration configuration = new(); // read from anywhere and initialize
Expand Down
Expand Up @@ -41,13 +41,6 @@ public FileConfigurationFluentValidator(IServiceProvider provider, RouteFluentVa
RuleForEach(configuration => configuration.Routes)
.Must((_, route) => IsPlaceholderNotDuplicatedIn(route.DownstreamPathTemplate))
.WithMessage((_, route) => $"{nameof(route.DownstreamPathTemplate)} '{route.DownstreamPathTemplate}' has duplicated placeholder");

RuleForEach(configuration => configuration.Routes)
.Must(IsUpstreamPlaceholderDefinedInDownstream)
.WithMessage((_, route) => $"{nameof(route.UpstreamPathTemplate)} '{route.UpstreamPathTemplate}' doesn't contain the same placeholders in {nameof(route.DownstreamPathTemplate)} '{route.DownstreamPathTemplate}'");
RuleForEach(configuration => configuration.Routes)
.Must(IsDownstreamPlaceholderDefinedInUpstream)
.WithMessage((_, route) => $"{nameof(route.DownstreamPathTemplate)} '{route.DownstreamPathTemplate}' doesn't contain the same placeholders in {nameof(route.UpstreamPathTemplate)} '{route.UpstreamPathTemplate}'");

RuleFor(configuration => configuration.GlobalConfiguration.ServiceDiscoveryProvider)
.Must(HaveServiceDiscoveryProviderRegistered)
Expand Down Expand Up @@ -122,37 +115,6 @@ private static bool IsPlaceholderNotDuplicatedIn(string pathTemplate)
return placeholders.Count == placeholders.Distinct().Count();
}

private static bool IsServiceFabricWithServiceName(FileConfiguration configuration, FileRoute route)
=> Servicefabric.Equals(configuration?.GlobalConfiguration?.ServiceDiscoveryProvider?.Type, StringComparison.InvariantCultureIgnoreCase)
&& !string.IsNullOrEmpty(route?.ServiceName) && PlaceholderRegex().IsMatch(route.ServiceName);

private bool IsUpstreamPlaceholderDefinedInDownstream(FileConfiguration configuration, FileRoute route)
=> IsServiceFabricWithServiceName(configuration, route)
? IsPlaceholderDefinedInBothTemplates(route.UpstreamPathTemplate, route.ServiceName + route.DownstreamPathTemplate)
: IsPlaceholderDefinedInBothTemplates(route.UpstreamPathTemplate, route.DownstreamPathTemplate);

private bool IsDownstreamPlaceholderDefinedInUpstream(FileConfiguration configuration, FileRoute route)
=> IsServiceFabricWithServiceName(configuration, route)
? IsPlaceholderDefinedInBothTemplates(route.ServiceName + route.DownstreamPathTemplate, route.UpstreamPathTemplate)
: IsPlaceholderDefinedInBothTemplates(route.DownstreamPathTemplate, route.UpstreamPathTemplate);

private static bool IsPlaceholderDefinedInBothTemplates(string firstPathTemplate, string secondPathTemplate)
{
var firstPlaceholders = PlaceholderRegex().Matches(firstPathTemplate)
.Select(m => m.Value).ToList();
var secondPlaceholders = PlaceholderRegex().Matches(secondPathTemplate)
.Select(m => m.Value).ToList();
foreach (var placeholder in firstPlaceholders)
{
if (!secondPlaceholders.Contains(placeholder))
{
return false;
}
}

return true;
}

private static bool DoesNotContainRoutesWithSpecificRequestIdKeys(FileAggregateRoute fileAggregateRoute,
IEnumerable<FileRoute> routes)
{
Expand Down
Expand Up @@ -753,11 +753,11 @@ public void Configuration_is_not_valid_when_host_and_port_is_empty()
[InlineData("/foo/{bar}/foo", "/yahoo/foo/{bar}")] // valid
[InlineData("/foo/{bar}/{foo}", "/yahoo/{foo}/{bar}")] // valid
[InlineData("/foo/{bar}/{bar}", "/yahoo/foo/{bar}", "UpstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder")] // invalid
[InlineData("/foo/{bar}/{bar}", "/yahoo/{foo}/{bar}", "UpstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder", "DownstreamPathTemplate '/yahoo/{foo}/{bar}' doesn't contain the same placeholders in UpstreamPathTemplate '/foo/{bar}/{bar}'")] // invalid
[InlineData("/foo/{bar}/{bar}", "/yahoo/{foo}/{bar}", "UpstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder")] // invalid
[InlineData("/yahoo/foo/{bar}", "/foo/{bar}/foo")] // valid
[InlineData("/yahoo/{foo}/{bar}", "/foo/{bar}/{foo}")] // valid
[InlineData("/yahoo/foo/{bar}", "/foo/{bar}/{bar}", "DownstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder")] // invalid
[InlineData("/yahoo/{foo}/{bar}", "/foo/{bar}/{bar}", "DownstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder", "UpstreamPathTemplate '/yahoo/{foo}/{bar}' doesn't contain the same placeholders in DownstreamPathTemplate '/foo/{bar}/{bar}'")] // invalid
[InlineData("/yahoo/{foo}/{bar}", "/foo/{bar}/{bar}", "DownstreamPathTemplate '/foo/{bar}/{bar}' has duplicated placeholder")] // invalid
public void IsPlaceholderNotDuplicatedIn_RuleForFileRoute_PathTemplatePlaceholdersAreValidated(string upstream, string downstream, params string[] expected)
{
// Arrange
Expand All @@ -772,26 +772,6 @@ public void IsPlaceholderNotDuplicatedIn_RuleForFileRoute_PathTemplatePlaceholde
ThenTheErrorMessagesAre(expected);
}

[Theory]
[Trait("PR", "1927")]
[InlineData("/foo/{bar}/{foo}", "/yahoo/{foo}/{bar}")] // valid
[InlineData("/foo/{bar}/{yahoo}", "/yahoo/{foo}/{bar}", "UpstreamPathTemplate '/foo/{bar}/{yahoo}' doesn't contain the same placeholders in DownstreamPathTemplate '/yahoo/{foo}/{bar}'", "DownstreamPathTemplate '/yahoo/{foo}/{bar}' doesn't contain the same placeholders in UpstreamPathTemplate '/foo/{bar}/{yahoo}'")] // invalid
[InlineData("/yahoo/{foo}/{bar}", "/foo/{bar}/{foo}")] // valid
[InlineData("/yahoo/{foo}/{bar}", "/foo/{bar}/{yahoo}", "UpstreamPathTemplate '/yahoo/{foo}/{bar}' doesn't contain the same placeholders in DownstreamPathTemplate '/foo/{bar}/{yahoo}'", "DownstreamPathTemplate '/foo/{bar}/{yahoo}' doesn't contain the same placeholders in UpstreamPathTemplate '/yahoo/{foo}/{bar}'")] // invalid
public void IsPlaceholderDefinedInBothTemplates_RuleForFileRoute_PathTemplatePlaceholdersAreValidated(string upstream, string downstream, params string[] expected)
{
// Arrange
var route = GivenDefaultRoute(upstream, downstream);
GivenAConfiguration(route);

// Act
WhenIValidateTheConfiguration();

// Assert
ThenThereAreErrors(expected.Length > 0);
ThenTheErrorMessagesAre(expected);
}

[Theory]
[Trait("PR", "1927")]
[Trait("Bug", "683")]
Expand Down

0 comments on commit 7c26c07

Please sign in to comment.