diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs new file mode 100644 index 00000000..ae85ac4a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/DeployNotifyFetchTests.cs @@ -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; + +/// +/// 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); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj index 6b80cb32..e25ecc1e 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/ZB.MOM.WW.ScadaBridge.IntegrationTests.csproj @@ -16,6 +16,7 @@ + @@ -36,6 +37,8 @@ + +