diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index dc58aed..3349137 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -6,6 +6,16 @@ enable enable true + + $(WarningsNotAsErrors);LS0001 true latest - $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234 + $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234;CA1711;CA1822 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 3b950e5..061491d 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -54,22 +54,66 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + diff --git a/backend/LearnStack.slnx b/backend/LearnStack.slnx index d0f568e..f599854 100644 --- a/backend/LearnStack.slnx +++ b/backend/LearnStack.slnx @@ -6,8 +6,14 @@ + + + + + + diff --git a/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..a530e20 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases. +; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..8312234 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release. +; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------ +LS0001 | Design | Warning | Rule "LearnStackException-DomainExceptionThrow" (ADR-0032 § Sub-decision 4 + Amendment 1). DomainException is reserved for programmer errors; expected business-rule violations return Result.Fail(business_rule_violation, ...). Roslyn diagnostic ids must be valid identifiers, so the wire id is LS0001; the hyphenated string is the human-readable rule name. Severity escalates to Error after Phase 03 exit. diff --git a/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs b/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs new file mode 100644 index 0000000..86fedd3 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs @@ -0,0 +1,126 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace LearnStack.Analyzers; + +/// +/// Rule "LearnStackException-DomainExceptionThrow" (Roslyn diagnostic id +/// = LS0001) — flags every +/// throw new DomainException(...) in Domain / Application code per +/// ADR-0032 § Sub-decision 4. Expected business-rule violations return +/// Result.Fail(business_rule_violation, ...); the exception is +/// reserved for programmer errors. +/// +/// +/// +/// The Roslyn diagnostic id MUST be a valid identifier (letters/digits, no +/// hyphens) — Roslyn throws AD0001 at report time otherwise. The +/// human-readable rule name LearnStackException-DomainExceptionThrow +/// (ADR-0032 / Standards 21 naming convention) is carried in the title and +/// help text; the wire-level id is LS0001. See ADR-0032 Amendment 1. +/// +/// +/// Severity: in Phase 02a. Per +/// ADR-0032 the severity escalates to +/// after Phase 03 exit when every existing call site has been migrated. +/// Until then LS0001 is listed in WarningsNotAsErrors +/// (Directory.Build.props) so a legitimate aggregate-invariant throw does +/// not break CI under TreatWarningsAsErrors before the documented +/// escalation point. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DomainExceptionThrowAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "LS0001"; + + /// The human-readable rule name (ADR-0032 / Standards 21). + public const string RuleName = "LearnStackException-DomainExceptionThrow"; + + private static readonly LocalizableString Title = + "Avoid throwing DomainException for expected business-rule violations (LearnStackException-DomainExceptionThrow)"; + + private static readonly LocalizableString MessageFormat = + "DomainException is reserved for programmer errors. " + + "Return Result.Fail(business_rule_violation, ...) instead."; + + private static readonly LocalizableString Description = + "ADR-0032 § Sub-decision 4 reserves DomainException for aggregate-invariant " + + "violations that signal a programming mistake. Expected business-rule " + + "violations are an outcome — return Result.Fail(business_rule_violation, ...) " + + "from the domain method."; + + private static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: Title, + messageFormat: MessageFormat, + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description, + helpLinkUri: "https://github.com/cemililik/LearnStack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md"); + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) throw new System.ArgumentNullException(nameof(context)); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeThrowExpression, SyntaxKind.ThrowExpression); + context.RegisterSyntaxNodeAction(AnalyzeThrowStatement, SyntaxKind.ThrowStatement); + } + + private static void AnalyzeThrowStatement(SyntaxNodeAnalysisContext context) + { + var node = (ThrowStatementSyntax)context.Node; + if (node.Expression is null) return; + InspectThrown(context, node.Expression); + } + + private static void AnalyzeThrowExpression(SyntaxNodeAnalysisContext context) + { + var node = (ThrowExpressionSyntax)context.Node; + InspectThrown(context, node.Expression); + } + + private static void InspectThrown(SyntaxNodeAnalysisContext context, ExpressionSyntax expression) + { + if (expression is not ObjectCreationExpressionSyntax creation) + { + return; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(creation, context.CancellationToken); + var symbol = typeInfo.Type; + if (symbol is null) + { + return; + } + + if (!IsDomainException(symbol)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, creation.GetLocation())); + } + + private static bool IsDomainException(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current.Name == "DomainException" && + current.ContainingNamespace?.ToDisplayString() == "LearnStack.SharedKernel.Errors") + { + return true; + } + } + + return false; + } +} diff --git a/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj b/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj new file mode 100644 index 0000000..7fb4170 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj @@ -0,0 +1,32 @@ + + + + + netstandard2.0 + enable + latest + false + LearnStack.Analyzers + true + + + + + all + + + all + + + + + + + + + + diff --git a/backend/src/LearnStack.Api/Common/HttpStatusMap.cs b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs new file mode 100644 index 0000000..4e21986 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs @@ -0,0 +1,78 @@ +using System.Net; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Api.Common; + +/// +/// Maps an (or a +/// subclass) to the HTTP status the API surface returns. The table mirrors +/// Standards 09 +/// § Result Type; adding a new error code requires updating both +/// places so the contract stays in sync. +/// +public static class HttpStatusMap +{ + public static int For(Error error) + { + ArgumentNullException.ThrowIfNull(error); + return For(error.Code); + } + + public static int For(string code) => code switch + { + "validation_failed" => (int)HttpStatusCode.BadRequest, + "unsupported_locale" => (int)HttpStatusCode.BadRequest, + "unauthorized" => (int)HttpStatusCode.Unauthorized, + "forbidden" => (int)HttpStatusCode.Forbidden, + "resource_scope_violation" => (int)HttpStatusCode.Forbidden, + "feature_disabled" => (int)HttpStatusCode.Forbidden, + "not_found" => (int)HttpStatusCode.NotFound, + "tenant_mismatch" => (int)HttpStatusCode.NotFound, + "concurrency_conflict" => (int)HttpStatusCode.Conflict, + "business_rule_violation" => (int)HttpStatusCode.Conflict, + "recording_consent_required" => (int)HttpStatusCode.Conflict, + "rate_limited" => (int)HttpStatusCode.TooManyRequests, + "dependency_unavailable" => (int)HttpStatusCode.ServiceUnavailable, + _ => (int)HttpStatusCode.InternalServerError, + }; + + public static int For(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + return exception switch + { + // 499 "client closed request" is an Nginx convention, not an + // IETF code — Standards 09 § Result Type does not pin a status + // for client disconnects. We pick 499 (rather than 408 / 503 / + // 500) because: + // * IIS, Nginx, Envoy, and APISIX all emit 499 for pre- + // response client aborts; log dashboards and SLO calculators + // already treat it as "not our fault". + // * 408 implies a server-side timeout (we did not time out — + // the client left). + // * 5xx codes would inflate error-budget metrics and trip + // PagerDuty rotations for nothing. + // L1 handler skips both Sentry capture and the response body + // write for OperationCanceled per ADR-0032 § Sub-decision 7; + // the status is set here for parity with the upstream proxy's + // behaviour. If a future ADR pins a different code, change + // this line. + OperationCanceledException => 499, + + // Every LearnStackException carries a structured Error; the HTTP + // status is derived from that Error.Code so the response status + // and the Problem Details `code` field can NEVER disagree. In + // particular `ProviderException.IsClientError` is an + // observability concern (it gates Sentry capture in + // ShouldCapture), NOT an HTTP-status concern: a bare provider + // failure carries `dependency_unavailable` → 503, and an adapter + // that wants to surface a provider 4xx as a client-actionable + // status passes an explicit Error (e.g. validation_failed → 400). + // Deriving from the code keeps body+status consistent for both. + LearnStackException known => For(known.Error), + _ => (int)HttpStatusCode.InternalServerError, + }; + } +} diff --git a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs new file mode 100644 index 0000000..c637a03 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs @@ -0,0 +1,163 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Api.Common; + +/// +/// L1 exception handler — the single catch site at the HTTP boundary per +/// ADR-0032 § Sub-decision 1. Builds the Problem Details body, records the +/// span error, and dispatches to only +/// when returns true +/// (Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary). +/// +/// +/// internal sealed — modules do not (and per ADR-0032 § Sub-decision 1 +/// must not) instantiate it; the framework's +/// services.AddExceptionHandler<T>() is the only entry. Tests +/// reach the type through InternalsVisibleTo. +/// +internal sealed class LearnStackExceptionHandler( + IErrorTrackingProvider errorTracker, + ITenantContextAccessor tenantContextAccessor, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(exception); + + var problem = ProblemDetailsFactory.For(exception, httpContext); + var capture = ShouldCapture(exception); + var isProviderClientError = exception is ProviderException { IsClientError: true }; + var isCancellation = exception is OperationCanceledException; + + // Span semantics per Standards 09 § Sentry vs OpenTelemetry table: + // OperationCanceled → leave span Unset, no RecordException + // Provider 4xx → SetStatus(Error), no RecordException + // everything else → RecordException + SetStatus(Error) + // Activity.AddException is the .NET 9+ replacement for the legacy + // Activity.RecordException — the ADR's Implementation Notes still + // reference the older name; both add the same exception.* tags. + if (!isCancellation) + { + if (!isProviderClientError) + { + Activity.Current?.AddException(exception); + } + + Activity.Current?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name); + } + + if (capture) + { + // The error-tracking provider must never abort the L1 handler — + // it is the last line of defense and the Problem Details + // response has to be written even if Sentry / the local-file + // sink throws (network blip, full disk, mis-configuration). + // Swallow + log the capture failure; the response write below + // always runs. + try + { + var capturedContext = BuildCapturedContext(httpContext); + await errorTracker.CaptureAsync(exception, capturedContext, cancellationToken) + .ConfigureAwait(false); + LogCaptured(logger, exception.GetType().FullName ?? "", exception); + } +#pragma warning disable CA1031 // L1 must complete; provider failure cannot escape. + catch (Exception captureFailure) +#pragma warning restore CA1031 + { + LogCaptureFailed(logger, exception.GetType().FullName ?? "", captureFailure); + } + } + else + { + LogSkipped(logger, exception.GetType().FullName ?? "", null); + } + + // OperationCanceled means the client has already disconnected. The + // outbound flush would throw on the closed socket anyway and the + // body would not reach a reader. Set the status for completeness + // and skip the body. + httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError; + if (isCancellation || cancellationToken.IsCancellationRequested) + { + return true; + } + + httpContext.Response.ContentType = "application/problem+json"; + await httpContext.Response.WriteAsJsonAsync( + problem, + problem.GetType(), + options: null, + contentType: "application/problem+json", + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return true; + } + + /// + /// The Sentry / OTel boundary table (Standards 09 § Sentry vs + /// OpenTelemetry — Error Capture Boundary) reduced to a switch. Internal + /// for unit-test visibility via InternalsVisibleTo; the rule + /// itself is binding from ADR-0032 § Sub-decision 7. + /// + internal static bool ShouldCapture(Exception exception) => exception switch + { + OperationCanceledException => false, + ProviderException pex when pex.IsClientError => false, + _ => true, + }; + + private CapturedContext BuildCapturedContext(HttpContext httpContext) + { + var context = tenantContextAccessor.Current; + + // Prefer the full W3C traceparent (00-trace-span-flags) per the + // ITenantContext.CorrelationId contract — Activity.Current.Id + // carries it, whereas TraceId is only the 32-hex trace component. + // Fall through to the resolved context's CorrelationId, then the + // ASP.NET request id so a capture is never left without a handle + // that correlates to the client's Problem Details body. + var correlationId = Activity.Current?.Id + ?? context?.CorrelationId + ?? httpContext.TraceIdentifier; + + return new CapturedContext( + CorrelationId: correlationId, + RequestPath: httpContext.Request.Path.Value, + RequestMethod: httpContext.Request.Method, + TenantId: context?.IsResolved == true ? context.TenantId : null, + OrganizationId: context?.OrganizationId, + UserId: context?.UserId?.Value, + ModuleName: context?.ModuleName, + AdditionalTags: null); + } + + private static readonly Action LogCaptured = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogCaptured)), + "L1 exception handler captured {ExceptionType} to IErrorTrackingProvider."); + + private static readonly Action LogSkipped = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogSkipped)), + "L1 exception handler skipped Sentry capture for {ExceptionType} per Standards 09 boundary."); + + private static readonly Action LogCaptureFailed = + LoggerMessage.Define( + LogLevel.Error, + new EventId(3, nameof(LogCaptureFailed)), + "L1 exception handler failed to capture {ExceptionType} to IErrorTrackingProvider; Problem Details response still written."); +} diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs new file mode 100644 index 0000000..e82d1da --- /dev/null +++ b/backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs @@ -0,0 +1,39 @@ +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Common; + +/// +/// subtype that defers the +/// body construction until ASP.NET invokes +/// . The deferred build is what lets the +/// sanctioned controller shape per ADR-0032 § Sub-decision 6 +/// ((await _mediator.Send(cmd, ct)).ToActionResult()) populate +/// and the correlationId +/// extension from the current request without the caller having to thread +/// explicitly. +/// +public sealed class ProblemDetailsActionResult : ObjectResult +{ + public ProblemDetailsActionResult(Error error) + : base(value: null) + { + Error = error ?? throw new ArgumentNullException(nameof(error)); + StatusCode = HttpStatusMap.For(error); + } + + /// The carried failure — kept around for tests + extension points. + public Error Error { get; } + + public override Task ExecuteResultAsync(ActionContext context) + { + ArgumentNullException.ThrowIfNull(context); + + var problem = ProblemDetailsFactory.For(Error, context.HttpContext); + Value = problem; + StatusCode = problem.Status; + ContentTypes.Clear(); + ContentTypes.Add("application/problem+json"); + return base.ExecuteResultAsync(context); + } +} diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs new file mode 100644 index 0000000..0978712 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs @@ -0,0 +1,173 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Common; + +/// +/// Builds RFC 7807 bodies from the project's +/// / hierarchy. +/// Shape pinned by Standards 09 § API Surface — every API error response +/// carries code, messageKey, correlationId, optional +/// errors. +/// +/// +/// intentionally carries the +/// lockey_* localization key (matches the Standards 09 § API Surface +/// example). The frontend resolves the key against its i18n catalogue; the +/// wire value is stable across locales so support staff debugging in +/// Insomnia / curl can match the lockey back to the catalogue entry. A +/// future LocalizedMessage → text projector (Phase 02b, Accept-Language +/// binding) may compose a human-readable Title alongside. +/// +public static class ProblemDetailsFactory +{ + private const string ProblemTypePrefix = "https://errors.learnstack.dev/"; + + public static ProblemDetails For(Error error, HttpContext? context = null) + { + ArgumentNullException.ThrowIfNull(error); + + var problem = BuildBase(error.Code, error.Message.Key, HttpStatusMap.For(error.Code), context); + + if (error.Details is { Count: > 0 }) + { + problem.Extensions["errors"] = ProjectDetails(error.Details); + } + + return problem; + } + + public static ProblemDetails For(Exception exception, HttpContext? context = null) + { + ArgumentNullException.ThrowIfNull(exception); + + // Status MUST come from HttpStatusMap.For(Exception) so + // ProviderException(IsClientError = true) returns 400 instead of + // falling through to the carried Error.Code's 503 default. Code + + // messageKey still come from the carried Error so the wire shape + // stays consistent. Standards 09 § Provider Failures + ADR-0032 + // § Sub-decision 7. + var status = HttpStatusMap.For(exception); + + if (exception is LearnStackException known) + { + var problem = BuildBase(known.Error.Code, known.Error.Message.Key, status, context); + if (known.Error.Details is { Count: > 0 }) + { + problem.Extensions["errors"] = ProjectDetails(known.Error.Details); + } + return problem; + } + + // Unhandled / unknown — surface a stable generic shape. + return BuildBase( + code: "internal_error", + messageKey: "lockey_internal_error", + status: status, + context: context); + } + + private static ProblemDetails BuildBase(string code, string messageKey, int status, HttpContext? context) + { + var problem = new ProblemDetails + { + // Standards 09 § API Surface example uses the short slug + // (e.g. /validation, not /validation_failed). Strip the + // trailing _failed when present so the URL stays clean across + // codes; other codes ride through unchanged. + Type = ProblemTypePrefix + TrimFailedSuffix(code), + Title = messageKey, + Status = status, + Instance = context?.Request.Path.Value, + }; + + problem.Extensions["code"] = code; + problem.Extensions["messageKey"] = messageKey; + problem.Extensions["correlationId"] = ResolveCorrelationId(context); + return problem; + } + + private static string TrimFailedSuffix(string code) => + code.EndsWith("_failed", StringComparison.Ordinal) + ? code[..^"_failed".Length] + : code; + + private static string? ResolveCorrelationId(HttpContext? context) + { + // Activity.Current.Id is the full W3C traceparent + // (00-trace-span-flags) — matches the ITenantContext.CorrelationId + // contract and what the L1 handler tags Sentry / LocalFile captures + // with, so the Problem Details body and the captured error share one + // handle. TraceId alone is only the 32-hex trace component. + var traceParent = Activity.Current?.Id; + if (!string.IsNullOrWhiteSpace(traceParent)) + { + return traceParent; + } + + return context?.TraceIdentifier; + } + + private static Dictionary> ProjectDetails( + IReadOnlyDictionary> details) + { + // Two distinct source keys can normalize to the same camelCase key + // (e.g. "UserId" and "UserID" both project to "userId") — merge + // their messages instead of letting the later key win and silently + // drop the earlier one's entries. + var merged = new Dictionary>(StringComparer.Ordinal); + foreach (var (key, list) in details) + { + var camelKey = ToCamelCase(key); + if (!merged.TryGetValue(camelKey, out var messages)) + { + messages = []; + merged[camelKey] = messages; + } + + messages.AddRange(list.Select(m => (object)new + { + key = m.Key, + @params = m.Params, + })); + } + + return merged.ToDictionary( + kv => kv.Key, + kv => (IReadOnlyList)kv.Value, + StringComparer.Ordinal); + } + + /// + /// Projects FluentValidation property paths to the request's camelCase + /// shape per Standards 09 § Validation Errors. Handles nested paths + /// (Address.Streetaddress.street) and acronyms via + /// , which + /// lowercases only the leading run of uppercase letters + /// (URLValueurlValue). + /// + private static string ToCamelCase(string propertyPath) + { + if (string.IsNullOrEmpty(propertyPath)) + { + return propertyPath; + } + + if (!propertyPath.Contains('.', StringComparison.Ordinal)) + { + return System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(propertyPath); + } + + var segments = propertyPath.Split('.'); + for (var i = 0; i < segments.Length; i++) + { + segments[i] = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(segments[i]); + } + + return string.Join('.', segments); + } +} diff --git a/backend/src/LearnStack.Api/Common/ResultExtensions.cs b/backend/src/LearnStack.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..719f106 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/ResultExtensions.cs @@ -0,0 +1,49 @@ +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Common; + +/// +/// Maps a to an per +/// ADR-0032 § Sub-decision 6. The sanctioned shape — explicit at every +/// controller endpoint: +/// +/// +/// [HttpPost] +/// public async Task<IActionResult> Create(CreateCourseCommand cmd, CancellationToken ct) +/// => (await _mediator.Send(cmd, ct)).ToActionResult(); +/// +/// +/// +/// +/// No action filter, no MediatR ResultUnwrapBehavior, no implicit +/// conversion. Explicit beats magic. +/// +/// +/// The failure path returns a +/// that defers assembly until +/// ExecuteResultAsync; that's when ASP.NET hands us the +/// so instance and the +/// correlationId extension populate without the caller having to +/// thread the context through. +/// +/// +public static class ResultExtensions +{ + public static IActionResult ToActionResult(this Result result) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.IsSuccess) + { + return new OkObjectResult(result.Value); + } + + var error = result.Error + ?? throw new InvalidOperationException( + "Result.IsFailure but Error is null — Result.Fail enforces a non-null Error."); + + return new ProblemDetailsActionResult(error); + } +} diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs new file mode 100644 index 0000000..91697ef --- /dev/null +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -0,0 +1,219 @@ +using LearnStack.Api.Common; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.Infrastructure.Observability; +using LearnStack.Infrastructure.Observability.Serilog; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Secrets; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetry.Metrics; +using Serilog; + +namespace LearnStack.Api.Composition; + +/// +/// Composition-root extension that wires the entire ADR-0032 surface in one +/// disciplined pass — Serilog, OpenTelemetry, error tracking, +/// , MediatR pipeline, the singleton +/// , and the request-scoped +/// default. The wire-cross-cutting-foundation +/// skill is the long-form walk; this method is the binary. +/// +public static class CrossCuttingFoundationExtensions +{ + /// + /// Wires the cross-cutting foundation against the supplied + /// . The Serilog bootstrap runs first so + /// startup errors are captured; OpenTelemetry tracing + metrics binds + /// next; branches by + /// ; the MediatR pipeline registers the + /// eight canonical behaviors. + /// + public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( + this WebApplicationBuilder builder, + DeploymentMode deploymentMode, + params System.Reflection.Assembly[] mediatorHandlerAssemblies) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(mediatorHandlerAssemblies); + + // The Serilog OTLP sink + the OTel pipeline both need + // ITenantContextAccessor (via enrichers / processor). Register the + // observability services first so DI sees the accessor as a + // singleton before either pipeline builds. + builder.Services.AddLearnStackObservabilityServices(); + + // ISecretProvider socket lands now so non-Dev DSN / license-key reads + // route through one seam. SelectSecretProvider is the SINGLE site + // that picks the implementation per DeploymentMode — both the DI + // registration and the local AddLearnStackErrorTracking call read + // the same instance. Packet 5 extends SelectSecretProvider with the + // Dapr branch so adding DaprSecretProvider touches one line, not + // two. + var secretProvider = SelectSecretProvider(deploymentMode, builder.Configuration); + builder.Services.TryAddSingleton(secretProvider); + + WireSerilog(builder, deploymentMode); + WireOpenTelemetry(builder, deploymentMode); + + builder.Services.AddLearnStackErrorTracking( + secretProvider, builder.Configuration, deploymentMode); + + // Request-scoped ITenantContext default — Packet 7 swaps this for the + // resolved instance produced by TenantResolverMiddleware. The + // singleton ITenantContextAccessor is set in + // AddLearnStackObservabilityServices above. + builder.Services.TryAddScoped(_ => UnresolvedTenantContext.Instance); + + builder.Services.AddProblemDetails(); + builder.Services.AddExceptionHandler(); + + builder.Services.AddLearnStackMediatRPipeline(mediatorHandlerAssemblies); + + return builder; + } + + private static void WireSerilog(WebApplicationBuilder builder, DeploymentMode deploymentMode) + { + builder.Host.UseSerilog((ctx, services, cfg) => + { + cfg.ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .Enrich.WithProperty("service.name", "learnstack-api") + // ADR-0032 § Sub-decision 8: the correlation-context enricher + // copies tenant.id / organization.id / user.id / module / + // correlation.id from the singleton ITenantContextAccessor + // onto every LogEvent. The redaction enricher then strips + // sensitive properties before the formatter touches them + // (Standards 11 § Sensitive Data Exposure). Both are resolved + // from DI (registered as singletons in + // AddLearnStackObservabilityServices) so the instances in the + // pipeline are the registered ones — no parallel new()'d copy. + .Enrich.With(services.GetRequiredService()) + .Enrich.With(services.GetRequiredService()) + .WriteTo.Console(new Serilog.Formatting.Compact.RenderedCompactJsonFormatter()); + + // Air-gapped never egresses logs over the network (Standards 20 + // § Composition Root and Deployment Mode). The console sink above + // is the operator's local capture; the OTLP sink is wired only + // for the network-capable modes. + // TODO(2026-05-22, @platform): Phase 11 ops — add a Serilog file + // sink under /var/learnstack/otel/ for SelfHostedAirGapped (the + // contract target in Standards 20). Deferred pending the + // file-target package decision. + var otlpEndpoint = ctx.Configuration["Telemetry:OtlpEndpoint"]; + if (deploymentMode != DeploymentMode.SelfHostedAirGapped + && !string.IsNullOrWhiteSpace(otlpEndpoint)) + { + cfg.WriteTo.OpenTelemetry(o => + { + o.Endpoint = otlpEndpoint; + o.Protocol = Serilog.Sinks.OpenTelemetry.OtlpProtocol.Grpc; + o.ResourceAttributes = new Dictionary(StringComparer.Ordinal) + { + ["service.name"] = "learnstack-api", + }; + }); + } + // The OTel LoggerProvider (AddOpenTelemetry().WithLogging()) is + // intentionally NOT registered alongside; double-export would + // duplicate every log line. ADR-0032 § Sub-decision 8. + }); + } + + private static void WireOpenTelemetry(WebApplicationBuilder builder, DeploymentMode deploymentMode) + { + var serviceName = builder.Configuration["Telemetry:Service:Name"] ?? "learnstack-api"; + var serviceVersion = builder.Configuration["Telemetry:Service:Version"] ?? "0.0.0-dev"; + var otlpEndpoint = builder.Configuration["Telemetry:OtlpEndpoint"]; + + // Air-gapped must not phone home to a network collector (Standards 20 + // § Composition Root and Deployment Mode). The exporter is wired only + // for the network-capable modes; air-gapped relies on the local + // capture path. The source / meter filters use the documented + // lowercase convention (learnstack.) so they match the manual + // ActivitySource / Meter names without depending on case-insensitive + // wildcard matching. + // TODO(2026-05-22, @platform): Phase 11 ops — add an OTLP file + // exporter under /var/learnstack/otel/ for SelfHostedAirGapped (the + // Standards 20 contract target). Deferred pending the file-exporter + // package decision; until then air-gapped traces/metrics stay in-process. + var exportOverNetwork = deploymentMode != DeploymentMode.SelfHostedAirGapped + && !string.IsNullOrWhiteSpace(otlpEndpoint); + + var otel = builder.Services + .AddOpenTelemetry() + .ConfigureResource(r => r.AddService( + serviceName: serviceName, + serviceVersion: serviceVersion)) + .WithTracing(t => + { + t.AddAspNetCoreInstrumentation(); + t.AddHttpClientInstrumentation(); + t.AddEntityFrameworkCoreInstrumentation(); + t.AddSource("learnstack.*"); + t.AddProcessor(); + if (exportOverNetwork) + { + t.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint!)); + } + }) + .WithMetrics(m => + { + m.AddAspNetCoreInstrumentation(); + m.AddHttpClientInstrumentation(); + m.AddMeter("learnstack.*"); + if (exportOverNetwork) + { + m.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint!)); + } + }); + + _ = otel; + } + + /// + /// Single composition-root site that picks the + /// implementation per + /// . Packet 5 extends this method with the + /// Dapr branch so the swap touches one line, not two. Both the DI + /// registration and the local AddLearnStackErrorTracking call + /// read the same instance returned here. + /// + /// + /// CA1859 (prefer concrete return type for perf) is suppressed + /// deliberately: the interface return is the entire point of the + /// helper — Packet 5 returns DaprSecretProvider for some + /// modes, and the call site must not bind to a concrete type. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "Return type is intentionally ISecretProvider so Packet 5 can swap implementations per DeploymentMode.")] + private static ISecretProvider SelectSecretProvider( + DeploymentMode deploymentMode, + IConfiguration configuration) + { + // TODO(2026-05-21, @platform): Phase 02a Packet 5 — light up the + // Dapr-backed branch. + // DeploymentMode.SaaS / Dedicated / SelfHostedOnline → + // new DaprSecretProvider(...) // Vault-backed + // DeploymentMode.SelfHostedAirGapped → + // new FileSecretProvider(...) // disk-backed + // DeploymentMode.Development → + // keep ConfigurationSecretProvider (delegates to IConfiguration). + // The signature stays the same so AddLearnStackErrorTracking's + // ISecretProvider argument resolves correctly across modes. + _ = deploymentMode; + return new ConfigurationSecretProvider(configuration); + } +} diff --git a/backend/src/LearnStack.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index 573b0a8..f0c4fff 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -11,6 +11,9 @@ + + + @@ -31,6 +34,20 @@ + + + + + + + + + + + diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 2beb310..ce90ab7 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -1,20 +1,25 @@ -// TODO(2026-05-19, @platform, phase-02a): wire OpenTelemetry — traces + -// metrics + logs via AddOpenTelemetry(); the OpenTelemetry.* packages are -// already reserved in Directory.Packages.props. LearnStack.Tests.Contract -// should then assert /openapi/v1.json advertises the correlation-id header. -// -// TODO(2026-05-19, @platform, phase-02a): revisit appsettings.Development.json -// EF Core logging level — currently `Information` logs every SQL statement -// including parameter values. Once handlers land and parameters may carry PII, -// drop to `Warning` and route SQL traces through OpenTelemetry instead -// (Standards 11 § Logging Hygiene). +using LearnStack.Api.Composition; +using LearnStack.SharedKernel.Hosting; var builder = WebApplication.CreateBuilder(args); +// Resolve the deployment mode once at the composition root. Modules never +// read DeploymentMode (architecture test +// Modules_Do_Not_Reference_DeploymentMode enforces it); the value selects +// the right error tracker, OTLP exporter target, and (later packets) the +// right Dapr / entitlement / host-resolver implementations per +// docs/standards/20-infrastructure-stack.md § Composition Root. +var deploymentMode = builder.Configuration.GetValue("Deployment:Mode", DeploymentMode.Development); + +builder.AddLearnStackCrossCuttingFoundation(deploymentMode); + builder.Services.AddOpenApi(); +builder.Services.AddControllers(); var app = builder.Build(); +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -23,6 +28,8 @@ app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })) .WithName("HealthCheck"); +app.MapControllers(); + app.Run(); // `public partial class Program` is the top-level-statements escape hatch diff --git a/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..8e38b67 --- /dev/null +++ b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +// LearnStackExceptionHandler.ShouldCapture is internal so tests can hold +// it to the Sentry-vs-OTel boundary contract without exposing the rule +// to module code. +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] +[assembly: InternalsVisibleTo("LearnStack.Tests.Architecture")] diff --git a/backend/src/LearnStack.Api/appsettings.json b/backend/src/LearnStack.Api/appsettings.json index 10f68b8..10c3a60 100644 --- a/backend/src/LearnStack.Api/appsettings.json +++ b/backend/src/LearnStack.Api/appsettings.json @@ -5,5 +5,47 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Deployment": { + "Mode": "Development" + }, + "Telemetry": { + "OtlpEndpoint": "http://otel-collector:4317", + "Service": { + "Name": "learnstack-api", + "Version": "0.0.0-dev" + } + }, + "ErrorTracking": { + "Sentry": { + "Dsn": "", + "Environment": "development", + "TracesSampleRate": 0.1 + }, + "LocalFile": { + "Directory": "/var/learnstack/errors/" + } + }, + "Resilience": { + "liveclass": { + "Retry": { "MaxAttempts": 3, "DelaySeconds": 1, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 30 }, + "Timeout": { "TotalSeconds": 10 } + }, + "payment": { + "Retry": { "MaxAttempts": 3, "DelaySeconds": 1, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 60 }, + "Timeout": { "TotalSeconds": 15 } + }, + "storage": { + "Retry": { "MaxAttempts": 5, "DelaySeconds": 0.5, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 30 }, + "Timeout": { "TotalSeconds": 30 } + }, + "search": { + "Retry": { "MaxAttempts": 2, "DelaySeconds": 0.25, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 15 }, + "Timeout": { "TotalSeconds": 5 } + } + } } diff --git a/backend/src/LearnStack.Application/LearnStack.Application.csproj b/backend/src/LearnStack.Application/LearnStack.Application.csproj index 1c6fe44..10a1017 100644 --- a/backend/src/LearnStack.Application/LearnStack.Application.csproj +++ b/backend/src/LearnStack.Application/LearnStack.Application.csproj @@ -8,11 +8,22 @@ + + + + + + diff --git a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs new file mode 100644 index 0000000..3752842 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs @@ -0,0 +1,90 @@ +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 3 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Per +/// ADR-0016 +/// this behavior wraps the inner pipeline with try / catch, writes a +/// failure-class audit entry on exception, and rethrows via +/// to preserve the original stack trace. +/// The L1 IExceptionHandler is the final catch site below the +/// framework. +/// +/// +/// +/// Phase 02a Packet 3 ships the shell: the catch / rethrow +/// contract is wired, but the audit-write path is a no-op until Packet 9 +/// lights up LearnStack.Infrastructure.Audit (per the Phase 02a +/// roadmap). The shell preserves two guarantees that Packet 9 cannot retrofit +/// without churn: +/// +/// +/// Exception rethrow uses — +/// handlers and the L1 boundary see the original stack. +/// Pipeline order: AuditLog wraps TenantContext + Authorization + +/// Transaction + OutboxFlush + Handler. The architecture test +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts the +/// wrap order; a Packet 9 swap must not change it. +/// +/// +/// When Packet 9 lights up IAuditStore + IAuditStateCapture, +/// this class moves to LearnStack.Infrastructure.Audit with the same +/// shape — only the no-op TODOs flip to real writes. +/// +/// +public sealed class AuditLogBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + try + { + var response = await next().ConfigureAwait(false); + + // TODO(2026-05-21, @platform): Phase 02a Packet 9 — on success, + // resolve IAuditStateCapture for the request type and write the + // success-class audit entry through IAuditStore. Per ADR-0016 + + // Standards 18 audit-coverage matrix. The shell shape here keeps + // the pipeline contract intact until the audit infrastructure + // lands. + + return response; + } +#pragma warning disable CA1031 // Do not catch general exception types — ADR-0016 binds the audit-then-rethrow contract here. + // Cancellation = client disconnect = noise per Standards 09 § + // Sentry vs OpenTelemetry table; the L1 handler already swallows + // it, and an audit entry for "user pressed Stop" is not useful. + // Skip the catch and rethrow naturally. + catch (Exception ex) when (ex is not OperationCanceledException) +#pragma warning restore CA1031 + { + // TODO(2026-05-21, @platform): Phase 02a Packet 9 — write the + // failure-class audit entry to audit_log via IAuditStore. The + // shell logs the audit-intent so we do not silently lose the + // failure visibility while Packet 9 is pending. + LogAuditIntent(logger, typeof(TRequest).Name, ex); + + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; // unreachable; the line above is the rethrow. + } + } + + private static readonly Action LogAuditIntent = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogAuditIntent)), + "AuditLogBehavior shell captured exception during {RequestName}; audit-write deferred until Packet 9 lights up IAuditStore."); +} diff --git a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs new file mode 100644 index 0000000..8547bfa --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs @@ -0,0 +1,41 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 5 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Calls +/// IAuthorizationService.AuthorizeAsync against the command's +/// resource; on deny returns Result.Fail(forbidden) rather than +/// throwing. +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// permission catalogue yet (it lands together with the Identity module in +/// Phase 03 + Standards 19). The shell passes every request through and +/// preserves the pipeline-order contract. When the permission catalogue +/// arrives the shell flips to consuming IAuthorizationService and +/// per-request [Authorize] metadata; the registration order does not +/// change. +/// +public sealed class AuthorizationBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform): Phase 03 — resolve the request's + // [Authorize(Policy)] attribute, call IAuthorizationService.AuthorizeAsync + // with the tenant + organization-scoped resource, and return + // Result.FailFor(forbidden) on deny. + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs new file mode 100644 index 0000000..eae817f --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 2 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Opens an +/// carrying the eight correlation fields (Standards 10 § Correlation), +/// starts a manual named mediatr.<RequestName> +/// on the learnstack.mediatr , and +/// measures the handler latency for downstream histogram reporting. +/// +/// +/// +/// The actual metric histogram is wired into the OpenTelemetry meter at the +/// composition root; this behavior simply records start / stop on a +/// per-invocation and attaches the elapsed +/// milliseconds to the log scope so the metric pipeline can pick it up. +/// +/// +/// The ActivitySource name is "learnstack.mediatr"; per-module +/// manual spans use their own source (e.g. "learnstack.education") so +/// trace consumers can filter independently. +/// +/// +public sealed class LoggingBehavior( + ILogger> logger, + ITenantContextAccessor tenantContextAccessor) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private static readonly ActivitySource ActivitySource = new("learnstack.mediatr"); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var requestName = typeof(TRequest).Name; + var context = tenantContextAccessor.Current; + + using var activity = ActivitySource.StartActivity( + $"mediatr.{requestName}", + ActivityKind.Internal); + + using var scope = logger.BeginScope(BuildScope(context, requestName)); + + var stopwatch = Stopwatch.StartNew(); + try + { + var response = await next().ConfigureAwait(false); + stopwatch.Stop(); + + if (response.IsSuccess) + { + LogSuccess(logger, requestName, stopwatch.ElapsedMilliseconds, null); + } + else + { + LogFailure(logger, requestName, response.Error?.Code ?? "", stopwatch.ElapsedMilliseconds, null); + } + + return response; + } + catch + { + stopwatch.Stop(); + // Re-throw to preserve the catch / audit / rethrow contract carried + // by AuditLogBehavior (step 3). LoggingBehavior is intentionally + // silent on exception so AuditLogBehavior owns the failure-audit + // path; the L1 handler logs the exception once at the boundary. + throw; + } + } + + private static Dictionary BuildScope(ITenantContext? context, string requestName) + { + return new Dictionary(StringComparer.Ordinal) + { + ["RequestName"] = requestName, + ["TenantId"] = context?.IsResolved == true ? context.TenantId : null, + ["OrganizationId"] = context?.OrganizationId, + ["UserId"] = context?.UserId?.Value, + ["CorrelationId"] = context?.CorrelationId, + ["Module"] = context?.ModuleName, + }; + } + + // LoggerMessage source-generated delegates (CA1848) — keep the format + // strings identical to the inlined-string version they replaced. + private static readonly Action LogSuccess = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(LogSuccess)), + "MediatR request {RequestName} completed successfully in {ElapsedMilliseconds} ms"); + + private static readonly Action LogFailure = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogFailure)), + "MediatR request {RequestName} returned Result.Fail({ErrorCode}) in {ElapsedMilliseconds} ms"); +} diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs new file mode 100644 index 0000000..f40e6a9 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -0,0 +1,69 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Application.Pipeline; + +/// +/// Composition-root extension that registers the canonical eight-step MediatR +/// pipeline (ADR-0032 § Sub-decision 2). Outermost (validation) first, +/// innermost (handler) last; the architecture test +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts the DI +/// registration order at startup. +/// +public static class MediatRPipelineRegistration +{ + /// + /// The 7 pipeline behaviors in canonical order. ADR-0032 + /// § Sub-decision 2 describes the chain as "eight steps" — these 7 + /// behaviors plus the handler at the innermost position make up that + /// sequence. MediatR resolves the handler after the behavior chain + /// unwinds; it is not registered here. Architecture tests reflect on + /// this list to assert the runtime DI order matches the contract; + /// **do not** reorder without amending ADR-0032. + /// + public static IReadOnlyList CanonicalBehaviorOrder { get; } = + [ + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(TenantContextBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + // Step 8 (the handler) is resolved by MediatR itself. + ]; + + /// + /// Registers the eight-step MediatR pipeline against the supplied + /// . The handler types themselves are scanned + /// from (typically each module's + /// AssemblyMarker assembly). + /// + public static IServiceCollection AddLearnStackMediatRPipeline( + this IServiceCollection services, + params System.Reflection.Assembly[] handlerAssemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(handlerAssemblies); + + // MediatR 12.x throws if no assemblies are registered for handler + // scanning. Fall back to the LearnStack.Application assembly itself — + // it carries no handlers in Phase 02a, but the behaviors below register + // through AddBehavior directly and do not depend on scanning. + var assembliesToScan = handlerAssemblies.Length > 0 + ? handlerAssemblies + : [typeof(AssemblyMarker).Assembly]; + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblies(assembliesToScan); + + foreach (var behaviorType in CanonicalBehaviorOrder) + { + cfg.AddBehavior(typeof(IPipelineBehavior<,>), behaviorType); + } + }); + + return services; + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs new file mode 100644 index 0000000..db873c0 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -0,0 +1,38 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 7 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Enrols IOutbox messages in the +/// current unit-of-work transaction; the outbox processor publishes them +/// via IEventBus on commit (see +/// 15-event-and-outbox.md). +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// IOutbox contract yet (it lands in Phase 02b). The shell delegates +/// to the inner pipeline so the order is correct now; Phase 02b lights up +/// the enrolment without changing the eight-step registration. +/// +public sealed class OutboxFlushBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform): Phase 02b — on a success-Result, flush + // IOutbox messages collected during the handler into outbox_messages + // via the unit-of-work seam so Dapr pub/sub dispatches them after + // commit. Per ADR-0006 + ADR-0014 + ADR-0032 § Sub-decision 12. + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs new file mode 100644 index 0000000..3ba3a1a --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -0,0 +1,73 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 4 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Asserts that the upstream resolution stage +/// populated ; when it has not, short-circuits +/// the request with Result.Fail(tenant_mismatch). The PostgreSQL RLS +/// session-variable wiring (app.tenant_id / app.organization_id +/// via a DbConnectionInterceptor) lights up in Packet 7 when the +/// resolver middleware lands. +/// +/// +/// Phase 02a Packet 3 ships the assertion shell. Until +/// Packet 7 lands the resolver middleware every request runs against +/// ; this behavior surfaces the fact +/// loudly so no handler reads an unresolved context by accident. Packet 7 +/// flips the default registration to the real resolver and adds the RLS +/// interceptor line below the assertion. +/// +public sealed class TenantContextBehavior( + ITenantContext tenantContext) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private static readonly Error TenantMismatchError = new( + new LocalizedMessage("lockey_tenant_mismatch")); + + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (!tenantContext.IsResolved && !AllowsUnresolvedContext(typeof(TRequest))) + { + return Task.FromResult(Result.FailFor(TenantMismatchError)); + } + + // TODO(2026-05-21, @platform): Phase 02a Packet 7 — set the PostgreSQL + // RLS GUCs via a DbConnectionInterceptor (transaction-local + // set_config('app.tenant_id', ..., true) / + // set_config('app.organization_id', ..., true)). The interceptor + // lands together with the tenant-owned schema + RLS policies. + + return next(); + } + + /// + /// Opt-in escape hatch for commands that are explicitly platform-wide + /// (e.g. tenant provisioning). The default is "no exceptions"; opt-in + /// arrives in Packet 7 alongside the EnterPlatformAdminScope(reason) + /// surface. Until then the predicate is a stub returning false — + /// every request needs a resolved context to proceed. + /// + /// + /// TODO(2026-05-21, @platform): Phase 02a Packet 7 — replace the stub + /// with a real discriminator. The intended seam is a marker attribute + /// ([AllowsUnresolvedTenantContext]) the predicate scans for + /// via reflection, paired with an architecture test that asserts the + /// attribute lives only on the narrow command-set that legitimately + /// runs before any tenant is resolved (e.g. ProvisionTenantCommand, + /// EnterPlatformAdminScopeCommand). Documenting the seam now + /// so Packet 7 doesn't reinvent it. + /// + private static bool AllowsUnresolvedContext(Type requestType) => false; +} diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs new file mode 100644 index 0000000..e878038 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -0,0 +1,42 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 6 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Opens a unit-of-work transaction; commits on +/// a success-Result and rolls back on a fail-Result or any +/// exception that bubbles through. Validation- and authorization-failed +/// requests short-circuit upstream and never open a transaction. +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// per-module DbContext yet (those land starting in Packet 6 + +/// Phase 03). The shell just delegates to the inner pipeline so the +/// canonical eight-step order can be wired now; Packet 6 swaps the body for +/// the real DbContext.Database.BeginTransactionAsync() + +/// commit-on-success-Result / rollback-on-failure pattern without changing +/// registration order. +/// +public sealed class TransactionBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform): Phase 02a Packet 6 — open the UoW + // transaction (per-module DbContext.Database.BeginTransactionAsync), + // commit on success-Result, rollback on fail-Result, and rollback + + // rethrow on exception (preserving the rethrow that AuditLogBehavior + // owns one frame out). + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs new file mode 100644 index 0000000..acc2800 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs @@ -0,0 +1,104 @@ +using FluentValidation; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 1 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Aggregates FluentValidation failures into +/// and returns +/// Result.FailFor<TResponse>(validation_failed, …); never +/// throws . The +/// ValidationBehavior_DoesNotThrow_ValidationException architecture +/// test enforces this end-to-end. +/// +/// +/// +/// The constraint +/// lets the behavior construct the concrete Result<T> shape via +/// without referencing the value type +/// (ADR-0032 § Sub-decision 3). +/// +/// +/// Field names are kept in their PascalCase property form here; the API +/// boundary (ProblemDetailsFactory) lower-cases them on the way out +/// per Standards 09 § Validation Errors. +/// +/// +public sealed class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private const string ValidationFailedKey = "lockey_validation_failed"; + + private readonly IValidator[] _validators = validators.ToArray(); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (_validators.Length == 0) + { + return await next().ConfigureAwait(false); + } + + var context = new ValidationContext(request); + var failures = new List(); + + foreach (var validator in _validators) + { + var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + if (!result.IsValid) + { + failures.AddRange(result.Errors); + } + } + + if (failures.Count == 0) + { + return await next().ConfigureAwait(false); + } + + var details = failures + .GroupBy(f => f.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(f => new LocalizedMessage(NormaliseKey(f.ErrorCode ?? f.ErrorMessage))) + .ToArray(), + StringComparer.Ordinal); + + var error = new Error( + new LocalizedMessage(ValidationFailedKey), + details); + + return Result.FailFor(error); + } + + /// + /// FluentValidation error codes are typically rule names ("NotEmpty", + /// "EmailValidator"); the API contract requires the + /// . If the validator's + /// ErrorCode already starts with lockey_ we trust it; + /// otherwise we coerce by lower-casing and prefixing so the constructor + /// invariant holds. + /// + private static string NormaliseKey(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return "lockey_validation_failed"; + } + + return raw.StartsWith(LocalizedMessage.RequiredPrefix, StringComparison.Ordinal) + ? raw + : LocalizedMessage.RequiredPrefix + raw.ToLowerInvariant(); + } +} diff --git a/backend/src/LearnStack.Domain/LearnStack.Domain.csproj b/backend/src/LearnStack.Domain/LearnStack.Domain.csproj index 760d371..0bb1b8e 100644 --- a/backend/src/LearnStack.Domain/LearnStack.Domain.csproj +++ b/backend/src/LearnStack.Domain/LearnStack.Domain.csproj @@ -6,6 +6,17 @@ + + + diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs new file mode 100644 index 0000000..bd3bcd0 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace LearnStack.Infrastructure.ErrorTracking; + +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs new file mode 100644 index 0000000..a65f5a2 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs @@ -0,0 +1,29 @@ +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// Configuration bound from ErrorTracking: in appsettings.json. +/// Per ADR-0032 § Sub-decision 9 the DSN itself comes from +/// ISecretProvider in non-Dev modes; the composition root reads the +/// secret and overwrites before +/// SentrySdk.Init. +/// +public sealed class ErrorTrackingOptions +{ + public const string SectionName = "ErrorTracking"; + + public SentrySettings Sentry { get; set; } = new(); + + public LocalFileOptions LocalFile { get; set; } = new(); +} + +public sealed class SentrySettings +{ + public string? Dsn { get; set; } + public string? Environment { get; set; } + public double TracesSampleRate { get; set; } = 0.1; +} + +public sealed class LocalFileOptions +{ + public string Directory { get; set; } = "/var/learnstack/errors/"; +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs new file mode 100644 index 0000000..82efb29 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs @@ -0,0 +1,112 @@ +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Sentry; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// Composition-root extension that branches on +/// to pick the right implementation per +/// ADR-0032 § Sub-decision 9. Sentry SDK initialisation happens here too — +/// modules never call SentrySdk.Init directly. +/// +/// +/// Sentry DSN reads through per ADR-0032 +/// § Sub-decision 9. Phase 02a Packet 3 ships the +/// ConfigurationSecretProvider default — Vault-equipped deployments +/// pick up the Dapr-backed implementation in Packet 5 without changing +/// this code path. +/// +public static class ErrorTrackingRegistration +{ + public static IServiceCollection AddLearnStackErrorTracking( + this IServiceCollection services, + ISecretProvider secretProvider, + IConfiguration configuration, + DeploymentMode deploymentMode) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(secretProvider); + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure( + configuration.GetSection(ErrorTrackingOptions.SectionName)); + + var options = configuration.GetSection(ErrorTrackingOptions.SectionName) + .Get() ?? new ErrorTrackingOptions(); + + // ADR-0032 § Sub-decision 9 binds DSN lookup to the secret provider. + // ConfigurationSecretProvider falls through to IConfiguration so + // dev / CI keep working with appsettings or env vars; Packet 5's + // DaprSecretProvider reads from Vault in production. + var resolvedDsn = secretProvider.GetSecret("ErrorTracking:Sentry:Dsn"); + + switch (deploymentMode) + { + case DeploymentMode.Development: + services.AddSingleton(); + break; + + case DeploymentMode.SaaS: + case DeploymentMode.Dedicated: + InitSentry(resolvedDsn, options.Sentry); + services.AddSingleton(); + break; + + case DeploymentMode.SelfHostedOnline: + if (!string.IsNullOrWhiteSpace(resolvedDsn)) + { + InitSentry(resolvedDsn, options.Sentry); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + break; + + case DeploymentMode.SelfHostedAirGapped: + services.AddSingleton(sp => + new LocalFileErrorTracker( + options.LocalFile.Directory, + sp.GetRequiredService>())); + break; + + default: + throw new System.Diagnostics.UnreachableException( + $"Unhandled DeploymentMode '{deploymentMode}' in error-tracking composition."); + } + + return services; + } + + private static void InitSentry(string? dsn, SentrySettings options) + { + if (string.IsNullOrWhiteSpace(dsn)) + { + throw new InvalidOperationException( + "DeploymentMode requires a Sentry DSN but ISecretProvider returned null for " + + "'ErrorTracking:Sentry:Dsn'. Provide the DSN via the secret provider — Vault " + + "in production, env var or appsettings in dev (the ConfigurationSecretProvider " + + "fall-through). Per ADR-0032 § Sub-decision 9."); + } + + // Sentry's TracesSampleRate setter throws when the value is outside + // [0, 1]. A mis-typed appsettings value (e.g. 1.5) would otherwise + // crash startup with a cryptic Sentry error; clamp defensively so + // the misconfiguration degrades to "sample everything / nothing" + // rather than a boot failure. + var sampleRate = Math.Clamp(options.TracesSampleRate, 0.0, 1.0); + + SentrySdk.Init(o => + { + o.Dsn = dsn; + o.Environment = options.Environment; + o.TracesSampleRate = sampleRate; + }); + } +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj b/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj new file mode 100644 index 0000000..90c62f4 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj @@ -0,0 +1,23 @@ + + + + LearnStack.Infrastructure.ErrorTracking + + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs new file mode 100644 index 0000000..e16c281 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs @@ -0,0 +1,167 @@ +using System.Text.Json; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that writes each +/// captured exception as a JSON envelope under the configured directory. +/// Selected by the composition root when +/// DeploymentMode.SelfHostedAirGapped — the runbook explains how to +/// ship those files off-network later if the customer ever wants to. +/// +/// +/// File names use a sortable timestamp + the trace id when present, so +/// operators see new captures at the bottom of the directory listing. +/// Failures of the write itself (full disk, permissions) are logged and +/// swallowed; air-gapped capture is best-effort by definition. +/// +internal sealed class LocalFileErrorTracker : IErrorTrackingProvider +{ + /// + /// W3C traceparent is 55 chars; defensive cap above that absorbs any + /// inbound oddity without blowing the stack via stackalloc. + /// + private const int MaxFileNameSegmentLength = 128; + + // AdditionalTags redaction reads from the SharedKernel-side + // SensitiveTokenCatalog so the Serilog enricher and this air-gapped + // capture path stay in sync. Adding a token there lights up both + // surfaces together. + private const string RedactedValue = SensitiveTokenCatalog.RedactedValue; + + private readonly string _directory; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + }; + + public LocalFileErrorTracker(string directory, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentNullException.ThrowIfNull(logger); + + _directory = directory; + _logger = logger; + Directory.CreateDirectory(_directory); + } + + public async ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + var envelope = new + { + timestamp = DateTimeOffset.UtcNow, + exception = new + { + type = exception.GetType().FullName, + message = exception.Message, + stackTrace = exception.StackTrace, + inner = exception.InnerException?.GetType().FullName, + }, + context.CorrelationId, + context.RequestPath, + context.RequestMethod, + context.TenantId, + context.OrganizationId, + context.UserId, + context.ModuleName, + additionalTags = RedactSensitiveTags(context.AdditionalTags), + }; + + var safeCorrelation = SanitiseForFileName(context.CorrelationId); + // Guid suffix guarantees uniqueness — millisecond precision + + // correlation id are not enough when two captures land in the same + // tick for the same trace (a multi-failure burst on a single + // request). + var fileName = + $"{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{safeCorrelation}-{Guid.NewGuid():N}.json"; + var path = Path.Combine(_directory, fileName); + // Serialize into a sibling .tmp file and move it into place only + // once the write succeeds, so a crash or cancellation mid-write + // never leaves a truncated/corrupt envelope at the final path. + var tempPath = path + ".tmp"; + + try + { + await using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync(stream, envelope, SerializerOptions, cancellationToken) + .ConfigureAwait(false); + } + + File.Move(tempPath, path); + } +#pragma warning disable CA1031 // Air-gapped capture is best-effort; swallow the write failure but log it. + catch (Exception writeFailure) +#pragma warning restore CA1031 + { + DeleteBestEffort(tempPath); + LogWriteFailure(_logger, path, writeFailure); + } + } + + private static void DeleteBestEffort(string tempPath) + { + try + { + File.Delete(tempPath); + } +#pragma warning disable CA1031 // Cleanup itself is best-effort; a failed delete just leaves an orphaned .tmp file. + catch (Exception) +#pragma warning restore CA1031 + { + } + } + + private static IReadOnlyDictionary? RedactSensitiveTags( + IReadOnlyDictionary? tags) + { + if (tags is null or { Count: 0 }) + { + return tags; + } + + var sanitised = new Dictionary(tags.Count, StringComparer.Ordinal); + foreach (var (key, value) in tags) + { + sanitised[key] = SensitiveTokenCatalog.IsSensitive(key) ? RedactedValue : value; + } + + return sanitised; + } + + private static string SanitiseForFileName(string? correlationId) + { + if (string.IsNullOrWhiteSpace(correlationId)) + { + return "noid"; + } + + // Defensive cap before the stackalloc: a multi-KB traceparent + // header from a misbehaving client must not blow the call stack. + var length = Math.Min(correlationId.Length, MaxFileNameSegmentLength); + Span buffer = stackalloc char[length]; + for (var i = 0; i < length; i++) + { + var c = correlationId[i]; + buffer[i] = char.IsLetterOrDigit(c) || c == '-' ? c : '_'; + } + + return new string(buffer); + } + + private static readonly Action LogWriteFailure = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogWriteFailure)), + "LocalFileErrorTracker failed to write capture envelope to {Path}."); +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs new file mode 100644 index 0000000..e7e8aa0 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs @@ -0,0 +1,27 @@ +using LearnStack.SharedKernel.Observability; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that discards capture +/// requests silently. Selected by the composition root when +/// DeploymentMode.Development — no external egress, and the local +/// developer sees the exception in their console / IDE without needing +/// Sentry running. +/// +internal sealed class NoOpErrorTracker : IErrorTrackingProvider +{ + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + // Guard for parity with the Sentry / LocalFile implementations so a + // null argument fails the same way in Development as it would in + // production — a contract bug surfaces locally instead of hiding + // behind the no-op. + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + return ValueTask.CompletedTask; + } +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..44fe94b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs new file mode 100644 index 0000000..9713cd9 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs @@ -0,0 +1,86 @@ +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; +using Sentry; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that dispatches to +/// Sentry. The Sentry hub is supplied by the SDK once +/// SentrySdk.Init has been called by the composition root. +/// +/// +/// Per ADR-0032 § Sub-decision 9 this is the only sanctioned site that +/// references the Sentry SDK; modules import +/// instead. The architecture test +/// Modules_Do_Not_Reference_Sentry_SDK_Directly enforces the scope. +/// +internal sealed class SentryErrorTracker : IErrorTrackingProvider +{ + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + SentrySdk.CaptureException(exception, scope => + { + if (context.TenantId is { } tenantId) + { + scope.SetTag("tenant.id", tenantId.ToString()); + } + + if (context.OrganizationId is { } orgId) + { + scope.SetTag("organization.id", orgId.ToString()); + } + + if (context.UserId is { } userId) + { + scope.User = new SentryUser { Id = userId.ToString() }; + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + scope.SetTag("correlation.id", context.CorrelationId); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + scope.SetTag("module", context.ModuleName); + } + + if (!string.IsNullOrWhiteSpace(context.RequestPath)) + { + scope.SetTag("http.route", context.RequestPath); + } + + if (!string.IsNullOrWhiteSpace(context.RequestMethod)) + { + scope.SetTag("http.method", context.RequestMethod); + } + + if (context.AdditionalTags is not null) + { + foreach (var (key, value) in context.AdditionalTags) + { + // Redact sensitive tag values before they leave the + // process — Sentry is external egress. Uses the same + // SensitiveTokenCatalog the Serilog enricher + the + // air-gapped LocalFileErrorTracker share so the three + // surfaces cannot drift (Standards 11 § Sensitive Data + // Exposure). + scope.SetTag( + key, + SensitiveTokenCatalog.IsSensitive(key) + ? SensitiveTokenCatalog.RedactedValue + : value); + } + } + }); + + return ValueTask.CompletedTask; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs new file mode 100644 index 0000000..e661d24 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs @@ -0,0 +1,7 @@ +namespace LearnStack.Infrastructure.Observability; + +/// +/// Reflection seam — gives assembly scanners (architecture tests, +/// composition-root extension wiring) a non-generic type to anchor against. +/// +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj b/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj new file mode 100644 index 0000000..fe4f67f --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj @@ -0,0 +1,28 @@ + + + + LearnStack.Infrastructure.Observability + + + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs new file mode 100644 index 0000000..6774f90 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs @@ -0,0 +1,30 @@ +using LearnStack.Infrastructure.Observability.Serilog; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Composition-root extension that registers the singleton +/// , the +/// , and the Serilog enrichers +/// ( + +/// ) so the Serilog and OTel +/// pipelines wired by LearnStack.Api can resolve them as singletons. +/// +public static class ObservabilityRegistration +{ + public static IServiceCollection AddLearnStackObservabilityServices( + this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs new file mode 100644 index 0000000..2dd2ef8 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs @@ -0,0 +1,66 @@ +using LearnStack.SharedKernel.Tenancy; +using Serilog.Core; +using Serilog.Events; + +namespace LearnStack.Infrastructure.Observability.Serilog; + +/// +/// Serilog enricher that copies the cross-cutting correlation tags from +/// onto every : +/// tenant.id, organization.id, user.id, +/// correlation.id, module. Per ADR-0032 § Sub-decision 8 the +/// Serilog implementation owns the cross-cutting log shape; the same five +/// fields ride on every OTel span via +/// TenantContextSpanProcessor. +/// +/// +/// The accessor is queried on every event. When no scope has populated +/// the accessor (warm-up logs at process start, background tasks before +/// any handler scope opens), the enricher no-ops — matching the same +/// no-throw contract the OTel processor guarantees per ADR-0032 +/// § Sub-decision 10. +/// +public sealed class CorrelationContextEnricher(ITenantContextAccessor accessor) : ILogEventEnricher +{ + private readonly ITenantContextAccessor _accessor = accessor + ?? throw new ArgumentNullException(nameof(accessor)); + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + var context = _accessor.Current; + if (context is null) return; + + if (context.IsResolved) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("tenant.id", context.TenantId.ToString())); + + if (context.OrganizationId is { } orgId) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("organization.id", orgId.ToString())); + } + + if (context.UserId is { } userId) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("user.id", userId.Value.ToString())); + } + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("correlation.id", context.CorrelationId)); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("module", context.ModuleName)); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs new file mode 100644 index 0000000..27478bf --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs @@ -0,0 +1,178 @@ +using LearnStack.SharedKernel.Secrets; +using Serilog.Core; +using Serilog.Events; + +namespace LearnStack.Infrastructure.Observability.Serilog; + +/// +/// Serilog enricher that scrubs sensitive log-scope properties before the +/// formatter touches them. Per +/// Standards 10 +/// § Logging Rules and +/// Standards 11 § Sensitive Data Exposure: +/// passwords, tokens, DSNs, JWTs, API keys, national / corporate identifiers, +/// authorization headers, full payment payloads must never reach the +/// console or the OTLP sink. +/// +/// +/// +/// The token list lives in +/// — +/// the canonical source the air-gapped LocalFileErrorTracker shares +/// so the two redaction surfaces cannot drift. Adding a token there lights +/// up both paths together. +/// +/// +/// Redaction is recursive: destructured objects +/// ({@User}), dictionaries, and sequences are walked so a sensitive +/// field nested inside a non-sensitive top-level property +/// (e.g. User.Password) is scrubbed too. Reconstruction is lazy — +/// a value with no sensitive descendant is returned by reference, so the +/// common (clean) event allocates nothing. +/// +/// +/// Stack traces and exception messages are NOT redacted — they ride +/// through , which the enricher leaves +/// alone. Modules must follow Standards 11 (never put secrets in +/// exception messages) so the boundary stays honest. +/// +/// +/// TODO(2026-05-21, @platform): augment the Serilog pipeline with a Roslyn +/// analyzer (extending LearnStack.Analyzers) in Phase 02b or later +/// that flags string-interpolated throw new ...Exception($"...{token}...") +/// patterns in Domain + Application projects. Today the +/// "no secrets in exception messages" rule rests on Standards 11 review +/// discipline; promoting it to a compile-time check closes the last +/// gap the runtime redactor cannot. +/// +/// +public sealed class RedactSensitiveFieldsEnricher : ILogEventEnricher +{ + /// Substituted in place of any matched property value. + public const string RedactedValue = SensitiveTokenCatalog.RedactedValue; + + private static readonly ScalarValue RedactedScalar = new(RedactedValue); + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + // Collect first, mutate after — never mutate logEvent.Properties + // while enumerating it. The list materialises only when a top-level + // property needs rewriting (its name is sensitive, or a sensitive + // value lives nested inside it), so a clean event allocates nothing. + List? rewrites = null; + + foreach (var (name, value) in logEvent.Properties) + { + if (SensitiveTokenCatalog.IsSensitive(name)) + { + (rewrites ??= []).Add(new LogEventProperty(name, RedactedScalar)); + continue; + } + + var redacted = Redact(value); + if (!ReferenceEquals(redacted, value)) + { + (rewrites ??= []).Add(new LogEventProperty(name, redacted)); + } + } + + if (rewrites is null) + { + return; + } + + foreach (var property in rewrites) + { + logEvent.AddOrUpdateProperty(property); + } + } + + /// + /// Returns a copy of with every sensitively-named + /// nested property / dictionary key redacted, or the same instance when + /// nothing changed (so clean values cost no allocation). + /// + private static LogEventPropertyValue Redact(LogEventPropertyValue value) + { + switch (value) + { + case StructureValue structure: + { + List? newProps = null; + for (var i = 0; i < structure.Properties.Count; i++) + { + var prop = structure.Properties[i]; + var newValue = SensitiveTokenCatalog.IsSensitive(prop.Name) + ? RedactedScalar + : Redact(prop.Value); + + if (newProps is null && ReferenceEquals(newValue, prop.Value)) + { + continue; + } + + newProps ??= [.. structure.Properties.Take(i)]; + newProps.Add(new LogEventProperty(prop.Name, newValue)); + } + + return newProps is null + ? structure + : new StructureValue(newProps, structure.TypeTag); + } + + case DictionaryValue dictionary: + { + // DictionaryValue.Elements is keyed by ScalarValue (not an + // indexable list). On the first change, copy the whole map + // then overwrite the changed keys; a clean dictionary returns + // by reference. + Dictionary? newElements = null; + foreach (var element in dictionary.Elements) + { + var keyName = element.Key.Value?.ToString(); + var newValue = keyName is not null && SensitiveTokenCatalog.IsSensitive(keyName) + ? RedactedScalar + : Redact(element.Value); + + if (ReferenceEquals(newValue, element.Value)) + { + continue; + } + + newElements ??= new Dictionary(dictionary.Elements); + newElements[element.Key] = newValue; + } + + return newElements is null + ? dictionary + : new DictionaryValue(newElements); + } + + case SequenceValue sequence: + { + List? newItems = null; + for (var i = 0; i < sequence.Elements.Count; i++) + { + var item = sequence.Elements[i]; + var newItem = Redact(item); + + if (newItems is null && ReferenceEquals(newItem, item)) + { + continue; + } + + newItems ??= [.. sequence.Elements.Take(i)]; + newItems.Add(newItem); + } + + return newItems is null ? sequence : new SequenceValue(newItems); + } + + default: + return value; // ScalarValue and unknown value kinds pass through. + } + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs new file mode 100644 index 0000000..e8dde9d --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs @@ -0,0 +1,23 @@ +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Singleton, -backed implementation of +/// . Per ADR-0032 § Sub-decision 10: +/// cross-cutting infrastructure (OTel span processor, Serilog enricher, +/// Sentry enricher) reads the current tenant context through this accessor +/// instead of injecting the request-scoped +/// directly — the lifetime mismatch (singleton processor versus scoped +/// context) would otherwise fail at startup. +/// +internal sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal CurrentContext = new(); + + public ITenantContext? Current + { + get => CurrentContext.Value; + set => CurrentContext.Value = value; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs new file mode 100644 index 0000000..42f4302 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Tenancy; +using OpenTelemetry; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Singleton OpenTelemetry span processor that enriches every started +/// with the cross-cutting correlation tags +/// (tenant.id, organization.id, user.id, +/// correlation.id, module) read from the singleton +/// . Per ADR-0032 § Sub-decision 10 the +/// processor must never throw — auto-instrumentation +/// libraries create warm-up activities before any handler scope has set the +/// accessor. +/// +/// +/// The architecture test +/// OTel_Pipeline_Includes_TenantContextSpanProcessor asserts the +/// processor is registered on the tracing pipeline. The unit test +/// TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// asserts the no-throw contract. +/// +public sealed class TenantContextSpanProcessor(ITenantContextAccessor accessor) + : BaseProcessor +{ + private readonly ITenantContextAccessor _accessor = accessor + ?? throw new ArgumentNullException(nameof(accessor)); + + public override void OnStart(Activity data) + { + ArgumentNullException.ThrowIfNull(data); + + var context = _accessor.Current; + if (context is null) + { + return; + } + + if (context.IsResolved) + { + // OTel attribute types are string / long / double / bool / + // array. A bare Guid is ToString-projected at export time + // with no contract on format (some exporters use "D", others + // "N"). Pin the wire format here for parity with + // SentryErrorTracker and Loki dashboards. + data.SetTag("tenant.id", context.TenantId.ToString()); + if (context.OrganizationId is { } orgId) + { + data.SetTag("organization.id", orgId.ToString()); + } + + if (context.UserId is { } userId) + { + data.SetTag("user.id", userId.Value.ToString()); + } + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + data.SetTag("correlation.id", context.CorrelationId); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + data.SetTag("module", context.ModuleName); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs new file mode 100644 index 0000000..2a6011c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace LearnStack.Infrastructure.Resilience; + +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj new file mode 100644 index 0000000..4a3dcf3 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj @@ -0,0 +1,22 @@ + + + + LearnStack.Infrastructure.Resilience + + + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..44fe94b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] diff --git a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs new file mode 100644 index 0000000..8821aac --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs @@ -0,0 +1,111 @@ +using System.Threading.RateLimiting; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Resilience; +using Polly; +using Polly.CircuitBreaker; +using Polly.RateLimiting; +using Polly.Retry; +using Polly.Timeout; + +namespace LearnStack.Infrastructure.Resilience; + +/// +/// Default implementation that +/// assembles a Polly v8 from the supplied +/// . Per ADR-0032 § Sub-decision 5 every +/// provider adapter consumes one of these in its constructor and routes +/// outbound calls through . +/// +/// +/// The pipeline order — retry → circuit breaker → timeout → bulkhead — is +/// the Polly recommended ordering: retries see the underlying failure; +/// the breaker opens against sustained failure ratios; the timeout bounds +/// a single attempt; the bulkhead caps concurrent in-flight calls so a +/// slow upstream cannot starve the host. Retry and the circuit breaker +/// apply when the exception is a non-client , +/// a transient , or a +/// raised by this pipeline's own +/// timeout strategy (Standards 09 § Retry vs. Don't Retry lists timeouts as +/// retryable). Polly's timeout strategy signals its own elapsed timeout via +/// , not , +/// so genuine caller-initiated cancellation is never mistaken for a +/// retryable timeout. +/// +internal sealed class ProviderResilience : IProviderResilience + where TPort : class +{ + public ProviderResilience(string portName, ResilienceOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(portName); + ArgumentNullException.ThrowIfNull(options); + + PortName = portName; + Pipeline = BuildPipeline(options); + } + + public ResiliencePipeline Pipeline { get; } + + public string PortName { get; } + + private static ResiliencePipeline BuildPipeline(ResilienceOptions options) + { + var builder = new ResiliencePipelineBuilder(); + + if (options.Retry.Enabled && options.Retry.MaxAttempts > 0) + { + builder.AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(static ex => !ex.IsClientError) + .Handle(), + MaxRetryAttempts = options.Retry.MaxAttempts, + Delay = TimeSpan.FromSeconds(options.Retry.DelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = options.Retry.UseJitter, + }); + } + + if (options.CircuitBreaker.Enabled) + { + builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(static ex => !ex.IsClientError) + .Handle(), + FailureRatio = options.CircuitBreaker.FailureRatio, + SamplingDuration = TimeSpan.FromSeconds(options.CircuitBreaker.SamplingDurationSeconds), + MinimumThroughput = options.CircuitBreaker.MinimumThroughput, + BreakDuration = TimeSpan.FromSeconds(options.CircuitBreaker.BreakDurationSeconds), + }); + } + + if (options.Timeout.Enabled && options.Timeout.TotalSeconds > 0) + { + builder.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(options.Timeout.TotalSeconds), + }); + } + + // Bulkhead — Polly v8 maps "bulkhead" onto the rate-limiter + // strategy backed by System.Threading.RateLimiting.ConcurrencyLimiter. + // Only wired when MaxConcurrency > 0 so the default options shape + // (Bulkhead = null) leaves the pipeline unchanged. + if (options.Bulkhead is { MaxConcurrency: > 0 } bulkhead) + { + builder.AddRateLimiter(new RateLimiterStrategyOptions + { + DefaultRateLimiterOptions = new ConcurrencyLimiterOptions + { + PermitLimit = bulkhead.MaxConcurrency, + QueueLimit = Math.Max(0, bulkhead.QueueLength), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }, + }); + } + + return builder.Build(); + } +} diff --git a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs new file mode 100644 index 0000000..47f2288 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs @@ -0,0 +1,47 @@ +using LearnStack.SharedKernel.Resilience; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Infrastructure.Resilience; + +/// +/// Composition-root extension that registers a single +/// for the given . +/// The configuration shape is fixed in +/// ADR-0032 +/// — Resilience:<portName>:. +/// +/// +/// +/// The decorator wiring itself lives on the per-adapter AddProviderAdapter +/// path (added together with the first real adapter; see +/// add-provider-adapter skill). Phase 02a Packet 3 ships the +/// resilience socket only — adapters consume +/// from their constructor and route +/// outbound calls through Pipeline.ExecuteAsync. Subsequent packets +/// can layer a Scrutor / DynamicProxy-based decorator on top without +/// changing the socket shape. +/// +/// +public static class ProviderResilienceRegistration +{ + public static IServiceCollection AddProviderResilience( + this IServiceCollection services, + IConfiguration configuration, + string portName) + where TPort : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(portName); + + var options = configuration + .GetSection($"Resilience:{portName}") + .Get() ?? new ResilienceOptions(); + + services.AddSingleton>( + _ => new ProviderResilience(portName, options)); + + return services; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs new file mode 100644 index 0000000..43b3a9a --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs @@ -0,0 +1,41 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Programmer-error exception. Raised only when an aggregate's +/// invariant is bypassed by a programming mistake — never for expected +/// business-rule violations (those return +/// Result.Fail(business_rule_violation, …) per +/// Standards 09 § Domain Exceptions). +/// +/// +/// The Roslyn analyzer LearnStackException-DomainExceptionThrow flags +/// every throw new DomainException(...) in Domain + Application +/// projects (Warning in Phase 02a, Error after Phase 03 exit). The companion +/// architecture test Domain_Methods_Do_Not_Throw_For_Expected_Cases +/// asserts the analyzer report is empty per module. +/// +public sealed class DomainException : LearnStackException +{ + // A DomainException reaching the L1 handler is a *bug* — an invariant + // was bypassed, not an expected outcome. Its default code must map to a + // 500 (programmer / internal error), NOT business_rule_violation (409), + // which is reserved for the Result.Fail path. Using + // lockey_business_rule_violation here would misclassify a crash as a + // refused business operation. Per ADR-0032 § Sub-decision 4 + + // Standards 09 § Domain Exceptions. + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_internal_error")); + + public DomainException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public DomainException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs b/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs new file mode 100644 index 0000000..5cd6653 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs @@ -0,0 +1,25 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Transient infrastructure fault (database connection, Valkey, SeaweedFS, +/// outbox dispatcher transport). Retryable per Standards 09 § Retry vs Don't +/// Retry. Captured to IErrorTrackingProvider at the L1 handler. +/// +public class InfrastructureException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public InfrastructureException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public InfrastructureException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs b/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs new file mode 100644 index 0000000..7854981 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs @@ -0,0 +1,36 @@ +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Base class for every exception LearnStack itself raises. Per +/// ADR-0032 +/// § Sub-decision 4 and +/// Standards 09 § Hierarchy: +/// exceptions are reserved for unexpected failures (bugs, transient +/// infrastructure faults, contract violations). Expected outcomes return +/// instead. +/// +/// +/// Carrying the structured at the exception site lets +/// the L1 IExceptionHandler map straight to RFC 7807 Problem Details +/// without re-deriving the code from the exception type. Subclasses pass the +/// appropriate stock lockey_*-keyed through +/// their constructors. +/// +public abstract class LearnStackException : Exception +{ + protected LearnStackException(Error error, string message, Exception? innerException = null) + : base(message, innerException) + { + ArgumentNullException.ThrowIfNull(error); + Error = error; + } + + /// + /// The stable the L1 handler projects to the + /// Problem Details body. Error.Code drives the HTTP status mapping + /// (Standards 09 § Result Type). + /// + public Error Error { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs b/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs new file mode 100644 index 0000000..d7a72d2 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs @@ -0,0 +1,80 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Wraps an upstream provider failure surfaced at the adapter boundary. +/// Per ADR-0032 +/// § Sub-decision 5 every adapter under +/// LearnStack.Infrastructure.<Adapter> translates SDK exception +/// types into the appropriate subclass; the +/// architecture test Adapters_Wrap_Provider_Exceptions enforces that +/// SDK exception types never leave the adapter assembly. +/// +/// +/// +/// The flag splits the Sentry-capture boundary +/// (Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary): +/// true for 4xx upstream (provider's user-mistake, no Sentry capture), +/// false for 5xx upstream / timeouts (Sentry-captured infra failure). +/// It does not drive the HTTP status returned to the +/// client. +/// +/// +/// The HTTP status comes from the carried 's code (see +/// HttpStatusMap.For(Exception)), so the response status and the +/// Problem Details code field can never disagree. The convenience +/// ctor defaults to dependency_unavailable (→ 503), which is the +/// right shape for an unspecified provider failure. When an adapter wants to +/// surface a provider 4xx as a client-actionable status, it passes an +/// explicit (e.g. validation_failed → 400) via +/// the error-carrying ctor — that error then drives both the body code and +/// the status consistently. +/// +/// +public class ProviderException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public ProviderException( + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(DefaultError, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + public ProviderException( + Error error, + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(error, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + /// + /// Stable provider identifier (e.g. "livekit", "stripe", + /// "meilisearch") tagged on metrics / spans / Sentry events. Must + /// not leak to end users (Standards 09 § Provider Failures). + /// + public string ProviderName { get; } + + /// + /// true when the upstream response is a 4xx-equivalent (the + /// adapter caller passed bad input). The L1 handler skips Sentry capture + /// for client errors. false when the upstream returned 5xx / + /// timeout — captured to Sentry as an infra fault. + /// + public bool IsClientError { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs new file mode 100644 index 0000000..6329d67 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs @@ -0,0 +1,22 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Thrown when application code requires a resolved tenant context but the +/// ambient ITenantContext.IsResolved is false. Reached only via +/// programmer-error paths — the request pipeline's TenantContextBehavior +/// asserts the context up front, so this exception escapes mainly from +/// background workers / outbox handlers that forgot to populate it. +/// +public sealed class TenantContextMissingException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_tenant_mismatch")); + + public TenantContextMissingException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs b/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs new file mode 100644 index 0000000..db19dce --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs @@ -0,0 +1,24 @@ +namespace LearnStack.SharedKernel.Hosting; + +/// +/// The deployment shape the composition root branches on per +/// ADR-0020 +/// and the +/// Standards 20 § Composition +/// Root and Deployment Mode table. SelfHosted is split into two +/// values so phone-home and signed-license-key entitlement providers can be +/// picked at startup without runtime branching. +/// +/// +/// Modules never read this enum directly. The composition +/// root selects provider implementations exactly once; the architecture test +/// Modules_Do_Not_Reference_DeploymentMode enforces the rule. +/// +public enum DeploymentMode +{ + Development, + SaaS, + Dedicated, + SelfHostedOnline, + SelfHostedAirGapped, +} diff --git a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj index a677d49..a580827 100644 --- a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj +++ b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj @@ -26,6 +26,19 @@ that build-time hook only - SharedKernel does not use any EF Core type directly. --> + + + + + + diff --git a/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs new file mode 100644 index 0000000..6230fdf --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs @@ -0,0 +1,42 @@ +namespace LearnStack.SharedKernel.Observability; + +/// +/// Sanctioned entry point for error capture. Per +/// ADR-0032 +/// § Sub-decision 9 the L1 IExceptionHandler is the only +/// production caller; modules never import Sentry.SentrySdk directly. +/// The composition root selects the implementation by DeploymentMode +/// (): +/// NoOpErrorTracker for Development, SentryErrorTracker for +/// SaaS / Dedicated / SelfHostedOnline (DSN via ISecretProvider), +/// LocalFileErrorTracker for SelfHostedAirGapped. +/// +/// +/// The capture boundary itself (which exceptions go here and which only tag +/// the OTel span) lives in +/// LearnStack.Api.Common.LearnStackExceptionHandler.ShouldCapture per +/// Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary. +/// +public interface IErrorTrackingProvider +{ + ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default); +} + +/// +/// Snapshot of cross-cutting tags every error capture flows with. The L1 +/// handler builds it from the current HttpContext + the singleton +/// ITenantContextAccessor; offline capture sites (the local-file +/// tracker, future worker hosts) build it themselves. +/// +public sealed record CapturedContext( + string? CorrelationId, + string? RequestPath, + string? RequestMethod, + Guid? TenantId, + Guid? OrganizationId, + Guid? UserId, + string? ModuleName, + IReadOnlyDictionary? AdditionalTags = null); diff --git a/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs b/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs new file mode 100644 index 0000000..e57c7c9 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs @@ -0,0 +1,40 @@ +using Polly; + +namespace LearnStack.SharedKernel.Resilience; + +/// +/// Carrier for the Polly v8 that wraps a +/// provider adapter (ILiveClassProvider, IPaymentProvider, +/// IStorageProvider, ISearchProvider, …). Per +/// ADR-0032 +/// § Sub-decision 5 every adapter receives one of these in its +/// constructor and routes outbound calls through . +/// +/// The port interface the resilience policy is keyed +/// to (e.g. ILiveClassProvider). Used as a DI discriminator only — +/// the type itself is not consumed at runtime. +/// +/// +/// The pipeline is built once from appsettings.Resilience:<PortName>: +/// (Standards 09 § Provider Resilience — Polly v8 ResiliencePipeline) with +/// retry (exponential backoff + jitter), circuit breaker, timeout, and +/// bulkhead policies. +/// +/// +/// Hub HTTP clients (IEntitlementProvider, IUsageReporter, +/// IHubTenantSync) are excluded from this pattern — +/// their resilience lives inside the mTLS + signed-JWT + HMAC wrapper per +/// ADR-0019. +/// +/// +#pragma warning disable CA1040 // Avoid empty interfaces — the generic type parameter is the DI discriminator. +public interface IProviderResilience +#pragma warning restore CA1040 + where TPort : class +{ + /// The pre-built Polly v8 pipeline; thread-safe and meant to be reused. + ResiliencePipeline Pipeline { get; } + + /// The configuration section name ("liveclass", "payment", …). + string PortName { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs b/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs new file mode 100644 index 0000000..e97b893 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs @@ -0,0 +1,51 @@ +namespace LearnStack.SharedKernel.Resilience; + +/// +/// Configuration shape bound from appsettings.Resilience:<portName>: +/// per ADR-0032 § Sub-decision 5. The decorator reads one of these per +/// provider port and assembles the Polly v8 pipeline. +/// +/// +/// Defaults match the conservative-but-useful shape from Standards 09 +/// § Provider Resilience (retry up to 3 attempts with jitter, breaker after +/// 50 % failure ratio over 30 s, single-attempt timeout 10 s, no bulkhead). +/// +public sealed class ResilienceOptions +{ + public RetryOptions Retry { get; set; } = new(); + + public CircuitBreakerOptions CircuitBreaker { get; set; } = new(); + + public TimeoutOptions Timeout { get; set; } = new(); + + public BulkheadOptions? Bulkhead { get; set; } +} + +public sealed class RetryOptions +{ + public int MaxAttempts { get; set; } = 3; + public double DelaySeconds { get; set; } = 1.0; + public bool UseJitter { get; set; } = true; + public bool Enabled { get; set; } = true; +} + +public sealed class CircuitBreakerOptions +{ + public double FailureRatio { get; set; } = 0.5; + public double SamplingDurationSeconds { get; set; } = 30; + public int MinimumThroughput { get; set; } = 10; + public double BreakDurationSeconds { get; set; } = 30; + public bool Enabled { get; set; } = true; +} + +public sealed class TimeoutOptions +{ + public double TotalSeconds { get; set; } = 10; + public bool Enabled { get; set; } = true; +} + +public sealed class BulkheadOptions +{ + public int MaxConcurrency { get; set; } = 100; + public int QueueLength { get; set; } +} diff --git a/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs b/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs new file mode 100644 index 0000000..9fae778 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; + +namespace LearnStack.SharedKernel.Secrets; + +/// +/// Default that delegates to +/// . Phase 02a Packet 3 ships this as the +/// composition-root default for every DeploymentMode; Packet 5 +/// swaps it for the Dapr-backed implementation when running against a +/// Vault-equipped environment. +/// +/// +/// The configuration layer already merges environment variables, user +/// secrets, and appsettings.{env}.json, so the default covers +/// developer workstations and CI without spinning up Vault. Production- +/// grade deployments override the registration in the composition root. +/// +public sealed class ConfigurationSecretProvider(IConfiguration configuration) : ISecretProvider +{ + private readonly IConfiguration _configuration = configuration + ?? throw new ArgumentNullException(nameof(configuration)); + + public string? GetSecret(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs b/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs new file mode 100644 index 0000000..01ee594 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs @@ -0,0 +1,35 @@ +namespace LearnStack.SharedKernel.Secrets; + +/// +/// Composition-root-resolved secret provider. Per +/// Standards 20 § ISecretProvider +/// and ADR-0032 § Sub-decision 9, every secret-bearing value (Sentry DSN, +/// signed-license RSA key paths, provider API keys, …) is read through +/// this contract — modules never call Environment.GetEnvironmentVariable +/// or hand-roll their own Vault clients. +/// +/// +/// +/// Phase 02a Packet 3 ships the contract + the default +/// implementation that delegates +/// to . Packet 5 adds the +/// DaprSecretProvider (Vault-backed) and the composition root branches by +/// DeploymentMode. +/// +/// +/// The interface is intentionally synchronous: most secret reads happen at +/// startup time, and Vault offers a synchronous fetch path. A future +/// async overload may land alongside the Dapr-backed implementation if a +/// hot-path use case appears. +/// +/// +public interface ISecretProvider +{ + /// + /// Resolves the secret identified by . Returns + /// null when the secret is not configured — callers decide + /// whether to fail fast or fall back to a default. + /// + /// The secret path, e.g. "ErrorTracking:Sentry:Dsn". + string? GetSecret(string key); +} diff --git a/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs new file mode 100644 index 0000000..b8d8fad --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs @@ -0,0 +1,163 @@ +namespace LearnStack.SharedKernel.Secrets; + +/// +/// The canonical list of word-tokens whose presence as a segment of +/// a property name (or AdditionalTags key) marks the value as sensitive — +/// passwords, tokens, secrets, payment identifiers, national / corporate IDs. +/// The Serilog RedactSensitiveFieldsEnricher and the air-gapped +/// LocalFileErrorTracker consume this single source of truth so the +/// two redaction surfaces cannot drift. +/// +/// +/// +/// Matching is on word boundaries, not raw substrings, to +/// avoid over-redaction: a property named ClassName tokenises to +/// ["class", "name"] and is NOT flagged for the ssn token (raw +/// Contains("ssn") would wrongly match "classname"), while +/// UserPassword["user", "password"] still matches +/// password. Two-word tokens (api_key, card_number) match +/// either the joined form (apikey) or adjacent segments +/// (Api+Key). Standards 11 § Sensitive Data Exposure is +/// authoritative for what counts as sensitive; this list is the runtime +/// expression of that rule. +/// +/// +/// New tokens land here, not in the consuming projects, so a future addition +/// lights up the Serilog path and the air-gapped path together. +/// +/// +public static class SensitiveTokenCatalog +{ + /// Substituted in place of any matched property value. + public const string RedactedValue = "***REDACTED***"; + + /// + /// Single-word tokens matched against a whole name-segment (or the + /// separator-stripped full name). Sorted alphabetically. + /// + private static readonly HashSet SingleWordTokens = + new(StringComparer.OrdinalIgnoreCase) + { + "apikey", + "authorization", + "cardnumber", + "credential", + "cvc", + "cvv", + "dsn", + "iban", + "jwt", + "passwd", + "password", + "secret", + "ssn", // national ID (US SSN / TR shorthand) + "tckn", // Turkish national ID + "token", + "vkn", // Turkish corporate tax number (Vergi Kimlik Numarası) + }; + + /// + /// Joined forms of two-word tokens, matched against adjacent + /// camelCase / separated segments (Api+Keyapikey). + /// + private static readonly HashSet TwoWordTokens = + new(StringComparer.OrdinalIgnoreCase) + { + "apikey", // api_key / ApiKey + "cardnumber", // card_number / CardNumber + "authheader", // auth_header / AuthHeader + }; + + /// + /// The canonical token list (for docs / tests). Returns a snapshot, not + /// the backing set, so a caller casting the + /// result cannot mutate the catalogue. + /// + public static IReadOnlyCollection DefaultTokens => + Array.AsReadOnly(SingleWordTokens.ToArray()); + + /// + /// Returns true when any whole segment of + /// (split on camelCase boundaries and _ . - separators) matches a + /// sensitive token. Word-boundary matching prevents the substring + /// false-positives that a naive Contains would produce (e.g. + /// ssn inside className). + /// + public static bool IsSensitive(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return false; + } + + var segments = Tokenize(propertyName); + + // Whole-name fallback: a property literally named "apikey" (no + // separators / case transitions) tokenises to a single segment that + // the single-word set already covers, so this is implicit. + for (var i = 0; i < segments.Count; i++) + { + if (SingleWordTokens.Contains(segments[i]) || TwoWordTokens.Contains(segments[i])) + { + return true; + } + + if (i + 1 < segments.Count + && TwoWordTokens.Contains(segments[i] + segments[i + 1])) + { + return true; + } + } + + return false; + } + + /// + /// Splits a property name into lowercase word segments on camelCase + /// transitions and _ . - (and any non-alphanumeric) separators. + /// + private static List Tokenize(string name) + { + var segments = new List(); + var start = 0; + + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + var isSeparator = !char.IsLetterOrDigit(c); + + // camelCase boundaries: + // case 1 aA / 1A — lower/digit → Upper (userToken → user|Token) + // case 2 ABc — Upper → Upper-then-lower (SSNToken → ssn|Token, + // APIKey → api|Key) so trailing acronym letters + // start the next word. + var isCamelBoundary = i > start && char.IsUpper(c) && + ((char.IsLower(name[i - 1]) || char.IsDigit(name[i - 1])) + || (char.IsUpper(name[i - 1]) + && i + 1 < name.Length + && char.IsLower(name[i + 1]))); + + if (isSeparator) + { + if (i > start) + { + segments.Add(name[start..i].ToLowerInvariant()); + } + + start = i + 1; + } + else if (isCamelBoundary) + { + segments.Add(name[start..i].ToLowerInvariant()); + start = i; + } + } + + if (start < name.Length) + { + segments.Add(name[start..].ToLowerInvariant()); + } + + return segments; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs new file mode 100644 index 0000000..19db264 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -0,0 +1,65 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Request-scoped tenant + organization + user context handed to MediatR +/// handlers, EF interceptors, and the audit pipeline. Populated at scope +/// start by TenantResolverMiddleware (HTTP), HubCorrelationMiddleware +/// (/api/internal/*), the Hangfire JobActivator (background jobs), +/// and the outbox / inbox handler scope (integration events). Modules never +/// write to this contract — they read it through DI. +/// +/// +/// Phase 02a Packet 3 ships the contract. The real population sites land in +/// Packet 7 (TenantResolverMiddleware) and Phase 02b (Hangfire + outbox +/// handler scope). Pre-population, the ambient context resolves to a +/// composition-root-provided UnresolvedTenantContext singleton whose +/// is false. +/// +public interface ITenantContext +{ + /// + /// true once the resolution pipeline has populated tenant + (where + /// applicable) organization. TenantContextBehavior short-circuits + /// the request with Result.Fail(tenant_mismatch) when this is + /// false. + /// + bool IsResolved { get; } + + /// + /// The resolved tenant. Reading on an unresolved context throws + /// — callers gate on + /// first. + /// + Guid TenantId { get; } + + /// + /// The resolved organization within the tenant, when the request targets + /// an [OrganizationScoped] resource. null for tenant-wide + /// requests. + /// + Guid? OrganizationId { get; } + + /// + /// The acting user, when authenticated. null for anonymous / + /// system-issued requests (background jobs, outbox handlers). + /// + UserId? UserId { get; } + + /// + /// W3C traceparent string ("00-<trace>-<span>-<flags>") + /// that threads through HTTP / outbox / Hangfire / Hub envelopes. The + /// observability stack reads this from the singleton accessor; modules + /// never set it directly. + /// + string? CorrelationId { get; } + + /// + /// Logical module that owns the current request (e.g. "education", + /// "classroom"). Tagged onto spans by the + /// TenantContextSpanProcessor; helpful when filtering Tempo / + /// Grafana queries. + /// + string? ModuleName { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs new file mode 100644 index 0000000..97fd3bd --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs @@ -0,0 +1,29 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Singleton, AsyncLocal<ITenantContext?>-backed accessor that +/// gives cross-cutting infrastructure (OTel span processor, Serilog enricher, +/// Sentry enricher) a way to read the current tenant context without +/// injecting the request-scoped — which would +/// fail the singleton-vs-scoped lifetime gate. +/// +/// +/// +/// Modules never write to this accessor. Population is +/// owned by the resolution sites listed in ADR-0032 § Sub-decision 10: +/// TenantResolverMiddleware (HTTP), HubCorrelationMiddleware +/// (/api/internal/*), Hangfire JobActivator (background jobs), +/// outbox / inbox handler scope (integration events). +/// +/// +/// is null outside any resolved scope (warm-up +/// activities created during startup, background tasks before any handler +/// scope opened) — readers must handle the null case rather than +/// throw. The TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// unit test guards the OTel processor's behaviour. +/// +/// +public interface ITenantContextAccessor +{ + ITenantContext? Current { get; set; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs new file mode 100644 index 0000000..a7ac804 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -0,0 +1,35 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Default registered at the composition root +/// so modules can inject the contract before any resolution middleware has +/// populated the request. is false; reading +/// raises so +/// the caller cannot accidentally proceed with a zero . +/// +/// +/// The real population sites (per ADR-0032 § Sub-decision 10) overwrite the +/// scoped instance once they resolve. Until Packet 7 lands +/// TenantResolverMiddleware, every request runs against this default — +/// the TenantContextBehavior short-circuits with +/// Result.Fail(tenant_mismatch) before any handler runs. +/// +public sealed class UnresolvedTenantContext : ITenantContext +{ + public static UnresolvedTenantContext Instance { get; } = new(); + + public bool IsResolved => false; + + public Guid TenantId => throw new InvalidOperationException( + "TenantId is not available on an unresolved tenant context. Gate reads on IsResolved."); + + public Guid? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => null; +} diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj index 40a8c09..9694008 100644 --- a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj index bed6853..9fe0477 100644 --- a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj b/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj index 81c589f..a508642 100644 --- a/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj +++ b/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj b/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj index 7b4e904..ebd5459 100644 --- a/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj +++ b/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj index 9c05697..b860077 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj index a8a4bc5..f307240 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj index e2aecc7..e7ebaff 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj index ca0ba54..fa5b00d 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj index f263e6b..bec2d31 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj index 59eda77..75a9043 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj b/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj index 3566dfa..20a7fbc 100644 --- a/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj +++ b/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj b/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj index 3272879..93134df 100644 --- a/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj +++ b/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj index 2971b97..a655c73 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj index 67f0c9e..46f276a 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs new file mode 100644 index 0000000..3ce2e18 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -0,0 +1,369 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Observability; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetArchTest.Rules; +using OpenTelemetry.Trace; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// Cross-cutting architecture rules per +/// ADR-0032 +/// and +/// Standards 21 § Cross-cutting. +/// The catalogue is the canonical reference for every identifier below. +/// +public sealed class CrossCuttingFoundationTests +{ + private static readonly string[] ModuleAssemblyShapes = + [ + "LearnStack.Modules.Tenancy.Application", + "LearnStack.Modules.Tenancy.Domain", + "LearnStack.Modules.Tenancy.Infrastructure", + "LearnStack.Modules.Identity.Application", + "LearnStack.Modules.Identity.Domain", + "LearnStack.Modules.Identity.Infrastructure", + "LearnStack.Modules.Customization.Application", + "LearnStack.Modules.Customization.Domain", + "LearnStack.Modules.Customization.Infrastructure", + "LearnStack.Modules.Audit.Application", + "LearnStack.Modules.Audit.Domain", + "LearnStack.Modules.Audit.Infrastructure", + "LearnStack.Modules.Content.Application", + "LearnStack.Modules.Content.Domain", + "LearnStack.Modules.Content.Infrastructure", + "LearnStack.Modules.Media.Application", + "LearnStack.Modules.Media.Domain", + "LearnStack.Modules.Media.Infrastructure", + "LearnStack.Modules.Education.Application", + "LearnStack.Modules.Education.Domain", + "LearnStack.Modules.Education.Infrastructure", + ]; + + [Fact] + public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() + { + // ADR-0032 § Sub-decision 2 — outermost (validation) first, + // innermost (handler) last. The expected list is hardcoded here on + // purpose so a future edit of MediatRPipelineRegistration's + // CanonicalBehaviorOrder can't sneak past the test by also + // reordering the test fixture. The catalogue entry + // MediatR_Pipeline_Order_Matches_Canonical_Sequence is the + // canonical reference for the contract. + Type[] expectedOrder = + [ + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(TenantContextBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + ]; + + var services = new ServiceCollection(); + services.AddLearnStackMediatRPipeline(); + + var behaviorTypes = services + .Where(d => d.ServiceType.IsGenericType + && d.ServiceType.GetGenericTypeDefinition() == typeof(IPipelineBehavior<,>) + && d.ImplementationType is not null) + .Select(d => d.ImplementationType!.GetGenericTypeDefinition()) + .ToArray(); + + behaviorTypes.Should().Equal( + expectedOrder, + "ADR-0032 § Sub-decision 2 pins the canonical 7-behavior order " + + "(plus the handler at the innermost position). Changing the order " + + "requires a new ADR; this hardcoded list is the test's " + + "drift-proof anchor."); + + // Belt-and-suspenders: the production CanonicalBehaviorOrder list + // must match the same hardcoded sequence so reflection-based + // consumers (e.g. the registration extension) see the same + // contract. + MediatRPipelineRegistration.CanonicalBehaviorOrder + .Should() + .Equal(expectedOrder, + "the production CanonicalBehaviorOrder is the public surface; " + + "it must match the hardcoded ADR-0032 sequence."); + } + + [Fact] + public void IExceptionHandler_Registered_AtStartup() + { + // ADR-0032 § Sub-decision 1 — every host registers + // LearnStackExceptionHandler via AddExceptionHandler(). + using var application = BuildMinimalApiHost(); + + var registered = application.Services + .GetServices() + .Select(h => h.GetType()) + .ToArray(); + + registered.Should().Contain(typeof(LearnStack.Api.Common.LearnStackExceptionHandler), + "the L1 handler is the only sanctioned catch site below the framework " + + "(ADR-0032 § Sub-decision 1)."); + } + + [Fact] + public void OTel_Pipeline_Includes_TenantContextSpanProcessor() + { + // ADR-0032 § Sub-decision 10 — the processor enriches every span + // (auto-instrumented and manual) with the correlation tags. If the + // composition root removes it, Tempo queries lose the per-tenant + // axis. + using var application = BuildMinimalApiHost(); + + // The processor is registered as a singleton so the OTel tracing + // pipeline can resolve it via AddProcessor(); confirming both + // the type registration and the IConfigureTracerProviderBuilder + // pipeline-attach is what catches a regression. + var processor = application.Services.GetService(); + processor.Should().NotBeNull( + "AddOpenTelemetry().WithTracing(...).AddProcessor() " + + "must remain wired (ADR-0032 § Sub-decision 10)."); + + // The tracer provider triggers processor construction at build + // time — resolving it ensures the pipeline successfully attached + // every processor in the closure, including ours. + var tracerProvider = application.Services.GetService(); + tracerProvider.Should().NotBeNull( + "AddOpenTelemetry().WithTracing(...) must register a TracerProvider singleton."); + } + + [Fact] + public void Logging_Goes_Through_Microsoft_Extensions_Logging() + { + // Standards 10 § Stack — modules log through ILogger; + // Serilog.ILogger is the implementation seam at the composition + // root and must not be imported from module assemblies. + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) + { + // Phase 02a packets do not necessarily fill every module + // assembly with code yet; an empty assembly is a vacuous + // pass. + continue; + } + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("Serilog") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references Serilog directly. Modules log through " + + "Microsoft.Extensions.Logging.ILogger; the Serilog impl is wired " + + "once at the composition root (ADR-0032 § Sub-decision 8)."); + } + } + + [Fact] + public void Modules_Do_Not_Reference_Sentry_SDK_Directly() + { + // ADR-0032 § Sub-decision 9 — only + // LearnStack.Infrastructure.ErrorTracking may reference the Sentry + // SDK. Modules call IErrorTrackingProvider instead. + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("Sentry") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references the Sentry SDK directly. Use IErrorTrackingProvider " + + "(ADR-0032 § Sub-decision 9)."); + } + } + + [Fact] + public void Adapters_Wrap_Provider_Exceptions() + { + // ADR-0032 § Sub-decision 5 — provider SDK exception types + // (LiveKit.NET.LiveKitException, Stripe.StripeException, + // Meilisearch.MeilisearchApiError, …) never escape + // LearnStack.Infrastructure.. Phase 02a has no adapters + // yet, so this test asserts the constraint vacuously by walking + // the well-known SDK namespaces against every non-adapter + // assembly. + var nonAdapterAssemblies = ModuleAssemblyShapes + .Append("LearnStack.SharedKernel") + .Append("LearnStack.Domain") + .Append("LearnStack.Application") + .Append("LearnStack.Application.Contracts") + .Append("LearnStack.Api") + .Select(TryLoadAssembly) + .Where(a => a is not null) + .ToArray(); + + string[] forbiddenSdkNamespaces = + [ + "LiveKit", + "Stripe", + "Meilisearch", + "Iyzipay", + ]; + + foreach (var assembly in nonAdapterAssemblies) + { + foreach (var sdkPrefix in forbiddenSdkNamespaces) + { + var result = Types.InAssembly(assembly!) + .Should() + .NotHaveDependencyOn(sdkPrefix) + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{assembly!.GetName().Name} references {sdkPrefix}. SDK exception types " + + "must stay inside LearnStack.Infrastructure.."); + } + } + } + + [Fact] + public void Handlers_Return_Result() + { + // The 8-step MediatR pipeline behaviors are constrained + // `where TResponse : IResultBase`; MediatR only instantiates an + // open-generic behavior for requests whose response satisfies the + // constraint. A handler declared IRequestHandler + // would therefore run with NO behaviors — no validation, no audit, + // and (once Packet 7 lands) no TenantContextBehavior (where RLS GUCs + // get set). This test locks the "handlers return Result" invariant + // now, while the pipeline contract is fresh. Vacuous today (no + // handlers yet); active the moment they land. Standards 02 § MediatR + // Use Cases (review-4 M1). + var applicationAssemblies = ModuleAssemblyShapes + .Where(n => n.EndsWith(".Application", StringComparison.Ordinal)) + .Append("LearnStack.Application") + .Select(TryLoadAssembly) + .Where(a => a is not null) + .ToArray(); + + foreach (var assembly in applicationAssemblies) + { + foreach (var type in assembly!.GetTypes()) + { + if (type.IsAbstract || type.IsInterface) + { + continue; + } + + foreach (var contract in type.GetInterfaces()) + { + if (!contract.IsGenericType + || contract.GetGenericTypeDefinition() != typeof(IRequestHandler<,>)) + { + continue; + } + + var responseType = contract.GetGenericArguments()[1]; + typeof(IResultBase).IsAssignableFrom(responseType).Should().BeTrue( + $"{type.FullName} handles a request whose response ({responseType.Name}) " + + "does not implement IResultBase. Handlers must return Result so the " + + "MediatR pipeline (validation / audit / tenant-context + RLS) applies " + + "— a raw-DTO response silently bypasses every behavior. " + + "Standards 02 § MediatR Use Cases."); + } + } + } + } + + [Fact] + public void Modules_Do_Not_Reference_DeploymentMode() + { + // Standards 20 § Composition Root and Deployment Mode — the + // composition root selects provider implementations once; + // modules must NEVER read DeploymentMode directly. The catalogue + // entry of the same name has lived without an implementation + // until now (Phase 02a Packet 3 review finding). + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("LearnStack.SharedKernel.Hosting") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references LearnStack.SharedKernel.Hosting (the DeploymentMode " + + "namespace). Composition-root branching is the only sanctioned read site " + + "(Standards 20 § Composition Root)."); + } + } + + [Fact] + public void IErrorTrackingProvider_Is_Singleton() + { + // ADR-0032 § Sub-decision 9 — the composition root registers a + // single IErrorTrackingProvider implementation per DeploymentMode. + // The boundary L1 handler resolves it once at startup. + using var application = BuildMinimalApiHost(); + + var providers = application.Services + .GetServices() + .ToArray(); + + providers.Should().HaveCount(1, + "exactly one IErrorTrackingProvider is registered per DeploymentMode " + + "(ADR-0032 § Sub-decision 9)."); + + // Registration count is necessary but not sufficient — assert the + // singleton *lifetime* by resolving twice from the root and once + // from a fresh scope; all three must be the same instance. + var first = application.Services.GetRequiredService(); + var second = application.Services.GetRequiredService(); + using var scope = application.Services.CreateScope(); + var scoped = scope.ServiceProvider.GetRequiredService(); + + second.Should().BeSameAs(first, "the provider is registered as a singleton."); + scoped.Should().BeSameAs(first, "a singleton resolves to the same instance across scopes."); + } + + private static WebApplication BuildMinimalApiHost() + { + var builder = WebApplication.CreateBuilder([]); + // Empty configuration is fine for Development — the NoOp tracker + // does not need a Sentry DSN, and the OTLP exporter is silently + // skipped when Telemetry:OtlpEndpoint is absent. + builder.Configuration.AddInMemoryCollection( + new Dictionary(StringComparer.Ordinal) + { + ["Deployment:Mode"] = nameof(DeploymentMode.Development), + }); + builder.AddLearnStackCrossCuttingFoundation(DeploymentMode.Development); + return builder.Build(); + } + + private static Assembly? TryLoadAssembly(string assemblyName) + { + try + { + return Assembly.Load(assemblyName); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj index cd92c4c..a51e8b1 100644 --- a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj +++ b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj @@ -22,6 +22,9 @@ + + + diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs new file mode 100644 index 0000000..8e7895b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -0,0 +1,204 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using FluentValidation; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// HTTP-level integration coverage for ADR-0032's L1 boundary + +/// ValidationBehavior. Backs the Standards 21 catalogue rows +/// IExceptionHandler_Registered_AtStartup and +/// ValidationBehavior_DoesNotThrow_ValidationException with the +/// end-to-end shape ASP.NET produces — Problem Details body, status +/// mapping, correlationId extension, content type. +/// +public sealed class CrossCuttingFoundationHttpTests(CrossCuttingHttpFixture fixture) + : IClassFixture +{ + private readonly HttpClient _client = fixture.CreateClient(); + + [Fact] + public async Task L1_Handler_Returns_ProblemDetails_For_Server_Side_ProviderException() + { + var response = await _client.GetAsync(new Uri("/test/throw-provider-5xx", UriKind.Relative)); + + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + response.Content.Headers.ContentType?.MediaType + .Should().Be("application/problem+json"); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.GetProperty("code").GetString().Should().Be("dependency_unavailable"); + problem.GetProperty("messageKey").GetString().Should().Be("lockey_dependency_unavailable"); + problem.GetProperty("instance").GetString().Should().Be("/test/throw-provider-5xx"); + problem.TryGetProperty("correlationId", out _).Should().BeTrue(); + } + + [Fact] + public async Task L1_Handler_Returns_Consistent_Body_And_Status_For_Client_Side_ProviderException() + { + var response = await _client.GetAsync(new Uri("/test/throw-provider-4xx", UriKind.Relative)); + + // An adapter surfacing a provider 4xx as client-actionable passes an + // explicit Error (here validation_failed). HTTP status is derived + // from that code, so body code and status agree — 400 + validation_failed, + // NOT a 400 carrying dependency_unavailable. IsClientError only gates + // Sentry capture, not the status. + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var problem = await response.Content.ReadFromJsonAsync(); + problem.GetProperty("code").GetString().Should().Be("validation_failed"); + problem.GetProperty("messageKey").GetString().Should().Be("lockey_validation_failed"); + } + + [Fact] + public async Task ValidationBehavior_Returns_400_ProblemDetails_For_Invalid_Command() + { + // ADR-0032 § Sub-decision 3 — invalid input never reaches the + // handler; ValidationBehavior returns Result.Fail(validation_failed) + // and the controller's ToActionResult() projects to a 400 + // Problem Details body. + var response = await _client.PostAsJsonAsync( + new Uri("/test/validate", UriKind.Relative), + new { Name = string.Empty }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.Content.Headers.ContentType?.MediaType + .Should().Be("application/problem+json"); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.GetProperty("code").GetString().Should().Be("validation_failed"); + problem.GetProperty("messageKey").GetString().Should().Be("lockey_validation_failed"); + problem.GetProperty("instance").GetString().Should().Be("/test/validate"); + problem.GetProperty("errors").GetProperty("name")[0] + .GetProperty("key").GetString().Should().Be("lockey_name_required"); + } + + [Fact] + public async Task ValidationBehavior_Passes_Through_When_Command_Is_Valid() + { + var response = await _client.PostAsJsonAsync( + new Uri("/test/validate", UriKind.Relative), + new { Name = "alice" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("alice"); + } +} + +/// +/// Shared that wires the +/// integration test's controllers + MediatR handler + validator into the +/// real LearnStack.Api host. Reuses the host's +/// AddLearnStackCrossCuttingFoundation wiring so the L1 handler + +/// ValidationBehavior tested here is the same code production runs. +/// +public sealed class CrossCuttingHttpFixture : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + builder.ConfigureTestServices(services => + { + services + .AddControllers() + .AddApplicationPart(typeof(CrossCuttingTestController).Assembly); + + // ValidationBehavior resolves IValidator from DI. We + // register the validator + the handler so the test exercises + // the full pipeline without re-running AddMediatR (which would + // double-register the behaviors). + services.AddTransient< + IRequestHandler>, + TestValidationHandler>(); + services.AddTransient, TestValidationValidator>(); + + // TenantContextBehavior short-circuits when ITenantContext is + // not resolved. Until Packet 7 lands TenantResolverMiddleware, + // production has no way to flip IsResolved → true. For the + // integration test we replace the scoped registration with a + // fixed test tenant so MediatR's pipeline reaches the inner + // handler. + services.RemoveAll(); + services.AddScoped(_ => TestResolvedTenantContext.Instance); + }); + } +} + +internal sealed class TestResolvedTenantContext : ITenantContext +{ + public static TestResolvedTenantContext Instance { get; } = new(); + + public bool IsResolved => true; + public Guid TenantId { get; } = Guid.Parse("018f4d40-0000-7000-8000-000000000001"); + public Guid? OrganizationId { get; } + public UserId? UserId { get; } + public string? CorrelationId => null; + public string? ModuleName => "integration-test"; +} + +[Route("test")] +public sealed class CrossCuttingTestController(IMediator mediator) : ControllerBase +{ + [HttpGet("throw-provider-5xx")] + [SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "Controller actions are instance methods by ASP.NET routing convention.")] + public IActionResult ThrowServerProvider() => + throw new ProviderException("test-provider", "upstream returned 5xx", isClientError: false); + + [HttpGet("throw-provider-4xx")] + [SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "Controller actions are instance methods by ASP.NET routing convention.")] + public IActionResult ThrowClientProvider() => + throw new ProviderException( + error: new Error(new LocalizedMessage("lockey_validation_failed")), + providerName: "test-provider", + message: "upstream returned 4xx", + isClientError: true); + + [HttpPost("validate")] + public async Task Validate( + [FromBody] TestValidationCommand command, + CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return result.ToActionResult(); + } +} + +public sealed record TestValidationCommand(string Name) : IRequest>; + +internal sealed class TestValidationValidator : AbstractValidator +{ + public TestValidationValidator() + { + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_name_required"); + } +} + +internal sealed class TestValidationHandler : IRequestHandler> +{ + public Task> Handle( + TestValidationCommand request, CancellationToken cancellationToken) => + Task.FromResult(Result.Ok(request.Name)); +} diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index fa5d581..e5809a2 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -28,6 +28,12 @@ + + + diff --git a/backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs b/backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs new file mode 100644 index 0000000..54010b5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using FluentAssertions; +using LearnStack.Analyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace LearnStack.Tests.Unit.Analyzers; + +/// +/// Locks the review-4 H1 regression: the analyzer's Roslyn diagnostic id +/// must be a valid identifier (LS0001) so reporting succeeds instead +/// of crashing with AD0001 ("not a valid identifier"). Runs the real +/// over synthetic compilations. +/// +public sealed class DomainExceptionThrowAnalyzerTests +{ + private const string DomainExceptionShim = """ + namespace LearnStack.SharedKernel.Errors + { + public class DomainException : System.Exception + { + public DomainException(string message) : base(message) { } + } + } + """; + + [Fact] + public void DiagnosticId_Is_A_Valid_Roslyn_Identifier() + { + // Roslyn requires ids to be valid identifiers (no hyphens). The + // human-readable rule name keeps the hyphenated form. + DomainExceptionThrowAnalyzer.DiagnosticId.Should().Be("LS0001"); + DomainExceptionThrowAnalyzer.DiagnosticId.Should().MatchRegex("^[A-Za-z][A-Za-z0-9]*$"); + DomainExceptionThrowAnalyzer.RuleName.Should().Be("LearnStackException-DomainExceptionThrow"); + } + + [Fact] + public async Task Reports_LS0001_On_Throw_New_DomainException() + { + const string source = DomainExceptionShim + """ + namespace Sample + { + public sealed class Aggregate + { + public void Mutate() => + throw new LearnStack.SharedKernel.Errors.DomainException("boom"); + } + } + """; + + var diagnostics = await RunAnalyzerAsync(source); + + // The key assertion: a real diagnostic with the valid id is produced — + // NOT an AD0001 analyzer crash. + diagnostics.Should().ContainSingle(d => d.Id == "LS0001"); + diagnostics.Should().NotContain(d => d.Id == "AD0001"); + } + + [Fact] + public async Task Does_Not_Report_When_No_DomainException_Is_Thrown() + { + const string source = DomainExceptionShim + """ + namespace Sample + { + public sealed class Handler + { + public string Handle() => "ok"; // returns a value, throws nothing + } + } + """; + + var diagnostics = await RunAnalyzerAsync(source); + + diagnostics.Should().NotContain(d => d.Id == "LS0001"); + diagnostics.Should().NotContain(d => d.Id == "AD0001"); + } + + private static async Task> RunAnalyzerAsync(string source) + { + var compilation = CSharpCompilation.Create( + assemblyName: "AnalyzerSample", + syntaxTrees: [CSharpSyntaxTree.ParseText(source)], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new DomainExceptionThrowAnalyzer())); + + return await withAnalyzers.GetAnalyzerDiagnosticsAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs new file mode 100644 index 0000000..b2ee25a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs @@ -0,0 +1,70 @@ +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Common; + +/// +/// HttpStatusMap mirrors Standards 09 § Result Type's error-code table. +/// Adding a new code requires updating both places; these tests catch the +/// drift. +/// +public sealed class HttpStatusMapTests +{ + [Theory] + [InlineData("validation_failed", 400)] + [InlineData("unauthorized", 401)] + [InlineData("forbidden", 403)] + [InlineData("feature_disabled", 403)] + [InlineData("not_found", 404)] + [InlineData("tenant_mismatch", 404)] + [InlineData("concurrency_conflict", 409)] + [InlineData("business_rule_violation", 409)] + [InlineData("rate_limited", 429)] + [InlineData("dependency_unavailable", 503)] + [InlineData("unknown_code", 500)] + public void For_Code_Matches_StandardsTable(string code, int expected) + { + HttpStatusMap.For(code).Should().Be(expected); + } + + [Fact] + public void For_ProviderException_Default_Derives_503_From_DependencyUnavailable_Code() + { + // IsClientError gates Sentry capture only — it does NOT drive the + // HTTP status. A bare provider failure carries the default + // dependency_unavailable code → 503, regardless of IsClientError, + // so the body code and status stay consistent. + var clientError = new ProviderException("test", "bad input", isClientError: true); + var serverError = new ProviderException("test", "upstream down", isClientError: false); + + HttpStatusMap.For(clientError).Should().Be(503); + HttpStatusMap.For(serverError).Should().Be(503); + } + + [Fact] + public void For_ProviderException_With_Explicit_Error_Derives_Status_From_That_Code() + { + // An adapter surfacing a provider 4xx as client-actionable passes an + // explicit Error; the status follows that code so body+status agree. + var ex = new ProviderException( + error: new Error(new LocalizedMessage("lockey_validation_failed")), + providerName: "test", + message: "provider rejected the request", + isClientError: true); + + HttpStatusMap.For(ex).Should().Be(400); + } + + [Fact] + public void For_OperationCanceled_Maps_To_499() + { + // 499 (client closed request) — Standards 10 § Tracing leaves + // OperationCanceled spans Unset; the HTTP surface returns 499 for + // the client disconnect. + HttpStatusMap.For(new OperationCanceledException()).Should().Be(499); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs new file mode 100644 index 0000000..9618b8f --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Common; + +/// +/// ResultExtensions.ToActionResult contract per ADR-0032 § Sub-decision 6 — +/// the sanctioned shape every controller endpoint uses. The failure path +/// returns , which defers the +/// body assembly until ExecuteResultAsync +/// runs; these tests verify both the immediate carry (Error + status) and +/// the executed body shape. +/// +public sealed class ResultExtensionsTests +{ + [Fact] + public void Success_Maps_To_OkObjectResult() + { + var result = Result.Ok("payload"); + + var action = result.ToActionResult(); + + action.Should().BeOfType() + .Which.Value.Should().Be("payload"); + } + + [Fact] + public void Failure_Returns_ProblemDetailsActionResult_With_StatusCode_From_Error() + { + var error = new Error(new LocalizedMessage("lockey_not_found")); + var result = Result.Fail(error); + + var action = result.ToActionResult(); + + var pdar = action.Should().BeOfType().Which; + pdar.Error.Should().BeSameAs(error); + pdar.StatusCode.Should().Be(404); + } + + [Fact] + public async Task ExecuteResultAsync_Populates_ProblemDetails_From_HttpContext() + { + // ProblemDetailsActionResult builds the body lazily so the + // sanctioned controller shape `(await Send(...)).ToActionResult()` + // (no HttpContext argument) still populates instance + correlation + // when ASP.NET invokes ExecuteResultAsync. + var error = new Error(new LocalizedMessage("lockey_not_found")); + var sut = new ProblemDetailsActionResult(error); + + var actionContext = BuildActionContext(path: "/v1/courses/abc"); + await sut.ExecuteResultAsync(actionContext); + + var body = actionContext.HttpContext.Items["WrittenBody"] + .Should().BeOfType().Which; + body.Status.Should().Be(404); + body.Instance.Should().Be("/v1/courses/abc"); + body.Extensions["code"].Should().Be("not_found"); + body.Extensions["messageKey"].Should().Be("lockey_not_found"); + body.Extensions.Should().ContainKey("correlationId"); + } + + private static ActionContext BuildActionContext(string path) + { + var services = new ServiceCollection(); + services.AddSingleton, CapturingObjectResultExecutor>(); + services.AddSingleton(NullLoggerFactory.Instance); + var sp = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext + { + RequestServices = sp, + }; + httpContext.Request.Path = new PathString(path); + + return new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor()); + } + + private sealed class CapturingObjectResultExecutor : IActionResultExecutor + { + public Task ExecuteAsync(ActionContext context, ObjectResult result) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(result); + context.HttpContext.Items["WrittenBody"] = result.Value; + if (result.StatusCode is { } code) + { + context.HttpContext.Response.StatusCode = code; + } + return Task.CompletedTask; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs new file mode 100644 index 0000000..d94ccf9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// AuditLogBehavior shell contract per ADR-0032 § Sub-decision 2 + +/// ADR-0016 § Pipeline behavior order. The shell catches handler +/// exceptions and rethrows via ExceptionDispatchInfo (preserving the +/// original stack); the audit-write itself is deferred to Packet 9 when +/// IAuditStore lights up. +/// +public sealed class AuditLogBehaviorTests +{ + public sealed record DummyCommand : IRequest>; + + [Fact] + public async Task Passes_Through_Successful_Result() + { + var behavior = new AuditLogBehavior>( + NullLogger>>.Instance); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("ok")); + + var result = await behavior.Handle(new DummyCommand(), next, default); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("ok"); + } + + [Fact] + public async Task Rethrows_Exception_From_Inner_Handler_Preserving_Stack() + { + var behavior = new AuditLogBehavior>( + NullLogger>>.Instance); + + var thrown = new InvalidOperationException("boom"); + RequestHandlerDelegate> next = () => throw thrown; + + var act = async () => await behavior.Handle(new DummyCommand(), next, default); + + // ExceptionDispatchInfo.Throw rethrows the original instance so + // reference equality still holds — the rethrow does not box a new + // wrapper exception. + var caught = await act.Should().ThrowAsync(); + caught.Which.Should().BeSameAs(thrown); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs new file mode 100644 index 0000000..e70dda0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// TenantContextBehavior shell contract — short-circuits with +/// Result.Fail(tenant_mismatch) when the resolution stage has not +/// populated ITenantContext. Until Packet 7 lands the resolver this is the +/// loud-fail guard for any handler executed without context. +/// +public sealed class TenantContextBehaviorTests +{ + public sealed record DummyCommand : IRequest>; + + [Fact] + public async Task Short_Circuits_When_Context_Unresolved() + { + var behavior = new TenantContextBehavior>( + UnresolvedTenantContext.Instance); + + var called = false; + RequestHandlerDelegate> next = () => + { + called = true; + return Task.FromResult(Result.Ok("should not run")); + }; + + var result = await behavior.Handle(new DummyCommand(), next, default); + + called.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("tenant_mismatch"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs new file mode 100644 index 0000000..f23bdf7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using FluentValidation; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// ValidationBehavior contract per ADR-0032 § Sub-decision 3 and Standards +/// 09 § Validation Errors. The behavior never throws +/// FluentValidation.ValidationException; it returns +/// Result.Fail(validation_failed, details) instead. +/// +public sealed class ValidationBehaviorTests +{ + public sealed record TestCommand(string Name) : IRequest>; + + private sealed class TestCommandValidator : AbstractValidator + { + public TestCommandValidator() + { + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_name_required"); + } + } + + [Fact] + public async Task Returns_Result_Fail_When_Validation_Fails() + { + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("should not reach")); + + var result = await behavior.Handle(new TestCommand(string.Empty), next, default); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().NotBeNull(); + result.Error!.Code.Should().Be("validation_failed"); + result.Error.Details.Should().NotBeNull(); + // FluentValidation keys properties in their original PascalCase shape; + // ProblemDetailsFactory at the HTTP boundary projects to camelCase per + // Standards 09 § Validation Errors. + result.Error.Details!["Name"].Should().Contain(m => m.Key == "lockey_name_required"); + } + + [Fact] + public async Task Calls_Inner_Handler_When_Validation_Succeeds() + { + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + var called = false; + RequestHandlerDelegate> next = () => + { + called = true; + return Task.FromResult(Result.Ok("ok")); + }; + + var result = await behavior.Handle(new TestCommand("alice"), next, default); + + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("ok"); + } + + [Fact] + public async Task Never_Throws_ValidationException() + { + // ADR-0032 § Sub-decision 3 binding: the pipeline never raises + // FluentValidation.ValidationException; the architecture-test + // catalogue entry ValidationBehavior_DoesNotThrow_ValidationException + // cites this assertion. + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("unreachable")); + + var act = async () => await behavior.Handle(new TestCommand(string.Empty), next, default); + + await act.Should().NotThrowAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs new file mode 100644 index 0000000..43753d0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.SharedKernel.Observability; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.ErrorTracking; + +/// +/// LocalFileErrorTracker is the air-gapped capture path per ADR-0032 § +/// Sub-decision 9. The capture must be best-effort: write a JSON envelope +/// if possible, swallow filesystem failures so the L1 handler still +/// returns the Problem Details body to the client. +/// +public sealed class LocalFileErrorTrackerTests : IDisposable +{ + private readonly string _directory; + + public LocalFileErrorTrackerTests() + { + _directory = Path.Combine( + Path.GetTempPath(), + "learnstack-errortracker-tests", + Guid.NewGuid().ToString("N")); + } + + [Fact] + public async Task CaptureAsync_Writes_JsonEnvelope_To_ConfiguredDirectory() + { + var sut = new LocalFileErrorTracker(_directory, NullLogger.Instance); + var context = new CapturedContext( + CorrelationId: "00-aabb-ccdd-01", + RequestPath: "/v1/courses", + RequestMethod: "POST", + TenantId: Guid.NewGuid(), + OrganizationId: null, + UserId: null, + ModuleName: "education"); + + await sut.CaptureAsync(new InvalidOperationException("boom"), context); + + var files = Directory.GetFiles(_directory); + files.Should().HaveCount(1); + + var raw = await File.ReadAllTextAsync(files[0]); + var doc = JsonDocument.Parse(raw); + doc.RootElement.GetProperty("CorrelationId").GetString().Should().Be("00-aabb-ccdd-01"); + doc.RootElement.GetProperty("RequestPath").GetString().Should().Be("/v1/courses"); + doc.RootElement.GetProperty("exception").GetProperty("type").GetString() + .Should().Be(typeof(InvalidOperationException).FullName); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs new file mode 100644 index 0000000..8e2ba18 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Observability.Serilog; +using LearnStack.SharedKernel.Secrets; +using Serilog.Core; +using Serilog.Events; +using Serilog.Parsing; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Observability; + +/// +/// RedactSensitiveFieldsEnricher behaviour (review-4): redacts sensitive +/// top-level AND nested properties, while leaving ordinary fields (and +/// substring-collision names like ClassName) untouched. +/// +public sealed class RedactSensitiveFieldsEnricherTests +{ + private const string Redacted = SensitiveTokenCatalog.RedactedValue; + + [Fact] + public void Redacts_Top_Level_Sensitive_Property() + { + var logEvent = CreateEvent( + new LogEventProperty("Password", new ScalarValue("hunter2")), + new LogEventProperty("UserName", new ScalarValue("alice"))); + + Enrich(logEvent); + + Scalar(logEvent, "Password").Should().Be(Redacted); + Scalar(logEvent, "UserName").Should().Be("alice"); + } + + [Fact] + public void Does_Not_Redact_Substring_Collision_Names() + { + var logEvent = CreateEvent( + new LogEventProperty("ClassName", new ScalarValue("OrderService")), + new LogEventProperty("BusinessName", new ScalarValue("Acme"))); + + Enrich(logEvent); + + Scalar(logEvent, "ClassName").Should().Be("OrderService"); + Scalar(logEvent, "BusinessName").Should().Be("Acme"); + } + + [Fact] + public void Redacts_Nested_Sensitive_Property_In_Destructured_Object() + { + var user = new StructureValue( + [ + new LogEventProperty("Id", new ScalarValue("u-1")), + new LogEventProperty("Password", new ScalarValue("hunter2")), + new LogEventProperty("Tckn", new ScalarValue("12345678901")), + ]); + var logEvent = CreateEvent(new LogEventProperty("User", user)); + + Enrich(logEvent); + + var redactedUser = (StructureValue)logEvent.Properties["User"]; + ScalarOf(redactedUser, "Id").Should().Be("u-1"); + ScalarOf(redactedUser, "Password").Should().Be(Redacted); + ScalarOf(redactedUser, "Tckn").Should().Be(Redacted); + } + + [Fact] + public void Leaves_Clean_Event_Unchanged_By_Reference() + { + var inner = new ScalarValue("plain"); + var logEvent = CreateEvent(new LogEventProperty("UserName", inner)); + + Enrich(logEvent); + + // No sensitive data anywhere → the original value instance is retained. + logEvent.Properties["UserName"].Should().BeSameAs(inner); + } + + private static void Enrich(LogEvent logEvent) => + new RedactSensitiveFieldsEnricher().Enrich(logEvent, new SimplePropertyFactory()); + + private static LogEvent CreateEvent(params LogEventProperty[] properties) + { + var logEvent = new LogEvent( + DateTimeOffset.UtcNow, + LogEventLevel.Information, + exception: null, + new MessageTemplate("test", []), + []); + + foreach (var property in properties) + { + logEvent.AddOrUpdateProperty(property); + } + + return logEvent; + } + + private static string? Scalar(LogEvent logEvent, string name) => + ((ScalarValue)logEvent.Properties[name]).Value?.ToString(); + + private static string? ScalarOf(StructureValue structure, string name) => + ((ScalarValue)structure.Properties.Single(p => p.Name == name).Value).Value?.ToString(); + + private sealed class SimplePropertyFactory : ILogEventPropertyFactory + { + public LogEventProperty CreateProperty(string name, object? value, bool destructureObjects = false) => + new(name, value as LogEventPropertyValue ?? new ScalarValue(value)); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs new file mode 100644 index 0000000..f5219dc --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs @@ -0,0 +1,91 @@ +using System.Diagnostics; +using FluentAssertions; +using LearnStack.Infrastructure.Observability; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Observability; + +/// +/// Backs the catalogue entry +/// TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// (ADR-0032 § Sub-decision 10). The processor must no-op when the +/// singleton accessor is null — auto-instrumentation libraries create +/// warm-up activities before any handler scope opens. +/// +public sealed class TenantContextSpanProcessorTests +{ + [Fact] + public void OnStart_DoesNotThrow_When_Accessor_Current_Is_Null() + { + var accessor = new TestAccessor(current: null); + var processor = new TenantContextSpanProcessor(accessor); + + using var activity = new Activity("warm-up"); + activity.Start(); + + var act = () => processor.OnStart(activity); + + act.Should().NotThrow(); + } + + [Fact] + public void OnStart_Enriches_Activity_When_Context_Is_Resolved() + { + var tenantId = Guid.Parse("018f4d40-1234-7000-8000-000000000001"); + var organizationId = Guid.Parse("018f4d40-1234-7000-8000-000000000002"); + var userId = UserId.From(Guid.Parse("018f4d40-1234-7000-8000-000000000003")); + + var accessor = new TestAccessor(new TestTenantContext( + IsResolved: true, + TenantId: tenantId, + OrganizationId: organizationId, + UserId: userId, + CorrelationId: "00-aabbccdd-eeff0011-01", + ModuleName: "education")); + + var processor = new TenantContextSpanProcessor(accessor); + using var activity = new Activity("test-span"); + activity.Start(); + + processor.OnStart(activity); + + // OTel attribute types are string / long / double / bool / array. + // The processor projects Guid via ToString() (default "D" format) + // so the wire format is stable across exporters — review-2 fix. + activity.GetTagItem("tenant.id").Should().Be(tenantId.ToString()); + activity.GetTagItem("organization.id").Should().Be(organizationId.ToString()); + activity.GetTagItem("user.id").Should().Be(userId.Value.ToString()); + activity.GetTagItem("correlation.id").Should().Be("00-aabbccdd-eeff0011-01"); + activity.GetTagItem("module").Should().Be("education"); + } + + [Fact] + public void OnStart_DoesNotEnrich_TenantTags_When_Context_Is_Unresolved() + { + var accessor = new TestAccessor(UnresolvedTenantContext.Instance); + var processor = new TenantContextSpanProcessor(accessor); + + using var activity = new Activity("unresolved"); + activity.Start(); + + var act = () => processor.OnStart(activity); + + act.Should().NotThrow(); + activity.GetTagItem("tenant.id").Should().BeNull(); + } + + private sealed class TestAccessor(ITenantContext? current) : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } = current; + } + + private sealed record TestTenantContext( + bool IsResolved, + Guid TenantId, + Guid? OrganizationId, + UserId? UserId, + string? CorrelationId, + string? ModuleName) : ITenantContext; +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs new file mode 100644 index 0000000..99415cc --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Resilience; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Resilience; +using Polly.Timeout; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Resilience; + +/// +/// ProviderResilience contract per ADR-0032 § Sub-decision 5. Phase 02a +/// Packet 3 ships the socket; these tests assert the pipeline is buildable +/// with the canonical options shape, retries non-client provider failures, +/// and skips retry for client errors. +/// +public sealed class ProviderResilienceTests +{ + private interface ITestPort + { + } + + [Fact] + public void Pipeline_Is_Built_With_Default_Options() + { + var sut = new ProviderResilience("test", new ResilienceOptions()); + + sut.PortName.Should().Be("test"); + sut.Pipeline.Should().NotBeNull(); + } + + [Fact] + public async Task Retry_Activates_On_Server_Side_ProviderException() + { + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 2, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = false }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + + var act = async () => await sut.Pipeline.ExecuteAsync(_ => + { + attempts++; + throw new ProviderException("test", "upstream 5xx", isClientError: false); +#pragma warning disable CS0162 // Unreachable code: makes the lambda's return type explicit. + return ValueTask.CompletedTask; +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(3, "MaxAttempts = 2 retries means 1 initial + 2 retries = 3 invocations"); + } + + [Fact] + public async Task Retry_Does_Not_Trigger_On_Client_Side_ProviderException() + { + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 5, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = false }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + + var act = async () => await sut.Pipeline.ExecuteAsync(_ => + { + attempts++; + throw new ProviderException("test", "bad input", isClientError: true); +#pragma warning disable CS0162 + return ValueTask.CompletedTask; +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(1, "client errors are not retried — Standards 09 § Retry vs Don't Retry"); + } + + [Fact] + public async Task Retry_Activates_On_Pipeline_Timeout() + { + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 2, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = true, TotalSeconds = 0.05 }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + + var act = async () => await sut.Pipeline.ExecuteAsync(async ct => + { + attempts++; + // Observes the token the Timeout strategy cancels — mirrors how + // a real provider call (e.g. HttpClient) respects the ambient + // cancellation token instead of blocking past it. + await Task.Delay(TimeSpan.FromSeconds(5), ct); + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(3, "MaxAttempts = 2 retries means 1 initial + 2 retries = 3 invocations, each timing out"); + } + + [Fact] + public async Task Caller_Cancellation_Is_Not_Retried_As_A_Timeout() + { + // Same enabled retry as Retry_Activates_On_Pipeline_Timeout, but the + // callback cancels the caller's own token mid-execution instead of + // hitting the pipeline's Timeout strategy — retry's ShouldHandle + // must tell the two apart and let real cancellation through. + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 2, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = false }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + using var cts = new CancellationTokenSource(); + + var act = async () => await sut.Pipeline.ExecuteAsync(async (ct) => + { + attempts++; + cts.Cancel(); + ct.ThrowIfCancellationRequested(); + await Task.CompletedTask; + }, cts.Token); + + await act.Should().ThrowAsync(); + attempts.Should().Be(1, "caller-initiated cancellation must propagate immediately, not be retried as a timeout"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj index 14c940c..a305a66 100644 --- a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj +++ b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj @@ -10,6 +10,14 @@ + + + + + + @@ -19,6 +27,9 @@ + +