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); } }