diff --git a/CLAUDE.md b/CLAUDE.md index fbc3d4c..2408f09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,7 +185,7 @@ Tests use **MSTest.Sdk** targeting net10.0 only. The test project (`Essentials.T - `CacheProviderTests.cs` - Tests cache operations including expiration, and that a legitimately cached `null` is honoured rather than treated as a miss: `Get`/`GetAsync` return it instead of throwing, and `GetOrAdd`/`GetOrAddAsync` return it without re-invoking the factory. `TryGet`'s `bool` is the sole authority on presence — a null check on the value is not a proxy for it. It also pins that an expired entry's removal leaves a value set concurrently under the same key in place (a key type that runs the `Set` between `TryGet`'s read and its remove makes that interleaving deterministic), and that a `TimeSpan.MaxValue` or `TimeSpan.MinValue` time-to-live saturates rather than overflowing - `CommandExecutorTests.cs` - Tests command execution, including the synchronous path, cancellation before and during a run (and that both paths kill a cancelled command rather than leaving it to finish, issue #53), a working directory that does not exist, that the synchronous and asynchronous paths capture the same bytes for a command whose output ends without a terminator, and that `ExecuteAndGetOutput` throws unwrapped. `ICommandExecutor`'s own synchronous defaults are reached through a test double that declares only the asynchronous members, since `NativeCommandExecutor` replaces them - `EncodingProviderTests.cs` - Tests Base64 and Hex encoding -- `ObfuscationProviderTests.cs` - Tests all obfuscation providers via round-trip (obfuscate → deobfuscate) +- `ObfuscationProviderTests.cs` - Tests all obfuscation providers via round-trip (obfuscate → deobfuscate), and that the Composite runs its stages in order to obfuscate and in reverse to deobfuscate, on the span, stream and async paths, using an order-sensitive `[Base64, Reverse]` pair, since a pipeline run backwards in both directions would still round-trip - `FileSystemProviderTests.cs` - Tests filesystem operations - `LoggingProviderTests.cs` - Tests the console logging provider through `AddEssentials()` - `FileLoggingProviderTests.cs` - Tests the rolling file logger against `InMemoryFileSystemProvider`: line format and escaping, rolling and retention, concurrency, dropped writes, DI diff --git a/Essentials.Tests/ObfuscationProviderTests.cs b/Essentials.Tests/ObfuscationProviderTests.cs index 8ee5aeb..ba91205 100644 --- a/Essentials.Tests/ObfuscationProviderTests.cs +++ b/Essentials.Tests/ObfuscationProviderTests.cs @@ -5,10 +5,12 @@ namespace ktsu.Essentials.Tests; using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading.Tasks; using ktsu.Essentials; using ktsu.Essentials.ObfuscationProviders.Base64; using ktsu.Essentials.ObfuscationProviders.Composite; using ktsu.Essentials.ObfuscationProviders.Hex; +using ktsu.Essentials.ObfuscationProviders.Reverse; using ktsu.Essentials.ObfuscationProviders.Xor; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -256,4 +258,95 @@ public void Obfuscation_Composite_WithFallibleStage_Roundtrips() CollectionAssert.AreEqual(original, provider.Deobfuscate(provider.Obfuscate(original))); } + + // Base64-encoding then reversing gives different bytes from reversing then encoding, so the pair + // is sensitive to stage order, which a round-trip test alone cannot see: a composite that ran + // both directions backwards would still undo itself. + private static readonly IObfuscationProvider FirstStage = new Base64ObfuscationProvider(); + private static readonly IObfuscationProvider SecondStage = new ReverseObfuscationProvider(); + + private static readonly byte[] OrderSample = Encoding.UTF8.GetBytes("composite stages run in order"); + + private static IObfuscationProvider OrderedComposite() + => new CompositeObfuscationProvider([FirstStage, SecondStage]); + + private static byte[] InOrder(byte[] data) => SecondStage.Obfuscate(FirstStage.Obfuscate(data)); + + private static byte[] OutOfOrder(byte[] data) => FirstStage.Obfuscate(SecondStage.Obfuscate(data)); + + private static byte[] ReverseOrder(byte[] data) => FirstStage.Deobfuscate(SecondStage.Deobfuscate(data)); + + [TestMethod] + public void Obfuscation_Composite_ChosenStagesAreOrderSensitive() + => CollectionAssert.AreNotEqual(InOrder(OrderSample), OutOfOrder(OrderSample), "The test pair must give different bytes in each order"); + + [TestMethod] + public void Obfuscation_Composite_Span_AppliesStagesInOrder() + { + IObfuscationProvider provider = OrderedComposite(); + byte[] destination = new byte[provider.GetMaxObfuscatedLength(OrderSample.Length)]; + + Assert.IsTrue(provider.TryObfuscate(OrderSample, destination, out int bytesWritten)); + + CollectionAssert.AreEqual(InOrder(OrderSample), destination.AsSpan(0, bytesWritten).ToArray()); + } + + [TestMethod] + public void Obfuscation_Composite_Span_DeobfuscatesStagesInReverseOrder() + { + IObfuscationProvider provider = OrderedComposite(); + byte[] obfuscated = InOrder(OrderSample); + byte[] destination = new byte[provider.GetMaxDeobfuscatedLength(obfuscated.Length)]; + + Assert.IsTrue(provider.TryDeobfuscate(obfuscated, destination, out int bytesWritten)); + + CollectionAssert.AreEqual(ReverseOrder(obfuscated), destination.AsSpan(0, bytesWritten).ToArray()); + CollectionAssert.AreEqual(OrderSample, destination.AsSpan(0, bytesWritten).ToArray()); + } + + [TestMethod] + public void Obfuscation_Composite_Stream_AppliesStagesInOrder() + { + IObfuscationProvider provider = OrderedComposite(); + using MemoryStream input = new(OrderSample); + using MemoryStream output = new(); + + Assert.IsTrue(provider.TryObfuscate(input, output)); + + CollectionAssert.AreEqual(InOrder(OrderSample), output.ToArray()); + } + + [TestMethod] + public void Obfuscation_Composite_Stream_DeobfuscatesStagesInReverseOrder() + { + IObfuscationProvider provider = OrderedComposite(); + byte[] obfuscated = InOrder(OrderSample); + using MemoryStream input = new(obfuscated); + using MemoryStream output = new(); + + Assert.IsTrue(provider.TryDeobfuscate(input, output)); + + CollectionAssert.AreEqual(ReverseOrder(obfuscated), output.ToArray()); + } + + [TestMethod] + public async Task Obfuscation_Composite_Async_AppliesStagesInOrder() + { + IObfuscationProvider provider = OrderedComposite(); + + byte[] obfuscated = await provider.ObfuscateAsync(OrderSample, TestContext.CancellationToken).ConfigureAwait(false); + + CollectionAssert.AreEqual(InOrder(OrderSample), obfuscated); + } + + [TestMethod] + public async Task Obfuscation_Composite_Async_DeobfuscatesStagesInReverseOrder() + { + IObfuscationProvider provider = OrderedComposite(); + byte[] obfuscated = InOrder(OrderSample); + + byte[] restored = await provider.DeobfuscateAsync(obfuscated, TestContext.CancellationToken).ConfigureAwait(false); + + CollectionAssert.AreEqual(ReverseOrder(obfuscated), restored); + } } \ No newline at end of file