Implemented HashService and tests

This commit is contained in:
Khwezi Mngoma
2026-06-01 09:15:14 +02:00
parent c4f73fd999
commit 8fe129e19c
13 changed files with 193 additions and 38 deletions
@@ -3,7 +3,7 @@ using LiteCharms.Features.MidrandBooks.Products;
namespace LiteCharms.Features.MidrandBooks.Seed; 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<CategorySeederService> logger) : BackgroundService ILogger<CategorySeederService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -1,6 +1,6 @@
namespace LiteCharms.Features.MidrandBooks.Seed.Configuration; namespace LiteCharms.Features.MidrandBooks.Seed.Configuration;
public class CdnSettings public sealed class CdnSettings
{ {
public string? BaseCdn { get; set; } public string? BaseCdn { get; set; }
@@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Orders.Models;
namespace LiteCharms.Features.MidrandBooks.Seed; 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<CustomerSeederService> logger) : BackgroundService ILogger<CustomerSeederService> logger) : BackgroundService
{ {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -5,7 +5,7 @@ using LiteCharms.Features.MidrandBooks.Seed.Configuration;
namespace LiteCharms.Features.MidrandBooks.Seed; 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<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService IFeatureManager features, IOptions<CdnSettings> options, ILogger<ProductsSeederService> logger) : BackgroundService
{ {
private readonly CdnSettings cdnSettings = options.Value; private readonly CdnSettings cdnSettings = options.Value;
@@ -5,7 +5,7 @@ using LiteCharms.Features.Models;
namespace LiteCharms.Features.MidrandBooks.Tests; namespace LiteCharms.Features.MidrandBooks.Tests;
public class AuthorServiceFeatureTests(Fixture fixture, ITestOutputHelper output) : IClassFixture<Fixture> public class AuthorServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{ {
private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>(); private readonly AuthorService authorService = fixture.Services.GetRequiredService<AuthorService>();
@@ -1,5 +1,4 @@
using LiteCharms.Features.Extensions; using LiteCharms.Features.Extensions;
using LiteCharms.Features.MidrandBooks.Abstractions;
using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Extensions;
namespace LiteCharms.Features.MidrandBooks.Tests.Common; namespace LiteCharms.Features.MidrandBooks.Tests.Common;
@@ -1,9 +1,8 @@
using LiteCharms.Features.Extensions; using LiteCharms.Features.Hasher;
using LiteCharms.Features.Models; using LiteCharms.Features.Models;
using LiteCharms.Features.TechShop.Extensions; using LiteCharms.Features.TechShop.Extensions;
using LiteCharms.Features.TechShop.Leads.Models; using LiteCharms.Features.TechShop.Leads.Models;
using LiteCharms.Features.TechShop.Postgres; using LiteCharms.Features.TechShop.Postgres;
using static LiteCharms.Features.Extensions.Hash;
namespace LiteCharms.Features.TechShop.Leads; namespace LiteCharms.Features.TechShop.Leads;
@@ -29,7 +28,7 @@ public class LeadService(IDbContextFactory<ShopDbContext> contextFactory)
FeedItemId = request.FeedItemId, FeedItemId = request.FeedItemId,
Status = LeadStatus.New, Status = LeadStatus.New,
TargetId = request.TargetId, 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 return await context.SaveChangesAsync(cancellationToken) > 0
+1
View File
@@ -26,6 +26,7 @@ public class Fixture : IDisposable
.AddGarageS3(Configuration) .AddGarageS3(Configuration)
.AddEmailServices(Configuration) .AddEmailServices(Configuration)
.AddSingleton(Configuration) .AddSingleton(Configuration)
.AddHashServices(Configuration)
.BuildServiceProvider(); .BuildServiceProvider();
Mediator = Services.GetRequiredService<IMediator>(); Mediator = Services.GetRequiredService<IMediator>();
@@ -0,0 +1,152 @@
using LiteCharms.Features.Hasher;
namespace LiteCharms.Features.Tests;
public class HashServiceFeatureTests(Fixture fixture) : IClassFixture<Fixture>
{
private readonly HashService hashService = fixture.Services.GetRequiredService<HashService>();
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<string, string>
{
{ "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);
}
}
@@ -27,6 +27,8 @@
<!-- Global Usings --> <!-- Global Usings -->
<ItemGroup> <ItemGroup>
<Using Include="System.Net" />
<Using Include="System.Text" />
<Using Include="Mediator" /> <Using Include="Mediator" />
<Using Include="Xunit.Abstractions" /> <Using Include="Xunit.Abstractions" />
<Using Include="Microsoft.Extensions.DependencyInjection" /> <Using Include="Microsoft.Extensions.DependencyInjection" />
+24 -12
View File
@@ -7,19 +7,31 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
{ {
private readonly HasherSettings settings = options.Value; private readonly HasherSettings settings = options.Value;
[System.Text.RegularExpressions.GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")] [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")]
private static partial System.Text.RegularExpressions.Regex HexHashRegex(); private static partial Regex HexHashRegex { get; }
public static readonly Func<string?, string?> StringToSha256Hash = (input) => [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)]
private static partial Regex Md5Regex { get; }
[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))); string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)));
public static readonly Func<Stream, string?> StreamToSha256Hash = (stream) => public static string? StreamToSha256Hash(Stream stream) =>
stream is null ? null : Convert.ToHexString(SHA256.HashData(stream)); stream is null ? null : Convert.ToHexString(SHA256.HashData(stream));
public static readonly Func<byte[], string?> BytesToSha256Hash = (bytes) => public static string? BytesToSha256Hash(byte[] bytes) =>
bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes));
public static Result<string> ComputeMd5Hash(string input) public static Result<string> ToMd5Hash(string input)
{ {
if (string.IsNullOrEmpty(input)) if (string.IsNullOrEmpty(input))
return Result.Fail<string>("Input content cannot be null or empty for MD5 processing."); return Result.Fail<string>("Input content cannot be null or empty for MD5 processing.");
@@ -36,16 +48,16 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
return Result.Fail<bool>("Validation failed: Missing signature string parameter."); return Result.Fail<bool>("Validation failed: Missing signature string parameter.");
var sortedFields = incomingFormData var sortedFields = incomingFormData
.Where(field => field.Key != "signature") .Where(field => !string.Equals(field.Key, "signature", StringComparison.OrdinalIgnoreCase))
.OrderBy(field => field.Key) .OrderBy(field => field.Key, StringComparer.Ordinal)
.Select(field => $"{field.Key}={Uri.EscapeDataString(field.Value).Replace("%20", "+")}"); .Select(field => $"{field.Key}={WebUtility.UrlEncode(field.Value)}");
string payload = string.Join("&", sortedFields); string payload = string.Join("&", sortedFields);
if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase)) 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) if (!localHashResult.IsSuccess)
return Result.Fail<bool>(localHashResult.Errors); return Result.Fail<bool>(localHashResult.Errors);
@@ -60,7 +72,7 @@ public sealed partial class HashService(IHashids hasher, IOptions<HasherSettings
} }
} }
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex().IsMatch(input) public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input)
? Result.Fail<string>("Input must be a valid hexadecimal string.") ? Result.Fail<string>("Input must be a valid hexadecimal string.")
: Result.Ok(hasher.EncodeHex(input)); : Result.Ok(hasher.EncodeHex(input));
@@ -148,6 +148,8 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="HashidsNet" /> <Using Include="HashidsNet" />
<Using Include="System.Net" />
<Using Include="System.Text.RegularExpressions" />
<Using Include="System.Globalization" /> <Using Include="System.Globalization" />
<Using Include="Microsoft.AspNetCore.Builder" /> <Using Include="Microsoft.AspNetCore.Builder" />
<Using Include="Microsoft.Extensions.Hosting" /> <Using Include="Microsoft.Extensions.Hosting" />
-12
View File
@@ -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();
}