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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user