using System.Security.Cryptography; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories; using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Transport.Signing; using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates; namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests; /// /// ScadaBridge#45 — bundle-format compatibility. The two bundles under Fixtures/ were /// exported by the Transport code from BEFORE bundle signing existed (origin/main /// 3ff463b6, real AddTransport() pipeline over in-memory EF, the /// seed: templates BaseDevice / Pump (composes /// BaseDevice as base) / Standalone, shared script HelperFn, /// external system ErpSystem, notification list OnCall; source environment /// legacy-env). pre-signing-plaintext is unencrypted; pre-signing-encrypted /// uses the passphrase . /// /// Never regenerate these files — their value is that old code wrote them. When the /// format changes again, add a new fixture beside them. /// /// public sealed class LegacyBundleCompatibilityTests { private const string LegacyPassphrase = "legacy-fixture-passphrase"; private static ServiceProvider BuildProvider(IDictionary settings) { var services = new ServiceCollection(); services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection(settings).Build()); var dbName = $"LegacyBundleCompat_{Guid.NewGuid()}"; services.AddDbContext(opts => opts .UseInMemoryDatabase(dbName) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); services.AddSingleton(new EphemeralDataProtectionProvider()); services.AddScoped(sp => new ScadaBridgeDbContext( sp.GetRequiredService>(), sp.GetRequiredService())); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddTransport(); return services.BuildServiceProvider(); } /// Signing configured (key + trusted keys) but not required — the realistic rollout state. private static Dictionary SigningConfigured() { using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); return new Dictionary { ["ScadaBridge:Transport:Signing:Enabled"] = "true", ["ScadaBridge:Transport:Signing:PrivateKey"] = key.ExportPkcs8PrivateKeyPem(), ["ScadaBridge:Transport:Signing:TrustedKeys:0:Name"] = "self", ["ScadaBridge:Transport:Signing:TrustedKeys:0:PublicKey"] = key.ExportSubjectPublicKeyInfoPem(), }; } private static byte[] Fixture(string name) => File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", name)); public static TheoryData Cases() => new() { { "pre-signing-plaintext.scadabundle", null, false }, { "pre-signing-encrypted.scadabundle", LegacyPassphrase, false }, { "pre-signing-plaintext.scadabundle", null, true }, { "pre-signing-encrypted.scadabundle", LegacyPassphrase, true }, }; /// /// A bundle produced before signing existed loads, previews and applies in full — with /// signing left at its defaults and with signing configured (not required). /// [Theory] [MemberData(nameof(Cases))] public async Task Pre_signing_bundle_still_imports(string fixture, string? passphrase, bool signingConfigured) { await using var provider = BuildProvider( signingConfigured ? SigningConfigured() : new Dictionary()); BundleSession session; await using (var scope = provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); session = await importer.LoadAsync(new MemoryStream(Fixture(fixture)), passphrase); } Assert.Equal(BundleSignatureStatus.Unsigned, session.SignatureStatus); Assert.Equal("legacy-env", session.Manifest.SourceEnvironment); ImportResult result; await using (var scope = provider.CreateAsyncScope()) { var importer = scope.ServiceProvider.GetRequiredService(); var preview = await importer.PreviewAsync(session.SessionId); Assert.Equal(6, preview.Items.Count); Assert.All(preview.Items, i => Assert.Equal(ConflictKind.New, i.Kind)); var resolutions = preview.Items .Select(i => new ImportResolution(i.EntityType, i.Name, ResolutionAction.Add, null)) .ToList(); result = await importer.ApplyAsync(session.SessionId, resolutions, user: "bob"); } Assert.Equal(6, result.Added); await using (var scope = provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var baseDevice = await ctx.Templates.Include(t => t.Scripts).SingleAsync(t => t.Name == "BaseDevice"); Assert.Equal("init", Assert.Single(baseDevice.Scripts).Name); var pump = await ctx.Templates.Include(t => t.Compositions).SingleAsync(t => t.Name == "Pump"); Assert.Equal(baseDevice.Id, Assert.Single(pump.Compositions).ComposedTemplateId); Assert.Equal("return 1;", (await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn")).Code); Assert.True(await ctx.ExternalSystemDefinitions.AnyAsync(e => e.Name == "ErpSystem")); Assert.True(await ctx.NotificationLists.AnyAsync(n => n.Name == "OnCall")); var imported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleImported"); Assert.Contains("\"SignatureStatus\":\"Unsigned\"", imported.AfterStateJson!, StringComparison.Ordinal); } } /// /// With RequireSignedBundles on, the same pre-signing bundle is refused — the flag is /// the one deliberate way to stop accepting old bundles. /// [Fact] public async Task Pre_signing_bundle_is_refused_when_signing_required() { var settings = SigningConfigured(); settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true"; await using var provider = BuildProvider(settings); await using var scope = provider.CreateAsyncScope(); var importer = scope.ServiceProvider.GetRequiredService(); await Assert.ThrowsAsync(() => importer.LoadAsync( new MemoryStream(Fixture("pre-signing-encrypted.scadabundle")), LegacyPassphrase)); } /// /// End to end through DI: with signing enabled the exporter writes a signed, encrypted bundle /// (and audits the key), and an importer that trusts the key loads it as Verified and /// records the signer on the BundleImported row. /// [Fact] public async Task Signed_export_imports_as_Verified_through_DI() { var settings = SigningConfigured(); settings["ScadaBridge:Transport:Signing:RequireSignedBundles"] = "true"; await using var provider = BuildProvider(settings); await using (var scope = provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); ctx.SharedScripts.Add(new SharedScript("Signed", "return 2;")); ctx.Templates.Add(new Template("SignedTemplate")); await ctx.SaveChangesAsync(); } byte[] bundle; await using (var scope = provider.CreateAsyncScope()) { var ctx = scope.ServiceProvider.GetRequiredService(); var selection = new ExportSelection( TemplateIds: await ctx.Templates.Select(t => t.Id).ToListAsync(), SharedScriptIds: await ctx.SharedScripts.Select(s => s.Id).ToListAsync(), ExternalSystemIds: Array.Empty(), DatabaseConnectionIds: Array.Empty(), NotificationListIds: Array.Empty(), SmtpConfigurationIds: Array.Empty(), ApiMethodIds: Array.Empty(), IncludeDependencies: true); var stream = await scope.ServiceProvider.GetRequiredService() .ExportAsync(selection, "alice", "signing-env", "pw-for-signed-bundle"); using var ms = new MemoryStream(); await stream.CopyToAsync(ms); bundle = ms.ToArray(); var exported = await ctx.AuditLogEntries.SingleAsync(a => a.Action == "BundleExported"); Assert.Contains("\"Signed\":true", exported.AfterStateJson!, StringComparison.Ordinal); } await using (var scope = provider.CreateAsyncScope()) { var keyId = scope.ServiceProvider.GetRequiredService().GetSigningKey()!.KeyId; var session = await scope.ServiceProvider.GetRequiredService() .LoadAsync(new MemoryStream(bundle), "pw-for-signed-bundle"); Assert.Equal(BundleSignatureStatus.Verified, session.SignatureStatus); Assert.Equal("self", session.SignerName); Assert.Equal(keyId, session.SignerKeyId); scope.ServiceProvider.GetRequiredService().Abandon(session.SessionId); } } }