commit 85b362e8cd91b933eca32d5f46a5451689ae3948 Author: Anatolii Grynchuk Date: Fri May 1 00:17:34 2026 +0300 chore: add hrynco common library solution - add the standalone HrynCo.Common solution and projects - include the shared common library source and tests - add package metadata and a repo gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b188456 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +artifacts/ +TestResults/ +.idea/ +*.DotSettings.user diff --git a/HrynCo.Common.Tests/BaseConfigurationCheckTests.cs b/HrynCo.Common.Tests/BaseConfigurationCheckTests.cs new file mode 100644 index 0000000..099f420 --- /dev/null +++ b/HrynCo.Common.Tests/BaseConfigurationCheckTests.cs @@ -0,0 +1,50 @@ +namespace HrynCo.Common.Tests; + +using System.ComponentModel.DataAnnotations; +using HrynCo.Common.HealthChecks; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +public sealed class BaseConfigurationCheckTests +{ + [Fact] + public async Task CheckConfigurationAsync_ShouldReturnHealthyWhenOptionsAreValid() + { + var check = new FakeConfigurationCheck(new SampleOptions + { + Code = "ok" + }, "Sample"); + + HealthCheckResult result = await check.CheckConfigurationAsync(default); + + result.Status.Should().Be(HealthStatus.Healthy); + result.Description.Should().Be("Sample configuration is valid."); + } + + [Fact] + public async Task CheckConfigurationAsync_ShouldReturnUnhealthyWhenOptionsAreInvalid() + { + var check = new FakeConfigurationCheck(new SampleOptions(), "Sample"); + + HealthCheckResult result = await check.CheckConfigurationAsync(default); + + result.Status.Should().Be(HealthStatus.Unhealthy); + result.Description.Should().Contain("Sample configuration invalid:"); + result.Description.Should().Contain("The Code field is required."); + } + + private sealed class FakeConfigurationCheck : BaseConfigurationCheck + { + public FakeConfigurationCheck(SampleOptions options, string name) + : base(options, name) + { + } + } + + private sealed class SampleOptions + { + [Required] + public string? Code { get; set; } + } +} diff --git a/HrynCo.Common.Tests/CachingTests.cs b/HrynCo.Common.Tests/CachingTests.cs new file mode 100644 index 0000000..504255b --- /dev/null +++ b/HrynCo.Common.Tests/CachingTests.cs @@ -0,0 +1,41 @@ +namespace HrynCo.Common.Tests; + +using HrynCo.Common.Caching; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Xunit; + +public sealed class CachingTests +{ + [Fact] + public async Task SessionCredentialStore_ShouldSaveGetAndRemoveValues() + { + using var cache = new MemoryCache(new MemoryCacheOptions()); + var store = new InMemorySessionCredentialStore(cache); + Guid userId = Guid.NewGuid(); + + await store.SaveAsync(userId, "github", "secret", TimeSpan.FromMinutes(5)); + + (await store.GetAsync(userId, "github")).Should().Be("secret"); + + await store.RemoveAsync(userId, "github"); + + (await store.GetAsync(userId, "github")).Should().BeNull(); + } + + [Fact] + public async Task SessionPromptStore_ShouldSaveGetAndRemoveValues() + { + using var cache = new MemoryCache(new MemoryCacheOptions()); + var store = new InMemorySessionPromptStore(cache); + Guid userId = Guid.NewGuid(); + + await store.SaveAsync(userId, "openai", "prompt", TimeSpan.FromMinutes(5)); + + (await store.GetAsync(userId, "openai")).Should().Be("prompt"); + + await store.RemoveAsync(userId, "openai"); + + (await store.GetAsync(userId, "openai")).Should().BeNull(); + } +} diff --git a/HrynCo.Common.Tests/ClockTests.cs b/HrynCo.Common.Tests/ClockTests.cs new file mode 100644 index 0000000..c60f6c7 --- /dev/null +++ b/HrynCo.Common.Tests/ClockTests.cs @@ -0,0 +1,44 @@ +namespace HrynCo.Common.Tests; + +using HrynCo.Common; +using FluentAssertions; +using Xunit; + +public sealed class ClockTests +{ + [Fact] + public void Clock_ShouldExposeUtcAndLocalTimeCloseToConstructionTime() + { + DateTimeOffset initialUtcNow = new(2026, 04, 30, 12, 0, 0, TimeSpan.Zero); + + var clock = new Clock(initialUtcNow); + + clock.UtcNow.Should().BeCloseTo(initialUtcNow, TimeSpan.FromSeconds(1)); + clock.Now.Should().BeCloseTo(initialUtcNow.ToLocalTime(), TimeSpan.FromSeconds(1)); + } + + [Fact] + public void GetNextDayOfWeek_ShouldReturnNextMatchingDay() + { + var clock = new Clock(new DateTimeOffset(2026, 04, 30, 12, 0, 0, TimeSpan.Zero)); + + clock.GetNextDayOfWeek(DayOfWeek.Friday).Should().Be(new DateOnly(2026, 5, 1)); + clock.GetNextDayOfWeek(DayOfWeek.Thursday).Should().Be(new DateOnly(2026, 5, 7)); + } + + [Fact] + public void GetLastDayOfMonth_ShouldReturnMonthEnd() + { + var clock = new Clock(new DateTimeOffset(2026, 02, 10, 8, 0, 0, TimeSpan.Zero)); + + clock.GetLastDayOfMonth().Should().Be(new DateOnly(2026, 2, 28)); + } + + [Fact] + public void GetNextMonthStart_ShouldReturnFirstDayOfNextMonth() + { + var clock = new Clock(new DateTimeOffset(2026, 12, 15, 8, 0, 0, TimeSpan.Zero)); + + clock.GetNextMonthStart().Should().Be(new DateOnly(2027, 1, 1)); + } +} diff --git a/HrynCo.Common.Tests/ConfigurationFactoryTests.cs b/HrynCo.Common.Tests/ConfigurationFactoryTests.cs new file mode 100644 index 0000000..0e7cdb1 --- /dev/null +++ b/HrynCo.Common.Tests/ConfigurationFactoryTests.cs @@ -0,0 +1,75 @@ +namespace HrynCo.Common.Tests; + +using HrynCo.Common; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Xunit; + +public sealed class ConfigurationFactoryTests +{ + [Fact] + public void CreateConfiguration_ShouldLoadJsonAndEnvironmentValues() + { + string basePath = CreateTempDirectory(); + string originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty; + + try + { + File.WriteAllText(Path.Combine(basePath, "appsettings.json"), """ + { + "Title": "base", + "Nested": { + "Value": "base" + } + } + """); + + File.WriteAllText(Path.Combine(basePath, "appsettings.Test.json"), """ + { + "Nested": { + "Value": "override" + } + } + """); + + File.WriteAllText(Path.Combine(basePath, "local.settings.json"), """ + { + "Local": "enabled" + } + """); + + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Test"); + Environment.SetEnvironmentVariable("TITLE", "from-env"); + + IConfigurationRoot configuration = ConfigurationFactory.CreateConfiguration(basePath); + + configuration["Title"].Should().Be("from-env"); + configuration["Nested:Value"].Should().Be("override"); + configuration["Local"].Should().Be("enabled"); + } + finally + { + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment); + Environment.SetEnvironmentVariable("TITLE", null); + Directory.Delete(basePath, true); + } + } + + [Fact] + public void CreateConfiguration_ShouldThrowWhenBasePathIsMissing() + { + string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + + Action act = () => ConfigurationFactory.CreateConfiguration(missingPath, "Production"); + + act.Should().Throw() + .WithMessage($"Directory {missingPath} does not exist. Wrong value of the basePath parameter."); + } + + private static string CreateTempDirectory() + { + string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } +} diff --git a/HrynCo.Common.Tests/GlobalUsings.cs b/HrynCo.Common.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b66d602 --- /dev/null +++ b/HrynCo.Common.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using System.Diagnostics.CodeAnalysis; diff --git a/HrynCo.Common.Tests/HealthChecksTests.cs b/HrynCo.Common.Tests/HealthChecksTests.cs new file mode 100644 index 0000000..2058494 --- /dev/null +++ b/HrynCo.Common.Tests/HealthChecksTests.cs @@ -0,0 +1,99 @@ +namespace HrynCo.Common.Tests; + +using HrynCo.Common.HealthChecks; +using HrynCo.Common.HealthChecks.Defaults; +using HrynCo.Common.HealthChecks.Interfaces; +using FluentAssertions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +public sealed class HealthChecksTests +{ + [Fact] + public async Task CompositeHealthCheck_ShouldUseConfigurationCheckWhenConfiguredForConfigOnly() + { + var configCheck = new FakeConfigurationCheck(); + var serviceCheck = new FakeServiceHealthCheck(); + var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: true); + + HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default); + + result.Status.Should().Be(HealthStatus.Healthy); + configCheck.Called.Should().BeTrue(); + serviceCheck.Called.Should().BeFalse(); + } + + [Fact] + public async Task CompositeHealthCheck_ShouldUseServiceCheckWhenNotConfigOnly() + { + var configCheck = new FakeConfigurationCheck(); + var serviceCheck = new FakeServiceHealthCheck(); + var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: false); + + HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default); + + result.Status.Should().Be(HealthStatus.Healthy); + configCheck.Called.Should().BeFalse(); + serviceCheck.Called.Should().BeTrue(); + } + + [Fact] + public async Task DbServiceHealthCheck_ShouldReturnHealthyWhenConnectionSucceeds() + { + var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(true)); + + HealthCheckResult result = await check.CheckHealthAsync(default); + + result.Status.Should().Be(HealthStatus.Healthy); + } + + [Fact] + public async Task DbServiceHealthCheck_ShouldReturnUnhealthyWhenConnectionFails() + { + var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(false)); + + HealthCheckResult result = await check.CheckHealthAsync(default); + + result.Status.Should().Be(HealthStatus.Unhealthy); + } + + private sealed class FakeConfigurationCheck : IConfigurationCheck + { + public bool Called { get; private set; } + + public Task CheckConfigurationAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + Called = true; + return Task.FromResult(HealthCheckResult.Healthy("config")); + } + } + + private sealed class FakeServiceHealthCheck : IServiceHealthCheck + { + public bool Called { get; private set; } + + public Task CheckHealthAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + Called = true; + return Task.FromResult(HealthCheckResult.Healthy("service")); + } + } + + private sealed class FakeDatabaseConnectionChecker : IDatabaseConnectionChecker + { + private readonly bool _canConnect; + + public FakeDatabaseConnectionChecker(bool canConnect) + { + _canConnect = canConnect; + } + + public Task CanConnectAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + return Task.FromResult(_canConnect); + } + } +} diff --git a/HrynCo.Common.Tests/HrynCo.Common.Tests.csproj b/HrynCo.Common.Tests/HrynCo.Common.Tests.csproj new file mode 100644 index 0000000..43e5cfc --- /dev/null +++ b/HrynCo.Common.Tests/HrynCo.Common.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/HrynCo.Common.Tests/ProfilerTests.cs b/HrynCo.Common.Tests/ProfilerTests.cs new file mode 100644 index 0000000..a69df2e --- /dev/null +++ b/HrynCo.Common.Tests/ProfilerTests.cs @@ -0,0 +1,94 @@ +namespace HrynCo.Common.Tests; + +using System.Collections.Concurrent; +using HrynCo.Common; +using FluentAssertions; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Xunit; + +public sealed class ProfilerTests +{ + [Fact] + public async Task MeasureExecutionAsync_ShouldReturnResultAndWriteStartAndEndEvents() + { + var sink = new CollectingSink(); + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(sink) + .CreateLogger(); + + var profiler = new Profiler(logger); + + int result = await profiler.MeasureExecutionAsync(async () => + { + await Task.Delay(1); + return 42; + }, "LoadItems"); + + result.Should().Be(42); + sink.Events.Count.Should().BeGreaterThan(1); + sink.Events.Should().ContainSingle(e => + e.Level == LogEventLevel.Information + && e.MessageTemplate.Text.Contains("Start", StringComparison.Ordinal) + && e.Properties["BlockName"].ToString().Contains("LoadItems", StringComparison.Ordinal)); + sink.Events.Should().ContainSingle(e => + e.Level == LogEventLevel.Information + && e.MessageTemplate.Text.Contains("End", StringComparison.Ordinal) + && e.Properties["Measurements"].ToString().Contains("True", StringComparison.Ordinal)); + } + + [Fact] + public async Task MeasureExecutionAsync_ActionOverload_ShouldInvokeAction() + { + var sink = new CollectingSink(); + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(sink) + .CreateLogger(); + + var profiler = new Profiler(logger); + bool invoked = false; + + await profiler.MeasureExecutionAsync(async () => + { + invoked = true; + await Task.CompletedTask; + }, "ActionBlock"); + + invoked.Should().BeTrue(); + } + + [Fact] + public async Task MeasureExecutionAsync_ShouldLogErrorAndRethrow() + { + var sink = new CollectingSink(); + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Verbose() + .WriteTo.Sink(sink) + .CreateLogger(); + + var profiler = new Profiler(logger); + + Func act = async () => + { + await profiler.MeasureExecutionAsync(() => throw new InvalidOperationException("boom"), "FailBlock"); + }; + + await act.Should().ThrowAsync(); + sink.Events.Should().Contain(e => + e.Level == LogEventLevel.Error + && e.MessageTemplate.Text.Contains("An error occurred", StringComparison.Ordinal)); + } + + private sealed class CollectingSink : ILogEventSink + { + public ConcurrentQueue Events { get; } = new(); + + public void Emit(LogEvent logEvent) + { + Events.Enqueue(logEvent); + } + } +} diff --git a/HrynCo.Common.Tests/SecretProtectorTests.cs b/HrynCo.Common.Tests/SecretProtectorTests.cs new file mode 100644 index 0000000..1c420cf --- /dev/null +++ b/HrynCo.Common.Tests/SecretProtectorTests.cs @@ -0,0 +1,81 @@ +namespace HrynCo.Common.Tests; + +using System.Security.Cryptography; +using HrynCo.Common.Security; +using FluentAssertions; +using Xunit; + +public sealed class SecretProtectorTests +{ + [Fact] + public void Constructor_ShouldRejectMissingKey() + { + Action act = () => _ = new SecretProtector(string.Empty); + + act.Should().Throw() + .WithMessage("Secret encryption key is not configured."); + } + + [Fact] + public void Constructor_ShouldRejectInvalidKeyLength() + { + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(31)); + + Action act = () => _ = new SecretProtector(key); + + act.Should().Throw() + .WithMessage("Secret encryption key must be 32 bytes encoded as Base64."); + } + + [Fact] + public void ProtectAndUnprotect_ShouldRoundTripPlaintext() + { + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var protector = new SecretProtector(key); + + string protectedValue = protector.Protect("hello world"); + string plaintext = protector.Unprotect(protectedValue); + + protectedValue.Should().StartWith("v1:"); + plaintext.Should().Be("hello world"); + } + + [Fact] + public void Unprotect_ShouldRejectUnsupportedFormat() + { + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var protector = new SecretProtector(key); + + Action act = () => protector.Unprotect("v2:payload"); + + act.Should().Throw() + .WithMessage("Unsupported protected value format."); + } + + [Fact] + public void Unprotect_ShouldFailWhenProtectedValueIsTamperedWith() + { + string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var protector = new SecretProtector(key); + string protectedValue = protector.Protect("hello world"); + string tamperedValue = protectedValue[..^1] + (protectedValue.EndsWith('A') ? 'B' : 'A'); + + Action act = () => protector.Unprotect(tamperedValue); + + act.Should().Throw(); + } + + [Fact] + public void Unprotect_ShouldFailWhenUsingTheWrongKey() + { + string originalKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + string otherKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + var protector = new SecretProtector(originalKey); + var wrongProtector = new SecretProtector(otherKey); + string protectedValue = protector.Protect("hello world"); + + Action act = () => wrongProtector.Unprotect(protectedValue); + + act.Should().Throw(); + } +} diff --git a/HrynCo.Common.Tests/TimeApiUsageGuardTests.cs b/HrynCo.Common.Tests/TimeApiUsageGuardTests.cs new file mode 100644 index 0000000..8995adb --- /dev/null +++ b/HrynCo.Common.Tests/TimeApiUsageGuardTests.cs @@ -0,0 +1,77 @@ +namespace HrynCo.Common.Tests; + +using FluentAssertions; +using Xunit; + +public sealed class TimeApiUsageGuardTests +{ + private static readonly string[] ForbiddenPatterns = + [ + "DateTime.Now", + "DateTimeOffset.Now" + ]; + + [Fact] + public void ProductionCode_ShouldNotUseLocalNowApis() + { + string solutionRoot = GetSolutionRoot(); + + var violations = Directory + .EnumerateFiles(solutionRoot, "*.cs", SearchOption.AllDirectories) + .Where(IsProductionSourceFile) + .SelectMany(file => FindViolations(solutionRoot, file)) + .ToArray(); + + violations.Should().BeEmpty( + "production code should use UTC-based APIs for persisted and serialized timestamps.{0}{1}", + Environment.NewLine, + string.Join(Environment.NewLine, violations)); + } + + private static string GetSolutionRoot() + { + string? current = AppContext.BaseDirectory; + + while (current is not null) + { + if (File.Exists(Path.Combine(current, "HrynCo.Common.sln"))) + { + return current; + } + + current = Directory.GetParent(current)?.FullName; + } + + throw new DirectoryNotFoundException("Could not locate the solution root from the test assembly output."); + } + + private static bool IsProductionSourceFile(string filePath) + { + string normalizedPath = filePath.Replace('\\', '/'); + + return !normalizedPath.Contains("/bin/", StringComparison.OrdinalIgnoreCase) + && !normalizedPath.Contains("/obj/", StringComparison.OrdinalIgnoreCase) + && !normalizedPath.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) + && !normalizedPath.Contains(".Tests/", StringComparison.OrdinalIgnoreCase) + && !normalizedPath.Contains(".IntegrationTests/", StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable FindViolations(string solutionRoot, string filePath) + { + string relativePath = Path.GetRelativePath(solutionRoot, filePath).Replace('\\', '/'); + string[] lines = File.ReadAllLines(filePath); + + for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++) + { + string line = lines[lineIndex]; + + foreach (string forbiddenPattern in ForbiddenPatterns) + { + if (line.Contains(forbiddenPattern, StringComparison.Ordinal)) + { + yield return $"{relativePath}:{lineIndex + 1} contains forbidden API `{forbiddenPattern}`"; + } + } + } + } +} diff --git a/HrynCo.Common.Tests/TreeUtilsTests.cs b/HrynCo.Common.Tests/TreeUtilsTests.cs new file mode 100644 index 0000000..dec1e1a --- /dev/null +++ b/HrynCo.Common.Tests/TreeUtilsTests.cs @@ -0,0 +1,151 @@ +namespace HrynCo.Common.Tests; + +using HrynCo.Common.Tree; +using FluentAssertions; +using Xunit; + +public sealed class FakeNode : ITreeNode +{ + public required Guid Id { get; init; } + public Guid? ParentId { get; init; } +} + +public sealed class FakeNamedNode : ITreeNode, INameNode +{ + public required string Name { get; init; } + public required Guid Id { get; init; } + public Guid? ParentId { get; init; } +} + +public sealed class TreeUtilsTests +{ + [Fact] + public void CollectDescendants_ShouldReturnSelfAndAllChildren() + { + // Arrange + var root = Guid.NewGuid(); + var child1 = Guid.NewGuid(); + var child2 = Guid.NewGuid(); + var nodes = new List + { + new() + { + Id = root, + ParentId = null + }, + new() + { + Id = child1, + ParentId = root + }, + new() + { + Id = child2, + ParentId = child1 + } + }; + + // Act + var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty); + + // Assert + result.Should().BeEquivalentTo(new[] + { + root, child1, child2 + }); + } + + [Fact] + public void BuildBreadcrumb_ShouldReturnPathFromRootToNode() + { + // Arrange + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var grandchild = Guid.NewGuid(); + + var map = new Dictionary + { + [root] = new() + { + Id = root, + ParentId = null, + Name = "Root" + }, + [child] = new() + { + Id = child, + ParentId = root, + Name = "Child" + }, + [grandchild] = new() + { + Id = grandchild, + ParentId = child, + Name = "Grandchild" + } + }; + + // Act + var result = TreeUtils.BuildBreadcrumb(grandchild, map); + + // Assert + result.Should().BeEquivalentTo(new[] + { + new BreadcrumbNode(root, "Root"), new BreadcrumbNode(child, "Child"), + new BreadcrumbNode(grandchild, "Grandchild") + }, options => options.WithStrictOrdering()); + } + + [Fact] + public void CollectDescendants_ShouldReturnOnlyRootWhenThereAreNoChildren() + { + // Arrange + var root = Guid.NewGuid(); + + // Act + var result = TreeUtils.CollectDescendants(root, Array.Empty(), Guid.Empty); + + // Assert + result.Should().BeEquivalentTo(new[] { root }); + } + + [Fact] + public void CollectDescendants_ShouldHandleCyclesWithoutRepeatingNodes() + { + // Arrange + var root = Guid.NewGuid(); + var child = Guid.NewGuid(); + var nodes = new List + { + new() + { + Id = root, + ParentId = child + }, + new() + { + Id = child, + ParentId = root + } + }; + + // Act + var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty); + + // Assert + result.Should().BeEquivalentTo(new[] { root, child }); + } + + [Fact] + public void BuildBreadcrumb_ShouldReturnEmptyListWhenNodeIsMissing() + { + // Arrange + var map = new Dictionary(); + + // Act + var result = TreeUtils.BuildBreadcrumb(Guid.NewGuid(), map); + + // Assert + result.Should().BeEmpty(); + } +} diff --git a/HrynCo.Common.sln b/HrynCo.Common.sln new file mode 100644 index 0000000..1436c8c --- /dev/null +++ b/HrynCo.Common.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HrynCo.Common", "HrynCo.Common\HrynCo.Common.csproj", "{5732ABF0-92F3-4123-865E-5B40F0E86F57}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HrynCo.Common.Tests", "HrynCo.Common.Tests\HrynCo.Common.Tests.csproj", "{E1769E76-9945-44B7-A1C9-BA3DF11A29C7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x64.ActiveCfg = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x64.Build.0 = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x86.ActiveCfg = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Debug|x86.Build.0 = Debug|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|Any CPU.Build.0 = Release|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x64.ActiveCfg = Release|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x64.Build.0 = Release|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x86.ActiveCfg = Release|Any CPU + {5732ABF0-92F3-4123-865E-5B40F0E86F57}.Release|x86.Build.0 = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x64.Build.0 = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Debug|x86.Build.0 = Debug|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|Any CPU.Build.0 = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x64.ActiveCfg = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x64.Build.0 = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x86.ActiveCfg = Release|Any CPU + {E1769E76-9945-44B7-A1C9-BA3DF11A29C7}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/HrynCo.Common/Caching/ISessionCredentialStore.cs b/HrynCo.Common/Caching/ISessionCredentialStore.cs new file mode 100644 index 0000000..1f1c025 --- /dev/null +++ b/HrynCo.Common/Caching/ISessionCredentialStore.cs @@ -0,0 +1,19 @@ +namespace HrynCo.Common.Caching; + +using System; +using System.Threading; +using System.Threading.Tasks; + +public interface ISessionCredentialStore +{ + Task GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + + Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + + Task SaveAsync( + Guid userId, + string provider, + string apiKey, + TimeSpan ttl, + CancellationToken cancellationToken = default); +} diff --git a/HrynCo.Common/Caching/ISessionPromptStore.cs b/HrynCo.Common/Caching/ISessionPromptStore.cs new file mode 100644 index 0000000..3a75b07 --- /dev/null +++ b/HrynCo.Common/Caching/ISessionPromptStore.cs @@ -0,0 +1,19 @@ +namespace HrynCo.Common.Caching; + +using System; +using System.Threading; +using System.Threading.Tasks; + +public interface ISessionPromptStore +{ + Task GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + + Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default); + + Task SaveAsync( + Guid userId, + string provider, + string prompt, + TimeSpan ttl, + CancellationToken cancellationToken = default); +} diff --git a/HrynCo.Common/Caching/InMemorySessionCredentialStore.cs b/HrynCo.Common/Caching/InMemorySessionCredentialStore.cs new file mode 100644 index 0000000..114602b --- /dev/null +++ b/HrynCo.Common/Caching/InMemorySessionCredentialStore.cs @@ -0,0 +1,55 @@ +namespace HrynCo.Common.Caching; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; + +public sealed class InMemorySessionCredentialStore : ISessionCredentialStore +{ + private readonly IMemoryCache _cache; + + public InMemorySessionCredentialStore(IMemoryCache cache) + { + _cache = cache; + } + + public Task GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.TryGetValue(BuildKey(userId, provider), out string? apiKey); + return Task.FromResult(apiKey); + } + + public Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.Remove(BuildKey(userId, provider)); + return Task.CompletedTask; + } + + public Task SaveAsync( + Guid userId, + string provider, + string apiKey, + TimeSpan ttl, + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.Set( + BuildKey(userId, provider), + apiKey, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl, + SlidingExpiration = TimeSpan.FromMinutes(30) + }); + + return Task.CompletedTask; + } + + private static string BuildKey(Guid userId, string provider) + { + return $"ai-session-key:{provider}:{userId:N}"; + } +} diff --git a/HrynCo.Common/Caching/InMemorySessionPromptStore.cs b/HrynCo.Common/Caching/InMemorySessionPromptStore.cs new file mode 100644 index 0000000..23becbd --- /dev/null +++ b/HrynCo.Common/Caching/InMemorySessionPromptStore.cs @@ -0,0 +1,55 @@ +namespace HrynCo.Common.Caching; + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; + +public sealed class InMemorySessionPromptStore : ISessionPromptStore +{ + private readonly IMemoryCache _cache; + + public InMemorySessionPromptStore(IMemoryCache cache) + { + _cache = cache; + } + + public Task GetAsync(Guid userId, string provider, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.TryGetValue(BuildKey(userId, provider), out string? prompt); + return Task.FromResult(prompt); + } + + public Task RemoveAsync(Guid userId, string provider, CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.Remove(BuildKey(userId, provider)); + return Task.CompletedTask; + } + + public Task SaveAsync( + Guid userId, + string provider, + string prompt, + TimeSpan ttl, + CancellationToken cancellationToken = default) + { + _ = cancellationToken; + _cache.Set( + BuildKey(userId, provider), + prompt, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl, + SlidingExpiration = TimeSpan.FromMinutes(30) + }); + + return Task.CompletedTask; + } + + private static string BuildKey(Guid userId, string provider) + { + return $"ai-session-prompt:{provider}:{userId:N}"; + } +} diff --git a/HrynCo.Common/Clock.cs b/HrynCo.Common/Clock.cs new file mode 100644 index 0000000..a49f0b6 --- /dev/null +++ b/HrynCo.Common/Clock.cs @@ -0,0 +1,40 @@ +namespace HrynCo.Common; + +public sealed class Clock : IClock +{ + private readonly DateTimeOffset _createdAt; + private readonly DateTimeOffset _initialNow; + private readonly DateTimeOffset _initialUtcNow; + + public Clock() + : this(DateTimeOffset.UtcNow) + { + } + + public Clock(DateTimeOffset initialUtcNow) + { + _initialUtcNow = initialUtcNow; + _initialNow = initialUtcNow.ToLocalTime(); + _createdAt = DateTimeOffset.UtcNow; + } + + public DateTimeOffset Now => _initialNow + (DateTimeOffset.UtcNow - _createdAt); + public DateTimeOffset UtcNow => _initialUtcNow + (DateTimeOffset.UtcNow - _createdAt); + public DateOnly Today => DateOnly.FromDateTime(Now.DateTime); + + public DateOnly GetNextDayOfWeek(DayOfWeek dayOfWeek) + { + int daysToAdd = ((int)dayOfWeek - (int)Today.DayOfWeek + 7) % 7; + return Today.AddDays(daysToAdd == 0 ? 7 : daysToAdd); + } + + public DateOnly GetLastDayOfMonth() + { + return new DateOnly(Now.Year, Now.Month, 1).AddMonths(1).AddDays(-1); + } + + public DateOnly GetNextMonthStart() + { + return new DateOnly(Now.Year, Now.Month, 1).AddMonths(1); + } +} \ No newline at end of file diff --git a/HrynCo.Common/ConfigurationFactory.cs b/HrynCo.Common/ConfigurationFactory.cs new file mode 100644 index 0000000..403de31 --- /dev/null +++ b/HrynCo.Common/ConfigurationFactory.cs @@ -0,0 +1,37 @@ +namespace HrynCo.Common; + +using Microsoft.Extensions.Configuration; + +public static class ConfigurationFactory +{ + /// + /// Use for .NET Core Console applications. + /// + public static IConfigurationRoot CreateConfiguration() + { + return CreateConfiguration(AppContext.BaseDirectory); + } + + public static IConfigurationRoot CreateConfiguration(string basePath) + { + string environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"; + return CreateConfiguration(basePath, environmentName); + } + + public static IConfigurationRoot CreateConfiguration(string basePath, string environmentName) + { + if (!Directory.Exists(basePath)) + { + throw new DirectoryNotFoundException( + $"Directory {basePath} does not exist. Wrong value of the {nameof(basePath)} parameter."); + } + + return new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", false, true) + .AddJsonFile($"appsettings.{environmentName}.json", true, true) + .AddJsonFile("local.settings.json", true) + .AddEnvironmentVariables() + .Build(); + } +} \ No newline at end of file diff --git a/HrynCo.Common/EnvironmentNames.cs b/HrynCo.Common/EnvironmentNames.cs new file mode 100644 index 0000000..0be5a9a --- /dev/null +++ b/HrynCo.Common/EnvironmentNames.cs @@ -0,0 +1,20 @@ +namespace HrynCo.Common; + +using System.Diagnostics.CodeAnalysis; + +[ExcludeFromCodeCoverage] +public static class EnvironmentNames +{ + public const string Development = "Development"; + public const string DockerDev = "DockerDev"; + public const string IntegrationTests = "IntegrationTests"; + public const string Production = "Production"; + public const string Staging = "Staging"; + + public static readonly string[] DevelopmentLike = + [ + Development, + DockerDev, + IntegrationTests + ]; +} diff --git a/HrynCo.Common/HealthChecks/BaseConfigurationCheck.cs b/HrynCo.Common/HealthChecks/BaseConfigurationCheck.cs new file mode 100644 index 0000000..67bbfc9 --- /dev/null +++ b/HrynCo.Common/HealthChecks/BaseConfigurationCheck.cs @@ -0,0 +1,39 @@ +namespace HrynCo.Common.HealthChecks; + +using System.ComponentModel.DataAnnotations; +using HrynCo.Common.HealthChecks.Interfaces; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public abstract class BaseConfigurationCheck : IConfigurationCheck + where TOptions : class +{ + private readonly string _name; + private readonly TOptions _options; + + protected BaseConfigurationCheck(TOptions options, string name) + { + _options = options; + _name = name; + } + + public Task CheckConfigurationAsync(CancellationToken cancellationToken) + { + var context = new ValidationContext(_options); + var results = new List(); + + bool isValid = Validator.TryValidateObject( + _options, + context, + results, + true + ); + + if (!isValid) + { + string errors = string.Join("; ", results.Select(r => r.ErrorMessage)); + return Task.FromResult(HealthCheckResult.Unhealthy($"{_name} configuration invalid: {errors}")); + } + + return Task.FromResult(HealthCheckResult.Healthy($"{_name} configuration is valid.")); + } +} diff --git a/HrynCo.Common/HealthChecks/CompositeHealthCheck.cs b/HrynCo.Common/HealthChecks/CompositeHealthCheck.cs new file mode 100644 index 0000000..ec2c683 --- /dev/null +++ b/HrynCo.Common/HealthChecks/CompositeHealthCheck.cs @@ -0,0 +1,30 @@ +namespace HrynCo.Common.HealthChecks; + +using HrynCo.Common.HealthChecks.Interfaces; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public class CompositeHealthCheck : IHealthCheck +{ + private readonly IConfigurationCheck _configCheck; + private readonly bool _configOnly; + private readonly IServiceHealthCheck _serviceCheck; + + public CompositeHealthCheck( + IConfigurationCheck configCheck, + IServiceHealthCheck serviceCheck, + bool configOnly) + { + _configCheck = configCheck; + _serviceCheck = serviceCheck; + _configOnly = configOnly; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken) + { + return _configOnly + ? _configCheck.CheckConfigurationAsync(cancellationToken) + : _serviceCheck.CheckHealthAsync(cancellationToken); + } +} diff --git a/HrynCo.Common/HealthChecks/Defaults/DbOptions.cs b/HrynCo.Common/HealthChecks/Defaults/DbOptions.cs new file mode 100644 index 0000000..452ab79 --- /dev/null +++ b/HrynCo.Common/HealthChecks/Defaults/DbOptions.cs @@ -0,0 +1,9 @@ +namespace HrynCo.Common.HealthChecks.Defaults; + +using System.ComponentModel.DataAnnotations; + +public class DbOptions +{ + [Required] + public string? ConnectionString { get; set; } = null!; +} \ No newline at end of file diff --git a/HrynCo.Common/HealthChecks/Defaults/DbServiceHealthCheck.cs b/HrynCo.Common/HealthChecks/Defaults/DbServiceHealthCheck.cs new file mode 100644 index 0000000..d1ccd50 --- /dev/null +++ b/HrynCo.Common/HealthChecks/Defaults/DbServiceHealthCheck.cs @@ -0,0 +1,21 @@ +namespace HrynCo.Common.HealthChecks.Defaults; + +using HrynCo.Common.HealthChecks.Interfaces; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public sealed class DbServiceHealthCheck : IServiceHealthCheck +{ + private readonly IDatabaseConnectionChecker _checker; + + public DbServiceHealthCheck(IDatabaseConnectionChecker checker) + { + _checker = checker; + } + + public async Task CheckHealthAsync(CancellationToken cancellationToken) + { + return await _checker.CanConnectAsync(cancellationToken) + ? HealthCheckResult.Healthy("Database reachable") + : HealthCheckResult.Unhealthy("Database unreachable"); + } +} diff --git a/HrynCo.Common/HealthChecks/Defaults/SeqOptions.cs b/HrynCo.Common/HealthChecks/Defaults/SeqOptions.cs new file mode 100644 index 0000000..806d56b --- /dev/null +++ b/HrynCo.Common/HealthChecks/Defaults/SeqOptions.cs @@ -0,0 +1,9 @@ +namespace HrynCo.Common.HealthChecks.Defaults; + +using System.ComponentModel.DataAnnotations; + +public class SeqOptions +{ + [Required] + public string? ServerUrl { get; set; } +} \ No newline at end of file diff --git a/HrynCo.Common/HealthChecks/Interfaces/IConfigurationCheck.cs b/HrynCo.Common/HealthChecks/Interfaces/IConfigurationCheck.cs new file mode 100644 index 0000000..a174cc3 --- /dev/null +++ b/HrynCo.Common/HealthChecks/Interfaces/IConfigurationCheck.cs @@ -0,0 +1,8 @@ +namespace HrynCo.Common.HealthChecks.Interfaces; + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public interface IConfigurationCheck +{ + Task CheckConfigurationAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/HrynCo.Common/HealthChecks/Interfaces/IDatabaseConnectionChecker.cs b/HrynCo.Common/HealthChecks/Interfaces/IDatabaseConnectionChecker.cs new file mode 100644 index 0000000..c722f50 --- /dev/null +++ b/HrynCo.Common/HealthChecks/Interfaces/IDatabaseConnectionChecker.cs @@ -0,0 +1,6 @@ +namespace HrynCo.Common.HealthChecks.Interfaces; + +public interface IDatabaseConnectionChecker +{ + Task CanConnectAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/HrynCo.Common/HealthChecks/Interfaces/IServiceHealthCheck.cs b/HrynCo.Common/HealthChecks/Interfaces/IServiceHealthCheck.cs new file mode 100644 index 0000000..0459685 --- /dev/null +++ b/HrynCo.Common/HealthChecks/Interfaces/IServiceHealthCheck.cs @@ -0,0 +1,8 @@ +namespace HrynCo.Common.HealthChecks.Interfaces; + +using Microsoft.Extensions.Diagnostics.HealthChecks; + +public interface IServiceHealthCheck +{ + Task CheckHealthAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/HrynCo.Common/HrynCo.Common.csproj b/HrynCo.Common/HrynCo.Common.csproj new file mode 100644 index 0000000..5af4cca --- /dev/null +++ b/HrynCo.Common/HrynCo.Common.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + HrynCo.Common + HrynCo.Common + HrynCo + Shared common utilities for HrynCo applications. + hrynco common utilities caching configuration healthchecks security + git + https://gitea.grynco.com.ua/hrynco/hrynco-common.git + README.md + + + + + + + + + + + + + + + + + + + + + diff --git a/HrynCo.Common/IClock.cs b/HrynCo.Common/IClock.cs new file mode 100644 index 0000000..897fe74 --- /dev/null +++ b/HrynCo.Common/IClock.cs @@ -0,0 +1,11 @@ +namespace HrynCo.Common; + +public interface IClock +{ + DateTimeOffset Now { get; } + DateOnly Today { get; } + DateTimeOffset UtcNow { get; } + DateOnly GetNextDayOfWeek(DayOfWeek dayOfWeek); + DateOnly GetLastDayOfMonth(); + DateOnly GetNextMonthStart(); +} \ No newline at end of file diff --git a/HrynCo.Common/Profiler.cs b/HrynCo.Common/Profiler.cs new file mode 100644 index 0000000..a3639b7 --- /dev/null +++ b/HrynCo.Common/Profiler.cs @@ -0,0 +1,62 @@ +namespace HrynCo.Common; + +using System.Diagnostics; +using Serilog; + +public interface IProfiler +{ + Task MeasureExecutionAsync(Func> function, string blockName = ""); + Task MeasureExecutionAsync(Func action, string blockName = ""); +} + +public class Profiler(ILogger logger) : IProfiler +{ + public async Task MeasureExecutionAsync(Func> function, string blockName = "") + { + logger.ForContext("PerformanceLog", true).Information("{BlockName} - Start", blockName); + + var stopwatch = new Stopwatch(); + var process = Process.GetCurrentProcess(); + + long memoryBefore = process.PrivateMemorySize64; + + try + { + stopwatch.Start(); + + T result = await function().ConfigureAwait(false); + + stopwatch.Stop(); + + long memoryAfter = process.PrivateMemorySize64; + long memoryUsed = memoryAfter - memoryBefore; + + long stopwatchElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + + logger + .ForContext("PerformanceLog", true) + .ForContext("Measurements", true) + .Information( + "{BlockName} - End. Duration: {Duration} ms. Memory used: {MemoryUsed} bytes", + blockName, stopwatchElapsedMilliseconds, memoryUsed); + + return result; + } + catch (Exception ex) // NOSONAR + { + logger + .ForContext("PerformanceLog", true) + .Error(ex, "{BlockName} - An error occurred", blockName); + throw; + } + } + + public async Task MeasureExecutionAsync(Func action, string blockName = "") + { + await MeasureExecutionAsync(async () => + { + await action(); + return null; + }, blockName); + } +} \ No newline at end of file diff --git a/HrynCo.Common/README.md b/HrynCo.Common/README.md new file mode 100644 index 0000000..813fd79 --- /dev/null +++ b/HrynCo.Common/README.md @@ -0,0 +1,16 @@ +# HrynCo.Common + +Shared common utilities for HrynCo applications. + +## Contents + +- Configuration helpers +- Health checks +- Caching helpers +- Security helpers +- Tree traversal helpers +- Time and profiling helpers + +## Packaging + +This package is intended for reuse through NuGet. The test project is excluded from packing. diff --git a/HrynCo.Common/Security/ISecretProtector.cs b/HrynCo.Common/Security/ISecretProtector.cs new file mode 100644 index 0000000..9299cf5 --- /dev/null +++ b/HrynCo.Common/Security/ISecretProtector.cs @@ -0,0 +1,8 @@ +namespace HrynCo.Common.Security; + +public interface ISecretProtector +{ + string Protect(string plaintext); + + string Unprotect(string protectedValue); +} diff --git a/HrynCo.Common/Security/SecretProtector.cs b/HrynCo.Common/Security/SecretProtector.cs new file mode 100644 index 0000000..84d73cc --- /dev/null +++ b/HrynCo.Common/Security/SecretProtector.cs @@ -0,0 +1,61 @@ +namespace HrynCo.Common.Security; + +using System; +using System.Security.Cryptography; +using System.Text; + +public sealed class SecretProtector : ISecretProtector +{ + private readonly byte[] _key; + + public SecretProtector(string encryptionKey) + { + if (string.IsNullOrWhiteSpace(encryptionKey)) + { + throw new InvalidOperationException("Secret encryption key is not configured."); + } + + _key = Convert.FromBase64String(encryptionKey); + if (_key.Length != 32) + { + throw new InvalidOperationException("Secret encryption key must be 32 bytes encoded as Base64."); + } + } + + public string Protect(string plaintext) + { + byte[] nonce = RandomNumberGenerator.GetBytes(12); + byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + byte[] ciphertext = new byte[plaintextBytes.Length]; + byte[] tag = new byte[16]; + + using var aes = new AesGcm(_key, tagSizeInBytes: 16); + aes.Encrypt(nonce, plaintextBytes, ciphertext, tag); + + byte[] payload = new byte[nonce.Length + tag.Length + ciphertext.Length]; + Buffer.BlockCopy(nonce, 0, payload, 0, nonce.Length); + Buffer.BlockCopy(tag, 0, payload, nonce.Length, tag.Length); + Buffer.BlockCopy(ciphertext, 0, payload, nonce.Length + tag.Length, ciphertext.Length); + + return $"v1:{Convert.ToBase64String(payload)}"; + } + + public string Unprotect(string protectedValue) + { + if (!protectedValue.StartsWith("v1:", StringComparison.Ordinal)) + { + throw new InvalidOperationException("Unsupported protected value format."); + } + + byte[] payload = Convert.FromBase64String(protectedValue[3..]); + byte[] nonce = payload[..12]; + byte[] tag = payload[12..28]; + byte[] ciphertext = payload[28..]; + byte[] plaintext = new byte[ciphertext.Length]; + + using var aes = new AesGcm(_key, tagSizeInBytes: 16); + aes.Decrypt(nonce, ciphertext, tag, plaintext); + + return Encoding.UTF8.GetString(plaintext); + } +} diff --git a/HrynCo.Common/Tree/BreadcrumbNode.cs b/HrynCo.Common/Tree/BreadcrumbNode.cs new file mode 100644 index 0000000..0b4c25f --- /dev/null +++ b/HrynCo.Common/Tree/BreadcrumbNode.cs @@ -0,0 +1,19 @@ +namespace HrynCo.Common.Tree; + +public sealed record BreadcrumbNode +{ + public BreadcrumbNode(TKey Id, string Name) + { + this.Id = Id; + this.Name = Name; + } + + public TKey Id { get; init; } + public string Name { get; init; } + + public void Deconstruct(out TKey Id, out string Name) + { + Id = this.Id; + Name = this.Name; + } +} \ No newline at end of file diff --git a/HrynCo.Common/Tree/INameNode.cs b/HrynCo.Common/Tree/INameNode.cs new file mode 100644 index 0000000..d87e7fe --- /dev/null +++ b/HrynCo.Common/Tree/INameNode.cs @@ -0,0 +1,6 @@ +namespace HrynCo.Common.Tree; + +public interface INameNode +{ + string Name { get; } +} \ No newline at end of file diff --git a/HrynCo.Common/Tree/ITreeNode.cs b/HrynCo.Common/Tree/ITreeNode.cs new file mode 100644 index 0000000..47c0916 --- /dev/null +++ b/HrynCo.Common/Tree/ITreeNode.cs @@ -0,0 +1,8 @@ +namespace HrynCo.Common.Tree; + +public interface ITreeNode + where TKey : struct +{ + TKey Id { get; } + TKey? ParentId { get; } +} \ No newline at end of file diff --git a/HrynCo.Common/Tree/TreeUtils.cs b/HrynCo.Common/Tree/TreeUtils.cs new file mode 100644 index 0000000..da65e39 --- /dev/null +++ b/HrynCo.Common/Tree/TreeUtils.cs @@ -0,0 +1,60 @@ +namespace HrynCo.Common.Tree; + +public static class TreeUtils +{ + public static HashSet CollectDescendants( + TKey rootId, + IEnumerable nodes, + TKey rootMarker) + where TNode : ITreeNode + where TKey : struct + { + var map = nodes + .GroupBy(x => x.ParentId ?? rootMarker) + .ToDictionary(g => g.Key, g => g.ToList()); + + var result = new HashSet + { + rootId + }; + var queue = new Queue(); + queue.Enqueue(rootId); + + while (queue.Count > 0) + { + TKey current = queue.Dequeue(); + if (!map.TryGetValue(current, out var children)) + { + continue; + } + + foreach (TNode child in children.Where(child => result.Add(child.Id))) + { + queue.Enqueue(child.Id); + } + } + + return result; + } + + public static List> BuildBreadcrumb( + TKey currentId, + IReadOnlyDictionary map) + where TNode : class, ITreeNode, INameNode + where TKey : struct + { + var path = new List>(); + TNode? current = map.GetValueOrDefault(currentId); + + while (current != null) + { + path.Add(new BreadcrumbNode(current.Id, current.Name)); + current = current.ParentId is { } parentId + ? map.GetValueOrDefault(parentId) + : null; + } + + path.Reverse(); + return path; + } +} \ No newline at end of file