using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ScadaLink.Commons.Entities.ExternalSystems; using ScadaLink.Commons.Entities.Notifications; using ScadaLink.Commons.Entities.Scripts; using ScadaLink.Commons.Entities.Templates; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Interfaces.Transport; using ScadaLink.Commons.Types.Transport; using ScadaLink.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; using ScadaLink.ConfigurationDatabase.Services; using ScadaLink.Transport; using ScadaLink.Transport.Encryption; using ScadaLink.Transport.Serialization; namespace ScadaLink.Transport.IntegrationTests.Export; /// /// Integration tests for . /// Builds a self-contained DI container (in-memory EF + the repositories + /// AddTransport's pipeline) and asserts the exporter round-trips a real bundle /// + writes the matching audit row. Using /// directly rather than WebApplicationFactory keeps the test focused on /// the exporter — no HTTP, no Akka, no LDAP. /// public sealed class BundleExporterTests : IDisposable { private readonly ServiceProvider _provider; public BundleExporterTests() { var services = new ServiceCollection(); // AddTransport's BindConfiguration call needs an IConfiguration in the // container — an empty one is fine for these tests because the defaults // baked into TransportOptions are exactly what we want. services.AddSingleton( new ConfigurationBuilder().AddInMemoryCollection().Build()); // In-memory EF — fresh database per test instance so seeded ids are // predictable and the audit assertions can scan the whole table. The // db name has to be resolved ONCE (outside the lambda) so every scope // resolves the same underlying InMemoryStore — otherwise each scope's // DbContext gets a new GUID and the seeded rows vanish between scopes. var dbName = $"BundleExporterTests_{Guid.NewGuid()}"; services.AddDbContext(opts => opts.UseInMemoryDatabase(dbName)); // Repositories the resolver pulls from. services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); // Audit pipeline — AuditService writes via the EF context + reads the // bundle-import correlation id from the scoped context (null here, since // export is not part of an import session). services.AddScoped(); services.AddScoped(); // Add the transport pipeline itself. services.AddTransport(); _provider = services.BuildServiceProvider(); } public void Dispose() => _provider.Dispose(); [Fact] public async Task ExportAsync_writes_audit_event_and_returns_valid_bundle() { // Arrange: seed two templates (one composing the other), a shared // script referenced from the parent template, one external system, one // notification list. Names live in the script bodies / DataSourceReference // so the dependency resolver's substring scan picks them up. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var helper = new SharedScript("HelperFn", "return 1;"); ctx.SharedScripts.Add(helper); var erp = new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"); ctx.ExternalSystemDefinitions.Add(erp); var list = new NotificationList("OnCall"); ctx.NotificationLists.Add(list); await ctx.SaveChangesAsync(); var baseTemplate = new Template("BaseDevice") { Description = "shared base", }; // Attribute body references the helper + external system, so // IncludeDependencies=true pulls them in automatically. baseTemplate.Scripts.Add(new TemplateScript("init", "var x = HelperFn(); ErpSystem.Call(\"x\");")); ctx.Templates.Add(baseTemplate); await ctx.SaveChangesAsync(); var composing = new Template("Pump") { Description = "composes BaseDevice", }; ctx.Templates.Add(composing); await ctx.SaveChangesAsync(); composing.Compositions.Add(new TemplateComposition("base") { TemplateId = composing.Id, ComposedTemplateId = baseTemplate.Id, }); await ctx.SaveChangesAsync(); } // Act Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: templateIds, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true); bundleStream = await exporter.ExportAsync( selection, user: "alice", sourceEnvironment: "dev", passphrase: null, cancellationToken: CancellationToken.None); } // Copy to byte[] so we can re-open the zip multiple times below. byte[] bundleBytes; using (var ms = new MemoryStream()) { await bundleStream.CopyToAsync(ms); bundleBytes = ms.ToArray(); } // Assert: non-empty, valid zip with the expected manifest + content. Assert.NotEmpty(bundleBytes); var serializer = _provider.GetRequiredService(); BundleManifest manifest; using (var ms = new MemoryStream(bundleBytes, writable: false)) { manifest = serializer.ReadManifest(ms); } Assert.Equal("dev", manifest.SourceEnvironment); Assert.Equal("alice", manifest.ExportedBy); Assert.Null(manifest.Encryption); Assert.Equal(2, manifest.Summary.Templates); Assert.Equal(1, manifest.Summary.SharedScripts); Assert.Equal(1, manifest.Summary.ExternalSystems); byte[] rawContent; using (var ms = new MemoryStream(bundleBytes, writable: false)) { rawContent = serializer.ReadContentBytes(ms, manifest); } var content = serializer.UnpackContent(rawContent, manifest, passphrase: null, encryptor: null); Assert.Equal(2, content.Templates.Count); Assert.Contains(content.Templates, t => t.Name == "BaseDevice"); Assert.Contains(content.Templates, t => t.Name == "Pump"); Assert.Single(content.SharedScripts); Assert.Equal("HelperFn", content.SharedScripts[0].Name); Assert.Single(content.ExternalSystems); Assert.Equal("ErpSystem", content.ExternalSystems[0].Name); // Audit row landed with the expected shape — Action discriminates the // unencrypted export path, EntityId is the SHA-256 of the zip bytes. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var entry = await ctx.AuditLogEntries.SingleAsync(); Assert.Equal("UnencryptedBundleExport", entry.Action); Assert.Equal("alice", entry.User); Assert.Equal("Bundle", entry.EntityType); Assert.Equal("dev", entry.EntityName); var expectedHash = "sha256:" + Convert.ToHexString(SHA256.HashData(bundleBytes)) .ToLowerInvariant(); Assert.Equal(expectedHash, entry.EntityId); } } [Fact] public async Task ExportAsync_with_passphrase_produces_encrypted_bundle() { // Arrange: minimal template only — we want to exercise the encryption // path, not the dependency resolver. await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.Templates.Add(new Template("Solo") { Description = "alone" }); await ctx.SaveChangesAsync(); } // Act Stream bundleStream; await using (var scope = _provider.CreateAsyncScope()) { var exporter = scope.ServiceProvider.GetRequiredService(); var ctx = scope.ServiceProvider.GetRequiredService(); var ids = await ctx.Templates.Select(t => t.Id).ToListAsync(); var selection = new ExportSelection( TemplateIds: ids, SharedScriptIds: Array.Empty(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiKeyIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true); bundleStream = await exporter.ExportAsync( selection, user: "bob", sourceEnvironment: "stg", passphrase: "correct horse battery staple", cancellationToken: CancellationToken.None); } byte[] bundleBytes; using (var ms = new MemoryStream()) { await bundleStream.CopyToAsync(ms); bundleBytes = ms.ToArray(); } // Assert: manifest carries fresh encryption metadata; wrong passphrase // throws (AES-GCM tag mismatch); audit action is "BundleExported". var serializer = _provider.GetRequiredService(); var encryptor = _provider.GetRequiredService(); BundleManifest manifest; byte[] rawContent; using (var ms = new MemoryStream(bundleBytes, writable: false)) { manifest = serializer.ReadManifest(ms); } using (var ms = new MemoryStream(bundleBytes, writable: false)) { rawContent = serializer.ReadContentBytes(ms, manifest); } Assert.NotNull(manifest.Encryption); Assert.Equal("AES-256-GCM", manifest.Encryption!.Algorithm); Assert.False(string.IsNullOrEmpty(manifest.Encryption.SaltB64)); Assert.False(string.IsNullOrEmpty(manifest.Encryption.IvB64)); // Wrong passphrase fails AES-GCM tag verification — the runtime throws // the more specific AuthenticationTagMismatchException (a CryptographicException // subclass on .NET 10), so ThrowsAny is correct here. Assert.ThrowsAny(() => serializer.UnpackContent(rawContent, manifest, "wrong", encryptor)); // Right passphrase unpacks back to the seeded template. var content = serializer.UnpackContent( rawContent, manifest, "correct horse battery staple", encryptor); Assert.Single(content.Templates); Assert.Equal("Solo", content.Templates[0].Name); await using (var scope = _provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var entry = await ctx.AuditLogEntries.SingleAsync(); Assert.Equal("BundleExported", entry.Action); Assert.Equal("bob", entry.User); Assert.Equal("stg", entry.EntityName); } } }