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
2 changes: 2 additions & 0 deletions .agents/rules/integration-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
- **Storage is wired eagerly.** `AddHeroStorage` reads `Storage:Provider` before the test config overlay, so it picks `LocalStorageService`. The factory **removes the `IStorageService`/`LocalStorageService`/`S3StorageService` descriptors post-registration and re-registers the S3 stack** at RustFS. Follow that when a test needs real object storage. (See `storage.md`.)
- **SignalR tests force long-polling** — TestServer has no WebSocket. Configure the client transport accordingly.
- **Rate limiting is read eagerly** — `Integration.Middleware.Tests` sets `RateLimitingOptions:Enabled` via env var **before** host build, since flipping it after has no effect.
- **Test order is not guaranteed** (xUnit v3 orders differently from v2, and it shifted several hidden dependencies). Clean up shared state you create. Tenants pointing at an unreachable database go through `_factory.RemoveTenantsAsync(ids)` in `DisposeAsync`, or all-tenant jobs run by later tests count them as failures. Don't assume a new tenant's audit rows fit on page 1, because provisioning writes its seed data as `EntityChange` rows.
- **`WithWebHostBuilder` hosts share two statics with the shared host**: Hangfire's `JobStorage.Current` and the static `Audit` publisher. `PrimaryHostStaticsRestorer` points both back at the shared host when a derived host stops. Always dispose derived factories (`await using`).

## Coverage

Expand Down
8 changes: 7 additions & 1 deletion .agents/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ Read before writing or changing tests.

## Stack

xUnit · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).
xUnit v3 (`xunit.v3.mtp-off`, runs on VSTest) · Shouldly (`result.ShouldBe(...)`) · NSubstitute (`Substitute.For<IService>()`) · AutoFixture (`_fixture.Create<T>()`) · NetArchTest (architecture rules) · Testcontainers (integration).

## xUnit v3 notes

