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;
///
/// Integration coverage for the "notify-and-fetch" deploy seam: central stages a flattened
/// config in a row and serves it from
/// GET /api/internal/deployments/{deploymentId}/config (token in the
/// X-Deployment-Token header); the SITE fetches it over HTTP via
/// . This decouples config size from Akka's 128 KB
/// remote-frame limit.
///
///
/// Harness. A minimal in-memory ASP.NET Core (NOT the full
/// WebApplicationFactory<Program>) maps ONLY the real
/// endpoint over a real
/// backed by a real (SQLite in-memory)
/// . 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.
///
///
///
/// Sandbox note. 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.
///
///
public sealed class DeployNotifyFetchTests
{
///
/// SQLite-adapted : maps columns
/// to sortable ISO 8601 strings (SQLite has no native DateTimeOffset), drops the SQL Server
/// rowversion concurrency token on , and is constructed with an
/// ephemeral Data Protection provider so secret-bearing columns are write-capable.
///
private sealed class SqliteFetchDbContext : ScadaBridgeDbContext
{
public SqliteFetchDbContext(DbContextOptions options)
: base(options, new EphemeralDataProtectionProvider())
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// SQLite cannot auto-generate SQL Server rowversion values.
modelBuilder.Entity(builder =>
{
builder.Property(d => d.RowVersion)
.IsRequired(false)
.IsConcurrencyToken(false)
.ValueGeneratedNever();
});
var converter = new ValueConverter(
v => v.UtcDateTime.ToString("o"),
v => DateTimeOffset.Parse(v));
var nullableConverter = new ValueConverter(
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");
}
}
}
}
}
///
/// 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
/// hosting ONLY the real config-fetch endpoint over a real repository, and the in-memory
/// the production fetcher drives.
///
private sealed class FetchHarness : IDisposable
{
private readonly SqliteConnection _connection;
private readonly IHost _host;
private readonly DbContextOptions _dbOptions;
public HttpClient Client { get; }
/// Base URL the site presents to the fetcher (the test server's address).
public string BaseUrl { get; }
private FetchHarness(
SqliteConnection connection,
IHost host,
DbContextOptions dbOptions,
HttpClient client)
{
_connection = connection;
_host = host;
_dbOptions = dbOptions;
Client = client;
BaseUrl = client.BaseAddress!.ToString();
}
public static async Task CreateAsync()
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var dbOptions = new DbContextOptionsBuilder()
.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(_ => new SqliteFetchDbContext(dbOptions));
services.AddScoped(sp =>
new DeploymentManagerRepository(sp.GetRequiredService()));
})
.Configure(app =>
{
app.UseRouting();
app.UseEndpoints(endpoints => endpoints.MapDeploymentConfigAPI());
});
})
.StartAsync();
var client = host.GetTestClient();
return new FetchHarness(connection, host, dbOptions, client);
}
///
/// Seeds an (with its required + )
/// and returns its generated id. The PendingDeployment → Instance FK is enforced by SQLite, so a
/// real row is required.
///
public async Task 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;
}
///
/// Stages a pending deployment through the REAL repository path
/// ( + SaveChanges), so
/// supersession of any prior row for the same instance is exercised exactly as in production.
///
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.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);
///
/// 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.
///
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(
() => 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(
() => 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(
() => 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);
}
}