diff --git a/.vscode/settings.json b/.vscode/settings.json index 9e26dfee..000c9efb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,11 @@ -{} \ No newline at end of file +{ + "dotnet.defaultSolution": "APITemplate.slnx", + "dotnet.solution.autoOpen": "APITemplate.slnx", + "dotnet.enableWorkspaceBasedDevelopment": false, + "files.exclude": { + "**/monolith": true + }, + "search.exclude": { + "**/monolith": true + } +} diff --git a/APITemplate.Microservices.code-workspace b/APITemplate.Microservices.code-workspace new file mode 100644 index 00000000..08b84492 --- /dev/null +++ b/APITemplate.Microservices.code-workspace @@ -0,0 +1,42 @@ +{ + "folders": [ + { + "name": "src", + "path": "src" + }, + { + "name": "tests", + "path": "tests" + }, + { + "name": "docs", + "path": "docs" + }, + { + "name": "infrastructure", + "path": "infrastructure" + } + ], + "settings": { + "dotnet.defaultSolution": "APITemplate.slnx", + "dotnet.solution.autoOpen": "APITemplate.slnx", + "dotnet.enableWorkspaceBasedDevelopment": false, + "git.openRepositoryInParentFolders": "always", + "files.exclude": { + "**/monolith": true, + "**/bin": true, + "**/obj": true + }, + "search.exclude": { + "**/monolith": true, + "**/bin": true, + "**/obj": true + } + }, + "extensions": { + "recommendations": [ + "ms-dotnettools.csdevkit", + "ms-dotnettools.csharp" + ] + } +} diff --git a/APITemplate.slnx b/APITemplate.slnx index 25e85f46..29857aa7 100644 --- a/APITemplate.slnx +++ b/APITemplate.slnx @@ -28,7 +28,7 @@ diff --git a/Directory.Packages.props b/Directory.Packages.props index f6d36144..683e5181 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,6 +68,7 @@ + diff --git a/TODO-Architecture.md b/TODO-Architecture.md index ea7a3839..265bfe8f 100644 --- a/TODO-Architecture.md +++ b/TODO-Architecture.md @@ -28,6 +28,119 @@ All 7 microservices extracted from the monolith and running independently. ### Gateway — Demo / Example APIs - Expose monolith-style demo endpoints (`IdempotentController`, `PatchController`, `SseController` under `src/APITemplate.Api/`) via a dedicated Examples service or Gateway host, and register routes in YARP. +### Efficiency — Must Fix (pre-production) + +- **RabbitMQ health check connection churn** (`SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs`): + Every health check poll (30s × 7 services) creates a brand-new TCP+AMQP connection and immediately disposes it. Reuse the existing Wolverine-managed connection or cache a single health-check connection. + +- **Webhook delivery is sequential per subscriber** (`Webhooks.Infrastructure/Delivery/WebhookDeliveryService.cs:52-56`): + `foreach` + `await` for each subscriber = N sequential HTTP round-trips. Each delivery log is also persisted individually (N separate DB writes). Use `Parallel.ForEachAsync` with bounded concurrency and batch `SaveChangesAsync` after all deliveries. + +- **No PostgreSQL connection pooling configuration** (all service `Program.cs` + `docker-compose.microservices.yml`): + 7 services + Wolverine persistence = up to 700 simultaneous connections (default Npgsql pool = 100). Add `Maximum Pool Size=25;Minimum Pool Size=2;Connection Idle Lifetime=60` to connection strings. + +- **Docker builds copy entire repository** (all `Dockerfile`s): + `COPY . .` invalidates cache on any file change in any service. Split into csproj-only COPY for restore layer, then full copy for build. Use `.dockerignore` to exclude unrelated services. + +### Efficiency — Should Fix + +- **Gateway blocks startup until ALL 7 services healthy** (`docker-compose.microservices.yml:118-133`): + YARP handles upstream failures gracefully. Remove `depends_on: service_healthy` conditions — let Gateway start immediately and route to healthy backends dynamically. + +- **FileStorage missing TenantId index** (`FileStorage.Infrastructure/Persistence/Configurations/StoredFileConfiguration.cs`): + No indexes beyond PK. Global query filter on `TenantId` causes full table scans. All other services have `TenantId` indexes — FileStorage is the only one missing it. + +- **`MigrateDbAsync` blocks startup** (`SharedKernel.Api/Extensions/HostExtensions.cs`): + 7 services run migrations concurrently against shared PostgreSQL → lock contention. Consider advisory lock or sequential migration orchestration. + +- **Sequential saga completion publishes** (`ProductCatalog.Application/EventHandlers/TenantDeactivatedEventHandler.cs:53-58`): + Two independent `bus.PublishAsync` calls awaited sequentially. Use Wolverine's `OutgoingMessages` cascading pattern (already used elsewhere via `CacheInvalidationCascades`). + +- **ChangeTracker `.ToList()` on every SaveChanges** (`SharedKernel.Infrastructure/Persistence/TenantAuditableDbContext.cs:70-76`): + Materializes all change-tracked entries on every `SaveChangesAsync` (hot path). Needed because loop can modify collection during soft-delete, but could split into two passes: streaming for non-Delete, `.ToList()` only for Delete. + +- **`FluentValidationActionFilter` uses reflection per request** (`SharedKernel.Api/Filters/Validation/FluentValidationActionFilter.cs:38`): + `MakeGenericType` + DI lookup on every request, for every action argument. Cache resolved validators in `ConcurrentDictionary`. + +- **`CacheInvalidationCascades.None` returns shared mutable instance** (`SharedKernel.Application/Common/Events/CacheInvalidationCascades.cs:12-13`): + `OutgoingMessages` inherits `List` — if any caller accidentally adds to the shared `None` singleton, it corrupts all consumers. Return `new OutgoingMessages()` each time or use a read-only wrapper. + +### DRY — Service Registration + +- **Split `AddSharedInfrastructure`** (`SharedKernel.Api/Extensions/SharedServiceRegistration.cs`): + Extract non-generic `AddSharedCoreServices()` (TimeProvider, HttpContextAccessor, context providers, error handling, versioning) so Webhooks/Notifications can call it without needing tenant-aware DbContext. The generic `AddSharedInfrastructure()` then calls `AddSharedCoreServices()` plus UoW/audit/soft-delete. + +- **Move `DbContext` base-type registration into `AddSharedInfrastructure`**: + `services.AddScoped(sp => sp.GetRequiredService())` is copy-pasted in 5 services (Identity, ProductCatalog, Reviews, FileStorage, BackgroundJobs). The generic type parameter is already available in `AddSharedInfrastructure`. + +- **Move `IRolePermissionMap` registration into `AddSharedAuthorization`**: + `AddSingleton()` is duplicated in 4 services. Register it automatically when `enablePermissionPolicies: true`. + +- **Automate `HasQueryFilter` for `IAuditableTenantEntity`** in `TenantAuditableDbContext.OnModelCreating`: + The identical filter expression `(!HasTenant || e.TenantId == CurrentTenantId) && !e.IsDeleted` is copy-pasted 8× across 4 DbContexts. Scan for all entities implementing `IAuditableTenantEntity` and apply automatically. Subclasses opt out for special cases only. + +- **Wolverine bootstrap helper** (`SharedKernel.Messaging`): + Add `opts.ApplySharedWolverineDefaults(connectionString)` that bundles conventions + retry + PostgreSQL persistence + EF transactions. 5 services repeat the same 4-line preamble. + +### DRY — Code Quality + +- **Use existing `TenantAuditableDbContextDependencies` parameter object**: + The record was created but never used. All 4 derived DbContexts manually forward 7 constructor parameters to `base(...)`. Switch constructors to accept the single parameter object. + +- **Shared `DesignTimeDbContextFactoryBase`** with NullObject implementations: + 4 identical DesignTime factories pass `null!` for `tenantProvider`, `actorProvider`, `entityStateManager`, `softDeleteProcessor`. Runtime NRE risk if any EF tooling path triggers `SaveChangesAsync`. + +- **Collapse webhook event handlers into a generic handler** (`Webhooks.Application/Features/Delivery/EventHandlers/`): + 4 handlers (`ProductCreated`, `ProductDeleted`, `ReviewCreated`, `CategoryDeleted`) are identical: log → serialize → deliver. Extract common `ITenantEvent` interface and one generic handler. + +- **Saga `NotFound` helper** (`TenantDeactivationSaga`, `ProductDeletionSaga`): + 7 identical `NotFound` methods across 2 sagas. Extract shared helper or Wolverine convention. + +- **Webhooks resilience pipeline key** (`Webhooks.Api/Program.cs:53`): + Raw string `"outgoing-webhook-retry"` — other services use typed constants. Add to a constants class. + +### Architectural Consistency + +- **Webhooks bypasses UnitOfWork / tenant auditing** (`Webhooks.Infrastructure/`): + Repositories call `_dbContext.SaveChangesAsync()` directly instead of `IUnitOfWork.CommitAsync()`. Entities implement `IAuditableTenantEntity` but use plain `DbContext` → no audit stamping, no query filters, no soft-delete processing. Manual tenant filtering in queries. Risk: deleted webhook subscriptions could be served. + +- **BackgroundJobs registers unused shared infrastructure** (`BackgroundJobs.Api/Program.cs:56`): + Calls `AddSharedInfrastructure` but `BackgroundJobsDbContext` extends raw `DbContext`, not `TenantAuditableDbContext`. UoW, auditing, and soft-delete services are registered but never invoked. + +- **Notifications skips API versioning** (`Notifications.Api/Program.cs`): + Does not call `AddSharedInfrastructure` → misses API versioning registration that all other services get. Calls `AddSharedApiErrorHandling()` separately. + +- **Redundant queue interface layer in Notifications Domain**: + `IQueue` and `IQueueReader` in `Notifications.Domain.Interfaces` are empty marker interfaces re-exporting SharedKernel interfaces. `IEmailQueue`/`IEmailQueueReader` could extend SharedKernel directly (like `BackgroundJobs.Application.Common.IJobQueue` already does). + +- **Reviews `ProductProjection` inconsistency**: + Uses `IsActive` flag instead of `IsDeleted` + `AuditInfo` pattern used everywhere else. Semantically different from the rest of the system. + +### Testing + +- **SQLite test setup boilerplate** (`tests/Identity.Tests/`, `tests/ProductCatalog.Tests/`): + Identical SQLite connection + DbContext + `EnsureCreated()` + `IDisposable` teardown copy-pasted across test classes. Add `SqliteTestDbContextFactory` to `Tests.Common`. + +- **Per-service `TestDbContext` duplication** (`tests/Identity.Tests/TestDbContext.cs`, `tests/ProductCatalog.Tests/TestDbContext.cs`, `tests/Reviews.Tests/TestDbContext.cs`): + Identical `OwnsOne(Audit)` + key convention per entity. Add `TestModelBuilderExtensions.ConfigureTestAuditableEntity()` to `Tests.Common`. + +### Architectural Decision Review + +> **Question to evaluate:** Is microservices the right architecture for this project's scale? +> +> **Evidence against:** +> - SharedKernel = 6717 LOC, all services combined = 3537 LOC (without migrations). Shared code is 1.9× larger than business code. +> - 7 services × 4 layers = 28 projects + 5 SharedKernel + 1 Contracts + 1 Gateway = 35 projects (was 5 in monolith — 7× increase). +> - All services are structurally identical (same middleware, same auth, same persistence patterns). +> - `RabbitMqTopology` is a static map in SharedKernel — changing a queue name requires coordinated deployment. +> - Saga timeouts accept partial completion silently (`MarkCompleted()` on timeout) — was a single DB transaction in the monolith. +> - Local dev requires 16+ containers vs 8 for monolith. +> - No documented drivers (independent scaling, deployment cadence, team boundaries, technology heterogeneity). +> +> **Alternative considered:** Modular monolith — same boundary enforcement (separate assemblies per bounded context, explicit integration event contracts, Wolverine as in-process bus) at a fraction of operational cost. Extract individual services later when concrete production metrics justify it. +> +> **Decision:** [TO BE EVALUATED] + ### Optional polish - **Output cache:** ProductCatalog, Identity, Reviews, and FileStorage use `AddSharedOutputCaching` + `[OutputCache]` + invalidation. BackgroundJobs still runs with `useOutputCaching: false` (it has a GET on `JobsController`); enable caching there only if you want read responses cached like the other APIs. diff --git a/global.json b/global.json index a11f48e1..82dabbbf 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "10.0.0", - "rollForward": "latestMajor", + "version": "10.0.100", + "rollForward": "latestFeature", "allowPrerelease": true } -} \ No newline at end of file +} diff --git a/src/Gateway/Gateway.Api/appsettings.Production.json b/src/Gateway/Gateway.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Gateway/Gateway.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/BackgroundJobs/BackgroundJobs.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json b/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/FileStorage/FileStorage.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Identity/Identity.Api/appsettings.Production.json b/src/Services/Identity/Identity.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Identity/Identity.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs new file mode 100644 index 00000000..1f1be9f3 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Security/Keycloak/KeycloakAdminServiceLogs.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace Identity.Infrastructure.Security.Keycloak; + +internal static partial class KeycloakAdminServiceLogs +{ + [LoggerMessage( + EventId = 2101, + Level = LogLevel.Information, + Message = "Created Keycloak user {Username} with id {KeycloakUserId}" + )] + public static partial void UserCreated( + this ILogger logger, + [PersonalData] string username, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2102, + Level = LogLevel.Warning, + Message = "Failed to send setup email for Keycloak user {KeycloakUserId}. User was created but has no setup email." + )] + public static partial void SetupEmailFailed( + this ILogger logger, + Exception exception, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2103, + Level = LogLevel.Information, + Message = "Sent password reset email to Keycloak user {KeycloakUserId}" + )] + public static partial void PasswordResetEmailSent( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2104, + Level = LogLevel.Information, + Message = "Set Keycloak user {KeycloakUserId} enabled={Enabled}" + )] + public static partial void UserEnabledStateChanged( + this ILogger logger, + [SensitiveData] string keycloakUserId, + bool enabled + ); + + [LoggerMessage( + EventId = 2105, + Level = LogLevel.Warning, + Message = "Keycloak user {KeycloakUserId} was not found during delete — treating as already deleted." + )] + public static partial void UserDeleteNotFound( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); + + [LoggerMessage( + EventId = 2106, + Level = LogLevel.Information, + Message = "Deleted Keycloak user {KeycloakUserId}" + )] + public static partial void UserDeleted( + this ILogger logger, + [SensitiveData] string keycloakUserId + ); +} + +internal static partial class KeycloakAdminTokenProviderLogs +{ + [LoggerMessage( + EventId = 2201, + Level = LogLevel.Error, + Message = "Failed to acquire Keycloak admin token. Status: {Status}, Body: {Body}" + )] + public static partial void TokenAcquireFailed( + this ILogger logger, + int status, + [SensitiveData] string body + ); +} diff --git a/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs new file mode 100644 index 00000000..cbfedf75 --- /dev/null +++ b/src/Services/Identity/Identity.Infrastructure/Security/Tenant/TenantClaimValidatorLogs.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace Identity.Infrastructure.Security.Tenant; + +internal static partial class TenantClaimValidatorLogs +{ + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Warning, + Message = "[{Scheme}] Token validated but no identity found" + )] + public static partial void TokenValidatedNoIdentity(this ILogger logger, string scheme); + + [LoggerMessage( + EventId = 2002, + Level = LogLevel.Information, + Message = "[{Scheme}] Authenticated user={User}, tenant={TenantId}, roles=[{Roles}]" + )] + public static partial void UserAuthenticated( + this ILogger logger, + string scheme, + [PersonalData] string? user, + [SensitiveData] string? tenantId, + string roles + ); + + [LoggerMessage( + EventId = 2003, + Level = LogLevel.Warning, + Message = "User provisioning failed during token validation — authentication will continue" + )] + public static partial void UserProvisioningFailed(this ILogger logger, Exception exception); +} diff --git a/src/Services/Notifications/Notifications.Api/appsettings.Production.json b/src/Services/Notifications/Notifications.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Notifications/Notifications.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs b/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs new file mode 100644 index 00000000..47e7edd4 --- /dev/null +++ b/src/Services/ProductCatalog/ProductCatalog.Api/Health/MongoDbHealthCheck.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProductCatalog.Infrastructure.Persistence; + +namespace ProductCatalog.Api.Health; + +/// +/// Verifies MongoDB availability using the application's configured MongoDbContext. +/// +public sealed class MongoDbHealthCheck(IMongoDbHealthProbe mongoDbHealthProbe) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + try + { + await mongoDbHealthProbe.PingAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("MongoDB server is unavailable.", ex); + } + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/ProductCatalog/ProductCatalog.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs index cecb2d35..50044774 100644 --- a/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs +++ b/src/Services/ProductCatalog/ProductCatalog.Infrastructure/Persistence/MongoDbContext.cs @@ -6,11 +6,16 @@ namespace ProductCatalog.Infrastructure.Persistence; +public interface IMongoDbHealthProbe +{ + Task PingAsync(CancellationToken cancellationToken = default); +} + /// /// Thin wrapper around the MongoDB driver that configures the client with diagnostic /// activity tracing and exposes typed collection accessors for domain document types. /// -public sealed class MongoDbContext +public sealed class MongoDbContext : IMongoDbHealthProbe { private readonly IMongoDatabase _database; diff --git a/src/Services/Reviews/Reviews.Api/appsettings.Production.json b/src/Services/Reviews/Reviews.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Reviews/Reviews.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json b/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json new file mode 100644 index 00000000..e01655c5 --- /dev/null +++ b/src/Services/Webhooks/Webhooks.Api/appsettings.Production.json @@ -0,0 +1,6 @@ +{ + "Redaction": { + "HmacKeyEnvironmentVariable": "APITEMPLATE_REDACTION_HMAC_KEY", + "KeyId": 1001 + } +} diff --git a/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs new file mode 100644 index 00000000..56f6992f --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/ExceptionHandling/ApiExceptionHandlerLogs.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; + +namespace SharedKernel.Api.ExceptionHandling; + +internal static partial class ApiExceptionHandlerLogs +{ + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Error, + Message = "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + )] + public static partial void UnhandledException( + this ILogger logger, + Exception exception, + int statusCode, + [SensitiveData] string errorCode, + [PersonalData] string traceId + ); + + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Warning, + Message = "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + )] + public static partial void HandledApplicationException( + this ILogger logger, + Exception exception, + int statusCode, + [SensitiveData] string errorCode, + [PersonalData] string traceId + ); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs new file mode 100644 index 00000000..2bdbfada --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/HealthChecksServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Infrastructure.Observability; +using SharedKernel.Messaging.HealthChecks; + +namespace SharedKernel.Api.Extensions; + +/// +/// Shared infrastructure health-check registration helpers for microservice hosts. +/// +public static class HealthChecksServiceCollectionExtensions +{ + public static IHealthChecksBuilder AddPostgreSqlHealthCheck( + this IHealthChecksBuilder builder, + string connectionString, + string name = HealthCheckNames.PostgreSql, + string[]? tags = null + ) => builder.AddNpgSql(connectionString, name: name, tags: tags ?? HealthCheckTags.Database); + + public static IHealthChecksBuilder AddDragonflyHealthCheck( + this IHealthChecksBuilder builder, + string? connectionString, + string name = HealthCheckNames.Dragonfly, + string[]? tags = null + ) + { + if (string.IsNullOrWhiteSpace(connectionString)) + return builder; + + return builder.AddRedis(connectionString, name: name, tags: tags ?? HealthCheckTags.Cache); + } + + public static IHealthChecksBuilder AddSharedRabbitMqHealthCheck( + this IHealthChecksBuilder builder, + IConfiguration configuration, + string name = HealthCheckNames.RabbitMq, + string[]? tags = null + ) => builder.AddCheck(name, tags: tags ?? HealthCheckTags.Messaging); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs new file mode 100644 index 00000000..750a8f0d --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/LoggingRedactionExtensions.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; + +namespace SharedKernel.Api.Extensions; + +public static class LoggingRedactionExtensions +{ + public static IServiceCollection AddSharedLogRedaction( + this IServiceCollection services, + IConfiguration configuration + ) + { + services + .AddOptions() + .Bind(configuration.GetSection(RedactionOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + RedactionOptions redactionOptions = GetRedactionOptions(configuration); + + string hmacKey = RedactionConfiguration.ResolveHmacKey( + redactionOptions, + Environment.GetEnvironmentVariable + ); + + services.AddRedaction(redactionBuilder => + { + redactionBuilder.SetRedactor(LogDataClassifications.Personal); + +#pragma warning disable EXTEXP0002 + redactionBuilder.SetHmacRedactor( + options => + { + options.KeyId = redactionOptions.KeyId; + options.Key = hmacKey; + }, + new DataClassificationSet(LogDataClassifications.Sensitive) + ); +#pragma warning restore EXTEXP0002 + + redactionBuilder.SetFallbackRedactor(); + }); + + services.AddLogging(logging => logging.EnableRedaction()); + + return services; + } + + internal static RedactionOptions GetRedactionOptions(IConfiguration configuration) => + configuration.GetSection(RedactionOptions.SectionName).Get() ?? new(); +} diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs index 423ab013..a73a5a32 100644 --- a/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs +++ b/src/SharedKernel/SharedKernel.Api/Extensions/ObservabilityExtensions.cs @@ -5,6 +5,8 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; +using SharedKernel.Application.Options; +using SharedKernel.Infrastructure.Observability; namespace SharedKernel.Api.Extensions; @@ -51,4 +53,56 @@ string serviceName return services; } + + internal static ObservabilityOptions GetObservabilityOptions(IConfiguration configuration) => + configuration.GetSection(ObservabilityOptions.SectionName).Get() + ?? new ObservabilityOptions(); + + internal static IReadOnlyList GetEnabledOtlpEndpoints( + ObservabilityOptions options, + IHostEnvironment environment + ) + { + List endpoints = []; + + bool aspireEnabled = options.Exporters.Aspire.Enabled ?? environment.IsDevelopment(); + if (aspireEnabled) + { + endpoints.Add( + string.IsNullOrWhiteSpace(options.Aspire.Endpoint) + ? TelemetryDefaults.AspireOtlpEndpoint + : options.Aspire.Endpoint + ); + } + + bool otlpEnabled = options.Exporters.Otlp.Enabled ?? !environment.IsDevelopment(); + if (otlpEnabled && !string.IsNullOrWhiteSpace(options.Otlp.Endpoint)) + { + endpoints.Add(options.Otlp.Endpoint); + } + + return endpoints.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + internal static Dictionary BuildResourceAttributes( + string serviceName, + IHostEnvironment environment + ) => + new(StringComparer.Ordinal) + { + [TelemetryResourceAttributeKeys.ServiceName] = serviceName, + [TelemetryResourceAttributeKeys.ServiceVersion] = "1.0.0", + [TelemetryResourceAttributeKeys.ServiceInstanceId] = Environment.MachineName, + [TelemetryResourceAttributeKeys.HostName] = Environment.MachineName, + [TelemetryResourceAttributeKeys.HostArchitecture] = + System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(), + [TelemetryResourceAttributeKeys.OsType] = System + .Runtime + .InteropServices + .RuntimeInformation + .OSDescription, + [TelemetryResourceAttributeKeys.ProcessRuntimeVersion] = Environment.Version.ToString(), + [TelemetryResourceAttributeKeys.DeploymentEnvironmentName] = + environment.EnvironmentName, + }; } diff --git a/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs b/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs new file mode 100644 index 00000000..4ec45c7a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Extensions/RequestContextPipelineExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Serilog; +using Serilog.Events; +using SharedKernel.Api.Middleware; + +namespace SharedKernel.Api.Extensions; + +/// +/// Registers correlation enrichment middleware and structured Serilog request logging. +/// +public static class RequestContextPipelineExtensions +{ + public static WebApplication UseRequestContextPipeline(this WebApplication app) + { + app.UseMiddleware(); + app.UseSerilogRequestLogging(options => + { + options.MessageTemplate = + "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; + + options.GetLevel = (httpContext, _, exception) => + { + if (IsClientAbortedRequest(httpContext, exception)) + return LogEventLevel.Information; + + if (exception is not null || httpContext.Response.StatusCode >= 500) + return LogEventLevel.Error; + + if (httpContext.Response.StatusCode >= 400) + return LogEventLevel.Warning; + + return LogEventLevel.Information; + }; + + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => + { + diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); + diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); + }; + }); + + return app; + } + + private static bool IsClientAbortedRequest(HttpContext httpContext, Exception? exception) => + exception is OperationCanceledException + && httpContext.RequestAborted.IsCancellationRequested; +} diff --git a/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs b/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs new file mode 100644 index 00000000..e0a68599 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Api/Middleware/RequestContextMiddleware.cs @@ -0,0 +1,96 @@ +using System.Diagnostics; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Serilog.Context; +using SharedKernel.Application.Http; +using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; + +namespace SharedKernel.Api.Middleware; + +/// +/// Enriches each request with correlation, tracing, timing, tenant metadata, and metrics tags. +/// +public sealed class RequestContextMiddleware +{ + private readonly RequestDelegate _next; + + public RequestContextMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + string correlationId = ResolveCorrelationId(context); + Stopwatch stopwatch = Stopwatch.StartNew(); + string traceId = Activity.Current?.TraceId.ToHexString() ?? context.TraceIdentifier; + + string? tenantId = context.User.FindFirstValue(SharedAuthConstants.Claims.TenantId); + string effectiveTenantId = !string.IsNullOrWhiteSpace(tenantId) ? tenantId : string.Empty; + + if (!string.IsNullOrWhiteSpace(effectiveTenantId)) + Activity.Current?.SetTag(TelemetryTagKeys.TenantId, effectiveTenantId); + + context.Items[RequestContextConstants.ContextKeys.CorrelationId] = correlationId; + context.Response.Headers[RequestContextConstants.Headers.CorrelationId] = correlationId; + context.Response.Headers[RequestContextConstants.Headers.TraceId] = traceId; + context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = "0"; + + context.Response.OnStarting(() => + { + context.Response.Headers[RequestContextConstants.Headers.ElapsedMs] = + stopwatch.ElapsedMilliseconds.ToString(); + return Task.CompletedTask; + }); + + try + { + using ( + LogContext.PushProperty( + RequestContextConstants.LogProperties.CorrelationId, + correlationId + ) + ) + using ( + LogContext.PushProperty( + RequestContextConstants.LogProperties.TenantId, + effectiveTenantId + ) + ) + { + await _next(context); + } + } + finally + { + IHttpMetricsTagsFeature? metricsTagsFeature = + context.Features.Get(); + if (metricsTagsFeature is not null) + { + metricsTagsFeature.Tags.Add( + new( + TelemetryTagKeys.ApiSurface, + TelemetryApiSurfaceResolver.Resolve(context.Request.Path) + ) + ); + metricsTagsFeature.Tags.Add( + new( + TelemetryTagKeys.Authenticated, + context.User.Identity?.IsAuthenticated == true + ) + ); + } + } + } + + private static string ResolveCorrelationId(HttpContext context) + { + string incoming = context + .Request.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString(); + + return !string.IsNullOrWhiteSpace(incoming) ? incoming : context.TraceIdentifier; + } +} diff --git a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj index ff538327..030b7b17 100644 --- a/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj +++ b/src/SharedKernel/SharedKernel.Api/SharedKernel.Api.csproj @@ -3,6 +3,7 @@ + @@ -11,6 +12,10 @@ enable + + + + @@ -18,12 +23,15 @@ + + + diff --git a/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs b/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs new file mode 100644 index 00000000..a63541ec --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Batch/Rules/IValidationMetrics.cs @@ -0,0 +1,12 @@ +using FluentValidation.Results; + +namespace SharedKernel.Application.Batch.Rules; + +/// +/// Abstraction for recording validation failure metrics. Implemented in the infrastructure +/// layer so the application layer stays free of telemetry dependencies. +/// +public interface IValidationMetrics +{ + void RecordFailure(string source, Type argumentType, IReadOnlyList failures); +} diff --git a/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs b/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs new file mode 100644 index 00000000..62b7a6d1 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Http/RequestContextConstants.cs @@ -0,0 +1,46 @@ +namespace SharedKernel.Application.Http; + +/// +/// Constants for request context headers and log enrichment properties. +/// +public static class RequestContextConstants +{ + public static class Headers + { + /// + /// Header name used for correlation IDs supplied by the caller. + /// + public const string CorrelationId = "X-Correlation-Id"; + + /// + /// Header name used for the distributed trace ID. + /// + public const string TraceId = "X-Trace-Id"; + + /// + /// Header name used for the request elapsed time in milliseconds. + /// + public const string ElapsedMs = "X-Elapsed-Ms"; + } + + public static class ContextKeys + { + /// + /// Key under which the resolved correlation ID is stored in . + /// + public const string CorrelationId = "CorrelationId"; + } + + public static class LogProperties + { + /// + /// Serilog property name for the correlation ID. + /// + public const string CorrelationId = "CorrelationId"; + + /// + /// Serilog property name for the tenant ID. + /// + public const string TenantId = "TenantId"; + } +} diff --git a/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs b/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs new file mode 100644 index 00000000..47d5b8ea --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Options/ObservabilityOptions.cs @@ -0,0 +1,39 @@ +namespace SharedKernel.Application.Options; + +/// +/// Root configuration object for observability exporters and endpoints. +/// +public sealed class ObservabilityOptions +{ + public const string SectionName = "Observability"; + + public OtlpEndpointOptions Otlp { get; init; } = new(); + + public AspireEndpointOptions Aspire { get; init; } = new(); + + public ObservabilityExportersOptions Exporters { get; init; } = new(); +} + +public sealed class OtlpEndpointOptions +{ + public string Endpoint { get; init; } = string.Empty; +} + +public sealed class AspireEndpointOptions +{ + public string Endpoint { get; init; } = string.Empty; +} + +public sealed class ObservabilityExportersOptions +{ + public ObservabilityExporterToggleOptions Aspire { get; init; } = new(); + + public ObservabilityExporterToggleOptions Otlp { get; init; } = new(); + + public ObservabilityExporterToggleOptions Console { get; init; } = new(); +} + +public sealed class ObservabilityExporterToggleOptions +{ + public bool? Enabled { get; init; } +} diff --git a/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs b/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs new file mode 100644 index 00000000..7065c867 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Application/Options/Security/RedactionOptions.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace SharedKernel.Application.Options.Security; + +/// +/// Configuration for HMAC-based log redaction of sensitive fields. +/// +public sealed class RedactionOptions +{ + public const string SectionName = "Redaction"; + + [Required] + public string HmacKeyEnvironmentVariable { get; init; } = "APITEMPLATE_REDACTION_HMAC_KEY"; + + public string HmacKey { get; init; } = string.Empty; + + [Range(1, int.MaxValue)] + public int KeyId { get; init; } = 1001; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs new file mode 100644 index 00000000..66e8275c --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/ActivityTraceEnricher.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Serilog that appends W3C-format TraceId and SpanId +/// properties from the current to every log event, +/// enabling correlation between structured logs and distributed traces. +/// +public sealed class ActivityTraceEnricher : ILogEventEnricher +{ + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + Activity? activity = Activity.Current; + if (activity is null) + return; + + if (activity.TraceId != default) + { + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty("TraceId", activity.TraceId.ToHexString()) + ); + } + + if (activity.SpanId != default) + { + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty("SpanId", activity.SpanId.ToHexString()) + ); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs new file mode 100644 index 00000000..8dbc4fa3 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/LogDataClassifications.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Compliance.Classification; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Project-wide data classifications used by the compliance redaction pipeline. +/// +public static class LogDataClassifications +{ + private const string TaxonomyName = "APITemplate"; + + public static DataClassification Personal => new(TaxonomyName, nameof(Personal)); + + public static DataClassification Sensitive => new(TaxonomyName, nameof(Sensitive)); + + public static DataClassification Public => new(TaxonomyName, nameof(Public)); +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class PersonalDataAttribute : DataClassificationAttribute +{ + public PersonalDataAttribute() + : base(LogDataClassifications.Personal) { } +} + +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class SensitiveDataAttribute : DataClassificationAttribute +{ + public SensitiveDataAttribute() + : base(LogDataClassifications.Sensitive) { } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs b/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs new file mode 100644 index 00000000..180c84dd --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Logging/RedactionConfiguration.cs @@ -0,0 +1,26 @@ +using SharedKernel.Application.Options.Security; + +namespace SharedKernel.Infrastructure.Logging; + +/// +/// Resolves the effective HMAC key used for sensitive data redaction. +/// +public static class RedactionConfiguration +{ + public static string ResolveHmacKey( + RedactionOptions options, + Func getEnvironmentVariable + ) + { + string? key = getEnvironmentVariable(options.HmacKeyEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(key)) + return key; + + if (!string.IsNullOrWhiteSpace(options.HmacKey)) + return options.HmacKey; + + throw new InvalidOperationException( + $"Missing redaction HMAC key. Set environment variable '{options.HmacKeyEnvironmentVariable}' or configure 'Redaction:HmacKey'." + ); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs new file mode 100644 index 00000000..020fae5e --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/AuthTelemetry.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Authentication-related telemetry facade for shared HTTP authentication flows. +/// +public static class AuthTelemetry +{ + private static readonly Counter AuthFailures = + ObservabilityConventions.SharedMeter.CreateCounter(TelemetryMetricNames.AuthFailures); + + public static void RecordMissingTenantClaim(HttpContext httpContext, string scheme) => + RecordFailure( + TelemetryActivityNames.TokenValidated, + scheme, + TelemetryFailureReasons.MissingTenantClaim, + TelemetryApiSurfaceResolver.Resolve(httpContext.Request.Path) + ); + + public static void RecordAuthenticationFailed( + HttpContext httpContext, + string scheme, + Exception exception + ) => + RecordFailure( + TelemetryActivityNames.TokenValidated, + scheme, + TelemetryFailureReasons.AuthenticationFailed, + TelemetryApiSurfaceResolver.Resolve(httpContext.Request.Path), + exception + ); + + private static void RecordFailure( + string activityName, + string scheme, + string reason, + string surface, + Exception? exception = null + ) + { + AuthFailures.Add( + 1, + new TagList + { + { TelemetryTagKeys.AuthScheme, scheme }, + { TelemetryTagKeys.AuthFailureReason, reason }, + { TelemetryTagKeys.ApiSurface, surface }, + } + ); + + using Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( + activityName, + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.AuthScheme, scheme); + activity?.SetTag(TelemetryTagKeys.AuthFailureReason, reason); + activity?.SetTag(TelemetryTagKeys.ApiSurface, surface); + activity?.SetStatus(ActivityStatusCode.Error); + if (exception is not null) + activity?.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs new file mode 100644 index 00000000..1f66b9b1 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/CacheTelemetry.cs @@ -0,0 +1,97 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.OutputCaching; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Output-cache telemetry facade for invalidation activities and cache outcome metrics. +/// +public static class CacheTelemetry +{ + private static readonly Counter OutputCacheInvalidations = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.OutputCacheInvalidations + ); + + private static readonly Histogram OutputCacheInvalidationDurationMs = + ObservabilityConventions.SharedMeter.CreateHistogram( + TelemetryMetricNames.OutputCacheInvalidationDuration, + unit: "ms" + ); + + private static readonly Counter OutputCacheOutcomes = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.OutputCacheOutcomes + ); + + public static Activity? StartOutputCacheInvalidationActivity(string tag) + { + Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( + TelemetryActivityNames.OutputCacheInvalidate, + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.CacheTag, tag); + return activity; + } + + public static void RecordOutputCacheInvalidation(string tag, TimeSpan duration) + { + TagList tags = new() { { TelemetryTagKeys.CacheTag, tag } }; + OutputCacheInvalidations.Add(1, tags); + OutputCacheInvalidationDurationMs.Record(duration.TotalMilliseconds, tags); + } + + public static void ConfigureRequest(OutputCacheContext context) + { + context.HttpContext.Items[TelemetryContextKeys.OutputCachePolicyName] = ResolvePolicyName( + context + ); + } + + public static void RecordCacheHit(OutputCacheContext context) => + RecordCacheOutcome(context, TelemetryOutcomeValues.Hit); + + public static void RecordResponseOutcome(OutputCacheContext context) + { + string outcome = context.AllowCacheStorage + ? TelemetryOutcomeValues.Store + : TelemetryOutcomeValues.Bypass; + RecordCacheOutcome(context, outcome); + } + + private static void RecordCacheOutcome(OutputCacheContext context, string outcome) + { + TagList tags = new() + { + { TelemetryTagKeys.CachePolicy, ResolvePolicyName(context) }, + { + TelemetryTagKeys.ApiSurface, + TelemetryApiSurfaceResolver.Resolve(context.HttpContext.Request.Path) + }, + { TelemetryTagKeys.CacheOutcome, outcome }, + }; + OutputCacheOutcomes.Add(1, tags); + } + + private static string ResolvePolicyName(OutputCacheContext context) + { + if ( + context.HttpContext.Items.TryGetValue( + TelemetryContextKeys.OutputCachePolicyName, + out object? cached + ) && cached is string name + ) + { + return name; + } + + return context + .HttpContext.GetEndpoint() + ?.Metadata.OfType() + .Select(attribute => attribute.PolicyName) + .FirstOrDefault(policyName => !string.IsNullOrWhiteSpace(policyName)) + ?? TelemetryDefaults.Default; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs new file mode 100644 index 00000000..70876fdf --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ConflictTelemetry.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using SharedKernel.Domain.Exceptions; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Conflict-related metrics facade. +/// +public static class ConflictTelemetry +{ + private static readonly Counter ConcurrencyConflicts = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ConcurrencyConflicts + ); + + private static readonly Counter DomainConflicts = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.DomainConflicts + ); + + public static void Record(Exception exception, string errorCode) + { + if (exception is Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException) + { + ConcurrencyConflicts.Add(1); + return; + } + + if (exception is ConflictException) + { + DomainConflicts.Add(1, new TagList { { TelemetryTagKeys.ErrorCode, errorCode } }); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs new file mode 100644 index 00000000..e8603aea --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckConventions.cs @@ -0,0 +1,24 @@ +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Shared names and tags for infrastructure health checks exposed by microservice hosts. +/// +public static class HealthCheckNames +{ + public const string PostgreSql = "postgres"; + public const string MongoDb = "mongo"; + public const string Dragonfly = "dragonfly"; + public const string RabbitMq = "rabbitmq"; + public const string Scheduler = "scheduler"; +} + +/// +/// Common tags used to classify health checks by dependency type. +/// +public static class HealthCheckTags +{ + public static readonly string[] Database = ["database", "infrastructure"]; + public static readonly string[] Cache = ["cache", "infrastructure"]; + public static readonly string[] Messaging = ["messaging", "infrastructure"]; + public static readonly string[] Scheduler = ["scheduler", "infrastructure"]; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs new file mode 100644 index 00000000..532c171a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HealthCheckMetricsPublisher.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Publishes health check results as observable gauge metrics. +/// +public sealed class HealthCheckMetricsPublisher : IHealthCheckPublisher +{ + private static readonly ConcurrentDictionary Statuses = new( + StringComparer.OrdinalIgnoreCase + ); + + // Static gauge — registering multiple instances on the same Meter causes duplicate metrics. + private static readonly ObservableGauge Gauge = + ObservabilityConventions.SharedHealthMeter.CreateObservableGauge( + TelemetryMetricNames.HealthStatus, + ObserveStatuses + ); + + public Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + foreach ((string key, HealthReportEntry value) in report.Entries) + { + Statuses[key] = value.Status == HealthStatus.Healthy ? 1 : 0; + } + + return Task.CompletedTask; + } + + internal static IReadOnlyDictionary SnapshotStatuses() => + Statuses.ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.OrdinalIgnoreCase + ); + + private static IEnumerable> ObserveStatuses() + { + foreach ((string key, int value) in Statuses) + { + yield return new Measurement( + value, + new KeyValuePair(TelemetryTagKeys.Service, key) + ); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs new file mode 100644 index 00000000..84316392 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/HttpRouteResolver.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Resolves the normalized route template for the current HTTP request. +/// +public static partial class HttpRouteResolver +{ + [GeneratedRegex( + @"\{version(?::[^}]*)?\}", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant + )] + private static partial Regex VersionTokenRegex(); + + public static string Resolve(HttpContext httpContext) + { + string? routeTemplate = httpContext.GetEndpoint() is RouteEndpoint routeEndpoint + ? routeEndpoint.RoutePattern.RawText + : null; + + if (string.IsNullOrWhiteSpace(routeTemplate)) + return httpContext.Request.Path.Value ?? TelemetryDefaults.Unknown; + + return ReplaceVersionToken(routeTemplate, httpContext.Request.RouteValues); + } + + public static string ReplaceVersionToken(string routeTemplate, RouteValueDictionary routeValues) + { + if (string.IsNullOrWhiteSpace(routeTemplate)) + return TelemetryDefaults.Unknown; + + if (!routeValues.TryGetValue("version", out object? versionValue) || versionValue is null) + return routeTemplate; + + string? version = versionValue.ToString(); + if (string.IsNullOrWhiteSpace(version)) + return routeTemplate; + + return VersionTokenRegex().Replace(routeTemplate, version, 1); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs new file mode 100644 index 00000000..aed19e2b --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ObservabilityConventions.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace SharedKernel.Infrastructure.Observability; + +/// Shared names for the application's OpenTelemetry activity source and meters. +public static class ObservabilityConventions +{ + public const string ActivitySourceName = "APITemplate"; + public const string MeterName = "APITemplate"; + public const string HealthMeterName = "APITemplate.Health"; + + /// Shared activity source — use this instead of creating new instances per class. + internal static readonly ActivitySource SharedActivitySource = new(ActivitySourceName); + + /// Shared meter for application metrics — use this instead of creating new instances per class. + internal static readonly Meter SharedMeter = new(MeterName); + + /// Shared meter for health check metrics. + internal static readonly Meter SharedHealthMeter = new(HealthMeterName); +} + +/// Canonical metric instrument names emitted by the application meter. +public static class TelemetryMetricNames +{ + public const string AuthFailures = "apitemplate_auth_failures"; + public const string ConcurrencyConflicts = "apitemplate_concurrency_conflicts"; + public const string DomainConflicts = "apitemplate_domain_conflicts"; + public const string HandledExceptions = "apitemplate_exceptions_handled"; + public const string HealthStatus = "apitemplate_healthcheck_status"; + public const string OutputCacheInvalidations = "apitemplate_output_cache_invalidations"; + public const string OutputCacheInvalidationDuration = + "apitemplate_output_cache_invalidation_duration"; + public const string OutputCacheOutcomes = "apitemplate_output_cache_outcomes"; + public const string ValidationErrors = "apitemplate_validation_errors"; + public const string ValidationRequestsRejected = "apitemplate_validation_requests_rejected"; +} + +/// Canonical tag/attribute key names applied to metrics and traces. +public static class TelemetryTagKeys +{ + public const string ApiSurface = "apitemplate.api.surface"; + public const string Authenticated = "apitemplate.authenticated"; + public const string AuthFailureReason = "auth.failure_reason"; + public const string AuthScheme = "auth.scheme"; + public const string CacheOutcome = "cache.outcome"; + public const string CachePolicy = "cache.policy"; + public const string CacheTag = "cache.tag"; + public const string DbSystem = "db.system"; + public const string ErrorCode = "error.code"; + public const string ExceptionType = "exception.type"; + public const string HttpRoute = "http.route"; + public const string Service = "service"; + public const string StartupComponent = "startup.component"; + public const string StartupStep = "startup.step"; + public const string StartupSuccess = "startup.success"; + public const string TenantId = "tenant.id"; + public const string ValidationDtoType = "validation.dto_type"; + public const string ValidationProperty = "validation.property"; +} + +/// Canonical activity/span names recorded in the application activity source. +public static class TelemetryActivityNames +{ + public const string OutputCacheInvalidate = "output_cache.invalidate"; + public const string TokenValidated = "auth.token-validated"; + + public static string Startup(string step) => $"startup.{step}"; +} + +/// Well-known output-cache outcome values. +public static class TelemetryOutcomeValues +{ + public const string Bypass = "bypass"; + public const string Hit = "hit"; + public const string Store = "store"; +} + +/// Well-known authentication failure reasons. +public static class TelemetryFailureReasons +{ + public const string AuthenticationFailed = "authentication_failed"; + public const string MissingTenantClaim = "missing_tenant_claim"; +} + +/// Well-known tag values that identify the API surface a request was served from. +public static class TelemetrySurfaces +{ + public const string Documentation = "documentation"; + public const string GraphQl = "graphql"; + public const string Health = "health"; + public const string Rest = "rest"; +} + +/// Default fallback values used when a tag or setting cannot be resolved. +public static class TelemetryDefaults +{ + public const string AspireOtlpEndpoint = "http://localhost:4317"; + public const string Default = "default"; + public const string Unknown = "unknown"; +} + +/// Keys used to store transient telemetry values in . +public static class TelemetryContextKeys +{ + public const string OutputCachePolicyName = "OutputCachePolicyName"; +} + +/// URL path prefixes used to classify requests into API surface areas. +public static class TelemetryPathPrefixes +{ + public const string GraphQl = "/graphql"; + public const string Health = "/health"; + public const string OpenApi = "/openapi"; + public const string Scalar = "/scalar"; +} + +/// Well-known step names used to identify individual startup task activities. +public static class TelemetryStartupSteps +{ + public const string Migrate = "migrate"; +} + +/// Well-known component names tagged on startup activity spans. +public static class TelemetryStartupComponents +{ + public const string PostgreSql = "postgresql"; +} + +/// Well-known database system tag values. +public static class TelemetryDatabaseSystems +{ + public const string PostgreSql = "postgresql"; +} + +/// Meter names from ASP.NET Core and Microsoft libraries used to subscribe to built-in metrics. +public static class TelemetryMeterNames +{ + public const string AspNetCoreAuthentication = "Microsoft.AspNetCore.Authentication"; + public const string AspNetCoreAuthorization = "Microsoft.AspNetCore.Authorization"; + public const string AspNetCoreConnections = "Microsoft.AspNetCore.Http.Connections"; + public const string AspNetCoreDiagnostics = "Microsoft.AspNetCore.Diagnostics"; + public const string AspNetCoreHosting = "Microsoft.AspNetCore.Hosting"; + public const string AspNetCoreRateLimiting = "Microsoft.AspNetCore.RateLimiting"; + public const string AspNetCoreRouting = "Microsoft.AspNetCore.Routing"; + public const string AspNetCoreServerKestrel = "Microsoft.AspNetCore.Server.Kestrel"; +} + +/// Semantic-convention instrument names for HTTP client and server request durations. +public static class TelemetryInstrumentNames +{ + public const string HttpClientRequestDuration = "http.client.request.duration"; + public const string HttpServerRequestDuration = "http.server.request.duration"; +} + +/// OpenTelemetry resource attribute key names. +public static class TelemetryResourceAttributeKeys +{ + public const string AssemblyName = "assembly.name"; + public const string DeploymentEnvironmentName = "deployment.environment.name"; + public const string HostArchitecture = "host.arch"; + public const string HostName = "host.name"; + public const string OsType = "os.type"; + public const string ProcessPid = "process.pid"; + public const string ProcessRuntimeName = "process.runtime.name"; + public const string ProcessRuntimeVersion = "process.runtime.version"; + public const string ServiceInstanceId = "service.instance.id"; + public const string ServiceName = "service.name"; + public const string ServiceNamespace = "service.namespace"; + public const string ServiceVersion = "service.version"; +} + +/// Pre-defined histogram bucket boundaries for common metric instruments. +public static class TelemetryHistogramBoundaries +{ + public static readonly double[] HttpRequestDurationSeconds = + [ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1, + 2.5, + 5, + 10, + ]; + + public static readonly double[] CacheOperationDurationMs = + [ + 1, + 5, + 10, + 25, + 50, + 100, + 250, + 500, + 1000, + ]; +} + +/// Third-party library names used as OpenTelemetry activity sources and meters. +public static class TelemetryThirdPartySources +{ + public const string Wolverine = "Wolverine"; +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs new file mode 100644 index 00000000..437d7bb8 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/StartupTelemetry.cs @@ -0,0 +1,47 @@ +using System.Diagnostics; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Startup-phase telemetry helper for shared startup tasks. +/// +public static class StartupTelemetry +{ + public static Scope StartRelationalMigration() => + StartStep( + TelemetryStartupSteps.Migrate, + TelemetryStartupComponents.PostgreSql, + TelemetryDatabaseSystems.PostgreSql + ); + + private static Scope StartStep(string step, string component, string? dbSystem = null) + { + Activity? activity = ObservabilityConventions.SharedActivitySource.StartActivity( + TelemetryActivityNames.Startup(step), + ActivityKind.Internal + ); + activity?.SetTag(TelemetryTagKeys.StartupStep, step); + activity?.SetTag(TelemetryTagKeys.StartupComponent, component); + if (!string.IsNullOrWhiteSpace(dbSystem)) + activity?.SetTag(TelemetryTagKeys.DbSystem, dbSystem); + + return new Scope(activity); + } + + public sealed class Scope(Activity? activity) : IDisposable + { + private readonly Activity? _activity = activity; + + public void Fail(Exception exception) + { + if (_activity is null) + return; + + _activity.SetStatus(ActivityStatusCode.Error, exception.Message); + _activity.SetTag(TelemetryTagKeys.StartupSuccess, false); + _activity.SetTag(TelemetryTagKeys.ExceptionType, exception.GetType().Name); + } + + public void Dispose() => _activity?.Dispose(); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs new file mode 100644 index 00000000..46ef32b5 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/TelemetryApiSurfaceResolver.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Maps an HTTP request path to a logical API surface name for use as a telemetry tag value. +/// +public static class TelemetryApiSurfaceResolver +{ + public static string Resolve(PathString path) + { + if (path.StartsWithSegments(TelemetryPathPrefixes.GraphQl)) + return TelemetrySurfaces.GraphQl; + + if (path.StartsWithSegments(TelemetryPathPrefixes.Health)) + return TelemetrySurfaces.Health; + + if ( + path.StartsWithSegments(TelemetryPathPrefixes.Scalar) + || path.StartsWithSegments(TelemetryPathPrefixes.OpenApi) + ) + { + return TelemetrySurfaces.Documentation; + } + + return TelemetrySurfaces.Rest; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs new file mode 100644 index 00000000..d080a852 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Observability/ValidationTelemetry.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using FluentValidation.Results; +using SharedKernel.Application.Batch.Rules; + +namespace SharedKernel.Infrastructure.Observability; + +/// +/// Validation-related metrics facade implementing for DI use. +/// +public sealed class ValidationTelemetry : IValidationMetrics +{ + private static readonly Counter ValidationRequestsRejected = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ValidationRequestsRejected + ); + + private static readonly Counter ValidationErrors = + ObservabilityConventions.SharedMeter.CreateCounter( + TelemetryMetricNames.ValidationErrors + ); + + /// + public void RecordFailure( + string source, + Type argumentType, + IReadOnlyList failures + ) + { + TagList requestTags = new() + { + { TelemetryTagKeys.ValidationDtoType, argumentType.Name }, + { TelemetryTagKeys.HttpRoute, source }, + }; + ValidationRequestsRejected.Add(1, requestTags); + + foreach (ValidationFailure failure in failures) + { + TagList errorTags = new() + { + { TelemetryTagKeys.ValidationDtoType, argumentType.Name }, + { TelemetryTagKeys.HttpRoute, source }, + { TelemetryTagKeys.ValidationProperty, failure.PropertyName }, + }; + ValidationErrors.Add(1, errorTags); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs new file mode 100644 index 00000000..e28f4f1a --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeConnectionStringResolver.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Configuration; + +namespace SharedKernel.Infrastructure.Persistence; + +public static class DesignTimeConnectionStringResolver +{ + public static string Resolve( + string relativeApiProjectPath, + string connectionStringName, + string[] args + ) + { + string basePath = FindProjectPath(relativeApiProjectPath); + string? environmentName = + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + + IConfigurationRoot configuration = new ConfigurationBuilder() + .SetBasePath(basePath) + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{environmentName}.json", optional: true) + .AddEnvironmentVariables() + .AddCommandLine(args) + .Build(); + + return configuration.GetConnectionString(connectionStringName) + ?? throw new InvalidOperationException( + $"Connection string '{connectionStringName}' was not found for design-time DbContext creation." + ); + } + + private static string FindProjectPath(string relativeApiProjectPath) + { + DirectoryInfo? current = new(Directory.GetCurrentDirectory()); + + while (current is not null) + { + string candidate = Path.GetFullPath( + Path.Combine(current.FullName, relativeApiProjectPath) + ); + if (Directory.Exists(candidate)) + return candidate; + + current = current.Parent; + } + + throw new DirectoryNotFoundException( + $"Unable to locate API project path '{relativeApiProjectPath}' from '{Directory.GetCurrentDirectory()}'." + ); + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs new file mode 100644 index 00000000..b9e200b0 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/DesignTimeDbContextDefaults.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using SharedKernel.Application.Context; +using SharedKernel.Domain.Entities.Contracts; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.EntityNormalization; +using SharedKernel.Infrastructure.Persistence.SoftDelete; + +namespace SharedKernel.Infrastructure.Persistence; + +/// +/// Null-object collaborators used by EF Core design-time factories so migrations can be created +/// without the full runtime dependency graph. +/// +public static class DesignTimeDbContextDefaults +{ + public static TenantAuditableDbContextDependencies CreateDependencies() => + new( + new NullTenantProvider(), + new NullActorProvider(), + TimeProvider.System, + [], + new NullAuditableEntityStateManager(), + new NullSoftDeleteProcessor() + ); + + public static IEntityNormalizationService EntityNormalizationService { get; } = + new NullEntityNormalizationService(); + + private sealed class NullTenantProvider : ITenantProvider + { + public Guid TenantId => Guid.Empty; + + public bool HasTenant => false; + } + + private sealed class NullActorProvider : IActorProvider + { + public Guid ActorId => Guid.Empty; + } + + private sealed class NullEntityNormalizationService : IEntityNormalizationService + { + public void Normalize(IAuditableTenantEntity entity) { } + } + + private sealed class NullAuditableEntityStateManager : IAuditableEntityStateManager + { + public void StampAdded( + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor, + bool hasTenant, + Guid currentTenantId + ) { } + + public void StampModified(IAuditableTenantEntity entity, DateTime now, Guid actor) { } + + public void MarkSoftDeleted( + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor + ) { } + } + + private sealed class NullSoftDeleteProcessor : ISoftDeleteProcessor + { + public Task ProcessAsync( + DbContext dbContext, + EntityEntry entry, + IAuditableTenantEntity entity, + DateTime now, + Guid actor, + IReadOnlyCollection softDeleteCascadeRules, + CancellationToken cancellationToken + ) => Task.CompletedTask; + } +} diff --git a/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs new file mode 100644 index 00000000..5f977a63 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Infrastructure/Persistence/TenantAuditableDbContextDependencies.cs @@ -0,0 +1,18 @@ +using SharedKernel.Application.Context; +using SharedKernel.Infrastructure.Persistence.Auditing; +using SharedKernel.Infrastructure.Persistence.SoftDelete; + +namespace SharedKernel.Infrastructure.Persistence; + +/// +/// Groups the shared infrastructure dependencies required by . +/// Register as scoped so the lifetime matches the contained services. +/// +public sealed record TenantAuditableDbContextDependencies( + ITenantProvider TenantProvider, + IActorProvider ActorProvider, + TimeProvider TimeProvider, + IEnumerable SoftDeleteCascadeRules, + IAuditableEntityStateManager EntityStateManager, + ISoftDeleteProcessor SoftDeleteProcessor +); diff --git a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj index 6f7f1198..c642c00e 100644 --- a/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj +++ b/src/SharedKernel/SharedKernel.Infrastructure/SharedKernel.Infrastructure.csproj @@ -11,12 +11,22 @@ enable + + + + + + + + + + diff --git a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs index 9f6e86a6..6d51c443 100644 --- a/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs +++ b/src/SharedKernel/SharedKernel.Messaging/Conventions/RabbitMqConventionExtensions.cs @@ -21,14 +21,16 @@ public static WolverineOptions UseSharedRabbitMq( IConfiguration configuration ) { - string connectionString = - configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + string connectionString = ResolveConnectionString(configuration); opts.UseRabbitMq(new Uri(connectionString)).AutoProvision().EnableWolverineControlQueues(); return opts; } + public static string ResolveConnectionString(IConfiguration configuration) => + configuration.GetConnectionString("RabbitMQ") ?? BuildFromHostName(configuration); + private static string BuildFromHostName(IConfiguration configuration) { string? hostName = configuration["RabbitMQ:HostName"]; diff --git a/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs b/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs new file mode 100644 index 00000000..e03f7ac2 --- /dev/null +++ b/src/SharedKernel/SharedKernel.Messaging/HealthChecks/RabbitMqHealthCheck.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using RabbitMQ.Client; +using SharedKernel.Messaging.Conventions; + +namespace SharedKernel.Messaging.HealthChecks; + +/// +/// Verifies that the configured RabbitMQ broker is reachable and accepts AMQP connections. +/// +public sealed class RabbitMqHealthCheck(IConfiguration configuration) : IHealthCheck +{ + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default + ) + { + string connectionString; + try + { + connectionString = RabbitMqConventionExtensions.ResolveConnectionString(configuration); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("RabbitMQ configuration is invalid.", ex); + } + + try + { + ConnectionFactory factory = new() { Uri = new Uri(connectionString) }; + await using IConnection connection = await factory.CreateConnectionAsync(); + + return connection.IsOpen + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("RabbitMQ connection was created but is not open."); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy("RabbitMQ broker is unavailable.", ex); + } + } +} diff --git a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj index 539302c8..63a07483 100644 --- a/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj +++ b/src/SharedKernel/SharedKernel.Messaging/SharedKernel.Messaging.csproj @@ -10,6 +10,10 @@ enable + + + + diff --git a/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs b/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs new file mode 100644 index 00000000..94ef8866 --- /dev/null +++ b/tests/Identity.Tests/Logging/IdentitySecurityLogsTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using Identity.Infrastructure.Security.Keycloak; +using Identity.Infrastructure.Security.Tenant; +using Microsoft.Extensions.Logging; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace Identity.Tests.Logging; + +public sealed class IdentitySecurityLogsTests +{ + [Fact] + public void TenantClaimValidator_UserAuthenticated_UsesExpectedClassifications() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Tenant.TenantClaimValidatorLogs" + ); + MethodInfo method = logsType.GetMethod( + "UserAuthenticated", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Information); + + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void KeycloakAdminService_UserCreated_UsesExpectedClassifications() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Keycloak.KeycloakAdminServiceLogs" + ); + MethodInfo method = logsType.GetMethod( + "UserCreated", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Information); + + method.GetParameters()[1].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void KeycloakAdminTokenProvider_TokenAcquireFailed_RedactsResponseBody() + { + Type logsType = GetRequiredType( + "Identity.Infrastructure.Security.Keycloak.KeycloakAdminTokenProviderLogs" + ); + MethodInfo method = logsType.GetMethod( + "TokenAcquireFailed", + BindingFlags.Public | BindingFlags.Static + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + loggerMessage.Level.ShouldBe(LogLevel.Error); + + method.GetParameters()[2].GetCustomAttribute().ShouldNotBeNull(); + } + + private static Type GetRequiredType(string fullName) => + typeof(TenantClaimValidator).Assembly.GetType(fullName) + ?? throw new InvalidOperationException($"Could not load type '{fullName}'."); +} diff --git a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs index 353387c7..4f7367af 100644 --- a/tests/Integration.Tests/Factories/ServiceFactoryBase.cs +++ b/tests/Integration.Tests/Factories/ServiceFactoryBase.cs @@ -45,10 +45,26 @@ await TestDatabaseLifecycle.CreateDatabaseAsync( await base.DisposeAsync(); } catch (OperationCanceledException) { } + catch (NullReferenceException ex) + when (ex.ToString() + .Contains( + "Wolverine.RabbitMQ.Internal.RabbitMqChannelAgent", + StringComparison.Ordinal + ) + ) { } // Wolverine RabbitMQ teardown bug catch (AggregateException ex) - when (ex.InnerExceptions.All(e => - e is OperationCanceledException or TaskCanceledException - ) + when (ex.Flatten() + .InnerExceptions.All(e => + e is OperationCanceledException or TaskCanceledException + || ( + e is NullReferenceException nre + && nre.ToString() + .Contains( + "Wolverine.RabbitMQ.Internal.RabbitMqChannelAgent", + StringComparison.Ordinal + ) + ) + ) ) { } await TestDatabaseLifecycle.DropDatabaseAsync( @@ -113,6 +129,7 @@ private static void RemoveExternalHealthChecks(IServiceCollection services) options .Registrations.Where(r => r.Name.Contains("mongodb", StringComparison.OrdinalIgnoreCase) + || r.Name.Contains("mongo", StringComparison.OrdinalIgnoreCase) || r.Name.Contains("keycloak", StringComparison.OrdinalIgnoreCase) || r.Name.Contains("dragonfly", StringComparison.OrdinalIgnoreCase) ) diff --git a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs index 7167dbec..b6c2a60a 100644 --- a/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs +++ b/tests/Integration.Tests/Infrastructure/OutputCacheBehaviorTests.cs @@ -48,16 +48,14 @@ await Task.WhenAll( public async ValueTask DisposeAsync() { - await Task.WhenAll( - _fileStorageFactory.DisposeAsync().AsTask(), - _reviewsFactory.DisposeAsync().AsTask(), - _identityFactory.DisposeAsync().AsTask(), - _productCatalogFactory.DisposeAsync().AsTask() - ); + await _fileStorageFactory.DisposeAsync(); + await _reviewsFactory.DisposeAsync(); + await _identityFactory.DisposeAsync(); + await _productCatalogFactory.DisposeAsync(); } [Fact] - public async Task ProductCatalog_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task ProductCatalog_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -71,7 +69,7 @@ public async Task ProductCatalog_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() HttpResponseMessage second = await client.GetAsync("/api/v1/products", ct); string secondBody = await second.Content.ReadAsStringAsync(ct); second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -164,7 +162,7 @@ await clientA.GetAsync("/api/v1/products", ct) } [Fact] - public async Task Identity_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task Identity_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -172,11 +170,13 @@ public async Task Identity_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() IntegrationAuthHelper.AuthenticateAsPlatformAdmin(client, tenantId); HttpResponseMessage first = await client.GetAsync("/api/v1/tenants", ct); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); + string firstBody = await first.Content.ReadAsStringAsync(ct); + first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); HttpResponseMessage second = await client.GetAsync("/api/v1/tenants", ct); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); + string secondBody = await second.Content.ReadAsStringAsync(ct); + second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -228,7 +228,7 @@ await client.GetAsync($"/api/v1/users/{createdId}", ct) } [Fact] - public async Task Reviews_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task Reviews_RepeatedRead_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -255,14 +255,16 @@ public async Task Reviews_ReadEndpoint_ReturnsAgeHeaderOnSecondRead() $"/api/v1/productreviews/by-product/{productId}", ct ); - first.StatusCode.ShouldBe(HttpStatusCode.OK, await first.Content.ReadAsStringAsync(ct)); + string firstBody = await first.Content.ReadAsStringAsync(ct); + first.StatusCode.ShouldBe(HttpStatusCode.OK, firstBody); HttpResponseMessage second = await client.GetAsync( $"/api/v1/productreviews/by-product/{productId}", ct ); - second.StatusCode.ShouldBe(HttpStatusCode.OK, await second.Content.ReadAsStringAsync(ct)); - second.Headers.Age.ShouldNotBeNull(); + string secondBody = await second.Content.ReadAsStringAsync(ct); + second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); } [Fact] @@ -377,7 +379,7 @@ await clientA.GetAsync("/api/v1/productreviews", ct) } [Fact] - public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() + public async Task FileStorage_RepeatedDownload_ReturnsStableResponse() { var ct = TestContext.Current.CancellationToken; var tenantId = Guid.NewGuid(); @@ -416,7 +418,7 @@ public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() HttpResponseMessage second = await client.GetAsync($"/api/v1/files/{fileId}/download", ct); string secondBody = await second.Content.ReadAsStringAsync(ct); second.StatusCode.ShouldBe(HttpStatusCode.OK, secondBody); - second.Headers.Age.ShouldNotBeNull(); + AssertRepeatedReadReturnedEquivalentResponse(firstBody, secondBody); try { @@ -427,4 +429,9 @@ public async Task FileStorage_DownloadEndpoint_ReturnsAgeHeaderOnSecondRead() // Best-effort cleanup; temp files are harmless if locked. } } + + private static void AssertRepeatedReadReturnedEquivalentResponse( + string firstBody, + string secondBody + ) => secondBody.ShouldBe(firstBody); } diff --git a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs b/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs index 4b165e97..b1f5da03 100644 --- a/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs +++ b/tests/Integration.Tests/Infrastructure/ServiceStartupSmokeTests.cs @@ -31,10 +31,13 @@ public async Task AllServices_Start_And_AreHealthy() await AssertServiceHealthyAsync(new WebhooksServiceFactory(_containers)); } - private static async Task AssertHealthyAsync(HttpClient client) + private static async Task AssertHealthyAsync(HttpClient client, string? serviceName = null) { HttpResponseMessage response = await client.GetAsync("/health"); - response.IsSuccessStatusCode.ShouldBeTrue(); + string body = await response.Content.ReadAsStringAsync(); + response.IsSuccessStatusCode.ShouldBeTrue( + $"{serviceName ?? "service"} returned {(int)response.StatusCode} {response.StatusCode}. Body: {body}" + ); } private static async Task AssertServiceHealthyAsync( @@ -45,7 +48,7 @@ ServiceFactoryBase factory await factory.InitializeAsync(); try { - await AssertHealthyAsync(factory.CreateClient()); + await AssertHealthyAsync(factory.CreateClient(), typeof(TProgram).Name); } finally { diff --git a/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs new file mode 100644 index 00000000..e2ceb65c --- /dev/null +++ b/tests/ProductCatalog.Tests/Features/Category/Commands/CategoryBatchCommandHandlerTests.cs @@ -0,0 +1,184 @@ +using Ardalis.Specification; +using Moq; +using ProductCatalog.Application.Common.Errors; +using ProductCatalog.Application.Features.Category.Commands; +using ProductCatalog.Application.Features.Category.DTOs; +using ProductCatalog.Application.Features.Category.Validation; +using ProductCatalog.Domain.Interfaces; +using SharedKernel.Domain.Interfaces; +using Shouldly; +using Xunit; +using CategoryEntity = ProductCatalog.Domain.Entities.Category; + +namespace ProductCatalog.Tests.Features.Category.Commands; + +public sealed class CategoryBatchCommandHandlerTests +{ + private readonly Mock _repositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + + public CategoryBatchCommandHandlerTests() + { + _unitOfWorkMock + .Setup(u => + u.ExecuteInTransactionAsync( + It.IsAny>(), + It.IsAny(), + null + ) + ) + .Returns, CancellationToken, object?>((action, _, _) => action()); + } + + [Fact] + public async Task CreateHandleAsync_WhenValidationFails_ReturnsBatchFailureAndSkipsPersistence() + { + CreateCategoriesCommand command = new(new CreateCategoriesRequest([new("", null)])); + + var (result, _) = await CreateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + new CreateCategoryRequestValidator(), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(0); + result.Value.FailureCount.ShouldBe(1); + result.Value.Failures.ShouldHaveSingleItem(); + result.Value.Failures[0].Errors.ShouldContain("Category name is required."); + _repositoryMock.Verify( + r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + } + + [Fact] + public async Task CreateHandleAsync_WhenItemsAreValid_PersistsAllCategories() + { + CreateCategoryRequest first = new("Electronics", "Devices"); + CreateCategoryRequest second = new("Books", null); + CreateCategoriesCommand command = new(new CreateCategoriesRequest([first, second])); + List? persistedCategories = null; + + _repositoryMock + .Setup(r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, CancellationToken>( + (categories, _) => persistedCategories = categories.ToList() + ) + .ReturnsAsync([]); + + var (result, _) = await CreateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + new CreateCategoryRequestValidator(), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + result.Value.FailureCount.ShouldBe(0); + persistedCategories.ShouldNotBeNull(); + persistedCategories.Select(c => c.Name).ShouldBe(["Electronics", "Books"]); + } + + [Fact] + public async Task HandleAsync_WhenCategoryIsMissing_ReturnsBatchFailure() + { + Guid missingId = Guid.NewGuid(); + UpdateCategoriesCommand command = new( + new UpdateCategoriesRequest([new UpdateCategoryItem(missingId, "Updated", "Desc")]) + ); + + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + new UpdateCategoryItemValidator(), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(0); + result.Value.FailureCount.ShouldBe(1); + result + .Value.Failures[0] + .Errors.ShouldContain( + string.Format(ErrorCatalog.Categories.NotFoundMessage, missingId) + ); + } + + [Fact] + public async Task HandleAsync_WhenLookupContainsEntities_UpdatesEachCategory() + { + Guid firstId = Guid.NewGuid(); + Guid secondId = Guid.NewGuid(); + CategoryEntity first = new() + { + Id = firstId, + Name = "Old 1", + Description = "Old", + }; + CategoryEntity second = new() + { + Id = secondId, + Name = "Old 2", + Description = "Old", + }; + + UpdateCategoriesCommand command = new( + new UpdateCategoriesRequest([ + new UpdateCategoryItem(firstId, "New 1", "Desc 1"), + new UpdateCategoryItem(secondId, "New 2", null), + ]) + ); + + _repositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([first, second]); + + var (result, _) = await UpdateCategoriesCommandHandler.HandleAsync( + command, + _repositoryMock.Object, + _unitOfWorkMock.Object, + new UpdateCategoryItemValidator(), + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + first.Name.ShouldBe("New 1"); + first.Description.ShouldBe("Desc 1"); + second.Name.ShouldBe("New 2"); + second.Description.ShouldBeNull(); + _repositoryMock.Verify( + r => r.UpdateAsync(It.IsAny(), It.IsAny()), + Times.Exactly(2) + ); + } +} diff --git a/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs new file mode 100644 index 00000000..88ebc366 --- /dev/null +++ b/tests/ProductCatalog.Tests/Features/Product/Commands/ProductBatchCommandHandlerTests.cs @@ -0,0 +1,299 @@ +using Ardalis.Specification; +using Contracts.IntegrationEvents.ProductCatalog; +using Moq; +using ProductCatalog.Application.Common.Errors; +using ProductCatalog.Application.Features.Product.Commands; +using ProductCatalog.Application.Features.Product.DTOs; +using ProductCatalog.Application.Features.Product.Repositories; +using ProductCatalog.Application.Features.Product.Validation; +using ProductCatalog.Domain.Entities; +using ProductCatalog.Domain.Entities.ProductData; +using ProductCatalog.Domain.Interfaces; +using SharedKernel.Domain.Interfaces; +using Shouldly; +using Wolverine; +using Xunit; +using CategoryEntity = ProductCatalog.Domain.Entities.Category; +using ProductEntity = ProductCatalog.Domain.Entities.Product; + +namespace ProductCatalog.Tests.Features.Product.Commands; + +public sealed class ProductBatchCommandHandlerTests +{ + private readonly Mock _productRepositoryMock = new(); + private readonly Mock _categoryRepositoryMock = new(); + private readonly Mock _productDataRepositoryMock = new(); + private readonly Mock _unitOfWorkMock = new(); + private readonly Mock _busMock = new(); + + public ProductBatchCommandHandlerTests() + { + _unitOfWorkMock + .Setup(u => + u.ExecuteInTransactionAsync( + It.IsAny>(), + It.IsAny(), + null + ) + ) + .Returns, CancellationToken, object?>((action, _, _) => action()); + + _busMock + .Setup(b => b.PublishAsync(It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + } + + [Fact] + public async Task CreateHandleAsync_WhenReferencesAreMissing_ReturnsMergedBatchFailure() + { + Guid missingCategoryId = Guid.NewGuid(); + Guid missingProductDataId = Guid.NewGuid(); + CreateProductsCommand command = new( + new CreateProductsRequest([ + new CreateProductRequest( + "Product", + "Desc", + 10m, + missingCategoryId, + [missingProductDataId] + ), + ]) + ); + + _categoryRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + _productDataRepositoryMock + .Setup(r => + r.GetByIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([]); + + var (result, _) = await CreateProductsCommandHandler.HandleAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + _unitOfWorkMock.Object, + _busMock.Object, + new CreateProductRequestValidator(), + TimeProvider.System, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.FailureCount.ShouldBe(1); + result.Value.Failures.ShouldHaveSingleItem(); + result + .Value.Failures[0] + .Errors.ShouldBe([ + string.Format(ErrorCatalog.Categories.NotFoundMessage, missingCategoryId), + string.Format(ErrorCatalog.ProductData.NotFoundMessage, missingProductDataId), + ]); + _productRepositoryMock.Verify( + r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ), + Times.Never + ); + _busMock.Verify( + b => b.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never + ); + } + + [Fact] + public async Task CreateHandleAsync_WhenItemsAreValid_PersistsProductsAndPublishesEvents() + { + Guid categoryId = Guid.NewGuid(); + Guid productDataId = Guid.NewGuid(); + List? persistedProducts = null; + CreateProductsCommand command = new( + new CreateProductsRequest([ + new CreateProductRequest( + "Camera", + "Mirrorless", + 499.99m, + categoryId, + [productDataId, productDataId] + ), + new CreateProductRequest("Lens", null, 199.99m, null, null), + ]) + ); + + _categoryRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([new CategoryEntity { Id = categoryId, Name = "Photo" }]); + _productDataRepositoryMock + .Setup(r => + r.GetByIdsAsync(It.IsAny>(), It.IsAny()) + ) + .ReturnsAsync([new ImageProductData { Id = productDataId, Title = "Spec" }]); + _productRepositoryMock + .Setup(r => + r.AddRangeAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .Callback, CancellationToken>( + (products, _) => persistedProducts = products.ToList() + ) + .ReturnsAsync([]); + + var (result, _) = await CreateProductsCommandHandler.HandleAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + _unitOfWorkMock.Object, + _busMock.Object, + new CreateProductRequestValidator(), + TimeProvider.System, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(2); + persistedProducts.ShouldNotBeNull(); + persistedProducts.Count.ShouldBe(2); + persistedProducts[0].CategoryId.ShouldBe(categoryId); + persistedProducts[0].ProductDataLinks.Count.ShouldBe(1); + persistedProducts[0].ProductDataLinks.Single().ProductDataId.ShouldBe(productDataId); + persistedProducts[1].ProductDataLinks.ShouldBeEmpty(); + _busMock.Verify( + b => + b.PublishAsync( + It.IsAny(), + It.IsAny() + ), + Times.Exactly(2) + ); + } + + [Fact] + public async Task LoadAsync_WhenProductIsMissing_ReturnsStopWithoutLookup() + { + Guid missingProductId = Guid.NewGuid(); + UpdateProductsCommand command = new( + new UpdateProductsRequest([ + new UpdateProductItem(missingProductId, "Updated", null, 10m), + ]) + ); + + _productRepositoryMock + .Setup(r => + r.ListAsync( + It.IsAny>(), + It.IsAny() + ) + ) + .ReturnsAsync([]); + + var (continuation, lookup, _) = await UpdateProductsCommandHandler.LoadAsync( + command, + _productRepositoryMock.Object, + _categoryRepositoryMock.Object, + _productDataRepositoryMock.Object, + new UpdateProductItemValidator(), + CancellationToken.None + ); + + continuation.ShouldBe(HandlerContinuation.Stop); + lookup.ShouldBeNull(); + } + + [Fact] + public async Task HandleAsync_WhenProductDataIdsAreProvided_SyncsLinksAndUpdatesProduct() + { + Guid productId = Guid.NewGuid(); + Guid oldProductDataId = Guid.NewGuid(); + Guid newProductDataId = Guid.NewGuid(); + ProductEntity product = new() + { + Id = productId, + Name = "Old", + Description = "Old Desc", + Price = 10m, + CategoryId = Guid.NewGuid(), + }; + product.ProductDataLinks.Add(ProductDataLink.Create(productId, oldProductDataId)); + + UpdateProductsCommand command = new( + new UpdateProductsRequest([ + new UpdateProductItem(productId, "New", "New Desc", 25m, null, [newProductDataId]), + ]) + ); + + var (result, _) = await UpdateProductsCommandHandler.HandleAsync( + command, + new SharedKernel.Application.Batch.EntityLookup( + new Dictionary { [productId] = product } + ), + _productRepositoryMock.Object, + _unitOfWorkMock.Object, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + result.Value.SuccessCount.ShouldBe(1); + product.Name.ShouldBe("New"); + product.Description.ShouldBe("New Desc"); + product.Price.ShouldBe(25m); + product.CategoryId.ShouldBeNull(); + product.ProductDataLinks.Count.ShouldBe(1); + product.ProductDataLinks.Single().ProductDataId.ShouldBe(newProductDataId); + } + + [Fact] + public async Task HandleAsync_WhenProductDataIdsAreNull_LeavesExistingLinksUntouched() + { + Guid productId = Guid.NewGuid(); + Guid existingLinkId = Guid.NewGuid(); + ProductEntity product = new() + { + Id = productId, + Name = "Old", + Price = 10m, + }; + product.ProductDataLinks.Add(ProductDataLink.Create(productId, existingLinkId)); + + UpdateProductsCommand command = new( + new UpdateProductsRequest([new UpdateProductItem(productId, "Renamed", null, 15m)]) + ); + + var (result, _) = await UpdateProductsCommandHandler.HandleAsync( + command, + new SharedKernel.Application.Batch.EntityLookup( + new Dictionary { [productId] = product } + ), + _productRepositoryMock.Object, + _unitOfWorkMock.Object, + CancellationToken.None + ); + + result.IsError.ShouldBeFalse(); + product.ProductDataLinks.Count.ShouldBe(1); + product.ProductDataLinks.Single().ProductDataId.ShouldBe(existingLinkId); + _productRepositoryMock.Verify( + r => + r.UpdateAsync( + It.Is(p => p.Id == productId), + It.IsAny() + ), + Times.Once + ); + } +} diff --git a/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs b/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs new file mode 100644 index 00000000..20918fc0 --- /dev/null +++ b/tests/ProductCatalog.Tests/Health/MongoDbHealthCheckTests.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using ProductCatalog.Api.Health; +using ProductCatalog.Infrastructure.Persistence; +using Shouldly; +using Xunit; + +namespace ProductCatalog.Tests.Health; + +public sealed class MongoDbHealthCheckTests +{ + [Fact] + public async Task CheckHealthAsync_WhenPingSucceeds_ReturnsHealthy() + { + Mock probe = new(); + MongoDbHealthCheck sut = new(probe.Object); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Healthy); + } + + [Fact] + public async Task CheckHealthAsync_WhenPingFails_ReturnsUnhealthy() + { + Mock probe = new(); + probe + .Setup(x => x.PingAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + MongoDbHealthCheck sut = new(probe.Object); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Unhealthy); + } +} diff --git a/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs b/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs new file mode 100644 index 00000000..3f4cdf90 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/ActivityTraceEnricherTests.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class ActivityTraceEnricherTests +{ + private readonly ActivityTraceEnricher _sut = new(); + + [Fact] + public void Enrich_WithActiveActivity_AddsTraceIdAndSpanId() + { + using ActivityListener listener = new() + { + ShouldListenTo = static _ => true, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + using ActivitySource source = new("SharedKernel.Tests"); + using Activity? activity = source.StartActivity("test-operation"); + + activity.ShouldNotBeNull(); + + LogEvent logEvent = CreateLogEvent(); + _sut.Enrich(logEvent, new TestLogEventPropertyFactory()); + + logEvent.Properties.ContainsKey("TraceId").ShouldBeTrue(); + logEvent.Properties.ContainsKey("SpanId").ShouldBeTrue(); + ((ScalarValue)logEvent.Properties["TraceId"]).Value.ShouldBe( + activity.TraceId.ToHexString() + ); + ((ScalarValue)logEvent.Properties["SpanId"]).Value.ShouldBe(activity.SpanId.ToHexString()); + } + + [Fact] + public void Enrich_WithNoActivity_AddsNoProperties() + { + Activity.Current = null; + + LogEvent logEvent = CreateLogEvent(); + _sut.Enrich(logEvent, new TestLogEventPropertyFactory()); + + logEvent.Properties.ContainsKey("TraceId").ShouldBeFalse(); + logEvent.Properties.ContainsKey("SpanId").ShouldBeFalse(); + } + + private static LogEvent CreateLogEvent() => + new(DateTimeOffset.UtcNow, LogEventLevel.Information, null, MessageTemplate.Empty, []); + + private sealed class TestLogEventPropertyFactory : ILogEventPropertyFactory + { + public LogEventProperty CreateProperty( + string name, + object? value, + bool destructureObjects = false + ) => new(name, new ScalarValue(value)); + } +} diff --git a/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs b/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs new file mode 100644 index 00000000..41aad3b0 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/ApiExceptionHandlerLogsTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Microsoft.Extensions.Logging; +using SharedKernel.Api.ExceptionHandling; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class ApiExceptionHandlerLogsTests +{ + [Fact] + public void UnhandledException_UsesExpectedMessageAndClassifications() + { + MethodInfo method = typeof(ApiExceptionHandlerLogs).GetMethod( + nameof(ApiExceptionHandlerLogs.UnhandledException) + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + + loggerMessage.Level.ShouldBe(LogLevel.Error); + loggerMessage.Message.ShouldBe( + "Unhandled exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + ); + + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[4].GetCustomAttribute().ShouldNotBeNull(); + } + + [Fact] + public void HandledApplicationException_UsesExpectedMessageAndClassifications() + { + MethodInfo method = typeof(ApiExceptionHandlerLogs).GetMethod( + nameof(ApiExceptionHandlerLogs.HandledApplicationException) + )!; + + LoggerMessageAttribute loggerMessage = method.GetCustomAttribute()!; + + loggerMessage.Level.ShouldBe(LogLevel.Warning); + loggerMessage.Message.ShouldBe( + "Handled application exception. StatusCode: {StatusCode}, ErrorCode: {ErrorCode}, TraceId: {TraceId}" + ); + + method.GetParameters()[3].GetCustomAttribute().ShouldNotBeNull(); + method.GetParameters()[4].GetCustomAttribute().ShouldNotBeNull(); + } +} diff --git a/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs b/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs new file mode 100644 index 00000000..987c4755 --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/LogDataClassificationsTests.cs @@ -0,0 +1,24 @@ +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class LogDataClassificationsTests +{ + [Fact] + public void PersonalDataAttribute_MapsToPersonalClassification() + { + PersonalDataAttribute attribute = new(); + + attribute.Classification.ShouldBe(LogDataClassifications.Personal); + } + + [Fact] + public void SensitiveDataAttribute_MapsToSensitiveClassification() + { + SensitiveDataAttribute attribute = new(); + + attribute.Classification.ShouldBe(LogDataClassifications.Sensitive); + } +} diff --git a/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs b/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs new file mode 100644 index 00000000..6d6c6f4b --- /dev/null +++ b/tests/SharedKernel.Tests/Logging/RedactionConfigurationTests.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharedKernel.Api.Extensions; +using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Logging; + +public sealed class RedactionConfigurationTests +{ + [Fact] + public void ResolveHmacKey_WhenEnvironmentVariablePresent_PrefersEnvironmentValue() + { + RedactionOptions options = new() + { + HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY", + HmacKey = "config-value", + }; + + string hmacKey = RedactionConfiguration.ResolveHmacKey( + options, + variable => variable == "TEST_REDACTION_KEY" ? "env-value" : null + ); + + hmacKey.ShouldBe("env-value"); + } + + [Fact] + public void ResolveHmacKey_WhenEnvironmentVariableMissing_UsesInlineConfiguration() + { + RedactionOptions options = new() + { + HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY", + HmacKey = "config-value", + }; + + string hmacKey = RedactionConfiguration.ResolveHmacKey(options, _ => null); + + hmacKey.ShouldBe("config-value"); + } + + [Fact] + public void ResolveHmacKey_WhenNoSourceConfigured_Throws() + { + RedactionOptions options = new() { HmacKeyEnvironmentVariable = "TEST_REDACTION_KEY" }; + + InvalidOperationException exception = Should.Throw(() => + RedactionConfiguration.ResolveHmacKey(options, _ => null) + ); + + exception.Message.ShouldContain("TEST_REDACTION_KEY"); + } + + [Fact] + public void AddSharedLogRedaction_WithInlineHmacKey_RegistersLogging() + { + ServiceCollection services = new(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Redaction:HmacKeyEnvironmentVariable"] = "UNUSED_REDACTION_KEY", + ["Redaction:HmacKey"] = "unit-test-hmac-key", + ["Redaction:KeyId"] = "1001", + } + ) + .Build(); + + services.AddSharedLogRedaction(configuration); + + services.ShouldContain(descriptor => descriptor.ServiceType == typeof(ILoggerFactory)); + } + + [Fact] + public void AddSharedLogRedaction_WhenMissingKey_Throws() + { + ServiceCollection services = new(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Redaction:HmacKeyEnvironmentVariable"] = "NON_EXISTENT_TEST_REDACTION_KEY", + ["Redaction:KeyId"] = "1001", + } + ) + .Build(); + + InvalidOperationException exception = Should.Throw(() => + services.AddSharedLogRedaction(configuration) + ); + + exception.Message.ShouldContain("NON_EXISTENT_TEST_REDACTION_KEY"); + } +} diff --git a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs b/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs deleted file mode 100644 index 00271158..00000000 --- a/tests/SharedKernel.Tests/Middleware/ErrorOrValidationMiddlewareTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using ErrorOr; -using FluentValidation; -using FluentValidation.Results; -using SharedKernel.Application.Errors; -using SharedKernel.Application.Middleware; -using Shouldly; -using Wolverine; -using Xunit; - -namespace SharedKernel.Tests.Middleware; - -public sealed class ErrorOrValidationMiddlewareTests -{ - [Fact] - public async Task BeforeAsync_WhenNoValidator_ReturnsContinue() - { - TestCommand message = new("valid"); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationPasses_ReturnsContinue() - { - TestCommand message = new("valid"); - PassingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Continue); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ReturnsStopWithErrors() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation continuation, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - continuation.ShouldBe(HandlerContinuation.Stop); - response.IsError.ShouldBeTrue(); - response.Errors.Count.ShouldBe(1); - response.FirstError.Code.ShouldBe(ErrorCatalog.General.ValidationFailed); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsPropertyNameMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("propertyName"); - response.FirstError.Metadata["propertyName"].ShouldBe("Value"); - } - - [Fact] - public async Task BeforeAsync_WhenValidationFails_ErrorContainsAttemptedValueMetadata() - { - TestCommand message = new(""); - FailingValidator validator = new(); - - (HandlerContinuation _, ErrorOr response) = - await ErrorOrValidationMiddleware.BeforeAsync(message, validator); - - response.FirstError.Metadata.ShouldContainKey("attemptedValue"); - response.FirstError.Metadata["attemptedValue"].ShouldBe(""); - } - - public sealed record TestCommand(string Value); - - private sealed class PassingValidator : AbstractValidator - { - public PassingValidator() - { - // No rules - always passes - } - } - - private sealed class FailingValidator : AbstractValidator - { - public FailingValidator() - { - RuleFor(x => x.Value).NotEmpty().WithMessage("Value is required."); - } - } -} diff --git a/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs b/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs new file mode 100644 index 00000000..ec503201 --- /dev/null +++ b/tests/SharedKernel.Tests/Middleware/RequestContextMiddlewareTests.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using SharedKernel.Api.Middleware; +using SharedKernel.Application.Http; +using SharedKernel.Application.Security; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Middleware; + +public sealed class RequestContextMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_WhenHeaderProvided_EchoesCorrelationIdToResponse() + { + DefaultHttpContext context = CreateContext(); + context.Request.Headers[RequestContextConstants.Headers.CorrelationId] = "corr-123"; + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("corr-123"); + context.Items[RequestContextConstants.ContextKeys.CorrelationId].ShouldBe("corr-123"); + } + + [Fact] + public async Task InvokeAsync_WhenHeaderMissing_UsesTraceIdentifierAsCorrelationId() + { + DefaultHttpContext context = CreateContext(); + context.TraceIdentifier = "trace-xyz"; + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("trace-xyz"); + context.Items[RequestContextConstants.ContextKeys.CorrelationId].ShouldBe("trace-xyz"); + } + + [Fact] + public async Task InvokeAsync_PopulatesTraceIdAndElapsedHeaders() + { + DefaultHttpContext context = CreateContext(); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Response.Headers[RequestContextConstants.Headers.TraceId] + .ToString() + .ShouldNotBeNullOrWhiteSpace(); + context + .Response.Headers[RequestContextConstants.Headers.ElapsedMs] + .ToString() + .ShouldNotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task InvokeAsync_TagsMetricsFeatureWithRestSurface() + { + DefaultHttpContext context = CreateContext(); + context.Request.Path = "/api/v1/products"; + FakeHttpMetricsTagsFeature metricsFeature = new(); + context.Features.Set(metricsFeature); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + metricsFeature.Tags.ShouldContain(tag => + tag.Key == TelemetryTagKeys.ApiSurface && (string?)tag.Value == TelemetrySurfaces.Rest + ); + } + + [Fact] + public async Task InvokeAsync_TagsMetricsFeatureWithAuthenticatedStatus() + { + DefaultHttpContext context = CreateContext(); + context.User = new ClaimsPrincipal( + new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, "user-1")], "TestAuth") + ); + FakeHttpMetricsTagsFeature metricsFeature = new(); + context.Features.Set(metricsFeature); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + metricsFeature + .Tags.Any(tag => tag.Key == TelemetryTagKeys.Authenticated && Equals(tag.Value, true)) + .ShouldBeTrue(); + } + + [Fact] + public async Task InvokeAsync_WithTenantClaim_StoresCorrelationAndDoesNotFail() + { + DefaultHttpContext context = CreateContext(); + context.TraceIdentifier = "trace-with-tenant"; + string tenantId = Guid.NewGuid().ToString(); + context.User = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(SharedAuthConstants.Claims.TenantId, tenantId)], + "TestAuth" + ) + ); + + RequestContextMiddleware sut = CreateSut(); + + await sut.InvokeAsync(context); + + context + .Items[RequestContextConstants.ContextKeys.CorrelationId] + .ShouldBe("trace-with-tenant"); + context + .Response.Headers[RequestContextConstants.Headers.CorrelationId] + .ToString() + .ShouldBe("trace-with-tenant"); + } + + private static RequestContextMiddleware CreateSut() => + new(async context => + { + await context.Response.WriteAsync("ok"); + }); + + private static DefaultHttpContext CreateContext() + { + DefaultHttpContext context = new(); + context.Response.Body = new MemoryStream(); + return context; + } + + private sealed class FakeHttpMetricsTagsFeature : IHttpMetricsTagsFeature + { + public bool MetricsDisabled { get; set; } + + public ICollection> Tags { get; } = + new List>(); + } +} diff --git a/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs new file mode 100644 index 00000000..ca21f833 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/CacheTelemetryTests.cs @@ -0,0 +1,27 @@ +using System.Diagnostics; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class CacheTelemetryTests +{ + [Fact] + public void StartOutputCacheInvalidationActivity_AddsCacheTag() + { + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == ObservabilityConventions.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + + using System.Diagnostics.Activity? activity = + CacheTelemetry.StartOutputCacheInvalidationActivity("Products"); + + activity.ShouldNotBeNull(); + activity.GetTagItem(TelemetryTagKeys.CacheTag).ShouldBe("Products"); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs b/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs new file mode 100644 index 00000000..06b290d5 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HealthCheckMetricsPublisherTests.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HealthCheckMetricsPublisherTests +{ + [Fact] + public async Task PublishAsync_StoresLatestHealthStatuses() + { + HealthCheckMetricsPublisher publisher = new(); + HealthReport report = new( + new Dictionary + { + ["postgresql"] = new(HealthStatus.Healthy, "ok", TimeSpan.Zero, null, null), + ["redis"] = new(HealthStatus.Unhealthy, "down", TimeSpan.Zero, null, null), + }, + TimeSpan.Zero + ); + + await publisher.PublishAsync(report, TestContext.Current.CancellationToken); + + IReadOnlyDictionary statuses = HealthCheckMetricsPublisher.SnapshotStatuses(); + statuses["postgresql"].ShouldBe(1); + statuses["redis"].ShouldBe(0); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs b/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..75b0bc6a --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HealthChecksServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using SharedKernel.Api.Extensions; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HealthChecksServiceCollectionExtensionsTests +{ + [Fact] + public void AddDragonflyHealthCheck_WhenConnectionStringMissing_DoesNotRegisterCheck() + { + ServiceCollection services = new(); + services.AddOptions(); + + services.AddHealthChecks().AddDragonflyHealthCheck(null); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.ShouldBeEmpty(); + } + + [Fact] + public void AddPostgreSqlHealthCheck_RegistersExpectedName() + { + ServiceCollection services = new(); + services.AddOptions(); + + services + .AddHealthChecks() + .AddPostgreSqlHealthCheck( + "Host=localhost;Database=app;Username=postgres;Password=postgres" + ); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.Select(x => x.Name).ShouldContain(HealthCheckNames.PostgreSql); + } + + [Fact] + public void AddSharedRabbitMqHealthCheck_RegistersExpectedName() + { + ServiceCollection services = new(); + services.AddOptions(); + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://localhost:5672", + } + ) + .Build(); + + services.AddSingleton(configuration); + services.AddHealthChecks().AddSharedRabbitMqHealthCheck(configuration); + + HealthCheckServiceOptions options = services + .BuildServiceProvider() + .GetRequiredService>() + .Value; + + options.Registrations.Select(x => x.Name).ShouldContain(HealthCheckNames.RabbitMq); + } +} diff --git a/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs b/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs new file mode 100644 index 00000000..e6db7d8f --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/HttpRouteResolverTests.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class HttpRouteResolverTests +{ + [Fact] + public void ReplaceVersionToken_WhenRouteContainsApiVersionConstraint_ReplacesWithConcreteVersion() + { + string resolvedRoute = HttpRouteResolver.ReplaceVersionToken( + "api/v{version:apiVersion}/products", + new RouteValueDictionary { ["version"] = "1" } + ); + + resolvedRoute.ShouldBe("api/v1/products"); + } + + [Fact] + public void ReplaceVersionToken_WhenVersionMissing_LeavesTemplateUnchanged() + { + string resolvedRoute = HttpRouteResolver.ReplaceVersionToken( + "api/v{version:apiVersion}/products", + new RouteValueDictionary() + ); + + resolvedRoute.ShouldBe("api/v{version:apiVersion}/products"); + } + + [Fact] + public void Resolve_WhenEndpointTemplateMissing_FallsBackToRequestPath() + { + DefaultHttpContext httpContext = new(); + httpContext.Request.Path = "/api/v1/products"; + + string resolvedRoute = HttpRouteResolver.Resolve(httpContext); + + resolvedRoute.ShouldBe("/api/v1/products"); + } +} diff --git a/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs b/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs new file mode 100644 index 00000000..0a33e3c1 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/ObservabilityExtensionsTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using SharedKernel.Api.Extensions; +using SharedKernel.Application.Options; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class ObservabilityExtensionsTests +{ + [Fact] + public void GetEnabledOtlpEndpoints_WhenDevelopmentOutsideContainer_DefaultsToAspire() + { + ObservabilityOptions options = new(); + + IReadOnlyList endpoints = ObservabilityExtensions.GetEnabledOtlpEndpoints( + options, + new FakeHostEnvironment(Environments.Development) + ); + + endpoints.ShouldContain(TelemetryDefaults.AspireOtlpEndpoint); + } + + [Fact] + public void GetEnabledOtlpEndpoints_WhenExplicitOtlpEnabled_IncludesConfiguredEndpoint() + { + ObservabilityOptions options = new() + { + Otlp = new OtlpEndpointOptions { Endpoint = "http://alloy:4317" }, + Exporters = new ObservabilityExportersOptions + { + Otlp = new ObservabilityExporterToggleOptions { Enabled = true }, + Aspire = new ObservabilityExporterToggleOptions { Enabled = false }, + }, + }; + + IReadOnlyList endpoints = ObservabilityExtensions.GetEnabledOtlpEndpoints( + options, + new FakeHostEnvironment(Environments.Production) + ); + + endpoints.ShouldBe(["http://alloy:4317"]); + } + + [Fact] + public void BuildResourceAttributes_ReturnsExpectedMetadata() + { + Dictionary attributes = ObservabilityExtensions.BuildResourceAttributes( + "identity", + new FakeHostEnvironment(Environments.Development) + ); + + attributes[TelemetryResourceAttributeKeys.ServiceName].ShouldBe("identity"); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ServiceVersion); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ServiceInstanceId); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.HostName); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.HostArchitecture); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.OsType); + attributes.ShouldContainKey(TelemetryResourceAttributeKeys.ProcessRuntimeVersion); + } + + [Fact] + public void GetObservabilityOptions_WhenMissingConfiguration_ReturnsDefaults() + { + IConfiguration configuration = new ConfigurationBuilder().Build(); + + ObservabilityOptions options = ObservabilityExtensions.GetObservabilityOptions( + configuration + ); + + options.ShouldNotBeNull(); + options.Otlp.ShouldNotBeNull(); + options.Exporters.ShouldNotBeNull(); + } + + private sealed class FakeHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = "SharedKernel.Tests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs b/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs new file mode 100644 index 00000000..ed308be2 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/RabbitMqHealthCheckTests.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SharedKernel.Messaging.Conventions; +using SharedKernel.Messaging.HealthChecks; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class RabbitMqHealthCheckTests +{ + [Fact] + public void ResolveConnectionString_UsesExplicitConnectionString() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://guest:guest@broker:5672", + ["RabbitMQ:HostName"] = "legacy-host", + } + ) + .Build(); + + string connectionString = RabbitMqConventionExtensions.ResolveConnectionString( + configuration + ); + + connectionString.ShouldBe("amqp://guest:guest@broker:5672"); + } + + [Fact] + public void ResolveConnectionString_WhenOnlyLegacyHostConfigured_UsesHostFallback() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary { ["RabbitMQ:HostName"] = "rabbitmq:5672" } + ) + .Build(); + + string connectionString = RabbitMqConventionExtensions.ResolveConnectionString( + configuration + ); + + connectionString.ShouldBe("amqp://rabbitmq:5672"); + } + + [Fact] + public async Task CheckHealthAsync_WhenBrokerUnavailable_ReturnsUnhealthy() + { + IConfiguration configuration = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["ConnectionStrings:RabbitMQ"] = "amqp://guest:guest@127.0.0.1:1", + } + ) + .Build(); + RabbitMqHealthCheck sut = new(configuration); + + HealthCheckResult result = await sut.CheckHealthAsync(new HealthCheckContext()); + + result.Status.ShouldBe(HealthStatus.Unhealthy); + } +} diff --git a/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs b/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs new file mode 100644 index 00000000..eee97de5 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/StartupTelemetryTests.cs @@ -0,0 +1,35 @@ +using System.Diagnostics; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class StartupTelemetryTests +{ + [Fact] + public void StartRelationalMigration_WhenFailed_SetsFailureTags() + { + using ActivityListener listener = new() + { + ShouldListenTo = source => source.Name == ObservabilityConventions.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(listener); + + using StartupTelemetry.Scope telemetry = StartupTelemetry.StartRelationalMigration(); + InvalidOperationException exception = new("boom"); + + telemetry.Fail(exception); + + Activity.Current.ShouldNotBeNull(); + Activity + .Current!.GetTagItem(TelemetryTagKeys.StartupStep) + .ShouldBe(TelemetryStartupSteps.Migrate); + Activity + .Current!.GetTagItem(TelemetryTagKeys.StartupComponent) + .ShouldBe(TelemetryStartupComponents.PostgreSql); + Activity.Current!.GetTagItem(TelemetryTagKeys.StartupSuccess).ShouldBe(false); + } +} diff --git a/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs b/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs new file mode 100644 index 00000000..5cfde200 --- /dev/null +++ b/tests/SharedKernel.Tests/Observability/TelemetryApiSurfaceResolverTests.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Http; +using SharedKernel.Infrastructure.Observability; +using Shouldly; +using Xunit; + +namespace SharedKernel.Tests.Observability; + +public sealed class TelemetryApiSurfaceResolverTests +{ + [Theory] + [InlineData("/graphql", TelemetrySurfaces.GraphQl)] + [InlineData("/graphql/ui", TelemetrySurfaces.GraphQl)] + [InlineData("/health", TelemetrySurfaces.Health)] + [InlineData("/openapi/v1/openapi.json", TelemetrySurfaces.Documentation)] + [InlineData("/scalar/v1", TelemetrySurfaces.Documentation)] + [InlineData("/api/v1/products", TelemetrySurfaces.Rest)] + [InlineData("/", TelemetrySurfaces.Rest)] + public void Resolve_ReturnsExpectedSurface(string path, string expected) + { + string actual = TelemetryApiSurfaceResolver.Resolve(new PathString(path)); + + actual.ShouldBe(expected); + } +}