- Test projects are executables (`<OutputType>Exe</OutputType>`). A new test project needs that plus the `xunit.v3.mtp-off`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk` and `coverlet.collector` references; copy an existing csproj.
- `IAsyncLifetime` returns `ValueTask` and extends `IAsyncDisposable`: `public async ValueTask InitializeAsync()` / `public ValueTask DisposeAsync()`. A `WebApplicationFactory` fixture **overrides** `DisposeAsync()`, calls `base.DisposeAsync()` first, then disposes its containers.
- `xUnit1051` (pass `TestContext.Current.CancellationToken`) is off for `src/Tests` in `src/.editorconfig`. Its code fix also adds the token to NSubstitute setups and `Received()` checks, which changes what the mock matches. Pass the token by hand to long-running IO in a test when it should honour cancellation.

## Naming & shape

Expand Down
7 changes: 7 additions & 0 deletions src/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,10 @@ dotnet_diagnostic.MSG0005.severity = none
[**/Migrations.PostgreSQL/**/*.cs]
dotnet_diagnostic.CA1062.severity = none
dotnet_diagnostic.CA1861.severity = none

# xUnit v3 (#1457): xUnit1051 asks every async call in a test to take TestContext.Current.CancellationToken.
# Off on purpose: its code fix adds the token to NSubstitute setups and Received() checks, which changes what
# the mock matches (handlers are called with CancellationToken.None), and the suite uses no test timeouts that
# the token would serve. Pass the token by hand where a test does long-running IO and should honour cancellation.
[Tests/**/*.cs]
dotnet_diagnostic.xUnit1051.severity = none
4 changes: 3 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<!-- mtp-off keeps VSTest so `dotnet test` and the coverlet collector work without a global.json test-runner
setting (the template does not ship global.json). Move to the MTP packages as a separate change. -->
<PackageVersion Include="xunit.v3.mtp-off" Version="4.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.14" />
<PackageVersion Include="Testcontainers" Version="4.16.0" />
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Architecture.Tests/Architecture.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -15,7 +16,7 @@
</PackageReference>
<PackageReference Include="NetArchTest.Rules" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Auditing.Tests/Auditing.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -13,7 +14,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Billing.Tests/Billing.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Billing.Tests</RootNamespace>
<AssemblyName>Billing.Tests</AssemblyName>
<IsPackable>false</IsPackable>
Expand All @@ -13,7 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Caching.Tests/Caching.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -16,7 +17,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Catalog.Tests/Catalog.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Catalog.Tests</RootNamespace>
<AssemblyName>Catalog.Tests</AssemblyName>
<IsPackable>false</IsPackable>
Expand All @@ -13,7 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Chat.Tests/Chat.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Chat.Tests</RootNamespace>
<AssemblyName>Chat.Tests</AssemblyName>
<IsPackable>false</IsPackable>
Expand All @@ -13,7 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Files.Tests/Files.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Files.Tests</RootNamespace>
<AssemblyName>Files.Tests</AssemblyName>
<IsPackable>false</IsPackable>
Expand All @@ -13,7 +14,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Framework.Tests/Framework.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Framework.Tests</RootNamespace>
<AssemblyName>Framework.Tests</AssemblyName>
<IsPackable>false</IsPackable>
Expand All @@ -14,7 +15,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Generic.Tests/Generic.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -14,7 +15,7 @@
</PackageReference>
<PackageReference Include="FluentValidation" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
3 changes: 2 additions & 1 deletion src/Tests/Identity.Tests/Identity.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -13,7 +14,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public MiddlewareWebApplicationFactory()
Environment.SetEnvironmentVariable("SecurityHeadersOptions__Enabled", "true");
}

public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await Task.WhenAll(_postgres.StartAsync(), _s3.StartAsync());
await CreateS3BucketAsync();
Expand All @@ -102,7 +102,7 @@ public async Task InitializeAsync()
}
}

public new async Task DisposeAsync()
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _postgres.DisposeAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
<ImplicitUsings>enable</ImplicitUsings>
Expand All @@ -24,7 +25,7 @@
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers" />
<PackageReference Include="Hangfire.InMemory" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.v3.mtp-off" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
30 changes: 25 additions & 5 deletions src/Tests/Integration.Middleware.Tests/Tests/RateLimitingTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Net;
using System.Text.Json;
using Integration.Middleware.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;

namespace Integration.Middleware.Tests.Tests;

Expand All @@ -21,6 +23,22 @@ public RateLimitingTests(MiddlewareWebApplicationFactory factory)
_factory = factory;
}

private async Task<int> IssueBadTokenFromAsync(IPAddress clientIp)
{
var body = JsonSerializer.SerializeToUtf8Bytes(new { email = "nobody@example.com", password = "wrong-password" });
var context = await _factory.Server.SendAsync(c =>
{
c.Connection.RemoteIpAddress = clientIp;
c.Request.Method = HttpMethods.Post;
c.Request.Path = $"{TestConstants.IdentityBasePath}/token/issue";
c.Request.Headers["tenant"] = TestConstants.RootTenantId;
c.Request.ContentType = "application/json";
c.Request.ContentLength = body.Length;
c.Request.Body = new MemoryStream(body);
});
return context.Response.StatusCode;
}

private static async Task<HttpResponseMessage> IssueBadTokenAsync(HttpClient client)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"{TestConstants.IdentityBasePath}/token/issue");
Expand All @@ -34,15 +52,17 @@ private static async Task<HttpResponseMessage> IssueBadTokenAsync(HttpClient cli
[Fact]
public async Task TokenIssue_Should_NotRateLimit_When_RequestsAreWithinTheAuthLimit()
{
// Arrange
using var client = _factory.CreateClient();
// Arrange: a client IP of its own. TestServer leaves RemoteIpAddress null, so requests from
// CreateClient() share the "unknown IP" partition with the 429 test below. If that test ran
// first, this one would start on an exhausted window.
var clientIp = IPAddress.Parse("203.0.113.10");

// Act + Assert: the first PermitLimit requests must NOT be rejected with 429.
for (int i = 0; i < AuthPermitLimit; i++)
{
using var response = await IssueBadTokenAsync(client);
response.StatusCode.ShouldNotBe(
HttpStatusCode.TooManyRequests,
var statusCode = await IssueBadTokenFromAsync(clientIp);
statusCode.ShouldNotBe(
(int)HttpStatusCode.TooManyRequests,
$"request #{i + 1} within the auth limit should not be rate limited");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using System.Text;
using FSH.Modules.Multitenancy.Data;
using FSH.Framework.Web.Modules;
using FSH.Modules.Auditing;
using Hangfire;
using Hangfire.InMemory;
using Microsoft.AspNetCore.Authentication.JwtBearer;
Expand All @@ -33,6 +34,12 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory<Program>, I
internal const string S3SecretKey = "rustfsadmin";
internal const string S3Bucket = "fsh-integration-test-uploads";

/// <summary>The shared host's Hangfire storage; see <see cref="PrimaryHostStaticsRestorer"/>.</summary>
internal static JobStorage? PrimaryJobStorage { get; private set; }

/// <summary>The shared host's audit configurator; see <see cref="PrimaryHostStaticsRestorer"/>.</summary>
internal static AuditingConfigurator? PrimaryAuditingConfigurator { get; private set; }

private static readonly SemaphoreSlim _migrationLock = new(1, 1);
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:18-alpine")
.WithDatabase("fsh_integration_tests")
Expand All @@ -53,13 +60,15 @@ public sealed class FshWebApplicationFactory : WebApplicationFactory<Program>, I
.WithCleanUp(true)
.Build();

public async Task InitializeAsync()
public async ValueTask InitializeAsync()
{
await Task.WhenAll(_postgres.StartAsync(), _s3.StartAsync());
await CreateS3BucketAsync();

// Force host creation via the Server property (no leaked HttpClient)
_ = Server;
PrimaryJobStorage = Services.GetRequiredService<JobStorage>();
PrimaryAuditingConfigurator = Services.GetServices<IHostedService>().OfType<AuditingConfigurator>().Single();

// Migrate + seed the root tenant. The semaphore stops test classes sharing the DB
// from migrating simultaneously.
Expand All @@ -74,13 +83,29 @@ public async Task InitializeAsync()
}
}

public new async Task DisposeAsync()
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await _postgres.DisposeAsync();
await _s3.DisposeAsync();
}

/// <summary>
/// Removes tenants a test created from the shared tenant store. Use it for tenants that point at an
/// unreachable database: later tests that walk every tenant (the legacy public-key migration job, for
/// one) would otherwise count them as failures. Test order is not guaranteed, so clean up after yourself.
/// </summary>
public async Task RemoveTenantsAsync(IEnumerable<string> tenantIds)
{
ArgumentNullException.ThrowIfNull(tenantIds);
using var scope = Services.CreateScope();
var store = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<AppTenantInfo>>();
foreach (var tenantId in tenantIds)
{
await store.RemoveAsync(tenantId);
}
}

/// <summary>The S3 (RustFS) endpoint URL exposed to the host configuration; useful for tests that need to PUT bytes directly.</summary>
public string S3ServiceUrl => $"http://{_s3.Hostname}:{_s3.GetMappedPublicPort(S3Port)}";

Expand Down Expand Up @@ -209,6 +234,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
}

services.AddHangfire(config => config.UseInMemoryStorage());
services.AddHostedService<PrimaryHostStaticsRestorer>();
services.AddHangfireServer(options =>
{
options.SchedulePollingInterval = TimeSpan.FromSeconds(1);
Expand Down
Loading
Loading