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