diff --git a/Light.TemporaryStreams.sln.DotSettings b/Light.TemporaryStreams.sln.DotSettings index bb2d7f1..0a92497 100644 --- a/Light.TemporaryStreams.sln.DotSettings +++ b/Light.TemporaryStreams.sln.DotSettings @@ -163,4 +163,5 @@ True True True + True diff --git a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs index 5c9fd69..c4a03d7 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/CopyToHashCalculator.cs @@ -22,15 +22,11 @@ public sealed class CopyToHashCalculator : IAsyncDisposable /// The hash algorithm to use. /// The enum value identifying how hash byte arrays are converted to strings. /// A name that uniquely identifies the hash algorithm. - public CopyToHashCalculator( - HashAlgorithm hashAlgorithm, - HashConversionMethod conversionMethod, - string? name = null - ) + public CopyToHashCalculator(HashAlgorithm hashAlgorithm, HashConversionMethod conversionMethod, string? name = null) { HashAlgorithm = hashAlgorithm.MustNotBeNull(); ConversionMethod = conversionMethod.MustBeValidEnumValue(); - Name = name ?? hashAlgorithm.GetType().Name; + Name = name ?? DetermineDefaultName(hashAlgorithm); } /// @@ -54,12 +50,21 @@ public CopyToHashCalculator( /// /// Thrown when has not been called yet. /// - public string Hash => - _hash.MustNotBeNull( - () => new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property." - ) - ); + public string Hash + { + get + { + var hash = _hash; + if (hash is null) + { + throw new InvalidOperationException( + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(Hash)} property" + ); + } + + return hash; + } + } /// /// The calculated hash in byte array representation. @@ -67,12 +72,21 @@ public CopyToHashCalculator( /// /// Thrown when has not been called yet. /// - public byte[] HashArray => - _hashArray.MustNotBeNull( - () => new InvalidOperationException( - $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property." - ) - ); + public byte[] HashArray + { + get + { + var hashArray = _hashArray; + if (hashArray is null) + { + throw new InvalidOperationException( + $"ObtainHashFromAlgorithm must be called before accessing the {nameof(HashArray)} property" + ); + } + + return hashArray; + } + } /// /// Asynchronously disposes the resources used by the current instance, including the CryptoStream and the hash algorithm. @@ -90,6 +104,23 @@ public async ValueTask DisposeAsync() HashAlgorithm.Dispose(); } + private static string DetermineDefaultName(HashAlgorithm hashAlgorithm) + { + /* Some of the .NET hash algorithms (like .SHA1, MD5) are actually just abstract base classes. They have a + * nested type called implementation which can be instantiated, but this type is not publicly visible. GetType() + * will likely return the implementation type, which is not what we want as callers would want the name of the + * base type instead. This is why we check the name of the type for ".Implementation" and if found, we return + * the name of the base type instead. + * + * Other types like HMACSHA256 are public non-abstract types and can be used as expected. */ + var type = hashAlgorithm.GetType(); + var name = type.Name; + var baseTypeName = type.BaseType?.Name; + return name.Equals("Implementation", StringComparison.Ordinal) && !baseTypeName.IsNullOrWhiteSpace() ? + baseTypeName : + name; + } + /// /// Creates a CryptoStream wrapped around the specified stream. The CryptoStream /// is configured to calculate a hash using the hash algorithm provided by the @@ -127,22 +158,13 @@ public void ObtainHashFromAlgorithm() .Hash .MustNotBeNull( () => new InvalidOperationException( - "The crypto stream was not written to - no hash was calculated." + "The crypto stream was not written to - no hash was calculated" ) ); - _hash = ConvertHashToString(_hashArray); + _hash = HashConverter.ConvertHashToString(_hashArray, ConversionMethod); } - private string ConvertHashToString(byte[] hashArray) => - ConversionMethod switch - { - HashConversionMethod.Base64 => Convert.ToBase64String(hashArray), - HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray), - HashConversionMethod.None => "", - _ => throw new InvalidDataException($"{nameof(ConversionMethod)} has an invalid value") - }; - /// /// Converts a to a using the default settings /// (hash array is converted to a Base64 string, name is identical to the type name). diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs new file mode 100644 index 0000000..b2d665e --- /dev/null +++ b/src/Light.TemporaryStreams.Core/Hashing/HashConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; + +namespace Light.TemporaryStreams.Hashing; + +/// +/// Converts hash byte arrays to strings. +/// +public static class HashConverter +{ + /// + /// Converts the hash byte array to a string based on the specified . + /// + /// The hash byte array to convert. + /// The enum value specifying how the hash byte array should be converted to a string. + /// The hash as a string. + /// + /// Thrown if has an invalid value. + /// + public static string ConvertHashToString(byte[] hashArray, HashConversionMethod conversionMethod) => + conversionMethod switch + { + HashConversionMethod.Base64 => Convert.ToBase64String(hashArray), + HashConversionMethod.UpperHexadecimal => Convert.ToHexString(hashArray), + HashConversionMethod.None => "", + _ => throw new ArgumentOutOfRangeException( + nameof(conversionMethod), + $"{nameof(conversionMethod)} has an invalid value '{conversionMethod}'" + ) + }; +} diff --git a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs index 534165d..6e4257f 100644 --- a/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs +++ b/src/Light.TemporaryStreams.Core/Hashing/HashingPlugin.cs @@ -74,18 +74,18 @@ public async ValueTask DisposeAsync() /// The inner stream to be wrapped by the hash calculators. /// The optional token to cancel the asynchronous operation. /// The outermost CryptoStream. + /// Thrown when is null. public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) { innerStream.MustNotBeNull(); - CryptoStream outermostStream = null!; // In the constructor, we ensure that the immutable array is not empty + Stream currentStream = innerStream; for (var i = 0; i < HashCalculators.Length; i++) { - outermostStream = - HashCalculators[i].CreateWrappingCryptoStream(innerStream, leaveWrappedStreamOpen: true); + currentStream = HashCalculators[i].CreateWrappingCryptoStream(currentStream, leaveWrappedStreamOpen: true); } - _outermostCryptoStream = outermostStream; - return new ValueTask(outermostStream); + _outermostCryptoStream = (CryptoStream)currentStream; + return new ValueTask(currentStream); } /// diff --git a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs index 8010220..e600e79 100644 --- a/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs +++ b/src/Light.TemporaryStreams.Core/TemporaryStreamServiceExtensions.cs @@ -38,7 +38,7 @@ public static class TemporaryStreamServiceExtensions /// Thrown when or are null. /// Thrown when has a value that is less than 0. public static async Task CopyToTemporaryStreamAsync( - this TemporaryStreamService temporaryStreamService, + this ITemporaryStreamService temporaryStreamService, Stream source, string? filePath = null, TemporaryStreamServiceOptions? options = null, @@ -100,7 +100,7 @@ await source /// Thrown when has a value that is less than 0. /// Thrown when is empty or the default instance. public static async Task CopyToTemporaryStreamAsync( - this TemporaryStreamService temporaryStreamService, + this ITemporaryStreamService temporaryStreamService, Stream source, ImmutableArray plugins, string? filePath = null, @@ -123,7 +123,7 @@ public static async Task CopyToTemporaryStreamAsync( { for (var i = 0; i < plugins.Length; i++) { - outermostStream = await plugins[i].SetUpAsync(source, cancellationToken).ConfigureAwait(false); + outermostStream = await plugins[i].SetUpAsync(outermostStream, cancellationToken).ConfigureAwait(false); } if (copyBufferSize.HasValue) diff --git a/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs new file mode 100644 index 0000000..65da05d --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/CopyToTemporaryStreamTests.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Light.TemporaryStreams.Hashing; +using Xunit; +using XunitException = Xunit.Sdk.XunitException; + +namespace Light.TemporaryStreams; + +public static class CopyToTemporaryStreamTests +{ + [Theory] + [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes - 1)] + [InlineData(12_000)] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateMemoryStream_WhenSourceIsSmall(int bufferSize) + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); + } + + [Theory] + [InlineData(TemporaryStreamServiceOptions.DefaultFileThresholdInBytes)] + [InlineData(100_000)] + public static async Task CopyToTemporaryStreamAsync_ShouldCreateFileStream_WhenSourceIsLarge(int bufferSize) + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(bufferSize); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldForwardBufferSize() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(40_000); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + copyBufferSize: 24 * 1024, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCopyBufferSize_WhenSpecified() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); + var cancellationToken = TestContext.Current.CancellationToken; + var filePath = Path.GetFullPath("test.txt"); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + filePath: filePath, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldUseCustomOptions_WhenProvided() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(50_000); // Would normally use MemoryStream + var cancellationToken = TestContext.Current.CancellationToken; + var customOptions = new TemporaryStreamServiceOptions + { + FileThresholdInBytes = 30_000 // Force FileStream usage + }; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + options: customOptions, + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldForwardFilePath() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + filePath: "test.txt", + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldDisposeTemporaryStream_WhenAnExceptionIsThrown() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000, useErroneousSourceStream: true); + var cancellationToken = TestContext.Current.CancellationToken; + var serviceSpy = new TemporaryStreamServiceSpy(service); + + // Act + var act = () => serviceSpy.CopyToTemporaryStreamAsync(sourceStream, cancellationToken: cancellationToken); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("Intentional exception to test error handling"); + serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue(); + } + + [Theory] + [InlineData(HashConversionMethod.Base64)] + [InlineData(HashConversionMethod.UpperHexadecimal)] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsSmall( + HashConversionMethod hashConversionMethod + ) + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(40_000); // Less than 80KB threshold + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA1.Create(), hashConversionMethod)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: false, + cancellationToken + ); + hashingPlugin.GetHashArray(nameof(SHA1)).Should().Equal(SHA1.HashData(sourceData)); + } + + [Theory] + [InlineData(HashConversionMethod.Base64)] + [InlineData(HashConversionMethod.UpperHexadecimal)] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldCalculateCorrectHash_WhenSourceIsLarge( + HashConversionMethod hashConversionMethod + ) + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); // More than 80KB threshold + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashingPlugin = + new HashingPlugin([new CopyToHashCalculator(MD5.Create(), hashConversionMethod)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Create(hashingPlugin), + cancellationToken: cancellationToken + ); + + // Assert + await AssertTemporaryStreamContentsMatchAsync( + temporaryStream, + sourceData, + expectFileBased: true, + cancellationToken + ); + hashingPlugin.GetHash(nameof(MD5)).Should().Be( + HashConverter.ConvertHashToString(MD5.HashData(sourceData), hashConversionMethod) + ); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldSupportMultipleHashAlgorithms() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(50_000); + var cancellationToken = TestContext.Current.CancellationToken; + + var sha1Calculator = new CopyToHashCalculator(SHA1.Create(), HashConversionMethod.UpperHexadecimal); + var sha256Calculator = new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.Base64); + + var expectedSha1Hash = HashConverter.ConvertHashToString( + SHA1.HashData(sourceData), + HashConversionMethod.UpperHexadecimal + ); + var expectedSha256Hash = HashConverter.ConvertHashToString( + SHA256.HashData(sourceData), + HashConversionMethod.Base64 + ); + + await using var hashingPlugin = new HashingPlugin([sha1Calculator, sha256Calculator]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + cancellationToken: cancellationToken + ); + + // Assert + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(expectedSha1Hash); + hashingPlugin.GetHash(nameof(SHA256)).Should().Be(expectedSha256Hash); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldUseCustomBufferSize() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(60_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + copyBufferSize: 4096, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.Length.Should().Be(sourceData.Length); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldForwardFilePath() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(100_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + var filePath = Path.GetFullPath("test.txt"); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashingPlugin], + filePath: filePath, + cancellationToken: cancellationToken + ); + + // Assert + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); + temporaryStream.Length.Should().Be(sourceData.Length); + temporaryStream.GetUnderlyingFilePath().Should().Be(filePath); + hashingPlugin.GetHash(nameof(SHA1)).Should().Be(Convert.ToBase64String(SHA1.HashData(sourceData))); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsEmpty() + { + var (service, _, sourceStream) = CreateTestSetup(10); + + var act = () => service.CopyToTemporaryStreamAsync( + sourceStream, + ImmutableArray.Empty + ); + + (await act.Should().ThrowAsync()) + .Which.ParamName.Should().Be("plugins"); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_ShouldThrow_WhenPluginArrayIsDefaultInstance() + { + var (service, _, sourceStream) = CreateTestSetup(10); + + var act = () => service.CopyToTemporaryStreamAsync( + sourceStream, + plugins: default + ); + + (await act.Should().ThrowAsync()) + .Which.ParamName.Should().Be("plugins"); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashNotFound() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = new HashingPlugin([SHA1.Create()]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of + var act = () => hashPlugin.GetHash(nameof(SHA256)); + + // Arrange + act.Should() + .Throw() + .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA256'")); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldThrowWhenHashArrayNotFound() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashPlugin is disposed of + var act = () => hashPlugin.GetHashArray(nameof(SHA3_512)); + + // Arrange + act.Should() + .Throw() + .Where(x => x.Message.StartsWith("There is no hash calculator with the name 'SHA3_512'")); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldReturnHashArray() + { + // Arrange + var (service, sourceData, sourceStream) = CreateTestSetup(20_000); + var cancellationToken = TestContext.Current.CancellationToken; + await using var hashPlugin = + new HashingPlugin([new CopyToHashCalculator(SHA256.Create(), HashConversionMethod.None)]); + + // Act + await using var temporaryStream = await service.CopyToTemporaryStreamAsync( + sourceStream, + [hashPlugin], + cancellationToken: cancellationToken + ); + + // Assert + var hashArray = hashPlugin.GetHashArray(nameof(SHA256)); + var expectedHash = SHA256.HashData(sourceData); + hashArray.Should().Equal(expectedHash); + } + + [Fact] + public static async Task CopyToTemporaryStreamAsync_WithHashingPlugin_ShouldDisposeTemporaryStreamDuringException() + { + // Arrange + var (service, _, sourceStream) = CreateTestSetup(20_000); + var serviceSpy = new TemporaryStreamServiceSpy(service); + var cancellationToken = TestContext.Current.CancellationToken; + + // Act + var act = () => serviceSpy.CopyToTemporaryStreamAsync( + sourceStream, + [new ErroneousPlugin()], + cancellationToken: cancellationToken + ); + + // Assert + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("Intentional exception to test error handling"); + serviceSpy.CapturedTemporaryStream.IsDisposed.Should().BeTrue(); + } + + private static (TemporaryStreamService service, byte[] sourceData, MemoryStream sourceStream) CreateTestSetup( + int dataSize, + bool useErroneousSourceStream = false + ) + { + var service = CreateDefaultService(); + var sourceData = CreateTestData(dataSize); + var sourceStream = useErroneousSourceStream ? new ErroneousMemoryStream() : new MemoryStream(sourceData); + + return (service, sourceData, sourceStream); + } + + private static async Task AssertTemporaryStreamContentsMatchAsync( + TemporaryStream temporaryStream, + byte[] expectedData, + bool expectFileBased, + CancellationToken cancellationToken + ) + { + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().Be(expectFileBased); + temporaryStream.Length.Should().Be(expectedData.Length); + + temporaryStream.Position = 0; + var copiedData = new byte[expectedData.Length]; + await temporaryStream.ReadExactlyAsync(copiedData, cancellationToken); + copiedData.Should().Equal(expectedData); + } + + private static TemporaryStreamService CreateDefaultService() => + new ( + new TemporaryStreamServiceOptions(), + new TemporaryStreamErrorHandlerProvider( + (_, exception) => { TestContext.Current.TestOutputHelper?.WriteLine(exception.ToString()); } + ) + ); + + private static byte[] CreateTestData(int size) + { + var data = new byte[size]; + var random = new Random(42); // Fixed seed for reproducible tests + random.NextBytes(data); + return data; + } + + private sealed class ErroneousPlugin : ICopyToTemporaryStreamPlugin + { + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + public ValueTask SetUpAsync(Stream innerStream, CancellationToken cancellationToken = default) => + new (innerStream); + + public ValueTask AfterCopyAsync(CancellationToken cancellationToken = default) + { + throw new XunitException("Intentional exception to test error handling"); + } + } + + private sealed class TemporaryStreamServiceSpy : ITemporaryStreamService + { + private readonly ITemporaryStreamService _wrappedService; + private TemporaryStream? _capturedTemporaryStream; + + public TemporaryStreamServiceSpy(ITemporaryStreamService wrappedService) => _wrappedService = wrappedService; + + public TemporaryStream CapturedTemporaryStream => + _capturedTemporaryStream ?? throw new InvalidOperationException("No temporary stream captured"); + + public TemporaryStream CreateTemporaryStream( + long expectedLengthInBytes, + string? filePath = null, + TemporaryStreamServiceOptions? options = null + ) + { + var temporaryStream = _wrappedService.CreateTemporaryStream(expectedLengthInBytes, filePath, options); + _capturedTemporaryStream = temporaryStream; + return temporaryStream; + } + } + + private sealed class ErroneousMemoryStream : MemoryStream + { + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + throw new XunitException("Intentional exception to test error handling"); + } + } +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs new file mode 100644 index 0000000..9593db8 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/CopyToHashCalculatorTests.cs @@ -0,0 +1,46 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class CopyToHashCalculatorTests +{ + [Fact] + public static async Task Hash_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand() + { + await using CopyToHashCalculator calculator = SHA1.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.Hash; + + act.Should().Throw() + .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the Hash property"); + } + + [Fact] + public static async Task HashArray_ShouldThrow_WhenObtainHashFromAlgorithmWasNotCalledBeforehand() + { + await using CopyToHashCalculator calculator = SHA256.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.HashArray; + + act.Should().Throw() + .Which.Message.Should().Be("ObtainHashFromAlgorithm must be called before accessing the HashArray property"); + } + + [Fact] + public static async Task ObtainHashFromAlgorithm_ShouldThrow_WhenNothingWasWrittenToUnderlyingCryptoStream() + { + await using CopyToHashCalculator calculator = SHA3_512.Create(); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before calculator is disposed of + var act = () => calculator.ObtainHashFromAlgorithm(); + + act.Should().Throw() + .Which.Message.Should().Be("The crypto stream was not written to - no hash was calculated"); + } +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs new file mode 100644 index 0000000..804dc94 --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashConverterTests.cs @@ -0,0 +1,72 @@ +using System; +using FluentAssertions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class HashConverterTests +{ + [Fact] + public static void ConvertHashToString_Base64() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.Base64); + + result.Should().Be("AQIDBAU="); + } + + [Fact] + public static void ConvertHashToString_UpperHexadecimal() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.UpperHexadecimal); + + result.Should().Be("0102030405"); + } + + [Fact] + public static void ConvertHashToString_None_ReturnsEmptyString() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + + var result = HashConverter.ConvertHashToString(hash, HashConversionMethod.None); + + result.Should().BeEmpty(); + } + + [Fact] + public static void ConvertHashToString_InvalidMethod_ThrowsArgumentOutOfRangeException() + { + byte[] hash = [0x01, 0x02, 0x03, 0x04, 0x05]; + const HashConversionMethod method = (HashConversionMethod) 999; + + Action act = () => HashConverter.ConvertHashToString(hash, method); + + act.Should() + .Throw() + .Where( + x => x.ParamName == "conversionMethod" && + x.Message == $"conversionMethod has an invalid value '{method}' (Parameter 'conversionMethod')" + ); + } + + [Fact] + public static void ConvertHashToString_EmptyArray_ReturnsCorrectResult() + { + var emptyHash = Array.Empty(); + + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.Base64).Should().BeEmpty(); + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.UpperHexadecimal).Should().BeEmpty(); + HashConverter.ConvertHashToString(emptyHash, HashConversionMethod.None).Should().BeEmpty(); + } + + [Fact] + public static void ConvertHashToString_NullArray_ThrowsArgumentNullException() + { + Action act = () => HashConverter.ConvertHashToString(null!, HashConversionMethod.Base64); + + act.Should().Throw(); + } +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs new file mode 100644 index 0000000..b7d620c --- /dev/null +++ b/tests/Light.TemporaryStreams.Core.Tests/Hashing/HashingPluginTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Immutable; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using Light.GuardClauses.Exceptions; +using Xunit; + +namespace Light.TemporaryStreams.Hashing; + +public static class HashingPluginTests +{ + [Fact] + public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsEmpty() + { + var hashCalculators = ImmutableArray.Empty; + + var act = () => new HashingPlugin(hashCalculators); + + act.Should().Throw().Which.ParamName.Should().Be("hashCalculators"); + } + + [Fact] + public static void Constructor_ThrowsEmptyCollectionException_WhenImmutableArrayIsDefault() + { + var hashCalculators = default(ImmutableArray); + + var act = () => new HashingPlugin(hashCalculators); + + act.Should().Throw().Which.ParamName.Should().Be("hashCalculators"); + } + + [Fact] + public static async Task DisposeAsync_DoesNotDisposeCalculators_WhenDisposeCalculatorsIsFalse() + { + using var sha1 = SHA1.Create(); + await using var hashingPlugin = new HashingPlugin([sha1], disposeCalculators: false); + } + + [Fact] + public static async Task AfterCopyAsync_ShouldThrow_WhenSetUpAsyncWasNotCalledBeforehand() + { + await using var hashingPlugin = new HashingPlugin([SHA1.Create()]); + + // ReSharper disable once AccessToDisposedClosure -- delegate called before hashingPlugin is disposed of + var act = () => hashingPlugin.AfterCopyAsync().AsTask(); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("SetUpAsync must be called before AfterCopyAsync"); + } +} diff --git a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs index f16af90..aa10afe 100644 --- a/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs +++ b/tests/Light.TemporaryStreams.Core.Tests/TemporaryStreamServiceTests.cs @@ -127,16 +127,9 @@ public static void CreateTemporaryStream_ShouldUseOverrideOptions_WhenOptionsAre using var temporaryStream = service.CreateTemporaryStream(750, options: overrideOptions); - try - { - temporaryStream.Should().NotBeNull(); - temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold) - temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly); - } - finally - { - File.Delete(temporaryStream.GetUnderlyingFilePath()); - } + temporaryStream.Should().NotBeNull(); + temporaryStream.IsFileBased.Should().BeTrue(); // 750 > 500 (override threshold) + temporaryStream.DisposeBehavior.Should().Be(TemporaryStreamDisposeBehavior.CloseUnderlyingStreamOnly); } [Fact]