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; /// /// Unit tests for . Uses the real /// , , /// , and /// — 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). /// 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(), Templates: Array.Empty(), SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()); private static BundleContentDto SmallContent() => new( TemplateFolders: Array.Empty(), Templates: new[] { new TemplateDto( Name: "Pump", FolderName: null, BaseTemplateName: null, Description: "the one and only", Attributes: Array.Empty(), Alarms: Array.Empty(), Scripts: Array.Empty(), Compositions: Array.Empty()), }, SharedScripts: Array.Empty(), ExternalSystems: Array.Empty(), DatabaseConnections: Array.Empty(), NotificationLists: Array.Empty(), SmtpConfigs: Array.Empty(), ApiMethods: Array.Empty()); 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? 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(), externalRepo: Substitute.For(), notificationRepo: Substitute.For(), inboundApiRepo: Substitute.For(), siteRepo: Substitute.For(), auditService: Substitute.For(), correlationContext: Substitute.For(), // 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().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", scadaBridgeVersion: "1.0.0", encryption: null, summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0), contents: Array.Empty(), 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", scadaBridgeVersion: "1.0.0", encryption: seed, summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0), contents: Array.Empty(), 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( 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => 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( () => rig.Importer.LoadAsync(new MemoryStream(bombBytes), passphrase: null)); } /// /// T-006 helper: rewrites an existing zip to add a fresh entry alongside the /// originals. Used by the "too many entries" test. /// 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(); } /// /// T-006 helper: rewrites an existing zip, replacing one entry's bytes with /// the supplied payload while preserving every other entry verbatim. /// 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(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( () => 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( () => 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( () => 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), Contents: Array.Empty()); var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false); await Assert.ThrowsAsync( () => 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", scadaBridgeVersion: "1.0.0", encryption: null, summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0), contents: Array.Empty(), 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( () => 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( () => rig.Importer.LoadAsync(stream, passphrase: null)); } // ───────────────────────────────────────────────────────────────────── // Register row 44 — the per-IP-per-hour unlock throttle is actually enforced. // BundleUnlockRateLimiter was registered, injected and unit-tested but never // invoked, so these all passed-by-doing-nothing before the wiring. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task LoadAsync_rejects_unlock_attempts_past_the_per_ip_hourly_cap() { // Per-bundle strikes are effectively disabled here (100) so the throttle is // unambiguously what stops the run. var rig = BuildRig(opts => { opts.MaxUnlockAttemptsPerIpPerHour = 3; opts.MaxUnlockAttemptsPerSession = 100; }); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct"); var bundleBytes = ((MemoryStream)packed).ToArray(); for (var attempt = 1; attempt <= 3; attempt++) { await Assert.ThrowsAnyAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5")); } var throttled = await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5")); Assert.Equal(3, throttled.MaxAttemptsPerWindow); Assert.Equal("10.1.1.5", throttled.ClientKey); // Fail closed: even the CORRECT passphrase is refused while the client is // over budget. await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "correct", clientKey: "10.1.1.5")); } [Fact] public async Task Throttled_unlock_is_not_a_passphrase_failure_and_burns_no_strike() { // A throttled attempt never reaches the decryptor, so it must not consume one // of the bundle's three strikes — otherwise the per-IP throttle would become a // way to lock a bundle that an operator is legitimately importing. var rig = BuildRig(opts => { opts.MaxUnlockAttemptsPerIpPerHour = 1; opts.MaxUnlockAttemptsPerSession = 3; }); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct"); var bundleBytes = ((MemoryStream)packed).ToArray(); var contentHash = rig.Serializer.ReadManifest(new MemoryStream(bundleBytes)).ContentHash; await Assert.ThrowsAnyAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6")); Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash)); await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6")); // Still 1 — the denial burned no strike, so the bundle is not locked. Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash)); } [Fact] public async Task Unlock_throttle_is_isolated_per_client() { // One noisy client must not spend another operator's budget. var rig = BuildRig(opts => { opts.MaxUnlockAttemptsPerIpPerHour = 1; opts.MaxUnlockAttemptsPerSession = 100; }); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct"); var bundleBytes = ((MemoryStream)packed).ToArray(); await Assert.ThrowsAnyAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7")); await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7")); // A different client still has its full budget — reaches the decryptor and // fails on the passphrase, not on the throttle. await Assert.ThrowsAnyAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.8")); } [Fact] public async Task Unattributed_callers_share_one_throttle_bucket_rather_than_bypassing_it() { // Conservative fallback: a caller with no IP is counted against a single // shared bucket, so "unknown client" is a stricter budget, never none. var rig = BuildRig(opts => { opts.MaxUnlockAttemptsPerIpPerHour = 1; opts.MaxUnlockAttemptsPerSession = 100; }); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct"); var bundleBytes = ((MemoryStream)packed).ToArray(); await Assert.ThrowsAnyAsync( () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong")); // Blank is treated the same as null — both land in the shared bucket, which // the first attempt already emptied. var throttled = await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: " ")); Assert.Equal(BundleImporter.UnattributedClientKey, throttled.ClientKey); } [Fact] public async Task Unencrypted_bundles_do_not_consume_the_unlock_budget() { // The throttle guards passphrase brute-forcing; a plain bundle has no unlock // step, so repeated loads must not be able to exhaust an operator's budget. var rig = BuildRig(opts => opts.MaxUnlockAttemptsPerIpPerHour = 1); using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent()); var bundleBytes = ((MemoryStream)packed).ToArray(); for (var i = 0; i < 3; i++) { var session = await rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: null, clientKey: "10.1.1.9"); Assert.NotNull(session); rig.Importer.Abandon(session.SessionId); } } [Fact] public async Task An_already_locked_bundle_does_not_spend_the_clients_hourly_budget() { // Ordering pin: the per-bundle lockout is checked BEFORE the throttle, so // hammering a locked bundle cannot burn through the client's hourly budget // and spill over into denying its next legitimate import. var rig = BuildRig(opts => { opts.MaxUnlockAttemptsPerSession = 1; opts.MaxUnlockAttemptsPerIpPerHour = 2; }); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct"); var bundleBytes = ((MemoryStream)packed).ToArray(); // One wrong attempt locks the bundle (cap 1) and spends 1 of 2 IP budget. await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10")); // Three more attempts on the locked bundle stay BundleLocked — none of them // register against the rate limiter. for (var i = 0; i < 3; i++) { await Assert.ThrowsAsync( () => rig.Importer.LoadAsync( new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.10")); } // A DIFFERENT bundle from the same client still has budget left. using var other = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct2"); var otherSession = await rig.Importer.LoadAsync( new MemoryStream(((MemoryStream)other).ToArray()), passphrase: "correct2", clientKey: "10.1.1.10"); Assert.NotNull(otherSession); } // ───────────────────────────────────────────────────────────────────── // Register row 43 — abandoned-session release. // ───────────────────────────────────────────────────────────────────── [Fact] public async Task Abandon_releases_the_session_slot_and_zeroes_its_decrypted_content() { // Row 43: only ApplyAsync used to release a session, so any caller that // loaded a bundle and walked away held one of MaxConcurrentImportSessions // (plus the decrypted plaintext) for the full TTL. var rig = BuildRig(); using var packed = PackEncryptedBundle( rig.Serializer, rig.ManifestBuilder, rig.Encryptor, SmallContent(), "correct"); var session = await rig.Importer.LoadAsync( new MemoryStream(((MemoryStream)packed).ToArray()), passphrase: "correct"); var decrypted = session.DecryptedContent; Assert.NotEmpty(decrypted); Assert.Contains(decrypted, b => b != 0); Assert.NotNull(rig.SessionStore.Get(session.SessionId)); rig.Importer.Abandon(session.SessionId); Assert.Null(rig.SessionStore.Get(session.SessionId)); // Zeroed in place, so even a caller still holding the reference (the Razor // page keeps one for the Result step) can no longer read the plaintext. Assert.All(decrypted, b => Assert.Equal(0, b)); } [Fact] public async Task Abandon_is_idempotent_and_tolerates_unknown_ids() { // The Razor page calls Abandon from Dispose even after a successful Apply // already released the same id; a double release must be a silent no-op. var rig = BuildRig(); using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent()); var session = await rig.Importer.LoadAsync(packed, passphrase: null); rig.Importer.Abandon(session.SessionId); rig.Importer.Abandon(session.SessionId); rig.Importer.Abandon(Guid.NewGuid()); Assert.Null(rig.SessionStore.Get(session.SessionId)); } [Fact] public async Task Abandon_does_not_release_another_callers_session() { // Sessions are keyed by a random GUID handed only to their opener, so one // operator abandoning their wizard must not evict a concurrent import. var rig = BuildRig(); using var packedA = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent()); using var packedB = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent()); var a = await rig.Importer.LoadAsync(packedA, passphrase: null); var b = await rig.Importer.LoadAsync(packedB, passphrase: null); rig.Importer.Abandon(a.SessionId); Assert.Null(rig.SessionStore.Get(a.SessionId)); var survivor = rig.SessionStore.Get(b.SessionId); Assert.NotNull(survivor); Assert.NotEmpty(survivor!.DecryptedContent); } [Fact] public async Task Abandon_frees_a_concurrent_import_session_slot() { // The availability half of row 43: abandoned sessions used to exhaust the // cap and lock out every import on the node until the TTL expired. var rig = BuildRig(opts => opts.MaxConcurrentImportSessions = 2); using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent()); var bundleBytes = ((MemoryStream)packed).ToArray(); var first = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null); await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null); var full = await Assert.ThrowsAsync( () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null)); Assert.Contains("Too many concurrent import sessions", full.Message); rig.Importer.Abandon(first.SessionId); var third = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: null); Assert.NotNull(rig.SessionStore.Get(third.SessionId)); } /// /// Builds a zip directly so the test can write a manifest whose /// ContentHash or BundleFormatVersion intentionally /// disagrees with the content bytes — paths the high-level /// won't produce because it always /// re-stamps the hash itself. /// 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); } }