diff --git a/src/ScadaLink.Transport/Export/BundleExporter.cs b/src/ScadaLink.Transport/Export/BundleExporter.cs new file mode 100644 index 0000000..8658d7d --- /dev/null +++ b/src/ScadaLink.Transport/Export/BundleExporter.cs @@ -0,0 +1,160 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using ScadaLink.Commons.Interfaces.Services; +using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Commons.Types.Transport; +using ScadaLink.ConfigurationDatabase; +using ScadaLink.Transport.Encryption; +using ScadaLink.Transport.Serialization; + +namespace ScadaLink.Transport.Export; + +/// +/// Ties together the resolver, serializer, encryptor, manifest builder, and audit +/// service into a single export pipeline. Produces a ZIP-formatted bundle stream +/// and emits one append-only audit row per export — distinguishing encrypted vs +/// unencrypted runs via the Action column. The returned stream is positioned +/// at 0 so callers can immediately copy to a response/file. +/// +public sealed class BundleExporter : IBundleExporter +{ + private readonly DependencyResolver _resolver; + private readonly EntitySerializer _entitySerializer; + private readonly ManifestBuilder _manifestBuilder; + private readonly BundleSecretEncryptor _encryptor; + private readonly BundleSerializer _bundleSerializer; + private readonly IAuditService _auditService; + private readonly ScadaLinkDbContext _dbContext; + private readonly IOptions _options; + + public BundleExporter( + DependencyResolver resolver, + EntitySerializer entitySerializer, + ManifestBuilder manifestBuilder, + BundleSecretEncryptor encryptor, + BundleSerializer bundleSerializer, + IAuditService auditService, + ScadaLinkDbContext dbContext, + IOptions options) + { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _entitySerializer = entitySerializer ?? throw new ArgumentNullException(nameof(entitySerializer)); + _manifestBuilder = manifestBuilder ?? throw new ArgumentNullException(nameof(manifestBuilder)); + _encryptor = encryptor ?? throw new ArgumentNullException(nameof(encryptor)); + _bundleSerializer = bundleSerializer ?? throw new ArgumentNullException(nameof(bundleSerializer)); + _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public async Task ExportAsync( + ExportSelection selection, + string user, + string sourceEnvironment, + string? passphrase, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(user); + ArgumentNullException.ThrowIfNull(sourceEnvironment); + + // 1. Resolve dependencies → ResolvedExport (topologically sorted templates, + // folder chain, transitive shared-scripts/external-systems if requested). + var resolved = await _resolver.ResolveAsync(selection, cancellationToken).ConfigureAwait(false); + + // 2. Convert to the wire-shaped DTO (strips EF identity, refs-by-name). + var aggregate = new EntityAggregate( + TemplateFolders: resolved.TemplateFolders, + Templates: resolved.Templates, + SharedScripts: resolved.SharedScripts, + ExternalSystems: resolved.ExternalSystems, + ExternalSystemMethods: resolved.ExternalSystemMethods, + DatabaseConnections: resolved.DatabaseConnections, + NotificationLists: resolved.NotificationLists, + SmtpConfigurations: resolved.SmtpConfigs, + ApiKeys: resolved.ApiKeys, + ApiMethods: resolved.ApiMethods); + var contentDto = _entitySerializer.ToBundleContent(aggregate); + + // 3. Per-category summary counts — used for fast humanly-readable preview at import. + var summary = new BundleSummary( + Templates: resolved.Templates.Count, + TemplateFolders: resolved.TemplateFolders.Count, + SharedScripts: resolved.SharedScripts.Count, + ExternalSystems: resolved.ExternalSystems.Count, + DbConnections: resolved.DatabaseConnections.Count, + NotificationLists: resolved.NotificationLists.Count, + SmtpConfigs: resolved.SmtpConfigs.Count, + ApiKeys: resolved.ApiKeys.Count, + ApiMethods: resolved.ApiMethods.Count); + + // 4. Build a TEMPLATE manifest. BundleSerializer.Pack re-stamps both + // ContentHash and EncryptionMetadata against the bytes it actually + // writes, so we only need to seed EncryptionMetadata.Iterations when + // a passphrase is supplied — Pack rebuilds salt/iv/hash internally. + var assemblyVersion = typeof(BundleExporter).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + EncryptionMetadata? encryptionSeed = passphrase is null + ? null + : new EncryptionMetadata( + Algorithm: "AES-256-GCM", + Kdf: "PBKDF2-SHA256", + Iterations: _options.Value.Pbkdf2Iterations, + SaltB64: string.Empty, + IvB64: string.Empty); + + var templateManifest = _manifestBuilder.Build( + sourceEnvironment: sourceEnvironment, + exportedBy: user, + scadaLinkVersion: assemblyVersion, + encryption: encryptionSeed, + summary: summary, + contents: resolved.ContentManifest, + // Pack re-stamps ContentHash against the bytes it actually writes + // (either fresh ciphertext or the JSON it just serialized), so the + // upstream hash here only matters for the plaintext path — Pack + // trusts the supplied hash and the serializer is deterministic. + contentBytes: _bundleSerializer.SerializeContentBytes(contentDto)); + + // 5. Pack the zip; with a passphrase, Pack re-encrypts and re-stamps + // EncryptionMetadata (fresh salt/iv) + ContentHash. + var zipStream = _bundleSerializer.Pack(contentDto, templateManifest, passphrase, _encryptor); + + // 6. Audit — entityId is the bundle's overall SHA-256 over the zip bytes + // (opaque to caller but uniquely identifies this export). Action + // discriminates encrypted vs unencrypted runs. + var bundleHash = ComputeStreamSha256(zipStream); + await _auditService.LogAsync( + user: user, + action: passphrase is null ? "UnencryptedBundleExport" : "BundleExported", + entityType: "Bundle", + entityId: bundleHash, + entityName: sourceEnvironment, + afterState: new + { + SourceEnvironment = sourceEnvironment, + Encrypted = passphrase is not null, + ArtifactCount = resolved.ContentManifest.Count, + Summary = summary, + }, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + zipStream.Position = 0; + return zipStream; + } + + /// + /// Hashes the zip stream's bytes (resets position before + after) so the audit + /// row's EntityId uniquely identifies this exact bundle without leaking + /// any of the manifest's internal hashes (which describe content, not the zip). + /// + private static string ComputeStreamSha256(Stream stream) + { + stream.Position = 0; + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(stream); + stream.Position = 0; + return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant(); + } +} diff --git a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs index bf0f7be..50fee11 100644 --- a/src/ScadaLink.Transport/ServiceCollectionExtensions.cs +++ b/src/ScadaLink.Transport/ServiceCollectionExtensions.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Interfaces.Transport; +using ScadaLink.Transport.Encryption; using ScadaLink.Transport.Export; using ScadaLink.Transport.Import; +using ScadaLink.Transport.Serialization; namespace ScadaLink.Transport; @@ -16,7 +18,17 @@ public static class ServiceCollectionExtensions ArgumentNullException.ThrowIfNull(services); services.AddOptions().BindConfiguration(OptionsSection); services.TryAddSingleton(TimeProvider.System); + + // Pipeline building blocks: stateless services live as singletons; the + // resolver and exporter are scoped because they reach into per-request + // repository scopes and the scoped DbContext. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddScoped(); + services.AddScoped(); services.AddSingleton(); // Remaining concrete services added in later tasks. return services; diff --git a/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs b/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs new file mode 100644 index 0000000..8361a8d --- /dev/null +++ b/tests/ScadaLink.Transport.IntegrationTests/Export/BundleExporterTests.cs @@ -0,0 +1,282 @@ +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); + } + } +}