diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b3d048..32f2721 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -388,6 +388,7 @@ For code examples: - Use meaningful names. - Prefer examples that compile and can be tested. - Add tests when the lesson depends on behavioral correctness. +- For executable samples, cover every documented outcome and stable reason code, prove negative side-effect invariants, and include relevant invalid-input and cancellation boundaries with descriptive test names. - Avoid embedding real credentials, secrets, tokens, connection strings, or personally identifiable information. - Use obviously fictional or placeholder values where examples require identifiers or sensitive-looking data. - When adding, renaming, or removing an executable sample, update both sample catalogs. Add or revise its repository-facing entry in `samples/README.md`, and update `docs/samples/index.md` with its learning objective, difficulty, key invariant, run command, and canonical README link so the published sample guide remains current. diff --git a/samples/acknowledgment-and-audit-residue/README.md b/samples/acknowledgment-and-audit-residue/README.md index 9637cec..8e5fa2a 100644 --- a/samples/acknowledgment-and-audit-residue/README.md +++ b/samples/acknowledgment-and-audit-residue/README.md @@ -64,7 +64,7 @@ From the repository root: dotnet test samples/acknowledgment-and-audit-residue/Tests/AcknowledgmentAndAuditResidue.Tests.csproj ``` -The focused xUnit tests make the boundary explicit: acknowledgment can satisfy one governance requirement, but it does not itself grant authorization or execution authority. Re-evaluation still controls the next step, and changed resource state can still block execution. +The focused xUnit tests cover every policy outcome and acknowledgment binding failure, including the expiration boundary and stable reason codes. They also prove that acknowledgment does not grant execution authority, changed resource state can still block execution, and the executable scenarios preserve their correlated audit timelines. The sample uses deterministic local data and does not call external services. diff --git a/samples/acknowledgment-and-audit-residue/Tests/AcknowledgmentBoundaryTests.cs b/samples/acknowledgment-and-audit-residue/Tests/AcknowledgmentBoundaryTests.cs index 89af895..552854d 100644 --- a/samples/acknowledgment-and-audit-residue/Tests/AcknowledgmentBoundaryTests.cs +++ b/samples/acknowledgment-and-audit-residue/Tests/AcknowledgmentBoundaryTests.cs @@ -22,6 +22,9 @@ public void AcknowledgmentDoesNotGrantExecutionAuthority() GovernanceDecisionOutcome.AcknowledgmentRequired, initialDecision.Outcome); Assert.False(initialDecision.CanProceed); + Assert.Equal( + "account.disable.reason-required", + Assert.Single(initialDecision.Reasons).Code); Assert.Equal(0, executor.InvocationCount); AcknowledgmentChallenge challenge = CreateChallenge( @@ -123,6 +126,215 @@ public void ExpiredAcknowledgmentDoesNotReachExecution() Assert.Equal(0, executor.InvocationCount); } + [Fact] + public void RejectedAcknowledgmentIsInvalid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + response with { Accepted = false }, + response.OccurredUtc); + + Assert.False(validation.IsValid); + Assert.Equal("acknowledgment.rejected", validation.ReasonCode); + } + + [Fact] + public void WrongChallengeIdentifierIsInvalid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + response with { ChallengeId = "different-challenge" }, + response.OccurredUtc); + + Assert.False(validation.IsValid); + Assert.Equal("acknowledgment.challenge-mismatch", validation.ReasonCode); + } + + [Fact] + public void WrongActorIsInvalid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + response with { ActorId = "operator-99" }, + response.OccurredUtc); + + Assert.False(validation.IsValid); + Assert.Equal("acknowledgment.actor-mismatch", validation.ReasonCode); + } + + [Fact] + public void WrongAcknowledgmentCodeIsInvalid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + response with { AcknowledgmentCode = "account.disable.wrong-code" }, + response.OccurredUtc); + + Assert.False(validation.IsValid); + Assert.Equal("acknowledgment.code-mismatch", validation.ReasonCode); + } + + [Fact] + public void WrongCorrelationIdentifierIsInvalid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + response with { CorrelationId = "different-correlation" }, + response.OccurredUtc); + + Assert.False(validation.IsValid); + Assert.Equal("acknowledgment.correlation-mismatch", validation.ReasonCode); + } + + [Fact] + public void ResponseAtExpirationBoundaryIsValid() + { + (AcknowledgmentChallenge challenge, AcknowledgmentResponse response) = + CreateValidExchange(); + AcknowledgmentResponse boundaryResponse = response with + { + OccurredUtc = challenge.ExpiresUtc + }; + + AcknowledgmentValidation validation = AcknowledgmentValidator.Validate( + challenge, + boundaryResponse, + boundaryResponse.OccurredUtc); + + Assert.True(validation.IsValid); + Assert.Equal("acknowledgment.accepted", validation.ReasonCode); + } + + [Fact] + public void NonAdministratorIsDeniedWithStableReasonCode() + { + DisableAccountPolicyContext original = CreateContext(); + DisableAccountPolicyContext context = original with + { + Actor = original.Actor with { IsAdministrator = false } + }; + + GovernanceDecision decision = DisableAccountPolicy.Evaluate(context); + + Assert.Equal(GovernanceDecisionOutcome.Denied, decision.Outcome); + Assert.Equal( + "account.disable.not-administrator", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void CrossTenantRequestIsDeniedWithStableReasonCode() + { + DisableAccountPolicyContext original = CreateContext(); + DisableAccountPolicyContext context = original with + { + Account = original.Account with { TenantId = "tenant-b" } + }; + + GovernanceDecision decision = DisableAccountPolicy.Evaluate(context); + + Assert.Equal(GovernanceDecisionOutcome.Denied, decision.Outcome); + Assert.Equal( + "account.disable.cross-tenant", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void ProtectedAccountRecommendsEscalationWithStableReasonCode() + { + DisableAccountPolicyContext original = CreateContext(); + DisableAccountPolicyContext context = original with + { + Account = original.Account with { IsProtected = true } + }; + + GovernanceDecision decision = DisableAccountPolicy.Evaluate(context); + + Assert.Equal( + GovernanceDecisionOutcome.EscalationRecommended, + decision.Outcome); + Assert.Equal( + "account.disable.protected-account", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void SuppliedReasonAllowsRequestWithoutReasonCodes() + { + DisableAccountPolicyContext original = CreateContext(); + DisableAccountPolicyContext context = original with + { + Intent = original.Intent with { Reason = "Security investigation" } + }; + + GovernanceDecision decision = DisableAccountPolicy.Evaluate(context); + + Assert.Equal(GovernanceDecisionOutcome.Allowed, decision.Outcome); + Assert.True(decision.CanProceed); + Assert.Empty(decision.Reasons); + } + + [Fact] + public void AuditResidueKeepsLifecycleIdentityExplicit() + { + var residue = new AuditResidue( + Sequence: 2, + EventId: "test-user-100-event-02", + OccurredUtc: _nowUtc, + ActorId: "operator-7", + OperationName: "account.disable", + Outcome: "AcknowledgmentAccepted", + ReasonCodes: ["account.disable.reason-required", "acknowledgment.accepted"], + CorrelationId: "test-user-100", + PolicyVersion: "3.2", + Stage: "acknowledgment-accepted"); + + Assert.Equal("test-user-100", residue.CorrelationId); + Assert.Equal("3.2", residue.PolicyVersion); + Assert.Equal("acknowledgment-accepted", residue.Stage); + Assert.Equal(2, residue.ReasonCodes.Count); + } + + [Fact] + public void ExecutableScenariosPreserveExpectedAuditTimelines() + { + System.Reflection.MethodInfo entryPoint = + Assert.IsAssignableFrom( + typeof(DisableAccountPolicy).Assembly.EntryPoint); + + entryPoint.Invoke(null, [Array.Empty()]); + } + + private static (AcknowledgmentChallenge Challenge, AcknowledgmentResponse Response) + CreateValidExchange() + { + DisableAccountPolicyContext context = CreateContext(); + GovernanceDecision decision = DisableAccountPolicy.Evaluate(context); + AcknowledgmentChallenge challenge = CreateChallenge( + context, + decision, + _nowUtc); + + return ( + challenge, + CreateAcceptedResponse(challenge, _nowUtc.AddSeconds(1))); + } + private static DisableAccountPolicyContext CreateContext() { return new DisableAccountPolicyContext( diff --git a/samples/centralized-error-handling-and-problem-details/README.md b/samples/centralized-error-handling-and-problem-details/README.md index c939aab..8eb47f9 100644 --- a/samples/centralized-error-handling-and-problem-details/README.md +++ b/samples/centralized-error-handling-and-problem-details/README.md @@ -181,7 +181,7 @@ From the repository root: dotnet test samples/centralized-error-handling-and-problem-details/Tests/CentralizedErrorHandlingAndProblemDetails.Tests.csproj ``` -The focused tests prove these invariants: +The focused tests cover every governance-to-HTTP outcome, invalid and unknown scenarios, safe known and unexpected exception responses, and trace correlation. They prove these invariants: ```text Denied governance decision diff --git a/samples/centralized-error-handling-and-problem-details/Tests/ErrorHandlingIntegrationTests.cs b/samples/centralized-error-handling-and-problem-details/Tests/ErrorHandlingIntegrationTests.cs index 06d8322..1f92ea7 100644 --- a/samples/centralized-error-handling-and-problem-details/Tests/ErrorHandlingIntegrationTests.cs +++ b/samples/centralized-error-handling-and-problem-details/Tests/ErrorHandlingIntegrationTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Logging; @@ -15,6 +16,23 @@ public sealed class ErrorHandlingIntegrationTests private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + [Fact] + public async Task Allowed_governance_decision_returns_no_content_without_exception_handler_log() + { + await using TestApplication application = + await TestApplication.StartAsync(TestContext.Current.CancellationToken); + + HttpResponseMessage response = await application.Client.GetAsync( + "/governance/allowed", + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Empty(await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken)); + Assert.DoesNotContain( + application.LogProvider.Entries, + entry => entry.CategoryName == typeof(ApplicationExceptionHandler).FullName); + } + [Fact] public async Task Denied_governance_decision_is_explicit_403_without_exception_handler_log() { @@ -65,6 +83,88 @@ await client.GetAsync( typeof(ApplicationExceptionHandler).FullName); } + [Fact] + public async Task Acknowledgment_required_decision_maps_to_explicit_409_problem() + { + await using TestApplication application = + await TestApplication.StartAsync(TestContext.Current.CancellationToken); + + HttpResponseMessage response = await application.Client.GetAsync( + "/governance/acknowledgment-required", + TestContext.Current.CancellationToken); + ProblemDetails problem = await ReadProblemAsync( + response, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("Acknowledgment Required", problem.Title); + Assert.Equal("/problems/acknowledgment-required", problem.Type); + Assert.Equal("/governance/acknowledgment-required", problem.Instance); + Assert.Equal( + "governance.acknowledgment-required", + GetExtensionString(problem, "code")); + } + + [Fact] + public async Task Escalation_recommended_decision_maps_to_distinct_409_problem() + { + await using TestApplication application = + await TestApplication.StartAsync(TestContext.Current.CancellationToken); + + HttpResponseMessage response = await application.Client.GetAsync( + "/governance/escalation-recommended", + TestContext.Current.CancellationToken); + ProblemDetails problem = await ReadProblemAsync( + response, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("Escalation Recommended", problem.Title); + Assert.Equal("/problems/escalation-recommended", problem.Type); + Assert.Equal( + "governance.escalation-recommended", + GetExtensionString(problem, "code")); + } + + [Fact] + public async Task Unknown_governance_scenario_remains_a_not_found_problem() + { + await using TestApplication application = + await TestApplication.StartAsync(TestContext.Current.CancellationToken); + + HttpResponseMessage response = await application.Client.GetAsync( + "/governance/not-a-scenario", + TestContext.Current.CancellationToken); + ProblemDetails problem = await ReadProblemAsync( + response, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(StatusCodes.Status404NotFound, problem.Status); + Assert.DoesNotContain( + application.LogProvider.Entries, + entry => entry.CategoryName == typeof(ApplicationExceptionHandler).FullName); + } + + [Fact] + public void Empty_governance_scenario_is_rejected_as_invalid_input() + { + Assert.Throws( + () => GovernanceDecision.TryFromScenario(" ", out _)); + } + + [Fact] + public void Governance_scenario_matching_is_case_insensitive() + { + bool found = GovernanceDecision.TryFromScenario( + "DeNiEd", + out GovernanceDecision decision); + + Assert.True(found); + Assert.Equal(GovernanceDecisionOutcome.Denied, decision.Outcome); + Assert.Equal("governance.denied", decision.Code); + } + [Fact] public async Task Unexpected_exception_becomes_safe_500_without_sensitive_detail() { diff --git a/samples/decision-before-execution/README.md b/samples/decision-before-execution/README.md index e2b20b1..d2d38c2 100644 --- a/samples/decision-before-execution/README.md +++ b/samples/decision-before-execution/README.md @@ -50,7 +50,7 @@ From the repository root: dotnet test samples/decision-before-execution/Tests/DecisionBeforeExecution.Tests.csproj ``` -The focused xUnit tests assert that denied, deferred, and acknowledgment-required decisions never invoke the executor. A positive-control test confirms that an allowed decision crosses the execution boundary exactly once. +The focused xUnit tests assert every outcome and stable reason code. Denied, deferred, acknowledgment-required, and escalation-recommended decisions never invoke the executor; an allowed decision crosses the boundary exactly once, and cancellation prevents that execution before its side effect is recorded. The sample evaluates five deterministic scenarios: diff --git a/samples/decision-before-execution/Tests/ExecutionBoundaryTests.cs b/samples/decision-before-execution/Tests/ExecutionBoundaryTests.cs index edb4a0c..e80d0c3 100644 --- a/samples/decision-before-execution/Tests/ExecutionBoundaryTests.cs +++ b/samples/decision-before-execution/Tests/ExecutionBoundaryTests.cs @@ -16,6 +16,7 @@ public async Task DeniedDecisionDoesNotReachExecutor() CancellationToken.None); Assert.Equal(DecisionOutcome.Denied, decision.Outcome); + Assert.Equal("account.disable.not-administrator", decision.ReasonCode); Assert.Equal(0, executor.InvocationCount); } @@ -31,6 +32,7 @@ public async Task DeferredDecisionDoesNotReachExecutor() CancellationToken.None); Assert.Equal(DecisionOutcome.Deferred, decision.Outcome); + Assert.Equal("account.disable.maintenance-hold", decision.ReasonCode); Assert.Equal(0, executor.InvocationCount); } @@ -48,6 +50,37 @@ public async Task AcknowledgmentRequiredDecisionDoesNotReachExecutor() Assert.Equal( DecisionOutcome.AcknowledgmentRequired, decision.Outcome); + Assert.Equal("account.disable.reason-required", decision.ReasonCode); + Assert.Equal(0, executor.InvocationCount); + } + + [Fact] + public async Task EscalationRecommendedDecisionDoesNotReachExecutor() + { + var executor = new RecordingDisableAccountExecutor(); + var workflow = new DisableAccountWorkflow(executor); + + GovernanceDecision decision = await workflow.ExecuteAsync( + CreateContext(isProtectedAccount: true), + CancellationToken.None); + + Assert.Equal(DecisionOutcome.EscalationRecommended, decision.Outcome); + Assert.Equal("account.disable.protected-account", decision.ReasonCode); + Assert.Equal(0, executor.InvocationCount); + } + + [Fact] + public async Task WhitespaceReasonRequiresAcknowledgmentWithoutExecution() + { + var executor = new RecordingDisableAccountExecutor(); + var workflow = new DisableAccountWorkflow(executor); + + GovernanceDecision decision = await workflow.ExecuteAsync( + CreateContext(reason: " "), + CancellationToken.None); + + Assert.Equal(DecisionOutcome.AcknowledgmentRequired, decision.Outcome); + Assert.Equal("account.disable.reason-required", decision.ReasonCode); Assert.Equal(0, executor.InvocationCount); } @@ -63,9 +96,38 @@ public async Task AllowedDecisionCrossesExecutionBoundaryExactlyOnce() CancellationToken.None); Assert.Equal(DecisionOutcome.Allowed, decision.Outcome); + Assert.Equal("decision.allowed", decision.ReasonCode); Assert.Equal(1, executor.InvocationCount); } + [Fact] + public async Task CancellationPreventsAllowedExecution() + { + var executor = new RecordingDisableAccountExecutor(); + var workflow = new DisableAccountWorkflow(executor); + var cancellationToken = new CancellationToken(canceled: true); + + await Assert.ThrowsAnyAsync( + () => workflow.ExecuteAsync(CreateContext(), cancellationToken)); + + Assert.Equal(0, executor.InvocationCount); + } + + [Fact] + public async Task BlockedDecisionDoesNotNeedToCrossCanceledExecutionBoundary() + { + var executor = new RecordingDisableAccountExecutor(); + var workflow = new DisableAccountWorkflow(executor); + var cancellationToken = new CancellationToken(canceled: true); + + GovernanceDecision decision = await workflow.ExecuteAsync( + CreateContext(requesterIsAdministrator: false), + cancellationToken); + + Assert.Equal(DecisionOutcome.Denied, decision.Outcome); + Assert.Equal(0, executor.InvocationCount); + } + private static DisableAccountContext CreateContext( bool requesterIsAdministrator = true, bool isProtectedAccount = false, diff --git a/samples/decision-pipeline-refactoring/README.md b/samples/decision-pipeline-refactoring/README.md index 958a481..b19e844 100644 --- a/samples/decision-pipeline-refactoring/README.md +++ b/samples/decision-pipeline-refactoring/README.md @@ -227,7 +227,7 @@ dotnet test samples/Samples.slnx ## What the Tests Prove -The test suite includes one diagnostic test for the flawed starter and focused invariants for the refactored pipeline. +The test suite includes one diagnostic test for the flawed starter and focused invariants for the refactored pipeline. Every outcome asserts its stable reason and decision evidence, blocked paths prove that all protected side-effect counters remain zero, and invalid or unknown account identifiers stop before decision evidence or execution. ### Starter diagnosis diff --git a/samples/decision-pipeline-refactoring/Sample/Program.cs b/samples/decision-pipeline-refactoring/Sample/Program.cs index be5706a..a366bf9 100644 --- a/samples/decision-pipeline-refactoring/Sample/Program.cs +++ b/samples/decision-pipeline-refactoring/Sample/Program.cs @@ -203,6 +203,9 @@ public sealed class AccountDisableContextBuilder(IAccountRepository repository) { public AccountDisableContext Build(AccountDisableRequest request) { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccountId); + AccountSnapshot account = repository.GetRequired(request.AccountId); return new AccountDisableContext( diff --git a/samples/decision-pipeline-refactoring/Tests/DecisionPipelineInvariantTests.cs b/samples/decision-pipeline-refactoring/Tests/DecisionPipelineInvariantTests.cs index 75c8a51..8829803 100644 --- a/samples/decision-pipeline-refactoring/Tests/DecisionPipelineInvariantTests.cs +++ b/samples/decision-pipeline-refactoring/Tests/DecisionPipelineInvariantTests.cs @@ -39,11 +39,9 @@ public void Denied_request_never_reaches_the_executor() acknowledgmentSatisfied: true)); Assert.Equal(DecisionOutcome.Denied, decision.Outcome); - Assert.Equal(0, fixture.Executor.InvocationCount); - Assert.Equal(0, fixture.Repository.DisableCount); - Assert.Equal(0, fixture.Notifications.SendCount); - Assert.Equal(0, fixture.Events.PublishCount); - Assert.Single(fixture.Evidence.Records); + Assert.Equal("account.disable.protected-account", decision.ReasonCode); + AssertNoSideEffects(fixture); + AssertDecisionEvidence(fixture, decision, "acct-protected", 7); } [Fact] @@ -57,9 +55,9 @@ public void Deferred_request_never_reaches_the_executor() acknowledgmentSatisfied: true)); Assert.Equal(DecisionOutcome.Deferred, decision.Outcome); - Assert.Equal(0, fixture.Executor.InvocationCount); - Assert.Equal(0, fixture.Repository.DisableCount); - Assert.Single(fixture.Evidence.Records); + Assert.Equal("account.disable.investigation-pending", decision.ReasonCode); + AssertNoSideEffects(fixture); + AssertDecisionEvidence(fixture, decision, "acct-pending", 4); } [Fact] @@ -73,9 +71,9 @@ public void Acknowledgment_required_without_satisfied_continuation_never_reaches acknowledgmentSatisfied: false)); Assert.Equal(DecisionOutcome.AcknowledgmentRequired, decision.Outcome); - Assert.Equal(0, fixture.Executor.InvocationCount); - Assert.Equal(0, fixture.Repository.DisableCount); - Assert.Single(fixture.Evidence.Records); + Assert.Equal("account.disable.acknowledgment-required", decision.ReasonCode); + AssertNoSideEffects(fixture); + AssertDecisionEvidence(fixture, decision, "acct-standard", 3); } [Fact] @@ -89,9 +87,9 @@ public void Escalation_recommended_never_reaches_the_executor() acknowledgmentSatisfied: true)); Assert.Equal(DecisionOutcome.EscalationRecommended, decision.Outcome); - Assert.Equal(0, fixture.Executor.InvocationCount); - Assert.Equal(0, fixture.Repository.DisableCount); - Assert.Single(fixture.Evidence.Records); + Assert.Equal("account.disable.manual-review-required", decision.ReasonCode); + AssertNoSideEffects(fixture); + AssertDecisionEvidence(fixture, decision, "acct-manual", 11); } [Fact] @@ -105,6 +103,7 @@ public void Allowed_request_reaches_the_executor_exactly_once() acknowledgmentSatisfied: true)); Assert.Equal(DecisionOutcome.Allowed, decision.Outcome); + Assert.Equal("account.disable.allowed", decision.ReasonCode); Assert.Equal(1, fixture.Executor.InvocationCount); Assert.Equal(1, fixture.Repository.DisableCount); Assert.Equal(1, fixture.Notifications.SendCount); @@ -112,6 +111,17 @@ public void Allowed_request_reaches_the_executor_exactly_once() Assert.Equal(2, fixture.Evidence.Records.Count); Assert.Equal("decision", fixture.Evidence.Records[0].Stage); Assert.Equal("execution", fixture.Evidence.Records[1].Stage); + Assert.All( + fixture.Evidence.Records, + record => + { + Assert.Equal("corr-acct-standard", record.CorrelationId); + Assert.Equal("admin-17", record.ActorId); + Assert.Equal("acct-standard", record.AccountId); + Assert.Equal(3, record.ResourceVersion); + Assert.Equal(DecisionOutcome.Allowed, record.Outcome); + Assert.Equal("account.disable.allowed", record.ReasonCode); + }); } [Fact] @@ -132,6 +142,61 @@ public void Non_administrator_is_denied_after_authoritative_context_is_loaded_bu Assert.Equal(3, fixture.Evidence.Records[0].ResourceVersion); } + [Fact] + public void Whitespace_account_identifier_is_rejected_before_decision_or_execution() + { + Fixture fixture = CreateFixture(); + + Assert.Throws( + () => fixture.Pipeline.Handle(Request( + accountId: " ", + isAdministrator: true, + acknowledgmentSatisfied: true))); + + AssertNoSideEffects(fixture); + Assert.Empty(fixture.Evidence.Records); + } + + [Fact] + public void Unknown_account_is_rejected_before_decision_or_execution() + { + Fixture fixture = CreateFixture(); + + Assert.Throws( + () => fixture.Pipeline.Handle(Request( + accountId: "acct-missing", + isAdministrator: true, + acknowledgmentSatisfied: true))); + + AssertNoSideEffects(fixture); + Assert.Empty(fixture.Evidence.Records); + } + + private static void AssertNoSideEffects(Fixture fixture) + { + Assert.Equal(0, fixture.Executor.InvocationCount); + Assert.Equal(0, fixture.Repository.DisableCount); + Assert.Equal(0, fixture.Notifications.SendCount); + Assert.Equal(0, fixture.Events.PublishCount); + } + + private static void AssertDecisionEvidence( + Fixture fixture, + GovernanceDecision decision, + string accountId, + int resourceVersion) + { + DecisionEvidenceRecord record = Assert.Single(fixture.Evidence.Records); + + Assert.Equal("decision", record.Stage); + Assert.Equal($"corr-{accountId}", record.CorrelationId); + Assert.Equal("admin-17", record.ActorId); + Assert.Equal(accountId, record.AccountId); + Assert.Equal(resourceVersion, record.ResourceVersion); + Assert.Equal(decision.Outcome, record.Outcome); + Assert.Equal(decision.ReasonCode, record.ReasonCode); + } + private static AccountDisableRequest Request( string accountId, bool isAdministrator, diff --git a/samples/middleware-ordering-changes-behavior/README.md b/samples/middleware-ordering-changes-behavior/README.md index 998df83..6412fa3 100644 --- a/samples/middleware-ordering-changes-behavior/README.md +++ b/samples/middleware-ordering-changes-behavior/README.md @@ -170,8 +170,10 @@ dotnet test samples/Samples.slnx The tests prove: 1. Request and response traversal occur in opposite directions. -2. The corrected exception boundary handles a fault produced downstream. -3. The deliberately incorrect order leaves the earlier fault outside that boundary. +2. The corrected exception boundary handles a downstream fault with the documented controlled response. +3. A handled fault does not reach inner middleware or the endpoint. +4. The deliberately incorrect order leaves the earlier fault outside that boundary and observes only the fault probe. +5. Normal requests reach the endpoint and report the configured pipeline mode. ## What This Sample Intentionally Omits diff --git a/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs b/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs index ebff98f..68009d0 100644 --- a/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs +++ b/samples/middleware-ordering-changes-behavior/Tests/MiddlewareOrderTests.cs @@ -60,6 +60,40 @@ public async Task CorrectOrder_CatchesFaultInsideExceptionBoundary() events); } + [Fact] + public async Task CorrectOrder_FaultProducesControlledProblemBoundaryResponse() + { + RequestDelegate pipeline = MiddlewareOrderDemo.Build(correctOrder: true); + DefaultHttpContext context = CreateContext("/fault"); + + await pipeline(context); + + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body); + string body = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + + Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode); + Assert.Equal("text/plain", context.Response.ContentType); + Assert.Equal( + "Handled by demo exception boundary: Demonstration failure.", + body); + } + + [Fact] + public async Task CorrectOrder_FaultStopsInnerMiddlewareAndEndpoint() + { + List events = []; + RequestDelegate pipeline = MiddlewareOrderDemo.Build(true, events.Add); + DefaultHttpContext context = CreateContext("/fault"); + + await pipeline(context); + + Assert.DoesNotContain("inner:request", events); + Assert.DoesNotContain("endpoint", events); + Assert.DoesNotContain("outer:response", events); + Assert.Equal("exception-boundary:response", events[^1]); + } + [Fact] public async Task IncorrectOrder_LeavesEarlierFaultOutsideExceptionBoundary() { @@ -81,6 +115,41 @@ await Assert.ThrowsAsync( events); } + [Fact] + public async Task IncorrectOrder_FaultProbeIsOnlyObservedStage() + { + List events = []; + RequestDelegate pipeline = MiddlewareOrderDemo.Build(false, events.Add); + DefaultHttpContext context = CreateContext("/fault"); + + await Assert.ThrowsAsync(() => pipeline(context)); + + Assert.Equal(["fault-probe:throw"], events); + } + + [Fact] + public async Task NormalEndpointReportsConfiguredPipelineMode() + { + RequestDelegate pipeline = MiddlewareOrderDemo.Build(correctOrder: false); + DefaultHttpContext context = CreateContext("/"); + + await pipeline(context); + + context.Response.Body.Position = 0; + using var reader = new StreamReader(context.Response.Body); + string body = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + Assert.Equal("Endpoint reached. Pipeline mode: incorrect.", body); + } + + [Fact] + public void ConfigureRejectsMissingApplicationBuilder() + { + Assert.Throws( + () => MiddlewareOrderDemo.Configure(null!, correctOrder: true)); + } + private static DefaultHttpContext CreateContext(string path) { var context = diff --git a/samples/policy-context-and-explicit-decision-outcomes/README.md b/samples/policy-context-and-explicit-decision-outcomes/README.md index cf8fe3e..12e72c6 100644 --- a/samples/policy-context-and-explicit-decision-outcomes/README.md +++ b/samples/policy-context-and-explicit-decision-outcomes/README.md @@ -56,7 +56,7 @@ From the repository root: dotnet test samples/policy-context-and-explicit-decision-outcomes/Tests/PolicyContextAndExplicitDecisionOutcomes.Tests.csproj ``` -The focused xUnit tests verify representative structured outcomes and confirm that denied, deferred, and acknowledgment-required decisions remain non-proceeding host instructions. +The focused xUnit tests verify the full structured outcome matrix, stable reason codes, warning and allowed proceed semantics, whitespace input, and intentional rule precedence. Denied, deferred, acknowledgment-required, and escalation-recommended decisions remain non-proceeding host instructions. The sample evaluates seven deterministic scenarios: diff --git a/samples/policy-context-and-explicit-decision-outcomes/Tests/DecisionOutcomeTests.cs b/samples/policy-context-and-explicit-decision-outcomes/Tests/DecisionOutcomeTests.cs index 4d98920..93078c4 100644 --- a/samples/policy-context-and-explicit-decision-outcomes/Tests/DecisionOutcomeTests.cs +++ b/samples/policy-context-and-explicit-decision-outcomes/Tests/DecisionOutcomeTests.cs @@ -30,6 +30,47 @@ public void DeferredOutcomeIsExplicitAndCannotProceed() Assert.Single(decision.Reasons).Code); } + [Fact] + public void CrossTenantOutcomeIsDeniedWithStableReasonCode() + { + GovernanceDecision decision = DisableAccountPolicy.Evaluate( + CreateContext(accountTenantId: "tenant-b")); + + Assert.Equal(GovernanceDecisionOutcome.Denied, decision.Outcome); + Assert.False(decision.CanProceed); + Assert.Equal( + "account.disable.cross-tenant", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void AlreadyDisabledOutcomeIsWarningAndCanProceed() + { + GovernanceDecision decision = DisableAccountPolicy.Evaluate( + CreateContext(isAlreadyDisabled: true)); + + Assert.Equal(GovernanceDecisionOutcome.Warning, decision.Outcome); + Assert.True(decision.CanProceed); + Assert.Equal( + "account.disable.already-disabled", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void ProtectedAccountOutcomeRecommendsEscalationAndCannotProceed() + { + GovernanceDecision decision = DisableAccountPolicy.Evaluate( + CreateContext(isProtected: true)); + + Assert.Equal( + GovernanceDecisionOutcome.EscalationRecommended, + decision.Outcome); + Assert.False(decision.CanProceed); + Assert.Equal( + "account.disable.protected-account", + Assert.Single(decision.Reasons).Code); + } + [Fact] public void AcknowledgmentRequiredOutcomeIsExplicitAndCannotProceed() { @@ -56,8 +97,44 @@ public void AllowedOutcomeCanProceedWithoutReasonCodes() Assert.Empty(decision.Reasons); } + [Fact] + public void WhitespaceReasonRequiresAcknowledgment() + { + GovernanceDecision decision = DisableAccountPolicy.Evaluate( + CreateContext(reason: " ")); + + Assert.Equal( + GovernanceDecisionOutcome.AcknowledgmentRequired, + decision.Outcome); + Assert.False(decision.CanProceed); + Assert.Equal( + "account.disable.reason-required", + Assert.Single(decision.Reasons).Code); + } + + [Fact] + public void AdministratorRequirementTakesPrecedenceOverResourceConditions() + { + GovernanceDecision decision = DisableAccountPolicy.Evaluate( + CreateContext( + isAdministrator: false, + isProtected: true, + isAlreadyDisabled: true, + maintenanceHoldActive: true, + reason: string.Empty)); + + Assert.Equal(GovernanceDecisionOutcome.Denied, decision.Outcome); + Assert.Equal( + "account.disable.not-administrator", + Assert.Single(decision.Reasons).Code); + } + private static DisableAccountPolicyContext CreateContext( bool isAdministrator = true, + string actorTenantId = "tenant-a", + string accountTenantId = "tenant-a", + bool isProtected = false, + bool isAlreadyDisabled = false, bool maintenanceHoldActive = false, string reason = "Security investigation") { @@ -68,13 +145,13 @@ private static DisableAccountPolicyContext CreateContext( Reason: reason), Actor: new ActorContext( ActorId: "operator-7", - TenantId: "tenant-a", + TenantId: actorTenantId, IsAdministrator: isAdministrator), Account: new AccountContext( AccountId: "user-100", - TenantId: "tenant-a", - IsProtected: false, - IsAlreadyDisabled: false), + TenantId: accountTenantId, + IsProtected: isProtected, + IsAlreadyDisabled: isAlreadyDisabled), Environment: new EnvironmentContext( MaintenanceHoldActive: maintenanceHoldActive, Region: "us-central"),