diff --git a/LiteCharms.Features.MidrandBooks.Seed/CategorySeederService.cs b/LiteCharms.Features.MidrandBooks.Seed/CategorySeederService.cs index 5856761..c4a2aae 100644 --- a/LiteCharms.Features.MidrandBooks.Seed/CategorySeederService.cs +++ b/LiteCharms.Features.MidrandBooks.Seed/CategorySeederService.cs @@ -3,7 +3,7 @@ using LiteCharms.Features.MidrandBooks.Products; namespace LiteCharms.Features.MidrandBooks.Seed; -public class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features, +public sealed class CategorySeederService(CategoryService categoryService, ProductService productService, IFeatureManager features, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/LiteCharms.Features.MidrandBooks.Seed/Configuration/CdnSettings.cs b/LiteCharms.Features.MidrandBooks.Seed/Configuration/CdnSettings.cs index 6eb1107..7b3a904 100644 --- a/LiteCharms.Features.MidrandBooks.Seed/Configuration/CdnSettings.cs +++ b/LiteCharms.Features.MidrandBooks.Seed/Configuration/CdnSettings.cs @@ -1,6 +1,6 @@ namespace LiteCharms.Features.MidrandBooks.Seed.Configuration; -public class CdnSettings +public sealed class CdnSettings { public string? BaseCdn { get; set; } diff --git a/LiteCharms.Features.MidrandBooks.Seed/CustomerSeederService.cs b/LiteCharms.Features.MidrandBooks.Seed/CustomerSeederService.cs index 6814a6d..0a22738 100644 --- a/LiteCharms.Features.MidrandBooks.Seed/CustomerSeederService.cs +++ b/LiteCharms.Features.MidrandBooks.Seed/CustomerSeederService.cs @@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Orders.Models; namespace LiteCharms.Features.MidrandBooks.Seed; -public class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features, +public sealed class CustomerSeederService(CustomerService customerService, OrderService orderService, IFeatureManager features, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) diff --git a/LiteCharms.Features.MidrandBooks.Seed/ProductsSeederService.cs b/LiteCharms.Features.MidrandBooks.Seed/ProductsSeederService.cs index 868a454..cb96bfe 100644 --- a/LiteCharms.Features.MidrandBooks.Seed/ProductsSeederService.cs +++ b/LiteCharms.Features.MidrandBooks.Seed/ProductsSeederService.cs @@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Seed.Configuration; namespace LiteCharms.Features.MidrandBooks.Seed; -public class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService, +public sealed class ProductsSeederService(ProductService productService, AuthorService authorService, BooksService booksService, IFeatureManager features, IOptions options, ILogger logger) : BackgroundService { private readonly CdnSettings cdnSettings = options.Value; diff --git a/LiteCharms.Features.MidrandBooks.Tests/AuthorServiceFeatureTests.cs b/LiteCharms.Features.MidrandBooks.Tests/AuthorServiceFeatureTests.cs index 1a0826a..d74aac1 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/AuthorServiceFeatureTests.cs +++ b/LiteCharms.Features.MidrandBooks.Tests/AuthorServiceFeatureTests.cs @@ -5,7 +5,7 @@ using LiteCharms.Features.Models; namespace LiteCharms.Features.MidrandBooks.Tests; -public class AuthorServiceFeatureTests(Fixture fixture, ITestOutputHelper output) : IClassFixture +public class AuthorServiceFeatureTests(Fixture fixture) : IClassFixture { private readonly AuthorService authorService = fixture.Services.GetRequiredService(); diff --git a/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs b/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs index 6948d48..dae42e0 100644 --- a/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs +++ b/LiteCharms.Features.MidrandBooks.Tests/Common/Fixture.cs @@ -1,5 +1,4 @@ using LiteCharms.Features.Extensions; -using LiteCharms.Features.MidrandBooks.Abstractions; using LiteCharms.Features.MidrandBooks.Extensions; namespace LiteCharms.Features.MidrandBooks.Tests.Common; diff --git a/LiteCharms.Features.TechShop/Leads/LeadService.cs b/LiteCharms.Features.TechShop/Leads/LeadService.cs index 79c9858..4469e03 100644 --- a/LiteCharms.Features.TechShop/Leads/LeadService.cs +++ b/LiteCharms.Features.TechShop/Leads/LeadService.cs @@ -1,9 +1,8 @@ -using LiteCharms.Features.Extensions; +using LiteCharms.Features.Hasher; using LiteCharms.Features.Models; using LiteCharms.Features.TechShop.Extensions; using LiteCharms.Features.TechShop.Leads.Models; using LiteCharms.Features.TechShop.Postgres; -using static LiteCharms.Features.Extensions.Hash; namespace LiteCharms.Features.TechShop.Leads; @@ -29,7 +28,7 @@ public class LeadService(IDbContextFactory contextFactory) FeedItemId = request.FeedItemId, Status = LeadStatus.New, TargetId = request.TargetId, - AttributionHash = StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") + AttributionHash = HashService.StringToSha256Hash.Invoke($"{request.ClickId}{request.AppClickId}{request.WebClickId}") }); return await context.SaveChangesAsync(cancellationToken) > 0 diff --git a/LiteCharms.Features.Tests/Fixture.cs b/LiteCharms.Features.Tests/Fixture.cs index 466d0a0..1ad8e4a 100644 --- a/LiteCharms.Features.Tests/Fixture.cs +++ b/LiteCharms.Features.Tests/Fixture.cs @@ -26,6 +26,7 @@ public class Fixture : IDisposable .AddGarageS3(Configuration) .AddEmailServices(Configuration) .AddSingleton(Configuration) + .AddHashServices(Configuration) .BuildServiceProvider(); Mediator = Services.GetRequiredService(); diff --git a/LiteCharms.Features.Tests/HashServiceFeatureTests.cs b/LiteCharms.Features.Tests/HashServiceFeatureTests.cs new file mode 100644 index 0000000..1515107 --- /dev/null +++ b/LiteCharms.Features.Tests/HashServiceFeatureTests.cs @@ -0,0 +1,152 @@ +using LiteCharms.Features.Hasher; + +namespace LiteCharms.Features.Tests; + +public class HashServiceFeatureTests(Fixture fixture) : IClassFixture +{ + private readonly HashService hashService = fixture.Services.GetRequiredService(); + private readonly string payfastPassphrase = fixture.Configuration.GetSection("HasherSettings:PayfastPassphrase").Value!; + + [Fact] + public void StringToSha256Hash_Should_GenerateHash() + { + var input = "We are the best"; + var expectedHash = "96E17275B53F6BEB7A0D1C4F789F226D3C71CBE398585F25B3028F2B432E78AB"; + + var result = HashService.StringToSha256Hash(input); + + Assert.NotNull(result); + Assert.True(HashService.IsSha256Hash(result)); + Assert.Equal(expectedHash, result); + } + + [Fact] + public void StreamToSha256Hash_Should_GenerateHash() + { + var input = "We are successful"; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(input)); + var expectedHash = "C27872EE494B09D72203C98FC858268F3CD3492D62AA7B766A873520C2C73AFB"; + + var result = HashService.StreamToSha256Hash(stream); + + Assert.NotNull(result); + Assert.True(HashService.IsSha256Hash(result)); + Assert.Equal(expectedHash, result); + } + + [Fact] + public void BytesToSha256Hash_Should_GenerateHash() + { + var inputBytes = Encoding.UTF8.GetBytes("We are wealthy"); + var expectedHash = "3876BF98F6E4A8E42B22C40415687D6FF13F0E887F3F508B71852298FC665737"; + + var result = HashService.BytesToSha256Hash(inputBytes); + + Assert.NotNull(result); + Assert.True(HashService.IsSha256Hash(result)); + Assert.Equal(expectedHash, result); + } + + [Fact] + public void ToMd5Hash_Should_GenerateHash() + { + var input = "We manifest our desired destiny"; + var expectedMd5Lowercase = "6c7816869bcebe4634f7afe9c66dfa08"; + + var result = HashService.ToMd5Hash(input); + + Assert.True(result.IsSuccess); + Assert.True(HashService.IsMd5Hash(result.Value)); + Assert.Equal(expectedMd5Lowercase, result.Value); + } + + [Fact] + public void VerifyPayfastWebhookSignature_Should_GenerateHash() + { + var paymentId = hashService.HashEncodeLongId(1001).Value; + + var incomingForm = new Dictionary + { + { "m_payment_id", paymentId }, + { "amount", "350.00" }, + { "item_name", "System Architecture Book" } + }; + + var rawPayload = $"amount=350.00&item_name=System+Architecture+Book&m_payment_id={paymentId}&passphrase={payfastPassphrase}"; + var generatedSignature = HashService.ToMd5Hash(rawPayload).Value; + + var result = hashService.VerifyPayfastWebhookSignature(incomingForm, generatedSignature); + + Assert.True(result.IsSuccess); + Assert.True(result.Value); + } + + [Fact] + public void HashEncodeHex_Should_GenerateHash() + { + var validHexInput = "DEADBEEF42"; + + var result = hashService.HashEncodeHex(validHexInput); + + Assert.True(result.IsSuccess); + Assert.False(string.IsNullOrWhiteSpace(result.Value)); + } + + [Fact] + public void HashEncodeIntId_Should_GenerateHash() + { + int targetId = 42; + + var result = hashService.HashEncodeIntId(targetId); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Length >= 10); + } + + [Fact] + public void HashEncodeLongId_Should_GenerateHash() + { + long targetId = 9904185012L; + + var result = hashService.HashEncodeLongId(targetId); + + Assert.True(result.IsSuccess); + Assert.True(result.Value.Length >= 10); + } + + [Fact] + public void DecodeIntIdHash_Should_GenerateHash() + { + int originalId = 88041; + var hashedString = hashService.HashEncodeIntId(originalId).Value; + + var result = hashService.DecodeIntIdHash(hashedString); + + Assert.True(result.IsSuccess); + Assert.Equal(originalId, result.Value); + } + + [Fact] + public void DecodeLongIdHash_Should_GenerateHash() + { + long originalId = 9081230491823L; + var hashedString = hashService.HashEncodeLongId(originalId).Value; + + var result = hashService.DecodeLongIdHash(hashedString); + + Assert.True(result.IsSuccess); + Assert.Equal(originalId, result.Value); + } + + [Fact] + public void DecodeHexHash_Should_GenerateHash() + { + var originalHex = "ABCDEF12345"; + var hashedString = hashService.HashEncodeHex(originalHex).Value; + + var result = hashService.DecodeHexHash(hashedString); + + Assert.True(result.IsSuccess); + Assert.Equal(originalHex, result.Value); + } +} \ No newline at end of file diff --git a/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj b/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj index 6f93280..015c0b1 100644 --- a/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj +++ b/LiteCharms.Features.Tests/LiteCharms.Features.Tests.csproj @@ -27,6 +27,8 @@ + + diff --git a/LiteCharms.Features/Hasher/HashService.cs b/LiteCharms.Features/Hasher/HashService.cs index fc18e38..bbe4a47 100644 --- a/LiteCharms.Features/Hasher/HashService.cs +++ b/LiteCharms.Features/Hasher/HashService.cs @@ -7,25 +7,37 @@ public sealed partial class HashService(IHashids hasher, IOptions StringToSha256Hash = (input) => - string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex Md5Regex { get; } - public static readonly Func StreamToSha256Hash = (stream) => + [GeneratedRegex(@"\A[0-9a-fA-F]{64}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + private static partial Regex Sha256Regex { get; } + + public static bool IsMd5Hash(string? value) => + !string.IsNullOrWhiteSpace(value) && Md5Regex.IsMatch(value); + + public static bool IsSha256Hash(string? value) => + !string.IsNullOrWhiteSpace(value) && Sha256Regex.IsMatch(value); + + public static string? StringToSha256Hash(string? input) => + string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + + public static string? StreamToSha256Hash(Stream stream) => stream is null ? null : Convert.ToHexString(SHA256.HashData(stream)); - public static readonly Func BytesToSha256Hash = (bytes) => + public static string? BytesToSha256Hash(byte[] bytes) => bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); - - public static Result ComputeMd5Hash(string input) + + public static Result ToMd5Hash(string input) { if (string.IsNullOrEmpty(input)) return Result.Fail("Input content cannot be null or empty for MD5 processing."); byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); - return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); + return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); } public Result VerifyPayfastWebhookSignature(IDictionary incomingFormData, string incomingSignature) @@ -36,16 +48,16 @@ public sealed partial class HashService(IHashids hasher, IOptions("Validation failed: Missing signature string parameter."); var sortedFields = incomingFormData - .Where(field => field.Key != "signature") - .OrderBy(field => field.Key) - .Select(field => $"{field.Key}={Uri.EscapeDataString(field.Value).Replace("%20", "+")}"); + .Where(field => !string.Equals(field.Key, "signature", StringComparison.OrdinalIgnoreCase)) + .OrderBy(field => field.Key, StringComparer.Ordinal) + .Select(field => $"{field.Key}={WebUtility.UrlEncode(field.Value)}"); string payload = string.Join("&", sortedFields); if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase)) - payload += $"&passphrase={Uri.EscapeDataString(settings.PayfastPassphrase).Replace("%20", "+")}"; + payload += $"&passphrase={WebUtility.UrlEncode(settings.PayfastPassphrase)}"; - var localHashResult = ComputeMd5Hash(payload); + var localHashResult = ToMd5Hash(payload); if (!localHashResult.IsSuccess) return Result.Fail(localHashResult.Errors); @@ -60,9 +72,9 @@ public sealed partial class HashService(IHashids hasher, IOptions HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex().IsMatch(input) - ? Result.Fail("Input must be a valid hexadecimal string.") - : Result.Ok(hasher.EncodeHex(input)); + public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) + ? Result.Fail("Input must be a valid hexadecimal string.") + : Result.Ok(hasher.EncodeHex(input)); public Result HashEncodeIntId(int id) => id < 0 ? Result.Fail("Id cannot be negative.") diff --git a/LiteCharms.Features/LiteCharms.Features.csproj b/LiteCharms.Features/LiteCharms.Features.csproj index 6079860..df4841b 100644 --- a/LiteCharms.Features/LiteCharms.Features.csproj +++ b/LiteCharms.Features/LiteCharms.Features.csproj @@ -148,6 +148,8 @@ + + diff --git a/LiteCharms.Features/Models/SearchState.cs b/LiteCharms.Features/Models/SearchState.cs deleted file mode 100644 index 6c483cd..0000000 --- a/LiteCharms.Features/Models/SearchState.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace LiteCharms.Features.Models; - -public class SearchState -{ - public string Query { get; private set; } = string.Empty; - - public event Action? OnSearchSubmitted; - - public void UpdateQuery(string newQuery) => Query = newQuery; - - public void SubmitSearch() => OnSearchSubmitted?.Invoke(); -}