test(deploy): integration coverage for notify-and-fetch HTTP seam (large config, supersession, token)
This commit is contained in:
@@ -0,0 +1,338 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration coverage for the "notify-and-fetch" deploy seam: central stages a flattened
|
||||||
|
/// config in a <see cref="PendingDeployment"/> row and serves it from
|
||||||
|
/// <c>GET /api/internal/deployments/{deploymentId}/config</c> (token in the
|
||||||
|
/// <c>X-Deployment-Token</c> header); the SITE fetches it over HTTP via
|
||||||
|
/// <see cref="HttpDeploymentConfigFetcher"/>. This decouples config size from Akka's 128 KB
|
||||||
|
/// remote-frame limit.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Harness.</b> A minimal in-memory ASP.NET Core <see cref="TestServer"/> (NOT the full
|
||||||
|
/// <c>WebApplicationFactory<Program></c>) maps ONLY the real
|
||||||
|
/// <see cref="DeploymentConfigEndpoints.MapDeploymentConfigAPI"/> endpoint over a real
|
||||||
|
/// <see cref="DeploymentManagerRepository"/> backed by a real (SQLite in-memory)
|
||||||
|
/// <see cref="ScadaBridgeDbContext"/>. The light TestServer is preferred over the full Host
|
||||||
|
/// because the central Host startup (migrations, Akka remoting, cluster bootstrap) cannot run
|
||||||
|
/// in this sandbox — and the real seam under test is the production serve-endpoint plus the
|
||||||
|
/// production fetcher over an in-memory HTTP transport, both of which run hermetically.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// <b>Sandbox note.</b> A full two-cluster end-to-end over Akka.Remote dot-netty sockets is
|
||||||
|
/// NOT exercised here (sockets cannot bind in this sandbox); the actor-level apply/replication
|
||||||
|
/// is covered by unit tests and the real cluster by a separate live-smoke task. These tests
|
||||||
|
/// prove the HTTP seam — the part the size-limit fix hinges on.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DeployNotifyFetchTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// SQLite-adapted <see cref="ScadaBridgeDbContext"/>: maps <see cref="DateTimeOffset"/> columns
|
||||||
|
/// to sortable ISO 8601 strings (SQLite has no native DateTimeOffset), drops the SQL Server
|
||||||
|
/// rowversion concurrency token on <see cref="DeploymentRecord"/>, and is constructed with an
|
||||||
|
/// ephemeral Data Protection provider so secret-bearing columns are write-capable.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class SqliteFetchDbContext : ScadaBridgeDbContext
|
||||||
|
{
|
||||||
|
public SqliteFetchDbContext(DbContextOptions<ScadaBridgeDbContext> options)
|
||||||
|
: base(options, new EphemeralDataProtectionProvider())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// SQLite cannot auto-generate SQL Server rowversion values.
|
||||||
|
modelBuilder.Entity<DeploymentRecord>(builder =>
|
||||||
|
{
|
||||||
|
builder.Property(d => d.RowVersion)
|
||||||
|
.IsRequired(false)
|
||||||
|
.IsConcurrencyToken(false)
|
||||||
|
.ValueGeneratedNever();
|
||||||
|
});
|
||||||
|
|
||||||
|
var converter = new ValueConverter<DateTimeOffset, string>(
|
||||||
|
v => v.UtcDateTime.ToString("o"),
|
||||||
|
v => DateTimeOffset.Parse(v));
|
||||||
|
var nullableConverter = new ValueConverter<DateTimeOffset?, string?>(
|
||||||
|
v => v.HasValue ? v.Value.UtcDateTime.ToString("o") : null,
|
||||||
|
v => v != null ? DateTimeOffset.Parse(v) : null);
|
||||||
|
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
foreach (var property in entityType.GetProperties())
|
||||||
|
{
|
||||||
|
if (property.ClrType == typeof(DateTimeOffset))
|
||||||
|
{
|
||||||
|
property.SetValueConverter(converter);
|
||||||
|
property.SetColumnType("TEXT");
|
||||||
|
}
|
||||||
|
else if (property.ClrType == typeof(DateTimeOffset?))
|
||||||
|
{
|
||||||
|
property.SetValueConverter(nullableConverter);
|
||||||
|
property.SetColumnType("TEXT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One self-contained test fixture: a kept-open SQLite in-memory connection (an in-memory
|
||||||
|
/// database is destroyed when its last connection closes), an in-memory <see cref="TestServer"/>
|
||||||
|
/// hosting ONLY the real config-fetch endpoint over a real repository, and the in-memory
|
||||||
|
/// <see cref="HttpClient"/> the production fetcher drives.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FetchHarness : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly IHost _host;
|
||||||
|
private readonly DbContextOptions<ScadaBridgeDbContext> _dbOptions;
|
||||||
|
|
||||||
|
public HttpClient Client { get; }
|
||||||
|
|
||||||
|
/// <summary>Base URL the site presents to the fetcher (the test server's address).</summary>
|
||||||
|
public string BaseUrl { get; }
|
||||||
|
|
||||||
|
private FetchHarness(
|
||||||
|
SqliteConnection connection,
|
||||||
|
IHost host,
|
||||||
|
DbContextOptions<ScadaBridgeDbContext> dbOptions,
|
||||||
|
HttpClient client)
|
||||||
|
{
|
||||||
|
_connection = connection;
|
||||||
|
_host = host;
|
||||||
|
_dbOptions = dbOptions;
|
||||||
|
Client = client;
|
||||||
|
BaseUrl = client.BaseAddress!.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<FetchHarness> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("DataSource=:memory:");
|
||||||
|
connection.Open();
|
||||||
|
|
||||||
|
var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// Create the schema once on a throwaway context over the shared connection.
|
||||||
|
using (var schemaContext = new SqliteFetchDbContext(dbOptions))
|
||||||
|
{
|
||||||
|
await schemaContext.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal host: routing + the REAL serve-endpoint over the REAL repository.
|
||||||
|
var host = await new HostBuilder()
|
||||||
|
.ConfigureWebHost(webBuilder =>
|
||||||
|
{
|
||||||
|
webBuilder
|
||||||
|
.UseTestServer()
|
||||||
|
.ConfigureServices(services =>
|
||||||
|
{
|
||||||
|
services.AddRouting();
|
||||||
|
services.AddScoped<ScadaBridgeDbContext>(_ => new SqliteFetchDbContext(dbOptions));
|
||||||
|
services.AddScoped<IDeploymentManagerRepository>(sp =>
|
||||||
|
new DeploymentManagerRepository(sp.GetRequiredService<ScadaBridgeDbContext>()));
|
||||||
|
})
|
||||||
|
.Configure(app =>
|
||||||
|
{
|
||||||
|
app.UseRouting();
|
||||||
|
app.UseEndpoints(endpoints => endpoints.MapDeploymentConfigAPI());
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.StartAsync();
|
||||||
|
|
||||||
|
var client = host.GetTestClient();
|
||||||
|
return new FetchHarness(connection, host, dbOptions, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seeds an <see cref="Instance"/> (with its required <see cref="Site"/> + <see cref="Template"/>)
|
||||||
|
/// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a
|
||||||
|
/// real row is required.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> SeedInstanceAsync(string uniqueName)
|
||||||
|
{
|
||||||
|
await using var ctx = new SqliteFetchDbContext(_dbOptions);
|
||||||
|
var site = new Site($"Site-{uniqueName}", $"S-{uniqueName}");
|
||||||
|
var template = new Template($"T-{uniqueName}");
|
||||||
|
ctx.Sites.Add(site);
|
||||||
|
ctx.Templates.Add(template);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var instance = new Instance(uniqueName) { SiteId = site.Id, TemplateId = template.Id };
|
||||||
|
ctx.Instances.Add(instance);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return instance.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stages a pending deployment through the REAL repository path
|
||||||
|
/// (<see cref="DeploymentManagerRepository.AddPendingDeploymentAsync"/> + SaveChanges), so
|
||||||
|
/// supersession of any prior row for the same instance is exercised exactly as in production.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StageAsync(PendingDeployment pending)
|
||||||
|
{
|
||||||
|
await using var ctx = new SqliteFetchDbContext(_dbOptions);
|
||||||
|
var repo = new DeploymentManagerRepository(ctx);
|
||||||
|
await repo.AddPendingDeploymentAsync(pending);
|
||||||
|
await repo.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Client.Dispose();
|
||||||
|
_host.Dispose();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpDeploymentConfigFetcher NewFetcher(FetchHarness harness)
|
||||||
|
=> new(harness.Client, NullLogger<HttpDeploymentConfigFetcher>.Instance);
|
||||||
|
|
||||||
|
private static PendingDeployment NewPending(
|
||||||
|
string deploymentId, int instanceId, string config, string token, DateTimeOffset expiresAtUtc)
|
||||||
|
=> new(
|
||||||
|
deploymentId, instanceId, revisionHash: "rev-" + deploymentId,
|
||||||
|
configurationJson: config, token: token,
|
||||||
|
createdAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1), expiresAtUtc: expiresAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic JSON string comfortably larger than Akka's 128 KB remote-frame limit
|
||||||
|
/// (~204 KB here). It need not be a real FlattenedConfiguration — the endpoint serves the
|
||||||
|
/// stored string verbatim.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildLargeConfigJson()
|
||||||
|
{
|
||||||
|
// 200 KiB of payload + JSON wrapper. ASCII only, so byte-for-byte == char-for-char.
|
||||||
|
var payload = new string('A', 200 * 1024);
|
||||||
|
return "{\"payload\":\"" + payload + "\"}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 1. Large config round-trips: the core proof that the 128 KB size limit is gone.
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_LargeConfigOver128Kb_RoundTripsExactBytes()
|
||||||
|
{
|
||||||
|
using var harness = await FetchHarness.CreateAsync();
|
||||||
|
var fetcher = NewFetcher(harness);
|
||||||
|
var instanceId = await harness.SeedInstanceAsync("LargeInst");
|
||||||
|
|
||||||
|
var largeJson = BuildLargeConfigJson();
|
||||||
|
Assert.True(
|
||||||
|
largeJson.Length > 128 * 1024,
|
||||||
|
$"staged config must exceed the old 128 KB Akka frame limit; was {largeJson.Length} bytes");
|
||||||
|
|
||||||
|
await harness.StageAsync(NewPending(
|
||||||
|
"dep-large", instanceId, largeJson, token: "tok-large",
|
||||||
|
expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));
|
||||||
|
|
||||||
|
var fetched = await fetcher.FetchAsync(harness.BaseUrl, "dep-large", "tok-large", CancellationToken.None);
|
||||||
|
|
||||||
|
// Byte-for-byte (ordinal) round-trip of a >128 KB body over the HTTP seam.
|
||||||
|
Assert.Equal(largeJson, fetched, StringComparer.Ordinal);
|
||||||
|
Assert.True(
|
||||||
|
fetched.Length > 128 * 1024,
|
||||||
|
$"fetched config must exceed the old 128 KB Akka frame limit; was {fetched.Length} bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 2. Supersession: staging B for an instance deletes its prior pending row A.
|
||||||
|
// Fetching A -> 404 (IsSuperseded); fetching B -> B's config.
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_SupersededDeployment_Throws404SupersededAndServesNewest()
|
||||||
|
{
|
||||||
|
using var harness = await FetchHarness.CreateAsync();
|
||||||
|
var fetcher = NewFetcher(harness);
|
||||||
|
var instanceId = await harness.SeedInstanceAsync("SupersedeInst");
|
||||||
|
var future = DateTimeOffset.UtcNow.AddMinutes(10);
|
||||||
|
|
||||||
|
await harness.StageAsync(NewPending("dep-A", instanceId, "{\"v\":1}", "tok-A", future));
|
||||||
|
// Staging B for the SAME instance supersedes (deletes) A via the real repository path.
|
||||||
|
await harness.StageAsync(NewPending("dep-B", instanceId, "{\"v\":2}", "tok-B", future));
|
||||||
|
|
||||||
|
// A is gone -> 404 -> IsSuperseded.
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync(harness.BaseUrl, "dep-A", "tok-A", CancellationToken.None));
|
||||||
|
Assert.True(ex.IsSuperseded);
|
||||||
|
|
||||||
|
// B is current and still served.
|
||||||
|
var fetchedB = await fetcher.FetchAsync(harness.BaseUrl, "dep-B", "tok-B", CancellationToken.None);
|
||||||
|
Assert.Equal("{\"v\":2}", fetchedB, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 3. Wrong token: a live, non-expired row with a bad token -> 401 (NOT 404).
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_WrongToken_Throws401NotSuperseded()
|
||||||
|
{
|
||||||
|
using var harness = await FetchHarness.CreateAsync();
|
||||||
|
var fetcher = NewFetcher(harness);
|
||||||
|
var instanceId = await harness.SeedInstanceAsync("TokenInst");
|
||||||
|
|
||||||
|
await harness.StageAsync(NewPending(
|
||||||
|
"dep-tok", instanceId, "{\"v\":1}", token: "right-token",
|
||||||
|
expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(10)));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync(harness.BaseUrl, "dep-tok", "wrong-token", CancellationToken.None));
|
||||||
|
|
||||||
|
// 401 (not 404): a valid, live id with a bad token is unauthorized, not "gone".
|
||||||
|
Assert.False(ex.IsSuperseded);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
// 4. Expired: a row past its TTL is indistinguishable from gone -> 404 (IsSuperseded),
|
||||||
|
// even with the correct token.
|
||||||
|
// ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FetchAsync_ExpiredRow_Throws404SupersededEvenWithCorrectToken()
|
||||||
|
{
|
||||||
|
using var harness = await FetchHarness.CreateAsync();
|
||||||
|
var fetcher = NewFetcher(harness);
|
||||||
|
var instanceId = await harness.SeedInstanceAsync("ExpiredInst");
|
||||||
|
|
||||||
|
await harness.StageAsync(NewPending(
|
||||||
|
"dep-exp", instanceId, "{\"v\":1}", token: "tok-exp",
|
||||||
|
expiresAtUtc: DateTimeOffset.UtcNow.AddMinutes(-1))); // TTL already elapsed
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<DeploymentConfigFetchException>(
|
||||||
|
() => fetcher.FetchAsync(harness.BaseUrl, "dep-exp", "tok-exp", CancellationToken.None));
|
||||||
|
|
||||||
|
// Expired is hidden as 404 (existence-hiding), so IsSuperseded is set.
|
||||||
|
Assert.True(ex.IsSuperseded);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="Akka.TestKit.Xunit2" />
|
<PackageReference Include="Akka.TestKit.Xunit2" />
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj" />
|
||||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.NotificationOutbox/ZB.MOM.WW.ScadaBridge.NotificationOutbox.csproj" />
|
||||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.csproj" />
|
||||||
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj" />
|
||||||
|
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user