Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleImporterLoadTests.cs
T
Joseph Doherty 5ab26a4233 fix(transport): enforce the documented per-IP bundle-unlock throttle (row 44)
BundleUnlockRateLimiter was registered (ServiceCollectionExtensions), injected
into BundleImporter, unit-tested, documented on
TransportOptions.MaxUnlockAttemptsPerIpPerHour = 10 and pinned in
appsettings.Central.json — but _unlockRateLimiter was never invoked. A
brute-force passphrase attacker against a stolen bundle was bounded only by the
per-bundle 3-strike lockout, which a re-export side-steps (new ContentHash).

LoadAsync now registers every unlock attempt against the limiter. Ordering is
load-bearing: AFTER the per-bundle lockout check (hammering a locked bundle
spends no hourly budget) and BEFORE the decrypt (so a denial burns no per-bundle
strike — otherwise the throttle would be a way to lock a bundle someone is
legitimately importing). Over budget throws the new
BundleUnlockThrottledException, fail-closed with a clear message on both
surfaces; it lives in Commons because the management surface (Commons-only
references) must catch it to return a curated error rather than MapFault's
generic internal-error text.

Client key plumbing:
- Central UI: new scoped CircuitClientAddressProvider snapshots
  Connection.RemoteIpAddress at circuit establishment — the same constraint and
  the same pattern as CookieAuthenticationStateProvider, since HttpContext is
  null by the time a circuit submits a passphrase.
- CLI/management: additive ManagementEnvelope.ClientIp, stamped by
  ManagementEndpoints and threaded through DispatchCommand to the preview/import
  bundle handlers.
- No attributable address => a single SHARED "<unattributed>" bucket, not a
  bypass: unknown clients get a stricter budget, never none. Per-client
  isolation is only real behind a ForwardedHeaders-trusted proxy, the same
  caveat the login throttle carries (Component-Security.md).

Docs: Component-Transport.md + docs/components/Transport.md record the two-level
ordering, the client-key sourcing, the shared-bucket fallback and a
troubleshooting entry for the new error.

Tests: 6 importer tests (cap enforced; denial != passphrase failure, no strike
burned; per-client isolation; shared unattributed bucket; unencrypted bundles
consume no budget; a locked bundle spends no hourly budget) — 4 of the 6 fail
with the limiter call disabled, i.e. against the dead-code state. Plus
ManagementActor tests pinning the ClientIp forwarding and the curated
throttle error.
2026-08-15 06:01:14 -04:00

795 lines
37 KiB
C#

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>(),
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>(),
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>(),
siteRepo: Substitute.For<ISiteRepository>(),
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",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(content.Templates.Count, 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",
scadaBridgeVersion: "1.0.0",
encryption: seed,
summary: new BundleSummary(content.Templates.Count, 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),
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",
scadaBridgeVersion: "1.0.0",
encryption: null,
summary: new BundleSummary(1, 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));
}
// ─────────────────────────────────────────────────────────────────────
// 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<CryptographicException>(
() => rig.Importer.LoadAsync(
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.5"));
}
var throttled = await Assert.ThrowsAsync<BundleUnlockThrottledException>(
() => 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<BundleUnlockThrottledException>(
() => 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<CryptographicException>(
() => rig.Importer.LoadAsync(
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.6"));
Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(contentHash));
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
() => 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<CryptographicException>(
() => rig.Importer.LoadAsync(
new MemoryStream(bundleBytes), passphrase: "wrong", clientKey: "10.1.1.7"));
await Assert.ThrowsAsync<BundleUnlockThrottledException>(
() => 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<CryptographicException>(
() => 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<CryptographicException>(
() => 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<BundleUnlockThrottledException>(
() => 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<BundleLockedException>(
() => 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<BundleLockedException>(
() => 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<InvalidOperationException>(
() => 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));
}
/// <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);
}
}