refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,113 @@
using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Encryption;
public sealed class BundleSecretEncryptorTests
{
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum);
// the production value is 600_000. Using the floor keeps the test fast while
// remaining a valid EncryptionMetadata.Iterations.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
[Fact]
public void Encrypt_then_Decrypt_roundtrips_arbitrary_bytes()
{
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("the quick brown fox jumps over the lazy dog");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "correct-horse-battery-staple", TestIterations);
var recovered = sut.Decrypt(ciphertext, metadata, "correct-horse-battery-staple");
Assert.Equal(plaintext, recovered);
}
[Fact]
public void Decrypt_with_wrong_passphrase_throws_CryptographicException()
{
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("secret payload");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "right-pass", TestIterations);
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "wrong-pass"));
}
[Fact]
public void Decrypt_with_tampered_ciphertext_throws_CryptographicException()
{
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("secret payload");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations);
ciphertext[0] ^= 0xFF; // Flip every bit in the first ciphertext byte.
Assert.ThrowsAny<CryptographicException>(() => sut.Decrypt(ciphertext, metadata, "pass"));
}
[Fact]
public void Encrypt_produces_distinct_ciphertext_for_same_input_due_to_random_iv()
{
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("same input");
var (ct1, meta1) = sut.Encrypt(plaintext, "pass", TestIterations);
var (ct2, meta2) = sut.Encrypt(plaintext, "pass", TestIterations);
Assert.NotEqual(ct1, ct2);
Assert.NotEqual(meta1.IvB64, meta2.IvB64);
Assert.NotEqual(meta1.SaltB64, meta2.SaltB64);
}
[Fact]
public void Decrypt_with_mismatched_AAD_throws_CryptographicException()
{
// T-005: AES-GCM AAD binds extra context (e.g. manifest non-derivative fields)
// into the auth tag. Decrypting with different AAD yields a tag mismatch even
// when the passphrase and ciphertext are correct.
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("secret payload");
var aadOnEncrypt = Encoding.UTF8.GetBytes("manifest-canonical-hash-A");
var aadOnDecrypt = Encoding.UTF8.GetBytes("manifest-canonical-hash-B");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations, aadOnEncrypt);
Assert.ThrowsAny<CryptographicException>(
() => sut.Decrypt(ciphertext, metadata, "pass", aadOnDecrypt));
}
[Fact]
public void Decrypt_with_matching_AAD_recovers_plaintext()
{
// T-005: round-trip with non-empty AAD must succeed when both sides match.
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("secret payload");
var aad = Encoding.UTF8.GetBytes("manifest-canonical-hash");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations, aad);
var recovered = sut.Decrypt(ciphertext, metadata, "pass", aad);
Assert.Equal(plaintext, recovered);
}
[Fact]
public void Encrypt_emits_metadata_matching_decryption_inputs()
{
var sut = new BundleSecretEncryptor();
var plaintext = Encoding.UTF8.GetBytes("payload");
var (ciphertext, metadata) = sut.Encrypt(plaintext, "pass", TestIterations);
Assert.Equal("AES-256-GCM", metadata.Algorithm);
Assert.Equal("PBKDF2-SHA256", metadata.Kdf);
Assert.Equal(TestIterations, metadata.Iterations);
// Salt is 16 bytes (24 chars b64 incl padding), Iv is 12 bytes (16 chars b64 incl padding).
Assert.Equal(16, Convert.FromBase64String(metadata.SaltB64).Length);
Assert.Equal(12, Convert.FromBase64String(metadata.IvB64).Length);
var recovered = sut.Decrypt(ciphertext, metadata, "pass");
Assert.Equal(plaintext, recovered);
}
}
@@ -0,0 +1,217 @@
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Export;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Export;
public sealed class DependencyResolverTests
{
private readonly ITemplateEngineRepository _templates = Substitute.For<ITemplateEngineRepository>();
private readonly IExternalSystemRepository _externalSystems = Substitute.For<IExternalSystemRepository>();
private readonly INotificationRepository _notifications = Substitute.For<INotificationRepository>();
private readonly IInboundApiRepository _inboundApi = Substitute.For<IInboundApiRepository>();
private DependencyResolver Sut() => new(_templates, _externalSystems, _notifications, _inboundApi);
private static ExportSelection SelectTemplates(params int[] ids) => new(
TemplateIds: ids,
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: Array.Empty<int>(),
IncludeDependencies: true);
private static ExportSelection SelectApiMethods(params int[] ids) => new(
TemplateIds: Array.Empty<int>(),
SharedScriptIds: Array.Empty<int>(),
ExternalSystemIds: Array.Empty<int>(),
DatabaseConnectionIds: Array.Empty<int>(),
NotificationListIds: Array.Empty<int>(),
SmtpConfigurationIds: Array.Empty<int>(),
ApiKeyIds: Array.Empty<int>(),
ApiMethodIds: ids,
IncludeDependencies: true);
private void StubTemplate(Template t)
{
_templates.GetTemplateWithChildrenAsync(t.Id, Arg.Any<CancellationToken>()).Returns(t);
}
private void StubAllSharedScripts(params SharedScript[] scripts)
{
_templates.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>()).Returns(scripts);
}
private void StubAllExternalSystems(params ExternalSystemDefinition[] systems)
{
_externalSystems.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()).Returns(systems);
foreach (var es in systems)
{
_externalSystems
.GetMethodsByExternalSystemIdAsync(es.Id, Arg.Any<CancellationToken>())
.Returns(Array.Empty<ExternalSystemMethod>());
}
}
private void StubAllFolders(params TemplateFolder[] folders)
{
_templates.GetAllFoldersAsync(Arg.Any<CancellationToken>()).Returns(folders);
}
[Fact]
public async Task Resolve_includes_base_template_for_composed_template()
{
var baseT = new Template("Base") { Id = 10 };
var composing = new Template("Top") { Id = 11 };
composing.Compositions.Add(new TemplateComposition("slot") { TemplateId = 11, ComposedTemplateId = 10 });
StubTemplate(composing);
StubTemplate(baseT);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(11), CancellationToken.None);
Assert.Equal(2, result.Templates.Count);
Assert.Contains(result.Templates, t => t.Id == 10);
Assert.Contains(result.Templates, t => t.Id == 11);
}
[Fact]
public async Task Resolve_includes_shared_script_referenced_by_template()
{
var shared = new SharedScript("UtilHelper", "return 42;") { Id = 100 };
var t = new Template("UsesUtil") { Id = 1 };
t.Scripts.Add(new TemplateScript("body", "var x = UtilHelper(); return x;") { TemplateId = 1 });
StubTemplate(t);
StubAllSharedScripts(shared, new SharedScript("OtherScript", "return 0;") { Id = 101 });
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None);
Assert.Single(result.SharedScripts);
Assert.Equal("UtilHelper", result.SharedScripts[0].Name);
}
[Fact]
public async Task Resolve_includes_external_system_referenced_by_template()
{
var es = new ExternalSystemDefinition("ErpSystem", "https://erp", "ApiKey") { Id = 7 };
var t = new Template("UsesErp") { Id = 2 };
t.Scripts.Add(new TemplateScript("call", "ErpSystem.Call(\"x\");") { TemplateId = 2 });
StubTemplate(t);
StubAllSharedScripts();
StubAllExternalSystems(es, new ExternalSystemDefinition("Other", "https://o", "Basic") { Id = 8 });
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(2), CancellationToken.None);
Assert.Single(result.ExternalSystems);
Assert.Equal("ErpSystem", result.ExternalSystems[0].Name);
}
[Fact]
public async Task Resolve_includes_api_method_shared_script_dependency()
{
var shared = new SharedScript("Validator", "return true;") { Id = 50 };
var method = new ApiMethod("submit", "var ok = Validator(input); return ok;") { Id = 5 };
_inboundApi.GetApiMethodByIdAsync(5, Arg.Any<CancellationToken>()).Returns(method);
StubAllSharedScripts(shared);
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectApiMethods(5), CancellationToken.None);
Assert.Single(result.ApiMethods);
Assert.Single(result.SharedScripts);
Assert.Equal("Validator", result.SharedScripts[0].Name);
}
[Fact]
public async Task Resolve_handles_diamond_dependency_without_duplication()
{
// A composes B and C; both B and C compose D. Selection={A}. D must appear once.
var d = new Template("D") { Id = 4 };
var b = new Template("B") { Id = 2 };
b.Compositions.Add(new TemplateComposition("d-in-b") { TemplateId = 2, ComposedTemplateId = 4 });
var c = new Template("C") { Id = 3 };
c.Compositions.Add(new TemplateComposition("d-in-c") { TemplateId = 3, ComposedTemplateId = 4 });
var a = new Template("A") { Id = 1 };
a.Compositions.Add(new TemplateComposition("b-in-a") { TemplateId = 1, ComposedTemplateId = 2 });
a.Compositions.Add(new TemplateComposition("c-in-a") { TemplateId = 1, ComposedTemplateId = 3 });
StubTemplate(a);
StubTemplate(b);
StubTemplate(c);
StubTemplate(d);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(1), CancellationToken.None);
Assert.Equal(4, result.Templates.Count);
Assert.Single(result.Templates, t => t.Id == 4);
}
[Fact]
public async Task Resolve_includes_template_folder_for_each_selected_template()
{
var root = new TemplateFolder("Root") { Id = 1, ParentFolderId = null };
var child = new TemplateFolder("Child") { Id = 2, ParentFolderId = 1 };
var grand = new TemplateFolder("Grand") { Id = 3, ParentFolderId = 2 };
var t = new Template("T") { Id = 99, FolderId = 3 };
StubTemplate(t);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders(root, child, grand,
new TemplateFolder("Unrelated") { Id = 4, ParentFolderId = null });
var result = await Sut().ResolveAsync(SelectTemplates(99), CancellationToken.None);
Assert.Equal(3, result.TemplateFolders.Count);
// Root-first ordering: depth 0, 1, 2.
Assert.Equal("Root", result.TemplateFolders[0].Name);
Assert.Equal("Child", result.TemplateFolders[1].Name);
Assert.Equal("Grand", result.TemplateFolders[2].Name);
}
[Fact]
public async Task Resolve_returns_topological_order_base_before_derived()
{
// Top composes Middle, Middle composes Leaf. Order must be Leaf, Middle, Top.
var leaf = new Template("Leaf") { Id = 30 };
var middle = new Template("Middle") { Id = 20 };
middle.Compositions.Add(new TemplateComposition("l") { TemplateId = 20, ComposedTemplateId = 30 });
var top = new Template("Top") { Id = 10 };
top.Compositions.Add(new TemplateComposition("m") { TemplateId = 10, ComposedTemplateId = 20 });
StubTemplate(top);
StubTemplate(middle);
StubTemplate(leaf);
StubAllSharedScripts();
StubAllExternalSystems();
StubAllFolders();
var result = await Sut().ResolveAsync(SelectTemplates(10), CancellationToken.None);
Assert.Equal(3, result.Templates.Count);
Assert.Equal("Leaf", result.Templates[0].Name);
Assert.Equal("Middle", result.Templates[1].Name);
Assert.Equal("Top", result.Templates[2].Name);
}
}
@@ -0,0 +1,532 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
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.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
/// <summary>
/// Unit tests for <see cref="BundleImporter.LoadAsync"/>. Uses the real
/// <see cref="BundleSerializer"/>, <see cref="ManifestValidator"/>,
/// <see cref="BundleSecretEncryptor"/>, <see cref="EntitySerializer"/> and
/// <see cref="BundleSessionStore"/> — they're stateless / in-memory and easier
/// to drive than mocks. Repositories + audit + DbContext are mocked because
/// LoadAsync does not exercise them (they're injected so the constructor stays
/// stable across T15/T16/T17).
/// </summary>
public sealed class BundleImporterLoadTests
{
private static readonly JsonSerializerOptions BundleJsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
private static BundleContentDto EmptyContent() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleContentDto SmallContent() => new(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: new[]
{
new TemplateDto(
Name: "Pump",
FolderName: null,
BaseTemplateName: null,
Description: "the one and only",
Attributes: Array.Empty<TemplateAttributeDto>(),
Alarms: Array.Empty<TemplateAlarmDto>(),
Scripts: Array.Empty<TemplateScriptDto>(),
Compositions: Array.Empty<TemplateCompositionDto>()),
},
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
}
private sealed record TestRig(
BundleImporter Importer,
BundleSerializer Serializer,
ManifestBuilder ManifestBuilder,
BundleSecretEncryptor Encryptor,
BundleSessionStore SessionStore,
TransportOptions Options);
private static TestRig BuildRig(Action<TransportOptions>? configure = null)
{
var opts = new TransportOptions();
configure?.Invoke(opts);
var iOpts = Options.Create(opts);
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var serializer = new BundleSerializer();
var validator = new ManifestValidator();
var encryptor = new BundleSecretEncryptor();
var entitySerializer = new EntitySerializer();
var manifestBuilder = new ManifestBuilder();
var store = new BundleSessionStore(iOpts, clock);
var importer = new BundleImporter(
bundleSerializer: serializer,
manifestValidator: validator,
encryptor: encryptor,
entitySerializer: entitySerializer,
sessionStore: store,
// T-004: the unlock rate limiter shares the test clock so its trailing-hour
// window pruning is deterministic. The window itself is the production
// default (1 hour).
unlockRateLimiter: new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow),
options: iOpts,
timeProvider: clock,
templateRepo: Substitute.For<ITemplateEngineRepository>(),
externalRepo: Substitute.For<IExternalSystemRepository>(),
notificationRepo: Substitute.For<INotificationRepository>(),
inboundApiRepo: Substitute.For<IInboundApiRepository>(),
auditService: Substitute.For<IAuditService>(),
correlationContext: Substitute.For<IAuditCorrelationContext>(),
// LoadAsync never touches the DbContext — Preview/Apply do. Build
// a no-provider DbContext so the importer's null check passes;
// the in-memory provider isn't worth pulling in for unit tests.
dbContext: new ScadaBridgeDbContext(
new DbContextOptionsBuilder<ScadaBridgeDbContext>().Options),
semanticValidator: new SemanticValidator());
return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
}
private static Stream PackPlainBundle(BundleSerializer serializer, ManifestBuilder builder, BundleContentDto content)
{
var contentBytes = serializer.SerializeContentBytes(content);
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase: null, encryptor: null);
}
private static Stream PackEncryptedBundle(
BundleSerializer serializer,
ManifestBuilder builder,
BundleSecretEncryptor encryptor,
BundleContentDto content,
string passphrase)
{
var contentBytes = serializer.SerializeContentBytes(content);
// Pack re-stamps salt/iv/hash from the ciphertext it actually writes,
// so the seed values here are placeholders.
var seed = new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256", 600_000, string.Empty, string.Empty);
var manifest = builder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: seed,
summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
return serializer.Pack(content, manifest, passphrase, encryptor);
}
[Fact]
public async Task LoadAsync_returns_session_for_unencrypted_bundle()
{
var rig = BuildRig();
var content = SmallContent();
var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, content);
var session = await rig.Importer.LoadAsync(stream, passphrase: null);
Assert.NotNull(session);
Assert.NotEqual(Guid.Empty, session.SessionId);
Assert.Equal("dev", session.Manifest.SourceEnvironment);
Assert.Equal("alice", session.Manifest.ExportedBy);
Assert.Null(session.Manifest.Encryption);
Assert.NotNull(rig.SessionStore.Get(session.SessionId));
}
[Fact]
public async Task LoadAsync_returns_session_for_encrypted_bundle_with_correct_passphrase()
{
var rig = BuildRig();
var content = SmallContent();
var stream = PackEncryptedBundle(rig.Serializer, rig.ManifestBuilder, rig.Encryptor, content, "secret123");
var session = await rig.Importer.LoadAsync(stream, passphrase: "secret123");
Assert.NotNull(session);
Assert.NotNull(session.Manifest.Encryption);
Assert.NotEmpty(session.DecryptedContent);
// The decrypted payload must round-trip back to the original DTO so the
// PreviewAsync phase can deserialize it directly from the session.
var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(
session.DecryptedContent, BundleJsonOptions);
Assert.NotNull(roundTripped);
Assert.Single(roundTripped!.Templates);
Assert.Equal("Pump", roundTripped.Templates[0].Name);
}
[Fact]
public async Task LoadAsync_throws_when_passphrase_wrong()
{
var rig = BuildRig();
var stream = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
// AES-GCM raises AuthenticationTagMismatchException, a CryptographicException
// subclass on .NET 10 — ThrowsAny is the right match.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(stream, passphrase: "wrong"));
}
[Fact]
public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers()
{
// T-003: the lockout is server-side and keyed by ContentHash, so replaying
// the SAME bundle bytes from a second caller (different stream, different
// session) must hit the same counter. After MaxUnlockAttemptsPerSession (3)
// failures the importer throws BundleLockedException, not another
// CryptographicException — and the lock survives a fresh LoadAsync from a
// pristine caller that has no idea about the prior attempts.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
var bundleBytes = ((MemoryStream)packed).ToArray();
// First two wrong-passphrase attempts surface as CryptographicException.
for (var attempt = 1; attempt <= 2; attempt++)
{
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
}
// Third wrong-passphrase attempt crosses the threshold and surfaces as
// BundleLockedException — even though this is a fresh stream / a caller
// that never saw the earlier failures.
var locked = await Assert.ThrowsAsync<BundleLockedException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
Assert.Equal(3, locked.FailedAttempts);
// Fourth attempt — even with the CORRECT passphrase — is still locked,
// because the lockout sticks until the TTL expires or the bundle is
// re-exported with a new content hash.
await Assert.ThrowsAsync<BundleLockedException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct"));
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_too_many_entries()
{
// T-006: a malicious bundle could pad the archive with arbitrary entries to
// exhaust per-entry handles or to slip an unexpected payload past the
// serializer. The envelope check rejects any archive whose entry count
// exceeds MaxBundleEntryCount BEFORE any decompression.
var rig = BuildRig(opts => opts.MaxBundleEntryCount = 2);
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
var paddedBytes = AppendExtraZipEntry(bytes, "extra.bin", new byte[8]);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null));
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_oversized_entry()
{
// T-006: caller-declared decompressed Length above the configured cap is
// a hostile bundle; reject without decompressing.
var rig = BuildRig(opts => opts.MaxBundleEntryDecompressedMb = 1);
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
// Replace content.json with a 2 MB entry of compressible zeros — uncompressed Length > cap.
var bigPayload = new byte[2 * 1024 * 1024];
var oversizedBytes = ReplaceZipEntry(bytes, "content.json", bigPayload);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null));
}
[Fact]
public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio()
{
// T-006: defence-in-depth — even if Length is within the per-entry cap, an
// extreme compression ratio is a hallmark of a decompression bomb and is
// rejected outright.
var rig = BuildRig(opts =>
{
opts.MaxBundleEntryDecompressedMb = 100;
opts.MaxBundleEntryCompressionRatio = 10;
});
using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
var bytes = ((MemoryStream)packed).ToArray();
// 1 MB of zeros compresses extremely well (>100x ratio) — well over the
// configured 10x cap.
var compressible = new byte[1024 * 1024];
var bombBytes = ReplaceZipEntry(bytes, "content.json", compressible);
await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(new MemoryStream(bombBytes), passphrase: null));
}
/// <summary>
/// T-006 helper: rewrites an existing zip to add a fresh entry alongside the
/// originals. Used by the "too many entries" test.
/// </summary>
private static byte[] AppendExtraZipEntry(byte[] zipBytes, string newEntryName, byte[] payload)
{
var output = new MemoryStream();
using (var src = new MemoryStream(zipBytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in srcZip.Entries)
{
var dst = dstZip.CreateEntry(entry.FullName);
using var inStream = entry.Open();
using var outStream = dst.Open();
inStream.CopyTo(outStream);
}
var extra = dstZip.CreateEntry(newEntryName);
using (var s = extra.Open()) { s.Write(payload); }
}
return output.ToArray();
}
/// <summary>
/// T-006 helper: rewrites an existing zip, replacing one entry's bytes with
/// the supplied payload while preserving every other entry verbatim.
/// </summary>
private static byte[] ReplaceZipEntry(byte[] zipBytes, string entryToReplace, byte[] newPayload)
{
var output = new MemoryStream();
using (var src = new MemoryStream(zipBytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in srcZip.Entries)
{
var dst = dstZip.CreateEntry(entry.FullName);
using var outStream = dst.Open();
if (entry.FullName == entryToReplace)
{
outStream.Write(newPayload);
}
else
{
using var inStream = entry.Open();
inStream.CopyTo(outStream);
}
}
}
return output.ToArray();
}
[Fact]
public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase()
{
// T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment,
// ExportedBy, …) have been edited must fail decryption with a tag mismatch.
// Without AAD an attacker could rewrite the SourceEnvironment label and slip
// past the Step-4 typo-resistant confirmation gate. We tamper the field by
// re-packing the manifest (with everything else, including the original
// ciphertext, unchanged) into a fresh ZIP and verify decrypt fails.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
var bytes = ((MemoryStream)packed).ToArray();
// Read manifest + ciphertext from the legit bundle, mutate SourceEnvironment,
// and re-pack with the SAME ciphertext bytes — the cipher is fine, only the
// plaintext manifest is changed.
BundleManifest originalManifest;
byte[] cipherBytes;
using (var src = new MemoryStream(bytes))
using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
{
using var ms = new MemoryStream();
srcZip.GetEntry("manifest.json")!.Open().CopyTo(ms);
originalManifest = JsonSerializer.Deserialize<BundleManifest>(ms.ToArray(), BundleJsonOptions)!;
using var ctMs = new MemoryStream();
srcZip.GetEntry("content.enc")!.Open().CopyTo(ctMs);
cipherBytes = ctMs.ToArray();
}
var tampered = originalManifest with { SourceEnvironment = "prod-spoofed" };
var tamperedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(tampered, BundleJsonOptions);
var tamperedZipBytes = new MemoryStream();
using (var outZip = new ZipArchive(tamperedZipBytes, ZipArchiveMode.Create, leaveOpen: true))
{
var mEntry = outZip.CreateEntry("manifest.json");
using (var s = mEntry.Open()) { s.Write(tamperedManifestBytes); }
var cEntry = outZip.CreateEntry("content.enc");
using (var s = cEntry.Open()) { s.Write(cipherBytes); }
}
tamperedZipBytes.Position = 0;
// ContentHash check is the FIRST thing that catches the tamper here because
// ContentHash is sealed against the cipher bytes and the cipher is unchanged
// BUT the manifest's plaintext fields are mutated — wait, ContentHash is
// against the cipher which we kept, so it still matches. The defence is
// the AES-GCM AAD: the tag check fails because AAD differs.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct"));
}
[Fact]
public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase()
{
// T-003: a legitimate operator who typos once or twice before getting it
// right must not be penalised on the next bundle. A successful LoadAsync
// clears the per-bundle counter.
var rig = BuildRig();
using var packed = PackEncryptedBundle(
rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
var bundleBytes = ((MemoryStream)packed).ToArray();
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
var session = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct");
Assert.NotNull(session);
// Counter cleared — a subsequent wrong-passphrase attempt starts from 0
// and surfaces as CryptographicException, not BundleLockedException.
await Assert.ThrowsAnyAsync<CryptographicException>(
() => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash));
}
[Fact]
public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported()
{
var rig = BuildRig();
// Synthesize a zip by hand whose manifest carries an unsupported format
// version. The validator looks at BundleFormatVersion first thing after
// null-checks, so the content hash doesn't need to be correct for this
// path — we just need a structurally valid manifest record.
var content = EmptyContent();
var contentBytes = rig.Serializer.SerializeContentBytes(content);
var forwardManifest = new BundleManifest(
BundleFormatVersion: 999,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "dev",
ExportedBy: "alice",
ScadaBridgeVersion: "1.0.0",
ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(),
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false);
await Assert.ThrowsAsync<NotSupportedException>(
() => rig.Importer.LoadAsync(bundleStream, passphrase: null));
}
[Fact]
public async Task LoadAsync_throws_InvalidDataException_when_content_hash_mismatch()
{
var rig = BuildRig();
// Build a normal bundle, then corrupt content.json's bytes after the
// manifest is stamped — the manifest still references the original hash.
var content = SmallContent();
var originalContentBytes = rig.Serializer.SerializeContentBytes(content);
var manifest = rig.ManifestBuilder.Build(
sourceEnvironment: "dev",
exportedBy: "alice",
scadaLinkVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: originalContentBytes);
// Corrupt the bytes so the validator's recomputed hash diverges from
// the manifest's frozen hash.
var corrupted = (byte[])originalContentBytes.Clone();
corrupted[0] ^= 0xFF;
var bundleStream = HandCraftZip(manifest, corrupted, encrypted: false);
var ex = await Assert.ThrowsAsync<InvalidDataException>(
() => rig.Importer.LoadAsync(bundleStream, passphrase: null));
Assert.Contains("hash", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task LoadAsync_throws_InvalidOperationException_when_bundle_exceeds_size_cap()
{
// MaxBundleSizeMb is an int; the smallest positive value is 1 MB. Pack
// a normal bundle and bump it past 1 MB by padding with a long
// description, then cap the limit to 0 — the comparison is `> maxBytes`
// so any positive byte count exceeds a 0 MB cap.
var rig = BuildRig(opts => opts.MaxBundleSizeMb = 0);
var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());
await Assert.ThrowsAsync<InvalidOperationException>(
() => rig.Importer.LoadAsync(stream, passphrase: null));
}
/// <summary>
/// Builds a zip directly so the test can write a manifest whose
/// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
/// disagrees with the content bytes — paths the high-level
/// <see cref="BundleSerializer.Pack"/> won't produce because it always
/// re-stamps the hash itself.
/// </summary>
private static Stream HandCraftZip(BundleManifest manifest, byte[] contentBytes, bool encrypted)
{
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, BundleJsonOptions);
var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
WriteEntry(archive, "manifest.json", manifestBytes);
WriteEntry(archive, encrypted ? "content.enc" : "content.json", contentBytes);
}
ms.Position = 0;
return ms;
}
private static void WriteEntry(ZipArchive archive, string name, byte[] payload)
{
var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
using var es = entry.Open();
es.Write(payload, 0, payload.Length);
}
}
@@ -0,0 +1,197 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
public sealed class BundleSessionStoreTests
{
private static IOptions<TransportOptions> Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions());
private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new()
{
SessionId = Guid.NewGuid(),
Manifest = StubManifest(),
DecryptedContent = Array.Empty<byte>(),
ExpiresAt = expiresAt,
};
private static BundleManifest StubManifest() => new(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UnixEpoch,
SourceEnvironment: "test",
ExportedBy: "t",
ScadaBridgeVersion: "1",
ContentHash: "0",
Encryption: null,
Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0, 0),
Contents: Array.Empty<ManifestContentEntry>());
[Fact]
public void Open_then_Get_returns_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var fetched = sut.Get(session.SessionId);
Assert.NotNull(fetched);
Assert.Same(session, fetched);
}
[Fact]
public void Get_after_TTL_returns_null_and_evicts()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(start.AddMinutes(1));
sut.Open(session);
clock.Advance(TimeSpan.FromMinutes(2));
Assert.Null(sut.Get(session.SessionId));
// Lookup with no time advancement now returns null again — proves the
// entry was evicted, not merely filtered.
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void Remove_evicts_session()
{
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1));
sut.Open(session);
sut.Remove(session.SessionId);
Assert.Null(sut.Get(session.SessionId));
}
[Fact]
public void EvictExpired_removes_all_past_ttl()
{
var start = DateTimeOffset.UtcNow;
var clock = new TestTimeProvider(start);
var sut = new BundleSessionStore(Options(), clock);
var keep = SessionExpiringAt(start.AddMinutes(30));
var dropA = SessionExpiringAt(start.AddSeconds(10));
var dropB = SessionExpiringAt(start.AddMinutes(1));
sut.Open(keep);
sut.Open(dropA);
sut.Open(dropB);
clock.Advance(TimeSpan.FromMinutes(2));
sut.EvictExpired();
Assert.NotNull(sut.Get(keep.SessionId));
Assert.Null(sut.Get(dropA.SessionId));
Assert.Null(sut.Get(dropB.SessionId));
}
[Fact]
public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference()
{
// T-003 legacy guard: the per-session FailedUnlockAttempts / Locked fields on
// BundleSession are no longer the source of truth (lockout moved to the store
// keyed by ContentHash), but the fields remain as a compatibility shim for
// callers/tests that still set them directly. The store hands out the shared
// reference so mutations made on one Get() are observable from another.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
sut.Open(session);
var live = sut.Get(session.SessionId);
Assert.NotNull(live);
live!.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.False(live.Locked);
live.FailedUnlockAttempts++;
Assert.True(live.Locked);
// Mutations are observable via re-fetch — confirms the store hands out
// the shared reference (not a snapshot).
var refetch = sut.Get(session.SessionId);
Assert.NotNull(refetch);
Assert.True(refetch!.Locked);
}
[Fact]
public void UnlockFailureCount_TracksPerBundleAndResets()
{
// T-003: the per-bundle unlock-failure counter is keyed by ContentHash and
// shared across sessions, so a second tab / CLI caller cannot side-step the
// lockout by re-uploading the same bytes. Increment must be atomic and
// ClearUnlockFailures must drop the entry so a legitimate operator who
// eventually types the right passphrase is not stuck on a stale count.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
const string contentHashA = "sha-A";
const string contentHashB = "sha-B";
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
Assert.Equal(1, sut.IncrementUnlockFailureCount(contentHashA));
Assert.Equal(2, sut.IncrementUnlockFailureCount(contentHashA));
// Per-bundle isolation: another bundle's counter is unaffected.
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashB));
sut.ClearUnlockFailures(contentHashA);
Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
}
[Fact]
public void UnlockFailures_ExpireOnTtlAndGetReturnsZero()
{
// T-003: failure records share the session TTL so a bundle that was locked
// hours ago clears on its own. Get must lazily expire stale entries.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
const string contentHash = "sha-expiring";
sut.IncrementUnlockFailureCount(contentHash);
sut.IncrementUnlockFailureCount(contentHash);
Assert.Equal(2, sut.GetUnlockFailureCount(contentHash));
// Advance beyond the configured TTL (default 30 min).
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(0, sut.GetUnlockFailureCount(contentHash));
}
[Fact]
public void UnlockFailures_EvictExpired_ClearsStaleEntries()
{
// T-003: EvictExpired sweep cleans the per-bundle counters too, not just sessions.
var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
var sut = new BundleSessionStore(Options(), clock);
sut.IncrementUnlockFailureCount("sha-old");
clock.Advance(TimeSpan.FromMinutes(31));
sut.EvictExpired();
Assert.Equal(0, sut.GetUnlockFailureCount("sha-old"));
}
/// <summary>
/// Minimal in-test <see cref="TimeProvider"/> with a mutable clock. Used in
/// place of <c>Microsoft.Extensions.TimeProvider.Testing</c> (not in the
/// central package list). Only <see cref="GetUtcNow"/> is overridden; the
/// store does not use timers, so the base implementations suffice elsewhere.
/// </summary>
private sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset _now;
public TestTimeProvider(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now = _now.Add(by);
}
}
@@ -0,0 +1,160 @@
using ZB.MOM.WW.ScadaBridge.Transport.Import;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;
/// <summary>
/// Transport-004: <see cref="BundleUnlockRateLimiter"/> must enforce a per-key cap
/// over a trailing window — the design doc's "per-IP-per-hour" cap (§11). The
/// limiter accepts any opaque caller key (typically a remote IP); these tests use
/// IP-style strings to mirror the documented intent.
/// </summary>
public sealed class BundleUnlockRateLimiterTests
{
private sealed class TestClock : TimeProvider
{
private DateTimeOffset _now;
public TestClock(DateTimeOffset start) { _now = start; }
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan delta) { _now += delta; }
}
[Fact]
public void TryRegisterAttempt_UnderLimit_ReturnsTrue()
{
// The first N attempts at the same key are permitted; the trailing-hour
// count tracks them.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 1; i <= 10; i++)
{
Assert.True(
limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10),
$"Attempt {i} should be allowed (under the cap).");
}
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_AtLimit_RejectsNextAttempt()
{
// N attempts allowed, attempt N+1 rejected — the headline contract.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 11th attempt within the hour exceeds the cap.
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Subsequent attempts also rejected — the limiter does NOT silently let a
// 12th, 13th, ... attempt through (no leak past the cap).
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// And the recorded count never exceeds the cap (rejected attempts are not
// appended to the trailing-hour queue).
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Fact]
public void TryRegisterAttempt_EntriesExpireAfterWindow()
{
// Once the trailing-hour window rolls past every recorded attempt the key
// is fully reset — a legitimate operator returning later is not penalised.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the window's end. Every recorded timestamp is now
// strictly older than (now - window) and must be pruned.
clock.Advance(TimeSpan.FromHours(1) + TimeSpan.FromSeconds(1));
Assert.Equal(0, limiter.GetAttemptCount("10.0.0.1"));
// A fresh full budget is available.
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PartialExpiry_ReleasesOldestSlotOnly()
{
// Sliding window — when only some of the recorded entries have aged out,
// exactly that many slots are released.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
// Five attempts at t=0.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
// 30 minutes later, five more — saturates the budget.
clock.Advance(TimeSpan.FromMinutes(30));
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// Roll just past the first batch's window. Only those five entries expire;
// the second batch (recorded at t=30) is still within window from t=61.
clock.Advance(TimeSpan.FromMinutes(31));
Assert.Equal(5, limiter.GetAttemptCount("10.0.0.1"));
// Five fresh slots are available.
for (var i = 0; i < 5; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
[Fact]
public void TryRegisterAttempt_PerKeyIsolation()
{
// The cap is per key — saturating one IP does not affect another.
var clock = new TestClock(DateTimeOffset.UtcNow);
var limiter = new BundleUnlockRateLimiter(clock, TimeSpan.FromHours(1));
for (var i = 0; i < 10; i++)
{
Assert.True(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
}
Assert.False(limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: 10));
// A different IP has its own untouched budget.
Assert.True(limiter.TryRegisterAttempt("10.0.0.2", maxAttemptsPerWindow: 10));
Assert.Equal(1, limiter.GetAttemptCount("10.0.0.2"));
Assert.Equal(10, limiter.GetAttemptCount("10.0.0.1"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void TryRegisterAttempt_BlankKey_Throws(string? key)
{
var limiter = new BundleUnlockRateLimiter();
Assert.ThrowsAny<ArgumentException>(
() => limiter.TryRegisterAttempt(key!, maxAttemptsPerWindow: 10));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TryRegisterAttempt_NonPositiveLimit_Throws(int limit)
{
var limiter = new BundleUnlockRateLimiter();
Assert.Throws<ArgumentOutOfRangeException>(
() => limiter.TryRegisterAttempt("10.0.0.1", maxAttemptsPerWindow: limit));
}
}
@@ -0,0 +1,129 @@
using System.IO.Compression;
using System.Security.Cryptography;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
public sealed class BundleSerializerTests
{
// Commons-015 sets the documented PBKDF2 floor to 100_000 (OWASP minimum).
// Using the floor keeps the suite fast while passing EncryptionMetadata
// validation.
private const int TestIterations = EncryptionMetadata.MinPbkdf2Iterations;
private static BundleContentDto SampleContent() => new(
TemplateFolders: new[] { new TemplateFolderDto("Root", ParentName: null, SortOrder: 0) },
Templates: Array.Empty<TemplateDto>(),
SharedScripts: new[] { new SharedScriptDto("util", "return 42;", ParameterDefinitions: null, ReturnDefinition: null) },
ExternalSystems: Array.Empty<ExternalSystemDto>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
private static BundleManifest BuildManifestFor(byte[] contentBytes, EncryptionMetadata? encryption = null) =>
new ManifestBuilder().Build(
sourceEnvironment: "test-env",
exportedBy: "tester",
scadaLinkVersion: "1.0.0",
encryption: encryption,
summary: new BundleSummary(0, 1, 1, 0, 0, 0, 0, 0, 0),
contents: Array.Empty<ManifestContentEntry>(),
contentBytes: contentBytes);
[Fact]
public void Pack_emits_manifest_and_content_json_when_no_passphrase()
{
var sut = new BundleSerializer();
var content = SampleContent();
var contentJson = sut.SerializeContentBytes(content);
var manifest = BuildManifestFor(contentJson);
using var stream = sut.Pack(content, manifest, passphrase: null, encryptor: null);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
Assert.NotNull(archive.GetEntry("manifest.json"));
Assert.NotNull(archive.GetEntry("content.json"));
Assert.Null(archive.GetEntry("content.enc"));
}
[Fact]
public void Pack_emits_manifest_and_content_enc_when_passphrase_supplied()
{
var sut = new BundleSerializer();
var encryptor = new BundleSecretEncryptor();
var content = SampleContent();
var contentJson = sut.SerializeContentBytes(content);
var (cipher, meta) = encryptor.Encrypt(contentJson, "pass", TestIterations);
var manifest = BuildManifestFor(cipher, encryption: meta);
using var stream = sut.Pack(content, manifest, passphrase: "pass", encryptor: encryptor);
using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true);
Assert.NotNull(archive.GetEntry("manifest.json"));
Assert.Null(archive.GetEntry("content.json"));
Assert.NotNull(archive.GetEntry("content.enc"));
}
[Fact]
public void Roundtrip_through_temp_stream_recovers_identical_content()
{
var sut = new BundleSerializer();
var content = SampleContent();
var contentJson = sut.SerializeContentBytes(content);
var manifest = BuildManifestFor(contentJson);
using var stream = sut.Pack(content, manifest, passphrase: null, encryptor: null);
stream.Position = 0;
var readManifest = sut.ReadManifest(stream);
stream.Position = 0;
var contentBytes = sut.ReadContentBytes(stream, readManifest);
var unpacked = sut.UnpackContent(contentBytes, readManifest, passphrase: null, encryptor: null);
Assert.Equal(manifest.ContentHash, readManifest.ContentHash);
Assert.Single(unpacked.TemplateFolders);
Assert.Equal("Root", unpacked.TemplateFolders[0].Name);
var shared = Assert.Single(unpacked.SharedScripts);
Assert.Equal("util", shared.Name);
Assert.Equal("return 42;", shared.Code);
}
[Fact]
public void ReadManifest_throws_when_zip_missing_manifest_json()
{
var sut = new BundleSerializer();
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var entry = archive.CreateEntry("garbage.txt");
using var es = entry.Open();
es.Write(new byte[] { 1, 2, 3 });
}
ms.Position = 0;
Assert.Throws<InvalidDataException>(() => sut.ReadManifest(ms));
}
[Fact]
public void UnpackContent_throws_CryptographicException_on_wrong_passphrase()
{
var sut = new BundleSerializer();
var encryptor = new BundleSecretEncryptor();
var content = SampleContent();
var contentJson = sut.SerializeContentBytes(content);
var (cipher, meta) = encryptor.Encrypt(contentJson, "right", TestIterations);
var manifest = BuildManifestFor(cipher, encryption: meta);
using var stream = sut.Pack(content, manifest, passphrase: "right", encryptor: encryptor);
stream.Position = 0;
var readManifest = sut.ReadManifest(stream);
stream.Position = 0;
var contentBytes = sut.ReadContentBytes(stream, readManifest);
Assert.ThrowsAny<CryptographicException>(() => sut.UnpackContent(contentBytes, readManifest, "wrong", encryptor));
}
}
@@ -0,0 +1,228 @@
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
public sealed class EntitySerializerTests
{
private static EntityAggregate MakeEmptyAggregate() => new(
TemplateFolders: Array.Empty<TemplateFolder>(),
Templates: Array.Empty<Template>(),
SharedScripts: Array.Empty<SharedScript>(),
ExternalSystems: Array.Empty<ExternalSystemDefinition>(),
ExternalSystemMethods: Array.Empty<ExternalSystemMethod>(),
DatabaseConnections: Array.Empty<DatabaseConnectionDefinition>(),
NotificationLists: Array.Empty<NotificationList>(),
SmtpConfigurations: Array.Empty<SmtpConfiguration>(),
ApiKeys: Array.Empty<ApiKey>(),
ApiMethods: Array.Empty<ApiMethod>());
[Fact]
public void ToDto_carves_external_system_credentials_into_secrets_block()
{
var sys = new ExternalSystemDefinition("erp", "https://erp/api", "ApiKey")
{
Id = 1,
AuthConfiguration = "{\"apiKey\":\"super-secret\"}",
};
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
var dtoSys = Assert.Single(dto.ExternalSystems);
Assert.NotNull(dtoSys.Secrets);
Assert.True(dtoSys.Secrets!.Values.ContainsKey("AuthConfiguration"));
Assert.Equal("{\"apiKey\":\"super-secret\"}", dtoSys.Secrets.Values["AuthConfiguration"]);
// Public part does not carry the secret.
Assert.Equal("erp", dtoSys.Name);
Assert.Equal("https://erp/api", dtoSys.BaseUrl);
Assert.Equal("ApiKey", dtoSys.AuthType);
}
[Fact]
public void ToDto_carves_smtp_password_into_secrets_block()
{
var smtp = new SmtpConfiguration("smtp.example.com", "Basic", "noreply@example.com")
{
Id = 1,
Port = 587,
Credentials = "user:p@ssw0rd",
};
var aggregate = MakeEmptyAggregate() with { SmtpConfigurations = new[] { smtp } };
var dto = new EntitySerializer().ToBundleContent(aggregate);
var dtoSmtp = Assert.Single(dto.SmtpConfigs);
Assert.NotNull(dtoSmtp.Secrets);
Assert.Equal("user:p@ssw0rd", dtoSmtp.Secrets!.Values["Credentials"]);
Assert.Equal("smtp.example.com", dtoSmtp.Host);
Assert.Equal(587, dtoSmtp.Port);
}
[Fact]
public void Roundtrip_template_preserves_attributes_alarms_scripts_composition()
{
var folder = new TemplateFolder("root") { Id = 1, SortOrder = 0 };
var basic = new Template("Basic") { Id = 1, FolderId = 1, Description = "base" };
basic.Attributes.Add(new TemplateAttribute("Pressure")
{
Id = 1,
TemplateId = 1,
DataType = DataType.Double,
Value = "0",
IsLocked = true,
Description = "PSI",
});
basic.Scripts.Add(new TemplateScript("OnUpdate", "return 1;")
{
Id = 1,
TemplateId = 1,
TriggerType = "Periodic",
ParameterDefinitions = "[]",
ReturnDefinition = "void",
IsLocked = false,
MinTimeBetweenRuns = TimeSpan.FromSeconds(30),
});
basic.Alarms.Add(new TemplateAlarm("High")
{
Id = 1,
TemplateId = 1,
PriorityLevel = 2,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = "{\"threshold\":100}",
IsLocked = false,
// FU-B / #37 — alarm fires "OnUpdate" script when triggered. The FK is
// an in-aggregate script id; the DTO carries the name and the
// importer resolves the FK on the way back in.
OnTriggerScriptId = 1,
});
var assembly = new Template("Assembly") { Id = 2, FolderId = 1 };
assembly.Compositions.Add(new TemplateComposition("MotorA")
{
Id = 1,
TemplateId = 2,
ComposedTemplateId = 1, // refers to "Basic".
});
var aggregate = MakeEmptyAggregate() with
{
TemplateFolders = new[] { folder },
Templates = new[] { basic, assembly },
};
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
// FU-B / #37 — verify the DTO carries OnTriggerScriptName by NAME (not id).
// The bundle is portable across environments so a script-id FK can't
// survive a round-trip; resolution back to a script id is the importer's
// job once the parent template's scripts have been re-persisted.
var dtoBasic = Assert.Single(dto.Templates, t => t.Name == "Basic");
var dtoAlarm = Assert.Single(dtoBasic.Alarms);
Assert.Equal("OnUpdate", dtoAlarm.OnTriggerScriptName);
var roundTripped = sut.FromBundleContent(dto);
var rtBasic = Assert.Single(roundTripped.Templates, t => t.Name == "Basic");
var rtAttr = Assert.Single(rtBasic.Attributes);
Assert.Equal("Pressure", rtAttr.Name);
Assert.Equal(DataType.Double, rtAttr.DataType);
Assert.Equal("0", rtAttr.Value);
Assert.True(rtAttr.IsLocked);
var rtAlarm = Assert.Single(rtBasic.Alarms);
Assert.Equal("High", rtAlarm.Name);
Assert.Equal(AlarmTriggerType.RangeViolation, rtAlarm.TriggerType);
Assert.Equal("{\"threshold\":100}", rtAlarm.TriggerConfiguration);
Assert.Equal(2, rtAlarm.PriorityLevel);
// FromBundleContent leaves OnTriggerScriptId null — the importer
// resolves it post-persist when target-DB script ids are known.
Assert.Null(rtAlarm.OnTriggerScriptId);
var rtScript = Assert.Single(rtBasic.Scripts);
Assert.Equal("OnUpdate", rtScript.Name);
Assert.Equal("return 1;", rtScript.Code);
Assert.Equal("Periodic", rtScript.TriggerType);
Assert.Equal(TimeSpan.FromSeconds(30), rtScript.MinTimeBetweenRuns);
var rtAssembly = Assert.Single(roundTripped.Templates, t => t.Name == "Assembly");
var rtComp = Assert.Single(rtAssembly.Compositions);
Assert.Equal("MotorA", rtComp.InstanceName);
}
[Fact]
public void Roundtrip_template_folder_preserves_hierarchy()
{
var root = new TemplateFolder("Root") { Id = 1, SortOrder = 0 };
var child = new TemplateFolder("Pumps") { Id = 2, ParentFolderId = 1, SortOrder = 1 };
var aggregate = MakeEmptyAggregate() with
{
TemplateFolders = new[] { root, child },
};
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
var rt = sut.FromBundleContent(dto);
Assert.Equal(2, rt.TemplateFolders.Count);
var rtRoot = Assert.Single(rt.TemplateFolders, f => f.Name == "Root");
var rtChild = Assert.Single(rt.TemplateFolders, f => f.Name == "Pumps");
Assert.Null(rtRoot.ParentFolderId);
// Hierarchy is preserved by name reference; new local ids get assigned but
// the child's parent must still point at the row whose name is "Root".
Assert.NotNull(rtChild.ParentFolderId);
Assert.Equal(rtRoot.Id, rtChild.ParentFolderId);
}
[Fact]
public void Roundtrip_external_system_preserves_retry_config()
{
var sys = new ExternalSystemDefinition("billing", "https://billing/api", "Basic")
{
Id = 1,
MaxRetries = 5,
RetryDelay = TimeSpan.FromSeconds(15),
};
var aggregate = MakeEmptyAggregate() with { ExternalSystems = new[] { sys } };
var sut = new EntitySerializer();
var dto = sut.ToBundleContent(aggregate);
var roundTripped = sut.FromBundleContent(dto);
var rtSys = Assert.Single(roundTripped.ExternalSystems);
Assert.Equal("billing", rtSys.Name);
Assert.Equal(5, rtSys.MaxRetries);
Assert.Equal(TimeSpan.FromSeconds(15), rtSys.RetryDelay);
}
[Fact]
public void FromDto_with_null_SecretsBlock_yields_entity_with_default_empty_secrets()
{
var dto = new BundleContentDto(
TemplateFolders: Array.Empty<TemplateFolderDto>(),
Templates: Array.Empty<TemplateDto>(),
SharedScripts: Array.Empty<SharedScriptDto>(),
ExternalSystems: new[]
{
new ExternalSystemDto("erp", "https://x", "None", MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Array.Empty<ExternalSystemMethodDto>(), Secrets: null),
},
DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
NotificationLists: Array.Empty<NotificationListDto>(),
SmtpConfigs: Array.Empty<SmtpConfigDto>(),
ApiKeys: Array.Empty<ApiKeyDto>(),
ApiMethods: Array.Empty<ApiMethodDto>());
var aggregate = new EntitySerializer().FromBundleContent(dto);
var sys = Assert.Single(aggregate.ExternalSystems);
Assert.Null(sys.AuthConfiguration);
}
}
@@ -0,0 +1,103 @@
using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Serialization;
public sealed class ManifestBuilderTests
{
private static BundleSummary EmptySummary => new(0, 0, 0, 0, 0, 0, 0, 0, 0);
private static IReadOnlyList<ManifestContentEntry> NoContents => Array.Empty<ManifestContentEntry>();
[Fact]
public void Build_populates_summary_from_contents()
{
var sut = new ManifestBuilder();
var summary = new BundleSummary(2, 1, 0, 0, 0, 0, 0, 0, 0);
var contents = new[]
{
new ManifestContentEntry("Template", "T1", 1, Array.Empty<string>()),
new ManifestContentEntry("Template", "T2", 1, new[] { "T1" }),
new ManifestContentEntry("TemplateFolder", "F1", 1, Array.Empty<string>()),
};
var manifest = sut.Build("env", "user", "1.0.0", encryption: null, summary, contents, contentBytes: new byte[] { 1, 2, 3 });
Assert.Equal(summary, manifest.Summary);
Assert.Equal(contents, manifest.Contents);
Assert.Equal("env", manifest.SourceEnvironment);
Assert.Equal("user", manifest.ExportedBy);
Assert.Equal("1.0.0", manifest.ScadaBridgeVersion);
Assert.Equal(1, manifest.BundleFormatVersion);
Assert.Equal("1.0", manifest.SchemaVersion);
Assert.Null(manifest.Encryption);
}
[Fact]
public void Build_computes_content_hash_with_sha256_prefix()
{
var sut = new ManifestBuilder();
var bytes = Encoding.UTF8.GetBytes("known-content");
var expectedHashHex = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var manifest = sut.Build("env", "user", "v", encryption: null, EmptySummary, NoContents, bytes);
Assert.Equal("sha256:" + expectedHashHex, manifest.ContentHash);
}
[Fact]
public void Validate_rejects_unsupported_bundleFormatVersion()
{
var bytes = new byte[] { 1, 2, 3 };
var hash = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var manifest = new BundleManifest(
BundleFormatVersion: 999,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "env",
ExportedBy: "u",
ScadaBridgeVersion: "v",
ContentHash: hash,
Encryption: null,
Summary: EmptySummary,
Contents: NoContents);
var result = new ManifestValidator().Validate(manifest, bytes);
Assert.Equal(ManifestValidationResult.UnsupportedFormatVersion, result);
}
[Fact]
public void Validate_rejects_when_contentHash_mismatch()
{
var bytes = new byte[] { 1, 2, 3 };
var manifest = new BundleManifest(
BundleFormatVersion: 1,
SchemaVersion: "1.0",
CreatedAtUtc: DateTimeOffset.UtcNow,
SourceEnvironment: "env",
ExportedBy: "u",
ScadaBridgeVersion: "v",
ContentHash: "sha256:deadbeef",
Encryption: null,
Summary: EmptySummary,
Contents: NoContents);
var result = new ManifestValidator().Validate(manifest, bytes);
Assert.Equal(ManifestValidationResult.ContentHashMismatch, result);
}
[Fact]
public void Validate_accepts_well_formed_v1_manifest()
{
var sut = new ManifestBuilder();
var bytes = Encoding.UTF8.GetBytes("hello");
var manifest = sut.Build("env", "u", "v", encryption: null, EmptySummary, NoContents, bytes);
var result = new ManifestValidator().Validate(manifest, bytes);
Assert.Equal(ManifestValidationResult.Ok, result);
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Akka.TestKit.Xunit2" />
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
</ItemGroup>
</Project>