From 8571bf3b118a94fd1904e16c8ace22747a122c49 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:24:21 -0700 Subject: [PATCH 01/89] refactor(apphost): extract WithClearDatabaseCommand into MongoDbResourceBuilderExtensions (#262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extracts the inline `WithCommand` clear-data block from `AppHost.cs` into a new `MongoDbResourceBuilderExtensions` class. ## Changes - **New**: `src/AppHost/MongoDbResourceBuilderExtensions.cs` — contains `WithMongoDbDevCommands` public entry point and private `WithClearDatabaseCommand` - **Simplified**: `src/AppHost/AppHost.cs` — reduced from ~157 lines to ~30 lines; single `mongo.WithMongoDbDevCommands("myblog")` call ## Testing All 10 existing tests pass: - 5 unit tests in `MongoDbClearCommandTests` - 5 integration tests in `MongoClearDataIntegrationTests` Closes #259 Working as Sam (Backend/.NET) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/AppHost/AppHost.cs | 129 +-------------- .../MongoDbResourceBuilderExtensions.cs | 154 ++++++++++++++++++ 2 files changed, 155 insertions(+), 128 deletions(-) create mode 100644 src/AppHost/MongoDbResourceBuilderExtensions.cs diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index fcc41c3f..bd542dbe 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -6,11 +6,6 @@ //Solution Name : MyBlog //Project Name : AppHost //======================================================= -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Logging; - -using MongoDB.Bson; -using MongoDB.Driver; var builder = DistributedApplication.CreateBuilder(args); @@ -19,129 +14,7 @@ var mongoDb = mongo.AddDatabase("myblog"); var redis = builder.AddRedis("redis"); -// AC2 (#249): Semaphore prevents overlapping clear runs. A second concurrent invocation -// returns immediately with operator feedback instead of racing against the first. -var clearMutex = new SemaphoreSlim(1, 1); - -// Expose the destructive clear-data action only during local runs (IsRunMode = false when publishing). -if (builder.ExecutionContext.IsRunMode) -{ - mongo.WithCommand( - "clear-myblog-data", - "⚠️ Clear MyBlog Data", - executeCommand: async context => - { - // AC2: Non-blocking acquire — return immediately if another clear is already in flight. - if (!await clearMutex.WaitAsync(0)) - { - context.Logger.LogWarning( - "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", - context.ResourceName); - - return new ExecuteCommandResult - { - Success = false, - Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." - }; - } - - try - { - context.Logger.LogWarning( - "Clear MyBlog data invoked on {ResourceName} — enumerating collections in 'myblog'.", - context.ResourceName); - - var connectionString = await mongo.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); - if (connectionString is null) - { - context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); - return new ExecuteCommandResult - { - Success = false, - Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" - }; - } - - var client = new MongoClient(connectionString); - var database = client.GetDatabase("myblog"); - - var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); - var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); - - var results = new List<(string Name, long Deleted)>(); - var warnings = new List(); - - foreach (var name in collectionNames) - { - // Skip MongoDB internal system collections (e.g. system.views, system.users). - if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) - continue; - - try - { - // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, - // and the loop continues so remaining collections are still processed. - var collection = database.GetCollection(name); - var deleteResult = await collection.DeleteManyAsync( - FilterDefinition.Empty, - context.CancellationToken); - - results.Add((name, deleteResult.DeletedCount)); - - context.Logger.LogInformation( - "Collection '{Collection}': {Count} document(s) deleted.", - name, deleteResult.DeletedCount); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var warning = $"{name}: {ex.Message}"; - warnings.Add(warning); - context.Logger.LogWarning( - ex, - "Collection '{Collection}' could not be cleared — skipping and continuing.", - name); - } - } - - var totalDeleted = results.Sum(static r => r.Deleted); - var perCollection = results.Count == 0 - ? "no non-system collections found" - : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); - - context.Logger.LogWarning( - "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", - totalDeleted, results.Count, warnings.Count); - - var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; - if (warnings.Count > 0) - message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; - - return new ExecuteCommandResult - { - Success = true, - Message = message - }; - } - finally - { - clearMutex.Release(); - } - }, - new CommandOptions - { - Description = "Permanently deletes all data from the myblog database. Local development only.", - ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", - IsHighlighted = true, - IconName = "DatabaseWarning", - // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT - // check dependent resources (Web, etc.). Clearing is valid while the app is live against - // local Mongo; the Web app running is not a reason to disable the command. - UpdateState = ctx => - ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled - }); -} +mongo.WithMongoDbDevCommands("myblog"); builder.AddProject("web") .WithReference(mongoDb) diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs new file mode 100644 index 00000000..bacb1a99 --- /dev/null +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -0,0 +1,154 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : MongoDbResourceBuilderExtensions.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : AppHost +//======================================================= + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Aspire.Hosting; + +internal static class MongoDbResourceBuilderExtensions +{ + // Shared semaphore — one per process; all commands share the same mutex. + private static readonly SemaphoreSlim _clearMutex = new(1, 1); + + public static IResourceBuilder WithMongoDbDevCommands( + this IResourceBuilder builder, + string databaseName) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + return builder; + + builder.WithClearDatabaseCommand(databaseName); + return builder; + } + + private static void WithClearDatabaseCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "clear-myblog-data", + "⚠️ Clear MyBlog Data", + executeCommand: async context => + { + // AC2: Non-blocking acquire — return immediately if another clear is already in flight. + if (!await _clearMutex.WaitAsync(0)) + { + context.Logger.LogWarning( + "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", + context.ResourceName); + + return new ExecuteCommandResult + { + Success = false, + Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." + }; + } + + try + { + context.Logger.LogWarning( + "Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.", + context.ResourceName, databaseName); + + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); + if (connectionString is null) + { + context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); + return new ExecuteCommandResult + { + Success = false, + Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" + }; + } + + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + + var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); + var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); + + var results = new List<(string Name, long Deleted)>(); + var warnings = new List(); + + foreach (var name in collectionNames) + { + // Skip MongoDB internal system collections (e.g. system.views, system.users). + if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, + // and the loop continues so remaining collections are still processed. + var collection = database.GetCollection(name); + var deleteResult = await collection.DeleteManyAsync( + FilterDefinition.Empty, + context.CancellationToken); + + results.Add((name, deleteResult.DeletedCount)); + + context.Logger.LogInformation( + "Collection '{Collection}': {Count} document(s) deleted.", + name, deleteResult.DeletedCount); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var warning = $"{name}: {ex.Message}"; + warnings.Add(warning); + context.Logger.LogWarning( + ex, + "Collection '{Collection}' could not be cleared — skipping and continuing.", + name); + } + } + + var totalDeleted = results.Sum(static r => r.Deleted); + var perCollection = results.Count == 0 + ? "no non-system collections found" + : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); + + context.Logger.LogWarning( + "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", + totalDeleted, results.Count, warnings.Count); + + var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; + if (warnings.Count > 0) + message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; + + return new ExecuteCommandResult + { + Success = true, + Message = message + }; + } + finally + { + _clearMutex.Release(); + } + }, + new CommandOptions + { + Description = "Permanently deletes all data from the myblog database. Local development only.", + ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", + IsHighlighted = true, + IconName = "DatabaseWarning", + // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT + // check dependent resources (Web, etc.). Clearing is valid while the app is live against + // local Mongo; the Web app running is not a reason to disable the command. + UpdateState = ctx => + ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled + }); + } +} From 976ec57f6ea08db9d5aa67c4a81f8a7fe3ed5e2c Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:31:31 -0700 Subject: [PATCH 02/89] fix(ci): use GH_PROJECT_TOKEN for Projects V2 access in squad-mark-released (#257) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the `squad-mark-released` workflow which was failing with: > `GraphqlResponseError: Resource not accessible by integration` ## Root Cause `GITHUB_TOKEN` cannot access GitHub Projects V2 via GraphQL mutations. This is a known GitHub limitation — Projects V2 mutations require a PAT with `project` scope. ## Fix Swap `secrets.GITHUB_TOKEN` → `secrets.GH_PROJECT_TOKEN`, which is the PAT already used by `project-board-automation.yml` and `add-issues-to-project.yml` for Projects V2 access. ## Board Update The v1.4.0 board update was performed manually — 22 items moved from **Done → Released** directly via GraphQL. ## Related - Fixes the `squad-mark-released` auto-trigger failure for v1.4.0 - Ensures future releases auto-update the board correctly --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-mark-released.yml | 2 +- .vscode/settings.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/squad-mark-released.yml b/.github/workflows/squad-mark-released.yml index 845da55d..997b4510 100644 --- a/.github/workflows/squad-mark-released.yml +++ b/.github/workflows/squad-mark-released.yml @@ -24,7 +24,7 @@ jobs: - name: Move Done → Released on project board uses: actions/github-script@v9 with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | const PROJECT_ID = process.env.PROJECT_ID; const STATUS_FIELD_ID = process.env.STATUS_FIELD_ID; diff --git a/.vscode/settings.json b/.vscode/settings.json index 70d34f9c..e65f2809 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,9 @@ { "cSpell.words": [ "ASPNETCORE", + "cref", "EECOM", + "inheritdoc", "msbuild", "rendermode", "reskill", From 924edfc63a93c39537dc245d31fe39a8d0780c88 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 09:53:12 -0700 Subject: [PATCH 03/89] feat(AppHost): add WithSeedDataCommand for local dev seeding (#263) Closes #260 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MongoDbResourceBuilderExtensions.cs | 378 +++++++++++------- .../MongoSeedIntegrationCollection.cs | 19 + .../AppHost.Tests/MongoDbSeedCommandTests.cs | 201 ++++++++++ .../MongoSeedDataIntegrationTests.cs | 146 +++++++ 4 files changed, 610 insertions(+), 134 deletions(-) create mode 100644 tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs create mode 100644 tests/AppHost.Tests/MongoDbSeedCommandTests.cs create mode 100644 tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index bacb1a99..d575abba 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -17,138 +17,248 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { - // Shared semaphore — one per process; all commands share the same mutex. - private static readonly SemaphoreSlim _clearMutex = new(1, 1); - - public static IResourceBuilder WithMongoDbDevCommands( - this IResourceBuilder builder, - string databaseName) - { - if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) - return builder; - - builder.WithClearDatabaseCommand(databaseName); - return builder; - } - - private static void WithClearDatabaseCommand( - this IResourceBuilder builder, - string databaseName) - { - builder.WithCommand( - "clear-myblog-data", - "⚠️ Clear MyBlog Data", - executeCommand: async context => - { - // AC2: Non-blocking acquire — return immediately if another clear is already in flight. - if (!await _clearMutex.WaitAsync(0)) - { - context.Logger.LogWarning( - "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", - context.ResourceName); - - return new ExecuteCommandResult - { - Success = false, - Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." - }; - } - - try - { - context.Logger.LogWarning( - "Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.", - context.ResourceName, databaseName); - - var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); - if (connectionString is null) - { - context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); - return new ExecuteCommandResult - { - Success = false, - Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" - }; - } - - var client = new MongoClient(connectionString); - var database = client.GetDatabase(databaseName); - - var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); - var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); - - var results = new List<(string Name, long Deleted)>(); - var warnings = new List(); - - foreach (var name in collectionNames) - { - // Skip MongoDB internal system collections (e.g. system.views, system.users). - if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) - continue; - - try - { - // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, - // and the loop continues so remaining collections are still processed. - var collection = database.GetCollection(name); - var deleteResult = await collection.DeleteManyAsync( - FilterDefinition.Empty, - context.CancellationToken); - - results.Add((name, deleteResult.DeletedCount)); - - context.Logger.LogInformation( - "Collection '{Collection}': {Count} document(s) deleted.", - name, deleteResult.DeletedCount); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - var warning = $"{name}: {ex.Message}"; - warnings.Add(warning); - context.Logger.LogWarning( - ex, - "Collection '{Collection}' could not be cleared — skipping and continuing.", - name); - } - } - - var totalDeleted = results.Sum(static r => r.Deleted); - var perCollection = results.Count == 0 - ? "no non-system collections found" - : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); - - context.Logger.LogWarning( - "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", - totalDeleted, results.Count, warnings.Count); - - var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; - if (warnings.Count > 0) - message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; - - return new ExecuteCommandResult - { - Success = true, - Message = message - }; - } - finally - { - _clearMutex.Release(); - } - }, - new CommandOptions - { - Description = "Permanently deletes all data from the myblog database. Local development only.", - ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", - IsHighlighted = true, - IconName = "DatabaseWarning", - // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT - // check dependent resources (Web, etc.). Clearing is valid while the app is live against - // local Mongo; the Web app running is not a reason to disable the command. - UpdateState = ctx => - ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy - ? ResourceCommandState.Enabled - : ResourceCommandState.Disabled - }); - } +// Shared semaphore — one per process; all commands share the same mutex. +private static readonly SemaphoreSlim _clearMutex = new(1, 1); + +public static IResourceBuilder WithMongoDbDevCommands( +this IResourceBuilder builder, +string databaseName) +{ +if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) +return builder; + +builder.WithClearDatabaseCommand(databaseName); +builder.WithSeedDataCommand(databaseName); +return builder; +} + +private static void WithClearDatabaseCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"clear-myblog-data", +"⚠️ Clear MyBlog Data", +executeCommand: async context => +{ +// AC2: Non-blocking acquire — return immediately if another clear is already in flight. +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", +context.ResourceName); + +return new ExecuteCommandResult +{ +Success = false, +Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." +}; +} + +try +{ +context.Logger.LogWarning( +"Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.", +context.ResourceName, databaseName); + +var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); +if (connectionString is null) +{ +context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); +return new ExecuteCommandResult +{ +Success = false, +Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" +}; +} + +var client = new MongoClient(connectionString); +var database = client.GetDatabase(databaseName); + +var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); +var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); + +var results = new List<(string Name, long Deleted)>(); +var warnings = new List(); + +foreach (var name in collectionNames) +{ +// Skip MongoDB internal system collections (e.g. system.views, system.users). +if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) +continue; + +try +{ +// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, +// and the loop continues so remaining collections are still processed. +var collection = database.GetCollection(name); +var deleteResult = await collection.DeleteManyAsync( +FilterDefinition.Empty, +context.CancellationToken); + +results.Add((name, deleteResult.DeletedCount)); + +context.Logger.LogInformation( +"Collection '{Collection}': {Count} document(s) deleted.", +name, deleteResult.DeletedCount); +} +catch (Exception ex) when (ex is not OperationCanceledException) +{ +var warning = $"{name}: {ex.Message}"; +warnings.Add(warning); +context.Logger.LogWarning( +ex, +"Collection '{Collection}' could not be cleared — skipping and continuing.", +name); +} +} + +var totalDeleted = results.Sum(static r => r.Deleted); +var perCollection = results.Count == 0 +? "no non-system collections found" +: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); + +context.Logger.LogWarning( +"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", +totalDeleted, results.Count, warnings.Count); + +var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; +if (warnings.Count > 0) +message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; + +return new ExecuteCommandResult +{ +Success = true, +Message = message +}; +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Permanently deletes all data from the myblog database. Local development only.", +ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", +IsHighlighted = true, +IconName = "DatabaseWarning", +// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT +// check dependent resources (Web, etc.). Clearing is valid while the app is live against +// local Mongo; the Web app running is not a reason to disable the command. +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} + +private static void WithSeedDataCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"seed-myblog-data", +"🌱 Seed MyBlog Data", +executeCommand: async context => +{ +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", +context.ResourceName); + +return new ExecuteCommandResult +{ +Success = false, +Message = "A database operation is already in progress. Wait for the current run to finish, then try again." +}; +} + +try +{ +context.Logger.LogInformation( +"Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{Database}'.", +context.ResourceName, databaseName); + +var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); +if (connectionString is null) +{ +context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); +return new ExecuteCommandResult +{ +Success = false, +Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" +}; +} + +var client = new MongoClient(connectionString); +var database = client.GetDatabase(databaseName); +var collection = database.GetCollection("blogposts"); + +var now = DateTime.UtcNow; +var seedDocuments = new BsonDocument[] +{ +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Welcome to MyBlog", +["Content"] = "This is the first post on MyBlog. Welcome!", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = true, +["Version"] = 1, +}, +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Getting Started with .NET Aspire", +["Content"] = "Learn how to build cloud-native apps with .NET Aspire.", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = true, +["Version"] = 1, +}, +new() +{ +["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), +["Title"] = "Draft: MongoDB Performance Tips", +["Content"] = "Work in progress — tips for optimising MongoDB queries.", +["Author"] = "Matthew Paulosky", +["CreatedAt"] = now, +["UpdatedAt"] = now, +["IsPublished"] = false, +["Version"] = 1, +}, +}; + +await collection.InsertManyAsync(seedDocuments, cancellationToken: context.CancellationToken); + +context.Logger.LogInformation( +"Seed MyBlog data complete: {Count} blog post(s) inserted.", +seedDocuments.Length); + +return new ExecuteCommandResult +{ +Success = true, +Message = $"blogposts: {seedDocuments.Length} inserted (2 published, 1 draft)" +}; +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Inserts seed blog posts into the myblog database. Local development only.", +IconName = "DatabaseArrowUp", +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} } diff --git a/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs b/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs new file mode 100644 index 00000000..7c3e548c --- /dev/null +++ b/tests/AppHost.Tests/Infrastructure/MongoSeedIntegrationCollection.cs @@ -0,0 +1,19 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoSeedIntegrationCollection.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +namespace AppHost.Tests.Infrastructure; + +/// +/// xUnit collection that shares one across all tests +/// in the "MongoSeedIntegration" collection (sequential execution, single Aspire host). +/// +[CollectionDefinition("MongoSeedIntegration")] +public sealed class MongoSeedIntegrationCollection : ICollectionFixture +{ +} diff --git a/tests/AppHost.Tests/MongoDbSeedCommandTests.cs b/tests/AppHost.Tests/MongoDbSeedCommandTests.cs new file mode 100644 index 00000000..335f0ff7 --- /dev/null +++ b/tests/AppHost.Tests/MongoDbSeedCommandTests.cs @@ -0,0 +1,201 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoDbSeedCommandTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using System.Collections.Immutable; + +using Aspire.Hosting; + +using FluentAssertions; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AppHost.Tests; + +/// +/// Model-level tests for the local-only MongoDB seed-data operator command (issue #260). +/// These tests verify the Aspire resource annotation contract only — they do not start the +/// Aspire host, spin up containers, or touch a live database. +/// +public sealed class MongoDbSeedCommandTests +{ + private const string CommandName = "seed-myblog-data"; + + private static Task CreateBuilderAsync() => + DistributedApplicationTestingBuilder.CreateAsync( + args: [], + configureBuilder: static (options, _) => { options.DisableDashboard = true; }, + cancellationToken: TestContext.Current.CancellationToken); + + /// + /// Acceptance criterion #1: The mongodb resource exposes a "seed-myblog-data" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_SeedMyBlogData_Command_Annotation() + { + // Arrange + var builder = await CreateBuilderAsync(); + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + // Act + var annotation = mongoResource.Annotations + .OfType() + .SingleOrDefault(static a => a.Name == CommandName); + + // Assert + annotation.Should().NotBeNull( + "the mongodb resource must expose a 'seed-myblog-data' operator action per issue #260"); + } + + /// + /// Acceptance criterion: The seed command must NOT be highlighted — it is additive, + /// not destructive, so it should not carry a danger indicator. + /// + [Fact] + public async Task SeedMyBlogData_Command_Is_Not_Highlighted() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeFalse( + "an additive seed command must set IsHighlighted = false to avoid alarming the operator"); + } + + /// + /// Acceptance criterion: The seed command must NOT require a confirmation prompt. + /// Seeding is non-destructive and reversible, so no y/n dialog is needed. + /// + [Fact] + public async Task SeedMyBlogData_Command_Has_No_ConfirmationMessage() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().BeNullOrEmpty( + "the seed command is additive and must not display a confirmation dialog"); + } + + /// + /// Acceptance criterion: The seed command's icon must be "DatabaseArrowUp". + /// + [Fact] + public async Task SeedMyBlogData_Command_Has_DatabaseArrowUp_Icon() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + // Assert + annotation.IconName.Should().Be("DatabaseArrowUp", + "the seed command must use the DatabaseArrowUp icon per issue #260"); + } + + /// + /// Acceptance criterion: The seed command is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task SeedMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Healthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Enabled, + "the seed-myblog-data command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion: The seed command must be disabled when MongoDB is not healthy + /// to prevent inserts against an unstable or stopped container. + /// + [Fact] + public async Task SeedMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetSeedMyBlogDataAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Unhealthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Disabled, + "the seed-myblog-data command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetSeedMyBlogDataAnnotation(IDistributedApplicationTestingBuilder builder) + { + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + /// + /// Creates a with the given health status. + /// + /// has an internal init accessor and + /// is a private computed property — + /// both are inaccessible from external assemblies via normal C#. Reflection is required. + /// + /// + private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) + { + var snapshot = new CustomResourceSnapshot + { + ResourceType = "MongoDB.Server", + Properties = [], + }; + + var reports = ImmutableArray.Create( + new HealthReportSnapshot("ready", health, null, null)); + + var type = typeof(CustomResourceSnapshot); + type + .GetProperty("HealthReports")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [reports]); + + type.GetProperty("HealthStatus")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [(HealthStatus?)health]); + + return snapshot; + } +} diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs new file mode 100644 index 00000000..f9add810 --- /dev/null +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -0,0 +1,146 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoSeedDataIntegrationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using AppHost.Tests.Infrastructure; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace AppHost.Tests; + +/// +/// Full integration tests for the "seed-myblog-data" operator command (issue #260). +/// +/// These tests require Docker because they boot the real Aspire host so that the handler's +/// closure-captured mongo.Resource.ConnectionStringExpression.GetValueAsync() can +/// resolve the live container's connection string. +/// +/// +[Collection("MongoSeedIntegration")] +public sealed class MongoSeedDataIntegrationTests(ClearCommandAppFixture fixture) +{ + private const string CommandName = "seed-myblog-data"; + + /// + /// After the command runs, the blogposts collection contains at least 3 documents, + /// including at least one unpublished draft. + /// + [Fact] + public async Task SeedMyBlogData_Inserts_Expected_Documents_Into_BlogPosts_Collection() + { + // Arrange — drop and recreate an empty blogposts collection + var client = new MongoClient(fixture.MongoConnectionString); + var db = client.GetDatabase("myblog"); + await db.DropCollectionAsync("blogposts", TestContext.Current.CancellationToken); + await db.CreateCollectionAsync("blogposts", cancellationToken: TestContext.Current.CancellationToken); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("the handler must succeed when MongoDB is reachable"); + + var count = await db.GetCollection("blogposts") + .CountDocumentsAsync(FilterDefinition.Empty, cancellationToken: TestContext.Current.CancellationToken); + + count.Should().BeGreaterThanOrEqualTo(3, "at least 3 seed documents must be inserted"); + + var draftCount = await db.GetCollection("blogposts") + .CountDocumentsAsync( + Builders.Filter.Eq("IsPublished", false), + cancellationToken: TestContext.Current.CancellationToken); + + draftCount.Should().BeGreaterThanOrEqualTo(1, "at least 1 document must be unpublished (draft)"); + } + + /// + /// Two simultaneous seed attempts must not run together: exactly one proceeds and + /// the other fails fast with operator-visible feedback. + /// + [Fact] + public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() + { + // Arrange + var client = new MongoClient(fixture.MongoConnectionString); + var db = client.GetDatabase("myblog"); + await db.DropCollectionAsync("blogposts", TestContext.Current.CancellationToken); + await db.CreateCollectionAsync("blogposts", cancellationToken: TestContext.Current.CancellationToken); + + var annotation = GetAnnotation(); + + // Act — fire two concurrent seed operations + var firstTask = annotation.ExecuteCommand(MakeContext()); + var secondTask = annotation.ExecuteCommand(MakeContext()); + var results = await Task.WhenAll(firstTask, secondTask); + + // Assert + results.Count(static r => r.Success).Should().Be(1, + "the semaphore should allow only one seed operation to run at a time"); + results.Count(static r => !r.Success).Should().Be(1, + "the overlapping seed attempt should fail fast instead of queueing"); + results.Single(static r => !r.Success).Message.Should().Contain("already in progress", + "the operator needs immediate feedback when another database operation is in flight"); + } + + /// + /// When the database is completely empty (no collections at all), seeding must still + /// create the blogposts collection and insert documents successfully. + /// + [Fact] + public async Task SeedMyBlogData_Empty_Database_Results_In_BlogPosts_After_Seed() + { + // Arrange — drop the entire database so no collection exists + var client = new MongoClient(fixture.MongoConnectionString); + await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("seeding an empty database must succeed"); + + var db = client.GetDatabase("myblog"); + var count = await db.GetCollection("blogposts") + .CountDocumentsAsync(FilterDefinition.Empty, cancellationToken: TestContext.Current.CancellationToken); + + count.Should().BeGreaterThanOrEqualTo(1, "blogposts collection must have documents after seed"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private ResourceCommandAnnotation GetAnnotation() + { + var mongoResource = fixture.Builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + private static ExecuteCommandContext MakeContext() => new() + { + ResourceName = "mongodb", + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + Logger = NullLogger.Instance, + CancellationToken = TestContext.Current.CancellationToken, + }; +} From 002b5bb53b762373e1d13a16ae8420e9b369c41d Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 10:16:11 -0700 Subject: [PATCH 04/89] feat(AppHost): add WithShowStatsCommand for local dev stats (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #261 Adds `WithShowStatsCommand` — the third and final Aspire dashboard command in `MongoDbResourceBuilderExtensions`: - Command name `show-myblog-stats`, icon `ChartMultiple`, non-highlighted - Markdown table of collection → document count via `_clearMutex` non-blocking guard - Empty DB returns `*(no collections found)*` row; `system.*` collections filtered - 5 unit tests + 3 integration tests (concurrent-invocation fix: seed 50 docs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MongoDbResourceBuilderExtensions.cs | 94 +++++++++ .../MongoStatsIntegrationCollection.cs | 19 ++ .../AppHost.Tests/MongoDbStatsCommandTests.cs | 186 ++++++++++++++++++ .../MongoShowStatsIntegrationTests.cs | 140 +++++++++++++ 4 files changed, 439 insertions(+) create mode 100644 tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs create mode 100644 tests/AppHost.Tests/MongoDbStatsCommandTests.cs create mode 100644 tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index d575abba..2beca5af 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -7,6 +7,8 @@ //Project Name : AppHost //======================================================= +using System.Text; + using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; @@ -29,6 +31,7 @@ public static IResourceBuilder WithMongoDbDevCommands( builder.WithClearDatabaseCommand(databaseName); builder.WithSeedDataCommand(databaseName); +builder.WithShowStatsCommand(databaseName); return builder; } @@ -261,4 +264,95 @@ private static void WithSeedDataCommand( : ResourceCommandState.Disabled }); } + +private static void WithShowStatsCommand( +this IResourceBuilder builder, +string databaseName) +{ +builder.WithCommand( +"show-myblog-stats", +"📊 Show MyBlog Stats", +executeCommand: async context => +{ +if (!await _clearMutex.WaitAsync(0)) +{ +context.Logger.LogWarning( +"Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", +context.ResourceName); + +return CommandResults.Failure( +"A database operation is already in progress. Wait for the current run to finish, then try again."); +} + +try +{ +context.Logger.LogInformation( +"Show MyBlog stats invoked on {ResourceName} — querying '{Database}'.", +context.ResourceName, databaseName); + +var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); +if (connectionString is null) +{ +context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); +return CommandResults.Failure("Could not resolve MongoDB connection string. Is the MongoDB resource running?"); +} + +var client = new MongoClient(connectionString); +var database = client.GetDatabase(databaseName); + +var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); +var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); +var userCollections = collectionNames +.Where(static n => !n.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) +.ToList(); + +var sb = new StringBuilder(); +sb.AppendLine("| Collection | Document Count |"); +sb.AppendLine("| --- | --- |"); + +if (userCollections.Count == 0) +{ +sb.AppendLine("| *(no collections found)* | - |"); +} +else +{ +foreach (var name in userCollections) +{ +var col = database.GetCollection(name); +var count = await col.CountDocumentsAsync( +FilterDefinition.Empty, +cancellationToken: context.CancellationToken); +sb.AppendLine($"| {name} | {count} |"); +} +} + +var markdownTable = sb.ToString(); +context.Logger.LogInformation( +"Show MyBlog stats complete: {Count} collection(s) reported.", +userCollections.Count); + +return CommandResults.Success( +$"{userCollections.Count} collection(s) found in '{databaseName}'", +new CommandResultData +{ +Value = markdownTable, +Format = CommandResultFormat.Markdown, +DisplayImmediately = true +}); +} +finally +{ +_clearMutex.Release(); +} +}, +new CommandOptions +{ +Description = "Displays document counts per collection in the myblog database. Local development only.", +IconName = "ChartMultiple", +UpdateState = ctx => +ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy +? ResourceCommandState.Enabled +: ResourceCommandState.Disabled +}); +} } diff --git a/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs b/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs new file mode 100644 index 00000000..053c627d --- /dev/null +++ b/tests/AppHost.Tests/Infrastructure/MongoStatsIntegrationCollection.cs @@ -0,0 +1,19 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoStatsIntegrationCollection.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +namespace AppHost.Tests.Infrastructure; + +/// +/// xUnit collection that shares one across all tests +/// in the "MongoStatsIntegration" collection (sequential execution, single Aspire host). +/// +[CollectionDefinition("MongoStatsIntegration")] +public sealed class MongoStatsIntegrationCollection : ICollectionFixture +{ +} diff --git a/tests/AppHost.Tests/MongoDbStatsCommandTests.cs b/tests/AppHost.Tests/MongoDbStatsCommandTests.cs new file mode 100644 index 00000000..38082bc9 --- /dev/null +++ b/tests/AppHost.Tests/MongoDbStatsCommandTests.cs @@ -0,0 +1,186 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoDbStatsCommandTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using System.Collections.Immutable; + +using Aspire.Hosting; + +using FluentAssertions; + +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AppHost.Tests; + +/// +/// Model-level tests for the local-only MongoDB show-stats operator command (issue #261). +/// These tests verify the Aspire resource annotation contract only — they do not start the +/// Aspire host, spin up containers, or touch a live database. +/// +public sealed class MongoDbStatsCommandTests +{ + private const string CommandName = "show-myblog-stats"; + + private static Task CreateBuilderAsync() => + DistributedApplicationTestingBuilder.CreateAsync( + args: [], + configureBuilder: static (options, _) => { options.DisableDashboard = true; }, + cancellationToken: TestContext.Current.CancellationToken); + + /// + /// Acceptance criterion #1: The mongodb resource exposes a "show-myblog-stats" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_ShowMyBlogStats_Command_Annotation() + { + // Arrange + var builder = await CreateBuilderAsync(); + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + // Act + var annotation = mongoResource.Annotations + .OfType() + .SingleOrDefault(static a => a.Name == CommandName); + + // Assert + annotation.Should().NotBeNull( + "the mongodb resource must expose a 'show-myblog-stats' operator action per issue #261"); + } + + /// + /// Acceptance criterion: The show-stats command must NOT be highlighted — it is read-only + /// and should not carry a danger indicator. + /// + [Fact] + public async Task ShowMyBlogStats_Command_Is_Not_Highlighted() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeFalse( + "a read-only stats command must set IsHighlighted = false to avoid alarming the operator"); + } + + /// + /// Acceptance criterion: The show-stats command must NOT require a confirmation prompt. + /// It is a read-only query and needs no y/n dialog. + /// + [Fact] + public async Task ShowMyBlogStats_Command_Has_No_ConfirmationMessage() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().BeNullOrEmpty( + "the stats command is read-only and must not display a confirmation dialog"); + } + + /// + /// Acceptance criterion: The show-stats command is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task ShowMyBlogStats_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Healthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Enabled, + "the show-myblog-stats command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion: The show-stats command must be disabled when MongoDB is not healthy + /// to prevent queries against an unstable or stopped container. + /// + [Fact] + public async Task ShowMyBlogStats_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Unhealthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Disabled, + "the show-myblog-stats command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetAnnotation(IDistributedApplicationTestingBuilder builder) + { + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + /// + /// Creates a with the given health status. + /// + /// has an internal init accessor and + /// is a private computed property — + /// both are inaccessible from external assemblies via normal C#. Reflection is required. + /// + /// + private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) + { + var snapshot = new CustomResourceSnapshot + { + ResourceType = "MongoDB.Server", + Properties = [], + }; + + var reports = ImmutableArray.Create( + new HealthReportSnapshot("ready", health, null, null)); + + var type = typeof(CustomResourceSnapshot); + type + .GetProperty("HealthReports")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [reports]); + + type.GetProperty("HealthStatus")! + .GetSetMethod(nonPublic: true)! + .Invoke(snapshot, [(HealthStatus?)health]); + + return snapshot; + } +} diff --git a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs new file mode 100644 index 00000000..92cab2e6 --- /dev/null +++ b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs @@ -0,0 +1,140 @@ +// ============================================ +// Copyright (c) 2026. All rights reserved. +// File Name : MongoShowStatsIntegrationTests.cs +// Company : mpaulosky +// Author : Matthew Paulosky +// Solution Name : MyBlog +// Project Name : AppHost.Tests +// ============================================= + +using AppHost.Tests.Infrastructure; + +using FluentAssertions; + +using Microsoft.Extensions.Logging.Abstractions; + +using MongoDB.Bson; +using MongoDB.Driver; + +namespace AppHost.Tests; + +/// +/// Full integration tests for the "show-myblog-stats" operator command (issue #261). +/// +/// These tests require Docker because they boot the real Aspire host so that the handler's +/// closure-captured mongo.Resource.ConnectionStringExpression.GetValueAsync() can +/// resolve the live container's connection string. +/// +/// +[Collection("MongoStatsIntegration")] +public sealed class MongoShowStatsIntegrationTests(ClearCommandAppFixture fixture) +{ + private const string CommandName = "show-myblog-stats"; + + /// + /// When at least one collection with documents exists, the command returns success and + /// reports the collection count in its message. + /// + [Fact] + public async Task ShowMyBlogStats_Returns_Collection_Names_And_Counts_In_Markdown() + { + // Arrange — drop db, then insert documents into blogposts + await PrepareAsync(blogPostCount: 1); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("the handler must succeed when MongoDB is reachable"); + result.Message.Should().Contain("collection(s)", + "the success message must report the number of collections found"); + } + + /// + /// When the database is completely empty (no user collections), the command must still + /// succeed — an empty database is not an error condition. + /// + [Fact] + public async Task ShowMyBlogStats_Empty_Database_Returns_No_Collections_Found() + { + // Arrange — drop the entire myblog database so no collection exists + await PrepareAsync(blogPostCount: 0); + + var annotation = GetAnnotation(); + var ctx = MakeContext(); + + // Act + var result = await annotation.ExecuteCommand(ctx); + + // Assert + result.Success.Should().BeTrue("an empty database must still return success — no collections is not an error"); + result.Message.Should().Contain("0 collection(s)", + "the message must indicate zero collections were found in the empty database"); + } + + /// + /// Two simultaneous stats attempts must not run together: exactly one proceeds and + /// the other fails fast with operator-visible feedback. + /// + [Fact] + public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() + { + // Arrange + await PrepareAsync(blogPostCount: 50); + + var annotation = GetAnnotation(); + + // Act — fire two concurrent stats operations + var firstTask = annotation.ExecuteCommand(MakeContext()); + var secondTask = annotation.ExecuteCommand(MakeContext()); + var results = await Task.WhenAll(firstTask, secondTask); + + // Assert + results.Count(static r => r.Success).Should().Be(1, + "the semaphore should allow only one stats operation to run at a time"); + results.Count(static r => !r.Success).Should().Be(1, + "the overlapping stats attempt should fail fast instead of queueing"); + results.Single(static r => !r.Success).Message.Should().Contain("already in progress", + "the operator needs immediate feedback when another database operation is in flight"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private async Task PrepareAsync(int blogPostCount = 0) + { + var client = new MongoClient(fixture.MongoConnectionString); + await client.DropDatabaseAsync("myblog", TestContext.Current.CancellationToken); + if (blogPostCount > 0) + { + var db = client.GetDatabase("myblog"); + var col = db.GetCollection("blogposts"); + var docs = Enumerable.Range(0, blogPostCount) + .Select(i => new BsonDocument("n", i)) + .ToList(); + await col.InsertManyAsync(docs, cancellationToken: TestContext.Current.CancellationToken); + } + } + + private ResourceCommandAnnotation GetAnnotation() + { + var mongoResource = fixture.Builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + private static ExecuteCommandContext MakeContext() => new() + { + ResourceName = "mongodb", + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + Logger = NullLogger.Instance, + CancellationToken = TestContext.Current.CancellationToken, + }; +} From d1d7e7933422799aa6732e1d84b5efc1005b67da Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:22:16 -0700 Subject: [PATCH 05/89] refactor: rename _clearMutex to _dbMutex in MongoDbResourceBuilderExtensions (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Renames the shared semaphore `_clearMutex` → `_dbMutex` in `MongoDbResourceBuilderExtensions`. The semaphore guards all three MongoDB dev commands (Clear, Seed, Stats), not just clear. The old name was misleading. ## Changes - `src/AppHost/MongoDbResourceBuilderExtensions.cs`: rename field declaration and all 6 usage sites (3× WaitAsync + 3× Release) plus updated comment ## Testing - Build: ✅ 0 errors - Architecture.Tests: ✅ 15/15 - Domain.Tests: ✅ 42/42 - Integration.Tests: ✅ 12/12 - No behavior change — rename only Closes #266 Working as Sam (Backend / .NET) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/AppHost/MongoDbResourceBuilderExtensions.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index 2beca5af..a8b08d2e 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -19,8 +19,8 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { -// Shared semaphore — one per process; all commands share the same mutex. -private static readonly SemaphoreSlim _clearMutex = new(1, 1); +// Shared semaphore — guards all three dev commands (Clear, Seed, Stats) so only one runs at a time. +private static readonly SemaphoreSlim _dbMutex = new(1, 1); public static IResourceBuilder WithMongoDbDevCommands( this IResourceBuilder builder, @@ -45,7 +45,7 @@ private static void WithClearDatabaseCommand( executeCommand: async context => { // AC2: Non-blocking acquire — return immediately if another clear is already in flight. -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", @@ -137,7 +137,7 @@ private static void WithClearDatabaseCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions @@ -165,7 +165,7 @@ private static void WithSeedDataCommand( "🌱 Seed MyBlog Data", executeCommand: async context => { -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", @@ -251,7 +251,7 @@ private static void WithSeedDataCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions @@ -274,7 +274,7 @@ private static void WithShowStatsCommand( "📊 Show MyBlog Stats", executeCommand: async context => { -if (!await _clearMutex.WaitAsync(0)) +if (!await _dbMutex.WaitAsync(0)) { context.Logger.LogWarning( "Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", @@ -342,7 +342,7 @@ private static void WithShowStatsCommand( } finally { -_clearMutex.Release(); +_dbMutex.Release(); } }, new CommandOptions From 3c309d220d42981704d12e9168a5dfc04cbd9b6a Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:30:04 -0700 Subject: [PATCH 06/89] =?UTF-8?q?fix(ci):=20Blog=20=E2=86=92=20README=20Sy?= =?UTF-8?q?nc=20=E2=80=94=20push=20to=20dev=20instead=20of=20main=20(#270)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The `blog-readme-sync.yml` workflow was pushing `README.md` updates directly to `main`, which is blocked by branch protection rules. ## Fix (Option C) Changed the push target from `git push` (implicit HEAD → main) to `git push origin HEAD:dev`. - The workflow still **triggers** on `push: branches: [main]` (reads `docs/blog/index.md` from main) - The **README update** is now pushed to `dev`, flowing through the normal dev→main release cycle - No new secrets or PAT bypass permissions required - `permissions: contents: write` was already present ## Root Cause ``` remote: GH013: Repository rule violations found for refs/heads/main. remote: - Changes must be made through a pull request. remote: - Required status check "Build Solution" is expected. ``` Closes #269 Working as Boromir (DevOps) Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/blog-readme-sync.yml | 2 +- .squad/agents/boromir/history.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/blog-readme-sync.yml b/.github/workflows/blog-readme-sync.yml index 68ca82ab..75b398b7 100644 --- a/.github/workflows/blog-readme-sync.yml +++ b/.github/workflows/blog-readme-sync.yml @@ -78,5 +78,5 @@ jobs: git diff --quiet README.md || ( git add README.md && git commit -m "docs: sync Dev Blog section from docs/blog/index.md [skip ci]" && - git push + git push origin HEAD:dev ) diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index 47cd46ad..8abe7659 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -48,6 +48,18 @@ ## Learnings +### 2026-05-XX — Issue #269: Blog → README Sync workflow branch protection fix + +**Problem:** `blog-readme-sync.yml` pushed directly to `main` after updating `README.md`, which is blocked by branch protection rules (direct pushes forbidden, "Build Solution" check required). + +**Fix (Option C):** Changed `git push` to `git push origin HEAD:dev` in the "Commit updated README" step. The workflow still triggers on `push: branches: [main]` (reading `docs/blog/index.md` from main), but the README update is pushed to `dev` — the normal development branch — and flows through the standard dev→main release cycle. + +**Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change. + +**Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`. + +--- + ### 2026-05-08 — Issue #249: AppHost Mongo Clear Hardening **What was done:** From c272febeac9d863b19f6ed9ab15fbeedbead785d Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 11:39:42 -0700 Subject: [PATCH 07/89] fix(ci): add pre-flight token validation and fix permissions block in squad-mark-released (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Working as Boromir (DevOps) Closes #268 ## Root Cause The workflow was failing with `Resource not accessible by integration` because: 1. `permissions: repository-projects: write` only controls `GITHUB_TOKEN` — it has **no effect** on a custom PAT passed via `github-token:` 2. When `GH_PROJECT_TOKEN` secret is not set, `actions/github-script` receives an empty string and falls back to using `GITHUB_TOKEN`, which **cannot** access GitHub Projects V2 GraphQL regardless of the permissions block ## Changes - **Fix permissions block**: `repository-projects: write` → `contents: read` (correct for workflows that rely exclusively on a custom PAT) - **Add pre-flight validation step**: Checks `GH_PROJECT_TOKEN` is set; fails early with an actionable error message if missing (includes setup instructions and required scope) - **Downgrade `actions/github-script@v9` → `@v7`** (stable LTS version) - **Add top-of-file comment** documenting that a classic PAT with `project` OAuth scope is required ## Setup Required To make this workflow functional, add `GH_PROJECT_TOKEN` as a repository secret: 1. Create a classic PAT at https://github.com/settings/tokens with `project` scope 2. Add it: Settings → Secrets and variables → Actions → New repository secret → `GH_PROJECT_TOKEN` Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-mark-released.yml | 20 ++++++++++++++++++-- .squad/agents/boromir/history.md | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/squad-mark-released.yml b/.github/workflows/squad-mark-released.yml index 997b4510..8588c9f3 100644 --- a/.github/workflows/squad-mark-released.yml +++ b/.github/workflows/squad-mark-released.yml @@ -5,8 +5,12 @@ on: types: [published, released] workflow_dispatch: {} +# NOTE: The default GITHUB_TOKEN cannot access GitHub Projects V2 via GraphQL. +# This workflow requires a repository secret named GH_PROJECT_TOKEN set to a +# classic Personal Access Token (PAT) with the `project` OAuth scope. +# See: https://docs.github.com/en/issues/planning-and-tracking-with-projects/automating-your-project/using-the-api-to-manage-projects permissions: - repository-projects: write + contents: read env: PROJECT_ID: PVT_kwHOA5k0b84BVFTy @@ -21,8 +25,20 @@ jobs: runs-on: ubuntu-latest steps: + - name: Validate GH_PROJECT_TOKEN secret is configured + env: + TOKEN_CHECK: ${{ secrets.GH_PROJECT_TOKEN }} + run: | + if [ -z "$TOKEN_CHECK" ]; then + echo "::error::GH_PROJECT_TOKEN secret is not set." + echo "::error::Add a classic PAT with the 'project' OAuth scope as a repository secret named GH_PROJECT_TOKEN." + echo "::error::See: Settings → Secrets and variables → Actions → New repository secret" + exit 1 + fi + echo "✅ GH_PROJECT_TOKEN secret is present." + - name: Move Done → Released on project board - uses: actions/github-script@v9 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GH_PROJECT_TOKEN }} script: | diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index 8abe7659..d9fbf06a 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -57,6 +57,26 @@ **Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change. **Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`. +### 2026-05-08 — Issue #268: Fix squad-mark-released GraphQL Permission Error + +**Root cause:** +The `permissions: repository-projects: write` block in the workflow was incorrect — it applies only to `GITHUB_TOKEN`, not to a custom PAT. The workflow uses `${{ secrets.GH_PROJECT_TOKEN }}`, but: + +1. If the secret is not set, `actions/github-script` receives an empty string and falls back to `GITHUB_TOKEN` +2. `GITHUB_TOKEN` cannot access GitHub Projects V2 GraphQL API, producing `Resource not accessible by integration` +3. Even if set, the PAT needs the `project` OAuth scope (classic PAT) for Projects V2 mutations + +**What was fixed:** + +1. Changed `permissions: repository-projects: write` → `permissions: contents: read` (correct for a workflow that only uses a custom PAT — no GITHUB_TOKEN escalation needed) +2. Added a pre-flight validation step that explicitly checks `GH_PROJECT_TOKEN` is set, failing early with an actionable error message including setup instructions +3. Downgraded `actions/github-script@v9` → `@v7` (stable LTS version) +4. Added a top-of-file comment documenting the required PAT scope (`project`) + +**Key lesson:** For GitHub Projects V2 GraphQL, `GITHUB_TOKEN` is never sufficient regardless of `permissions` block settings. A classic PAT with `project` OAuth scope (or fine-grained PAT with Projects read/write) is required. Always add a pre-flight secret validation step so failures are immediately actionable. + +**Files changed:** `.github/workflows/squad-mark-released.yml` +**Related decisions inbox:** `boromir-268-project-token.md` --- From c074b8f3f31db3a26a4265e01669908476fd370b Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 12:25:16 -0700 Subject: [PATCH 08/89] test: harden AppHost.Tests flaky timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Root Cause The three `*_Concurrent_Invocations_Allow_Only_One_Run` tests fired two `ExecuteCommand` calls **sequentially on the same thread**: ```csharp var firstTask = annotation.ExecuteCommand(MakeContext()); // runs sync to first I/O yield var secondTask = annotation.ExecuteCommand(MakeContext()); // runs AFTER first completes? ``` Each call executes the async lambda synchronously until its first genuine `await` point. Against a warm, fast, local MongoDB container (exactly CI's hot-path after fixture startup), `InsertManyAsync` for 3 small documents can return a synchronously-completed task — meaning the entire first invocation (including the `finally { _dbMutex.Release() }`) runs before the second call even begins. At that point the semaphore count is back to 1, the second call also acquires it, and both succeed → assertion blows up with `found 2`. This explains the **intermittent** nature: sometimes MongoDB I/O genuinely yields (test passes), sometimes it completes inline (test fails). ## Fix Dispatch both calls via `Task.Run` held behind a `SemaphoreSlim(0,2)` start gate: ```csharp var ct = TestContext.Current.CancellationToken; using var startGate = new SemaphoreSlim(0, 2); var firstTask = Task.Run(async () => { await startGate.WaitAsync(ct); return await annotation.ExecuteCommand(MakeContext()); }, ct); var secondTask = Task.Run(async () => { await startGate.WaitAsync(ct); return await annotation.ExecuteCommand(MakeContext()); }, ct); startGate.Release(2); // both workers race for _dbMutex simultaneously var results = await Task.WhenAll(firstTask, secondTask); ``` Both workers are released at the same instant so they **race** to `_dbMutex.WaitAsync(0)`. One wins (proceeds with MongoDB I/O) and the other loses (returns the `already in progress` failure) — deterministically, regardless of MongoDB response time. ## Affected Tests - `MongoSeedDataIntegrationTests.SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run` - `MongoClearDataIntegrationTests.ClearMyBlogData_Concurrent_Invocations_Allow_Only_One_Run` - `MongoShowStatsIntegrationTests.ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run` Production code (`MongoDbResourceBuilderExtensions.cs`) is unchanged — the `_dbMutex` logic is correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/ralph/history.md | 27 ++++ .squad/decisions.md | 127 ++++++++++++++++++ .squad/decisions/decisions.md | 63 +++++++++ .../MongoClearDataIntegrationTests.cs | 23 +++- .../MongoSeedDataIntegrationTests.cs | 23 +++- .../MongoShowStatsIntegrationTests.cs | 23 +++- 6 files changed, 277 insertions(+), 9 deletions(-) diff --git a/.squad/agents/ralph/history.md b/.squad/agents/ralph/history.md index a91babde..2cc8e36b 100644 --- a/.squad/agents/ralph/history.md +++ b/.squad/agents/ralph/history.md @@ -134,3 +134,30 @@ Initial setup complete. - Remote state: `origin` now has only `dev` and `main` **Board state after:** 0 open issues, 0 open PRs. Board clear. + +### 2026-05-08 — Board Sweep: Release Labeling, Mutex Rename, CI Failures Filed + +**Trigger:** User "Ralph, go" — autonomous board sweep. + +**Board scan result:** 2 open issues (#265, #266). 0 open PRs at scan start. + +**Actions taken:** + +- **Issue #265** (Milestone Review): Decided Option A — release candidate, v1.5.0 minor bump (2 additive user-facing enhancements #259, #260; no breaking changes; CI green). Applied `release-candidate` label, removed `pending-review`, commented decision on issue. Issue auto-closed by `milestone-blog.yml`. +- **Issue #266** (Rename `_clearMutex → _dbMutex`): Delegated to Sam. Sam created branch `squad/266-rename-clear-mutex-to-db-mutex`, renamed field across 7 sites in `src/AppHost/MongoDbResourceBuilderExtensions.cs` (1 declaration + 6 usage sites + 1 comment), ran pre-push gates (build 0 errors, Architecture.Tests 15/15, Domain.Tests 42/42, Integration.Tests 12/12), opened PR #267 targeting `dev`. +- Identified 2 pre-existing CI failures; filed Issue #268 (`squad-mark-released.yml` fails — `GITHUB_TOKEN` lacks `project` scope for GraphQL) and Issue #269 (Blog→README Sync fails — direct push to `main` blocked by branch protection). Both labeled `squad:boromir,bug`. + +**Board state after:** Issue #265 closed, PR #267 open targeting `dev` (awaiting merge), Issues #268 and #269 queued for Boromir. + +### 2026-05-08 — CI Fix Sprint + +**Session issues closed:** + +- **#266** — rename `_clearMutex` → `_dbMutex` in `MongoDbResourceBuilderExtensions.cs` (PR #267, squash-merged) +- **#268** — `squad-mark-released.yml` GraphQL permission error; added pre-flight `GH_PROJECT_TOKEN` validation, fixed `permissions: contents: read`, pinned `actions/github-script@v7` (PR #271, squash-merged) +- **#269** — `blog-readme-sync.yml` direct push to `main` blocked by branch protection; changed to `git push origin HEAD:dev` (PR #270, squash-merged) + +**Notes:** +- Board clear at end of session. No open squad issues or PRs. +- `GH_PROJECT_TOKEN` secret must be set manually in repo Settings → Secrets with a PAT scoped to `project` for squad-mark-released to work. + diff --git a/.squad/decisions.md b/.squad/decisions.md index 19a2253b..c6a79ef8 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -1948,3 +1948,130 @@ This model choice supersedes the Layer 0 defaults in all squad sessions going fo - Should this decision apply retroactively to existing tests in the codebase? (Not in scope for this decision; can be a future refactoring sprint.) - Should PR reviews include explicit checks for TDD violations? (Already covered by Aragorn's PR gate; this formalizes the standard.) - If other squad members would benefit from GPT-5.4 overrides, document those decisions separately with similar rationale. + +--- + +## Sprint 18 Release Decisions + +### AppHost.Tests CI Hang Fix — Parallel Collection Serialization + +**Date:** 2025-07-25 +**Author:** Aragorn (Lead Developer) +**Branch:** squad/247-mongo-clear-command-tests +**PR:** #251 + +#### Problem + +`AppHost.Tests` was hanging in CI (PR #251) while all other test jobs were green. Root cause: `xunit.runner.json` had `parallelizeTestCollections: true`. The assembly contains two xUnit collections that each boot a full Aspire host (with DCP + Docker MongoDB). When both collections started simultaneously, they competed for DCP resources, causing `App.StartAsync()` to hang indefinitely. + +#### Fix + +Changed `tests/AppHost.Tests/xunit.runner.json`: + +```diff +- "parallelizeTestCollections": true ++ "parallelizeTestCollections": false +``` + +This serializes xUnit collections, allowing only one Aspire host to start at a time. No Docker volume conflicts, no DCP contention, no hang. + +#### Rationale + +Minimum-correct change: one line, zero code changes, zero regression risk. Sequential execution adds ~5-10 minutes over prior parallel execution — well within the 45-minute CI budget. + +--- + +### PR Review Outcomes — #262 and #257 + +**Author:** Aragorn (Lead Developer) +**Date:** 2026-05-08 + +#### PR #262 — APPROVED and squash-merged ✅ + +**Branch:** `squad/259-extract-withcleardatabasecommand` +**Closes:** #259 +**Author:** Sam (Backend/.NET) + +Sam's refactor cleanly extracts the inline `WithCommand` clear-data block from `AppHost.cs` into `MongoDbResourceBuilderExtensions`. All checklist items passed. CI green. + +Two Copilot inline comments flagged for follow-up (not blocking current single-instance project): + +1. **Static SemaphoreSlim** — `_clearMutex` is `static readonly` at class level, violating the extension's reusability contract. Harmless in this project but should be fixed before a second MongoDB resource is added. +2. **Hard-coded database name in UX strings** — Parameter drives logic but UI strings still hard-code "myblog". + +**Decision:** Both are legitimate design defects but do not affect current behavior. A follow-up issue should be raised. + +#### PR #257 — APPROVED and squash-merged ✅ + +**Branch:** `squad/256-fix-squad-mark-released-token` +**Closes:** #256 +**Author:** mpaulosky + +Correct fix: `secrets.GITHUB_TOKEN` lacks Projects V2 GraphQL mutation rights; `secrets.GH_PROJECT_TOKEN` (a PAT with project scope) is the correct credential. No hardcoded secrets. No `.squad/` files. + +Branch was behind dev after PR #262 landed. Required manual: `git fetch → git merge origin/dev → git push` before merge. **Lesson:** Merge concurrent PRs in strict priority order and update downstream branches before attempting merge. + +--- + +### Issue #249 AppHost Clear Hardening — Implementation Choices + +**Date:** 2026-05-08 +**Author:** Boromir + +Issue #249 asks for three resilience properties on the `clear-myblog-data` operator action. All are AppHost/runtime concerns. + +#### AC1: UpdateState gates only on mongo health + +**Decision:** The existing `UpdateState` lambda was already correct. Added an explicit comment rather than a code change. + +**Rationale:** `UpdateState` receives a snapshot of the resource the command belongs to (`mongodb`). Checking the `web` resource's state from here would couple the clear command's availability to the liveness of the application layer—not a valid reason to disable a DBA operator action. + +#### AC2: Single-run protection via SemaphoreSlim(1,1) + WaitAsync(0) + +**Decision:** Declared `var clearMutex = new SemaphoreSlim(1, 1)` in top-level statements scope and applied `WaitAsync(0)` (non-blocking try-acquire) at the top of the `executeCommand` lambda. Failure to acquire returns `{ Success = false, Message = "..." }` immediately. The semaphore is released in `finally`. + +**Rationale:** + +- `WaitAsync(0)` is the idiomatic .NET non-blocking semaphore try-acquire +- `finally` guarantees release even on early-return paths, preventing permanent lock-out +- Top-level scope is appropriate: the `clearMutex` is a process-lifetime singleton and protects a single resource + +#### AC3: Best-effort per-collection via per-collection try/catch + +**Decision:** Wrapped each `DeleteManyAsync` call in its own `catch` block for +`Exception ex when (ex is not OperationCanceledException)`. Caught exceptions are logged at Warning +level, appended to a warnings list, and do NOT halt the loop. The final result message appends +`⚠️ {N} collection(s) had errors: ...` when warnings exist. `OperationCanceledException` is +intentionally excluded to allow operator-initiated cancellation to propagate normally. + +**Rationale:** Issue #249 AC3 states: *"the action continues remaining collections and returns warnings plus partial-progress results."* This implementation satisfies that literally. + +#### Gimli Follow-up (AC4) + +Tests should cover: (1) UpdateState returns Enabled with mongo Healthy regardless of web state; (2) Two concurrent handler invocations—exactly one succeeds, other returns failure; (3) Simulate a handler where the second collection throws—assert first and third collections appear in results, second in warnings, Success = true overall. + +--- + +### Sprint 18 Release PR #272 Opened + +**Date:** 2026-05-08 +**Author:** Boromir (DevOps) + +Release PR **#272** has been opened to promote `dev` → `main` for Sprint 18. + +**PR:** [#272 — [RELEASE] Promote dev to main — Sprint 18](https://github.com/mpaulosky/MyBlog/pull/272) +**Branch:** `dev` → `main` +**Commits ahead:** 55 +**Sprint 18 PRs included:** #262, #263, #264, #267, #270, #271 + +**CI status at PR open:** + +- ✅ Squad CI (`ci.yml`) — green on latest `dev` commit +- ⚠️ Test Suite — 1 flaky failure: `SeedMyBlogData Concurrent Invocations` (timing race in test harness; not a production regression). Squad CI is the authoritative gate. + +**Next steps:** + +1. Aragorn reviews scope and approves +2. PR CI must pass before merge +3. Merge to `main` via squash merge +4. Tag `main` with appropriate `vX.Y.Z` after CI green diff --git a/.squad/decisions/decisions.md b/.squad/decisions/decisions.md index 8d68b1c5..274a105f 100644 --- a/.squad/decisions/decisions.md +++ b/.squad/decisions/decisions.md @@ -2268,3 +2268,66 @@ Add a pre-commit git hook (`.github/hooks/pre-commit`) that runs `markdownlint-c - Contributors must run `npm install` to get the linter; the hook warns them if they haven't. --- + +--- + +# Decision: GitHub Projects V2 requires a classic PAT with `project` scope + +**Date:** 2026-05-08 +**Author:** Boromir (DevOps Engineer) +**Issue:** #268 +**PR:** #271 (squash-merged) +**Status:** ✅ Implemented + +## Context + +The `squad-mark-released.yml` workflow uses `actions/github-script` to call the GitHub Projects V2 GraphQL API. The previous `permissions: repository-projects: write` block applied only to `GITHUB_TOKEN` and was ineffective for Projects V2 mutations. + +## Decision + +1. **`GH_PROJECT_TOKEN` secret is required** for any workflow that calls the GitHub Projects V2 GraphQL API. The default `GITHUB_TOKEN` cannot be used — it is not an integration token with project scope. + +2. **Required PAT scopes:** + - Classic PAT: `project` OAuth scope + - Fine-grained PAT: "Projects" → read + write + +3. **Workflow `permissions` block** should be `contents: read` (minimum) in workflows that rely solely on a custom PAT for external API access. + +4. **Pre-flight validation** — any workflow using `GH_PROJECT_TOKEN` must include a validation step that checks the secret is set and fails early with an actionable error message if it is missing. + +## Setup + +To configure the secret: +1. Create a classic PAT at https://github.com/settings/tokens with `project` scope +2. Add it as repository secret: Settings → Secrets and variables → Actions → `GH_PROJECT_TOKEN` + +--- + +# Decision: Blog README Sync pushes to `dev`, not `main` + +**Date:** 2026-05-08 +**Author:** Boromir (DevOps Engineer) +**Issue:** #269 +**PR:** #270 (squash-merged) +**Status:** ✅ Implemented + +## Context + +The `blog-readme-sync.yml` workflow reads `docs/blog/index.md` from `main` and writes an updated `README.md`. It previously pushed directly back to `main`, which violates branch protection rules (direct pushes blocked, PR + "Build Solution" check required). + +## Decision + +The workflow's "Commit updated README" step now uses `git push origin HEAD:dev` instead of `git push`. README updates flow into `dev` and are released to `main` via the normal dev→main PR/release cycle. + +## Rationale + +- No new secrets or PAT permissions required. +- Consistent with the project's branch flow: `squad/*` → `dev` → `main`. +- `README.md` on `main` is slightly behind `dev` until the next release, which is acceptable — it reflects the released state. + +## Alternatives Considered + +- **Option A (PAT bypass):** Create a PAT with bypass permission. Rejected — adds secret management overhead and bypasses protection intentionally. +- **Option B (PR from workflow):** Have the workflow open a PR to main. Rejected — requires `pull-requests: write`, adds noise, and needs the "Build Solution" check to pass before merge. +- **Option C (push to dev) ← CHOSEN:** Simple one-line fix, no new permissions. + diff --git a/tests/AppHost.Tests/MongoClearDataIntegrationTests.cs b/tests/AppHost.Tests/MongoClearDataIntegrationTests.cs index 35d7c64f..5038f254 100644 --- a/tests/AppHost.Tests/MongoClearDataIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoClearDataIntegrationTests.cs @@ -141,9 +141,26 @@ public async Task ClearMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() var annotation = GetAnnotation(); - // Act - var firstTask = annotation.ExecuteCommand(MakeContext()); - var secondTask = annotation.ExecuteCommand(MakeContext()); + // Act — dispatch both calls to thread-pool workers and open the gate at the same + // moment so they race to acquire _dbMutex. Without this the async lambda may run + // entirely synchronously (fast local MongoDB) and release the semaphore before the + // second call even starts, causing both to succeed (flake). + var ct = TestContext.Current.CancellationToken; + using var startGate = new SemaphoreSlim(0, 2); + + var firstTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + var secondTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + startGate.Release(2); // open the gate — both workers race for _dbMutex var results = await Task.WhenAll(firstTask, secondTask); // Assert diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs index f9add810..c1bbcc3c 100644 --- a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -81,9 +81,26 @@ public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() var annotation = GetAnnotation(); - // Act — fire two concurrent seed operations - var firstTask = annotation.ExecuteCommand(MakeContext()); - var secondTask = annotation.ExecuteCommand(MakeContext()); + // Act — dispatch both calls to thread-pool workers and open the gate at the same + // moment so they race to acquire _dbMutex. Without this the async lambda may run + // entirely synchronously (fast local MongoDB) and release the semaphore before the + // second call even starts, causing both to succeed (flake). + var ct = TestContext.Current.CancellationToken; + using var startGate = new SemaphoreSlim(0, 2); + + var firstTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + var secondTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + startGate.Release(2); // open the gate — both workers race for _dbMutex var results = await Task.WhenAll(firstTask, secondTask); // Assert diff --git a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs index 92cab2e6..49920101 100644 --- a/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoShowStatsIntegrationTests.cs @@ -87,9 +87,26 @@ public async Task ShowMyBlogStats_Concurrent_Invocations_Allow_Only_One_Run() var annotation = GetAnnotation(); - // Act — fire two concurrent stats operations - var firstTask = annotation.ExecuteCommand(MakeContext()); - var secondTask = annotation.ExecuteCommand(MakeContext()); + // Act — dispatch both calls to thread-pool workers and open the gate at the same + // moment so they race to acquire _dbMutex. Without this the async lambda may run + // entirely synchronously (fast local MongoDB) and release the semaphore before the + // second call even starts, causing both to succeed (flake). + var ct = TestContext.Current.CancellationToken; + using var startGate = new SemaphoreSlim(0, 2); + + var firstTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + var secondTask = Task.Run(async () => + { + await startGate.WaitAsync(ct); + return await annotation.ExecuteCommand(MakeContext()); + }, ct); + + startGate.Release(2); // open the gate — both workers race for _dbMutex var results = await Task.WhenAll(firstTask, secondTask); // Assert From 35471138f4da470374c7f47f251b8452ebae4486 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 12:41:17 -0700 Subject: [PATCH 09/89] docs(squad): merge 4 inbox decisions into decisions.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Merges 4 pending inbox decisions into `.squad/decisions.md`: - **Decision #22:** Aragorn gate — PR #272 Release Sprint 18 approved - **Decision #23:** Aragorn gate — PR #273 AppHost.Tests flake hardening approved - **Decision #24:** Gimli — Two-tier test strategy for AppHost Clear Command (#248) - **Decision #25:** Gimli — TDD as default approach (charter supplement) Also updates agent history files for Aragorn, Boromir, Sam, and Scribe. No source code changes. Squad docs only. --- _Opened by Scribe (squad automation)_ --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/aragorn/history.md | 47 ++++++++++ .squad/agents/boromir/history.md | 14 +++ .squad/agents/sam/history.md | 18 ++++ .squad/agents/scribe/history.md | 36 +++++++ .squad/decisions.md | 156 +++++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+) diff --git a/.squad/agents/aragorn/history.md b/.squad/agents/aragorn/history.md index 512d24c1..ae56452d 100644 --- a/.squad/agents/aragorn/history.md +++ b/.squad/agents/aragorn/history.md @@ -1,4 +1,35 @@ +## 2026-05-08 — PR #273 Gate: harden AppHost.Tests flaky timing + +Reviewed and squash-merged PR #273 (`squad/harden-apphost-tests-flake` → `dev`). Gimli hardened three `*_Concurrent_Invocations_Allow_Only_One_Run` tests across MongoClearData, MongoSeedData, and MongoShowStats. + +### Learnings + +**`SemaphoreSlim(0,2)` start gate pattern is the correct fix for async concurrency tests.** +The original flake stemmed from `ExecuteCommand` being awaited sequentially on the same async task +— with a fast local MongoDB, `_dbMutex.WaitAsync(0)` completed synchronously twice and released +before the second call started. Dispatching via `Task.Run` and holding both workers on a closed +`SemaphoreSlim(0,2)` until `Release(2)` opens the gate forces genuine thread-pool parallelism and +a real race for the production `_dbMutex`. + +**MongoDB I/O duration is the practical guarantee.** Copilot raised a valid theoretical concern: +`Release(2)` fires before both workers necessarily reach `WaitAsync`. In practice this is not a +problem because the I/O within `ExecuteCommand` takes tens of milliseconds — orders of magnitude +longer than thread scheduling latency. The risk window is negligible. CI confirmed: AppHost.Tests +green on first run. + +**Readiness-barrier alternative exists but adds complexity.** A `CountdownEvent(2)` where each +worker signals before entering `WaitAsync` would be more formally correct. However, that pattern +has its own race (signal then wait has a tiny gap), and the practical benefit over the +`Task.Run` + gate approach is marginal for integration tests backed by real I/O. Accept the current +pattern; file follow-up only if flakiness recurs. + +**Copilot scope comments on `.squad/` files are advisory, not blocking.** When Ralph's ops history is bundled into a test PR, Copilot correctly notes scope mismatch. These are appends to existing files, not new files — per gate checklist, not a blocker. Note for future: squad ops PRs should ideally be separated from test-fix PRs. + +**GitHub self-approval lockout is persistent.** Approval verdict posted as a PR comment per established protocol. Squash merge proceeds without the formal GitHub "approved" state. + +--- + ## 2026-05-08 — PR #245 Re-Review After Sam/Boromir Fix Cycle Re-reviewed PR #245 (`test: raise Web coverage above 80%`) after Gimli's CHANGES_REQUESTED triggered the lockout/fix cycle. @@ -949,3 +980,19 @@ The project already had the TDD skill (`.github/skills/tdd/`), but it was option - Decision #23 (decisions.md) provides team-level rationale and impact analysis This change makes TDD not just a suggestion but a structural part of Gimli's identity and the squad's testing pipeline. + +## 2026-05-08 — PR #272 Gate Review: Sprint 18 Release + +**Task:** Review and gate release PR #272 (dev→main, Sprint 18: AppHost MongoDB Dev Commands Refactor) + +### Review Findings + +- **Scope**: 12 files, all expected — `src/AppHost/` (2), `tests/AppHost.Tests/` (6), `.github/workflows/` (2 CI fixes), `.squad/agents/boromir/history.md`, `.vscode/settings.json`. No `.squad/` files from feature branches — acceptable on dev→main release PR. +- **CI**: Squad CI (authoritative gate) **GREEN** on both push and pull_request. AppHost.Tests had 1 flaky failure (`SeedMyBlogData Concurrent` timing race) but prior run on same SHA (c272febe) was fully green — confirmed non-blocking flake. +- **Automated reviews**: No GitHub Copilot automated review comments. No Codecov coverage decrease flagged. +- **Architecture**: Clean VSA-aligned extraction of 3 dev commands into `MongoDbResourceBuilderExtensions` — additive only, zero breaking changes. +- **GitHub approve blocked**: `gh pr review --approve` rejected (cannot approve own PR). Posted gate decision as PR comment instead. + +### Decision: APPROVED ✅ + +PR #272 is safe to squash-merge to `main`. Communicated approval via PR comment #4409029831. diff --git a/.squad/agents/boromir/history.md b/.squad/agents/boromir/history.md index d9fbf06a..0f7b069b 100644 --- a/.squad/agents/boromir/history.md +++ b/.squad/agents/boromir/history.md @@ -48,6 +48,19 @@ ## Learnings +### 2026-05-08 — Sprint 18 Release PR #272 + +**What was done:** Opened release PR #272 to promote `dev` → `main` for Sprint 18 (AppHost +MongoDB Dev Commands Refactor). Verified Squad CI was green on `dev`. Noted one flaky test +(`SeedMyBlogData Concurrent Invocations Allow Only One Run` — timing race in test harness, not +prod code) in the Test Suite workflow; Squad CI gate remained authoritative and green. PR body +includes Sprint 18 summary (PRs #262, #263, #264, #267, #270, #271), CI status note, and standard +release checklist per playbook. Awaiting Aragorn approval and PR CI pass before merge. + +**PR:** #272 — https://github.com/mpaulosky/MyBlog/pull/272 + +--- + ### 2026-05-XX — Issue #269: Blog → README Sync workflow branch protection fix **Problem:** `blog-readme-sync.yml` pushed directly to `main` after updating `README.md`, which is blocked by branch protection rules (direct pushes forbidden, "Build Solution" check required). @@ -57,6 +70,7 @@ **Key insight:** The `permissions: contents: write` block was already present. No new secrets or PAT bypass needed. One-line change. **Decision:** Captured in `.squad/decisions/inbox/boromir-269-readme-sync-target.md`. + ### 2026-05-08 — Issue #268: Fix squad-mark-released GraphQL Permission Error **Root cause:** diff --git a/.squad/agents/sam/history.md b/.squad/agents/sam/history.md index 7ff5fe19..cbe094fb 100644 --- a/.squad/agents/sam/history.md +++ b/.squad/agents/sam/history.md @@ -160,3 +160,21 @@ Replace Boromir's tracer-bullet handler in `AppHost.cs` with actual `DeleteManyA - `CustomResourceSnapshot.HealthStatus` and `HealthReports` are read-only (no `init` setter) — object-initializer syntax fails - Tests reference command name `"clear-data"` but the actual command is `"clear-myblog-data"` - These failures are Gimli's responsibility (issue #249) + +## 2026-05-xx — Issue #266: Rename `_clearMutex` to `_dbMutex` + +### Task + +Rename the shared semaphore in `MongoDbResourceBuilderExtensions` from `_clearMutex` to `_dbMutex`; +the field guards all three dev commands (Clear, Seed, Stats), not just Clear. + +### Changes Made + +- `src/AppHost/MongoDbResourceBuilderExtensions.cs`: updated field comment + renamed 1 declaration + 3 WaitAsync + 3 Release (7 sites) + +### Build Validation + +- ✅ Build: 0 errors +- ✅ Architecture.Tests: 15/15, Domain.Tests: 42/42, Integration.Tests: 12/12 + +### PR: #267 diff --git a/.squad/agents/scribe/history.md b/.squad/agents/scribe/history.md index 03d95696..5d6aceb9 100644 --- a/.squad/agents/scribe/history.md +++ b/.squad/agents/scribe/history.md @@ -45,3 +45,39 @@ Initial setup complete. - **Gimli:** Resolve pre-existing `CustomResourceSnapshot` init setter issue; align command name `"clear-myblog-data"` - **Boromir:** Proceed with #249 hardening after Gimli validates coverage (tests turn GREEN) + +--- + +## Session: 2026-05-08 — Ralph Board Sweep: Release Labeling, Mutex Rename, CI Failures Filed + +**Triggered by:** Ralph (Work Monitor) — "Ralph, go" autonomous board sweep +**Team Outcome:** ✅ Issue #265 closed (release-candidate label applied); 🔄 PR #267 open targeting `dev`; 🆕 Issues #268 and #269 filed for Boromir + +### Agents & Issues + +- **Ralph (#265):** ✅ Milestone review — decided Option A (release candidate, minor version bump to v1.5.0). Rationale: PRs #259 (`WithClearDatabaseCommand`) and #260 (`WithSeedDataCommand`) are additive user-facing enhancements, no breaking changes, CI green. Applied `release-candidate` label, removed `pending-review`, commented decision on issue. Issue auto-closed by `milestone-blog.yml` automation. +- **Sam (#266):** ✅ Refactor rename complete — created branch `squad/266-rename-clear-mutex-to-db-mutex`, + renamed `_clearMutex → _dbMutex` across 7 sites in `src/AppHost/MongoDbResourceBuilderExtensions.cs` + (1 declaration + 6 usage sites + 1 comment updated). Pre-push gates green: build 0 errors, + Architecture.Tests 15/15, Domain.Tests 42/42, Integration.Tests 12/12. + PR #267 opened targeting `dev`, Copilot review requested. +- **Ralph (CI triage):** 🆕 Filed Issue #268 — `squad-mark-released.yml` fails with GraphQL permission error (`GITHUB_TOKEN` lacks `project` scope for ProjectV2 queries; fix: `PROJECT_TOKEN` PAT secret). Filed Issue #269 — Blog→README Sync workflow fails because direct push to `main` is blocked by branch ruleset (fix: PR-based approach via `sync/*` branch). Both labeled `squad:boromir,bug`. + +### Cross-Team Decisions + +None — no new patterns or conventions introduced this session. This was a release-labeling and refactor-rename sweep. + +### Board State at Session End + +| Item | Status | +|------|--------| +| Issue #265 | ✅ Closed — `release-candidate` label applied; auto-closed by `milestone-blog.yml` | +| Issue #266 | ✅ Closed — resolved by PR #267 | +| PR #267 | 🔄 Open, targeting `dev`, awaiting merge | +| Issue #268 | 🆕 Filed for Boromir — Squad Mark Released CI GraphQL permission fix | +| Issue #269 | 🆕 Filed for Boromir — Blog→README Sync CI direct-push-to-main fix | + +### Blockers & Next Steps + +- **Boromir:** Fix CI issues #268 (add `PROJECT_TOKEN` secret, update `squad-mark-released.yml`) and #269 (PR-based sync workflow for `main`) +- **PR #267:** Awaiting reviewer merge to `dev` diff --git a/.squad/decisions.md b/.squad/decisions.md index c6a79ef8..5cc61d78 100644 --- a/.squad/decisions.md +++ b/.squad/decisions.md @@ -2075,3 +2075,159 @@ Release PR **#272** has been opened to promote `dev` → `main` for Sprint 18. 2. PR CI must pass before merge 3. Merge to `main` via squash merge 4. Tag `main` with appropriate `vX.Y.Z` after CI green + +--- + +### 24. AppHost Clear-Command Test Harness Architecture + +**Status:** ✅ Proposed +**Date:** 2025 +**Decided by:** Gimli (Tester) +**Issue:** #248 + +#### Context + +Issue #248 required automated test coverage for the `clear-myblog-data` Aspire operator command. The handler in `AppHost.cs` captures the `mongo` resource builder in a closure and calls `mongo.Resource.ConnectionStringExpression.GetValueAsync(ct)` to resolve the live MongoDB connection string. This architectural choice bypasses the `ServiceProvider` — standard DI mocks cannot intercept it. + +#### Decision + +Use a **two-tier test strategy**: + +1. **Model-level unit tests** (`MongoDbClearCommandTests`, no Docker): + Boot `DistributedApplicationTestingBuilder.CreateAsync()` without calling `StartAsync()`. Verify the Aspire annotation contract: command name, `IsHighlighted`, `ConfirmationMessage`, and `UpdateState` enabled/disabled by health status. + **Do NOT call `ExecuteCommand` from unit tests** — `GetValueAsync()` blocks without DCP. + +2. **Integration tests** (`MongoClearDataIntegrationTests`, Docker required): + Use `ClearCommandAppFixture` (IAsyncLifetime) to boot a full Aspire host via `DistributedApplicationTestingBuilder.CreateAsync` + `StartAsync`. Seed MongoDB via the Driver, invoke `ExecuteCommand` through the registered annotation, and assert post-clear database state. + +#### Consequences + +- Unit tests run in CI without Docker. +- Integration tests are gated by Docker availability (same gate as existing integration tests). +- Tests are in xUnit collection `"MongoClearIntegration"` to share one fixture instance across all three integration tests (single container boot). +- Any future handler that captures DI resources in closures must use the same two-tier pattern. + +#### Rejected Alternatives + +- **Single Testcontainers test**: Would lose fast-feedback unit coverage of the annotation contract. +- **Mock `ConnectionStringExpression`**: The `MongoDBServerResource` type is sealed/internal in Aspire; expression resolution is not mockable from external assemblies. +- **`[Fact(Skip)]` for the unreachable code path**: Violates Gimli charter (no skipped tests). The null-connection-string graceful failure path is covered implicitly — if `GetValueAsync` returns null in integration, the test fails with a descriptive message. + +--- + +### 25. Gimli's Default Test Approach — TDD / Red-Green-Refactor + +**Status:** ✅ Documented +**Date:** 2026-05-XX +**Decided by:** Boromir (requested) + Ralph (executed) + +> **Note:** This decision supplements and reinforces Decision #23 (Gimli's Testing Approach & Model Override). It documents the TDD policy independently of the model override change. + +#### Decision + +Gimli's default test-authoring approach is **Test-Driven Development (TDD)** using the **red-green-refactor** cycle. All testing work defaults to behavior-first, observable-outcome validation, not implementation-detail coupling. + +#### Rationale + +1. **Behavior-first testing is more maintainable:** Tests that verify observable outcomes through public interfaces survive refactoring. Tests coupled to implementation details fail when internal structure changes, even if behavior remains unchanged. +2. **Red-green-refactor prevents premature design:** Vertical slices (one test → one implementation → repeat) respond to what we learn from actual code. +3. **TDD catches design problems early:** Writing tests first reveals interface friction. If a feature is hard to test, that signals an interface design problem. +4. **Project skill alignment:** The project maintains `.github/skills/tdd/SKILL.md` with tracer-bullet patterns, anti-patterns, and refactoring guidance. Gimli routing injects this skill by default for all testing tasks. + +#### Implementation + +- **Charter:** Gimli's `.squad/agents/gimli/charter.md` documents TDD as the default approach with references to behavior-first principles and the `/tdd` skill suite. +- **Routing:** `.squad/routing.md` injects `.squad/skills/tdd/SKILL.md` + `.github/skills/tdd/tests.md` for every Gimli testing task. +- **Critical Rules:** Gimli enforces the full pre-push test suite (`dotnet test tests/Unit.Tests tests/Architecture.Tests -c Release`) and coverage gate (89% line threshold) before any branch push. + +#### Implications + +- New or updated test work always uses TDD — even bug fixes require writing the test first. +- Red-green-refactor is non-negotiable; horizontal slicing (write all tests, then code) is not permitted. +- If a feature is hard to test, designers (Aragorn, Sam, Legolas) are consulted before implementation. +- Vertical slices preferred: each behavior is a single RED→GREEN→REFACTOR loop. + +#### Related Artifacts + +- `.squad/agents/gimli/charter.md` — Gimli's full charter and critical rules +- `.github/skills/tdd/SKILL.md` — Core TDD workflow, tracer bullets, anti-patterns +- `.github/skills/tdd/tests.md` — Behavior-first vs. implementation-detail examples +- `.github/skills/tdd/refactoring.md` — Refactor patterns and post-GREEN cleanup +- `.github/skills/tdd/mocking.md` — Mocking guidelines +- `.squad/routing.md` — Skill injection for all Gimli testing tasks + +--- + +### PR #273 Gate Decision — squad/harden-apphost-tests-flake + +**Date:** 2026-05-08 +**Author:** Aragorn (Lead Developer) +**PR:** squad/harden-apphost-tests-flake → dev +**Verdict:** APPROVED ✅ + +#### What Changed + +Three `*_Concurrent_Invocations_Allow_Only_One_Run` integration tests in `AppHost.Tests` +(MongoClearData, MongoSeedData, MongoShowStats) were hardened against timing flakiness. +The original code called `ExecuteCommand` sequentially on the same async task, so +`_dbMutex.WaitAsync(0)` could complete synchronously twice — no real race ever occurred. +Fix: each invocation is dispatched via `Task.Run` to a thread-pool worker; a `SemaphoreSlim(0,2)` +start gate holds both workers until `Release(2)` opens them simultaneously, forcing a genuine +concurrent race for the production `_dbMutex`. Additionally, `.squad/` decision and Ralph history +files were updated with prior-session operational notes (appends to existing files). + +#### Rationale + +Approved. The `SemaphoreSlim(0,2)` + `Release(2)` pattern is idiomatic and correct. MongoDB I/O +duration (tens of milliseconds) dwarfs thread-scheduling latency (sub-millisecond), so the start +gate reliably forces a genuine race in practice. Copilot flagged a theoretical residual flakiness +window (Release fires before both workers reach WaitAsync), but: (1) both Task.Run items are queued +before Release is called on a pre-warmed thread pool, and (2) CI confirmed AppHost.Tests green on +first run. The `.squad/` additions are appends to existing files, not new files — minor scope note, +not a blocker. All 19 CI checks passed including codecov/project and codecov/patch. + +**Coverage delta:** No report in PR comments; codecov/project: pass, codecov/patch: pass — no decrease detected. + +--- + +### Gate Decision: Release PR #272 — Sprint 18 + +**Date:** 2026-05-08 +**Author:** Aragorn (Lead Developer) +**PR:** [#272 — RELEASE: Promote dev to main — Sprint 18](https://github.com/mpaulosky/MyBlog/pull/272) +**Base:** `main` ← **Head:** `dev` +**Decision:** APPROVED ✅ + +#### Rationale + +**Scope:** Diff is clean and bounded to Sprint 18 work: + +- `src/AppHost/MongoDbResourceBuilderExtensions.cs` + `AppHost.cs` — feature implementation +- `tests/AppHost.Tests/` (6 files) — test coverage for all 3 new commands +- `.github/workflows/blog-readme-sync.yml`, `squad-mark-released.yml` — CI fixes (#270, #271) +- `.squad/agents/boromir/history.md` — release log (acceptable on dev→main) +- `.vscode/settings.json` — tooling + +No `.squad/` files from feature branches. No unexpected production code changes. + +**CI Gate:** Squad CI (authoritative gate per playbook): GREEN on both push and pull_request. +AppHost.Tests flaky failure is non-blocking — the `SeedMyBlogData Concurrent Invocations Allow Only +One Run` test is a known timing-sensitive race in the test harness. The prior push run +(ID 25572554825) on the same head SHA (c272febe) passed all tests. One subsequent run failed due to +CI environment timing variance — this is a flake, not a regression. + +**Automated Reviews:** GitHub Copilot automated review: No comments posted. Codecov bot: No coverage decrease flagged. + +**Architecture Quality:** Sprint 18 work is a clean refactor — three dev-lifecycle methods extracted into a dedicated `MongoDbResourceBuilderExtensions` static class. Follows VSA patterns. Additive only; no breaking changes to public API surface. + +**Sprint Completeness:** All 4 Sprint 18 milestone issues closed (#262, #263, #264, #267). No open Sprint 18 PRs. + +#### Note on Approval Mechanism + +GitHub rejected `gh pr review --approve` (cannot approve own PR via same account). Gate decision posted as PR comment #4409029831 instead. Merge authority remains with mpaulosky. + +#### Post-Merge Actions Required + +1. Squash merge PR #272 +2. Tag `main` with `vX.Y.Z` after CI green +3. Run `squad-mark-released` workflow From 5b1828f097c817029e855fb29eab273c7bf635c6 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Fri, 8 May 2026 13:37:20 -0700 Subject: [PATCH 10/89] docs(squad): merge Sprint 18 decisions and Ralph board sweep log (#275) Squash-merges Sprint 18 release decisions into .squad/decisions.md and .squad/decisions/decisions.md, and logs the 2026-05-08 board sweep and CI-fix sprint in Ralph's agent history. Closes #278 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 7c1fea24c4490718a4f5c698b978c34c8856f97b Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sun, 10 May 2026 11:19:17 -0700 Subject: [PATCH 11/89] fix(profile): fall back to authenticated email claims on /profile (#279) ## Summary Fix the profile email display when the authenticated principal exposes a legitimate email through alternate claim forms, and keep the Auth0 management client compatible with both current and legacy configuration keys. Working as Sam (Backend / .NET). Ralph coordinated final delivery. ## What changed - `src/Web/Program.cs` - Requests the `email` scope alongside `openid profile` so Auth0 can issue the direct email claim when available. - `src/Web/Features/UserManagement/Profile.razor` - Preserves direct `email` claim handling and falls back to alternate legitimate authenticated email claim forms before rendering the profile card. - `tests/Architecture.Tests/ProfileEmailAuthContractTests.cs` - Locks in the explicit `email` scope requirement in `Program.cs`. - `tests/Web.Tests.Bunit/Features/ProfileTests.cs` - Adds regressions for both direct email claims and fallback shapes such as `preferred_username`. - `src/Web/Features/UserManagement/UserManagementHandler.cs` - Resolves Auth0 Management API settings from both `Auth0Management:*` and legacy `Auth0:ManagementApi*` keys, treats whitespace as missing, and preserves explicit configuration and HTTP failure behavior. ## Validation - Focused tests - `tests/Architecture.Tests/Architecture.Tests.csproj --filter ProfileEmailAuthContractTests`: 1 passed - `tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj --filter ProfileTests`: 7 passed - `tests/Web.Tests/Web.Tests.csproj --filter UserManagementHandlerTests`: 16 passed - Full suite - `tests/Web.Tests/Web.Tests.csproj`: 148 passed, 0 failed - AppHost runtime verification - Started `src/AppHost/AppHost.csproj` - Authenticated via `/test/login?role=Admin` - Confirmed `/profile` renders `test@example.com` in the live app - Real Auth0 verification - Prior branch validation also included a real Auth0 check to confirm the profile email renders for a genuine authenticated principal Closes #278 --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 5 +- docs/build-log.txt | 56 +++ src/Web/Features/UserManagement/Profile.razor | 207 ++++++++--- .../UserManagement/UserManagementHandler.cs | 340 ++++++++++-------- src/Web/Program.cs | 15 +- .../ProfileEmailAuthContractTests.cs | 42 +++ .../Web.Tests.Bunit/Features/ProfileTests.cs | 105 +++++- .../Handlers/UserManagementHandlerTests.cs | 177 +++++++-- 8 files changed, 707 insertions(+), 240 deletions(-) create mode 100644 tests/Architecture.Tests/ProfileEmailAuthContractTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a02c2454..2cd1e97d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,7 @@ true + true @@ -21,6 +22,8 @@ + + @@ -49,4 +52,4 @@ - \ No newline at end of file + diff --git a/docs/build-log.txt b/docs/build-log.txt index 72eeed6c..4f7bee55 100644 --- a/docs/build-log.txt +++ b/docs/build-log.txt @@ -156,6 +156,62 @@ The solution builds successfully and all tests pass. The only issue is a minor code coverage gap of 0.54%. Adding tests for approximately 5 more lines will meet the 89% threshold. +ADDENDUM: PR #279 SHARPCOMPRESS / NU1902 CI INVESTIGATION +--------------------------------------------------------- +Generated: 2026-05-10 +Scope: Allowed-file remediation only. Preserved existing dirty app files in + src/Web/Features/UserManagement/UserManagementHandler.cs and + src/Web/Program.cs. + +ROOT CAUSE +---------- +- CI restore was failing with NU1902 because MongoDB.Driver 3.6.0 resolved the + transitive package SharpCompress 0.30.1. +- NuGet restore audit is enabled and warnings are treated as errors in CI, so + advisory GHSA-6c8g-7p36-r338 became a build-blocking restore error. +- Scratch-package verification during this session showed MongoDB.Driver 3.7.1 + and 3.8.0 still resolve SharpCompress 0.30.1, so upgrading only the driver was + not a minimal safe fix. + +FIX APPLIED +----------- +- Enabled CentralPackageTransitivePinningEnabled in Directory.Packages.props. +- Added a central transitive pin for SharpCompress 0.48.0. + +VERIFICATION +------------ +1. Command: dotnet restore MyBlog.slnx + Status: ✅ SUCCESS + Result: Restore completed without NU1902. + +2. Command: dotnet build MyBlog.slnx --configuration Release --no-restore + Status: ✅ SUCCESS + Result: Build completed with 326 existing analyzer/code-quality warnings and + 0 errors. No SharpCompress / NU1902 failure remained. + +3. Command: dotnet package list --project src/Web/Web.csproj --include-transitive --vulnerable --format json --no-restore + Status: ✅ SUCCESS + Result: Vulnerable package count = 0 + +4. Command: dotnet package list --project src/AppHost/AppHost.csproj --include-transitive --vulnerable --format json --no-restore + Status: ✅ SUCCESS + Result: Vulnerable package count = 0 + +5. Test verification (Release, --no-build) + Status: ✅ SUCCESS + Passed projects: + - tests/Architecture.Tests/Architecture.Tests.csproj + - tests/Domain.Tests/Domain.Tests.csproj + - tests/Web.Tests/Web.Tests.csproj + - tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj + - tests/Web.Tests.Integration/Web.Tests.Integration.csproj + - tests/AppHost.Tests/AppHost.Tests.csproj + +REMAINING BLOCKER +----------------- +None found for the SharpCompress / NU1902 issue after the package pin was +applied and verified. + ================================================================================ END OF BUILD LOG ================================================================================ diff --git a/src/Web/Features/UserManagement/Profile.razor b/src/Web/Features/UserManagement/Profile.razor index a7cbae47..c68278ca 100644 --- a/src/Web/Features/UserManagement/Profile.razor +++ b/src/Web/Features/UserManagement/Profile.razor @@ -1,5 +1,6 @@ @page "/profile" @using System.Security.Claims +@using System.Text.Json @using MyBlog.Web.Security @attribute [Authorize] @@ -18,13 +19,13 @@ else
@if (!string.IsNullOrWhiteSpace(_pictureUrl)) { - @($ + @($ } else { -
+
@_initials
} @@ -34,8 +35,9 @@ else

@_displayName

@if (_isAdmin) { - Admin + Admin }

@_emailAddress

@@ -47,7 +49,8 @@ else
-
+

Identity

@@ -61,7 +64,8 @@ else
-
+

Roles

@if (_roles.Count > 0) { @@ -70,11 +74,13 @@ else { @if (role.Equals("Admin", StringComparison.OrdinalIgnoreCase)) { - @role + @role } else { - @role + @role } }
@@ -90,40 +96,45 @@ else

Claims

-

Claims currently present on your authenticated user principal.

-
- - @if (_claims.Count == 0) - { -
-

No claims were found.

+

Claims currently present on your + authenticated user principal.

- } - else - { -
- - - - - - - - - @foreach (var claim in _claims) - { - - - - - } - -
Claim TypeValue
@claim.Type@claim.Value
+ + @if (_claims.Count == 0) + { +
+

No claims were found.

+
+ } + else + { +
+ + + + + + + + + @foreach (var claim in _claims) + { + + + + + } + +
+ Claim Type + Value
@claim.Type@claim.Value
+
+ } +
} - -
-} @code { [CascadingParameter] @@ -139,7 +150,7 @@ else private IReadOnlyList _roles = []; private IReadOnlyList _claims = []; - private static readonly HashSet _sensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet SensitiveClaimTypes = new(StringComparer.OrdinalIgnoreCase) { "nonce", "c_hash", "at_hash", "aud", "azp", "auth_time", "iat", "exp", "nbf" }; @@ -161,9 +172,7 @@ else "nickname", ClaimTypes.GivenName) ?? _user.Identity?.Name ?? "Unknown User"; - _emailAddress = GetFirstClaimValue(_user, - ClaimTypes.Email, - "email") ?? "No email claim found"; + _emailAddress = GetEmailAddress(_user) ?? "No email claim found"; _userId = GetFirstClaimValue(_user, ClaimTypes.NameIdentifier, @@ -178,12 +187,31 @@ else _roles = RoleClaimsHelper.GetRoles(_user); _isAdmin = _roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); _claims = _user.Claims - .Where(c => !_sensitiveClaimTypes.Contains(c.Type)) + .Where(c => !SensitiveClaimTypes.Contains(c.Type)) .OrderBy(c => c.Type) .ThenBy(c => c.Value) .ToList(); } + private static string? GetEmailAddress(ClaimsPrincipal user) + { + string? directEmail = GetFirstClaimValueMatching(user, + static claim => claim.Type.Equals(ClaimTypes.Email, StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("email", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(directEmail)) + { + return directEmail; + } + + return GetFirstClaimValueMatching(user, + static claim => claim.Type.Equals(ClaimTypes.Upn, StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("upn", StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("preferred_username", StringComparison.OrdinalIgnoreCase) + || claim.Type.Equals("emails", StringComparison.OrdinalIgnoreCase) + || IsEmailClaimType(claim.Type)); + } + private static string? GetFirstClaimValue(ClaimsPrincipal user, params string[] claimTypes) { foreach (var claimType in claimTypes) @@ -200,9 +228,94 @@ else } + private static string? GetFirstClaimValueMatching(ClaimsPrincipal user, Func predicate) + { + foreach (Claim claim in user.Claims.Where(predicate)) + { + foreach (string value in GetClaimValues(claim.Value)) + { + if (LooksLikeEmailAddress(value)) + { + return value; + } + } + } + + return null; + } + + private static IReadOnlyList GetClaimValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + string trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using JsonDocument document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Where(static element => element.ValueKind == JsonValueKind.String) + .Select(static element => element.GetString()) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + return []; + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + private static bool IsEmailClaimType(string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return false; + } + + string tail = GetClaimTypeTail(claimType); + return tail.Equals("email", StringComparison.OrdinalIgnoreCase) + || tail.Equals("emails", StringComparison.OrdinalIgnoreCase); + } + + private static string GetClaimTypeTail(string claimType) + { + int lastSlash = claimType.LastIndexOf('/'); + int lastColon = claimType.LastIndexOf(':'); + int separatorIndex = Math.Max(lastSlash, lastColon); + + return separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; + } + + private static bool LooksLikeEmailAddress(string? value) + { + return !string.IsNullOrWhiteSpace(value) + && value.Contains('@', StringComparison.Ordinal) + && !value.Contains(' ', StringComparison.Ordinal); + } + private static string GetInitials(string displayName, string emailAddress) { - var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", StringComparison.OrdinalIgnoreCase) + var source = !string.IsNullOrWhiteSpace(displayName) && !displayName.Equals("Unknown User", +StringComparison.OrdinalIgnoreCase) ? displayName : emailAddress; diff --git a/src/Web/Features/UserManagement/UserManagementHandler.cs b/src/Web/Features/UserManagement/UserManagementHandler.cs index 9797286c..461d2052 100644 --- a/src/Web/Features/UserManagement/UserManagementHandler.cs +++ b/src/Web/Features/UserManagement/UserManagementHandler.cs @@ -7,6 +7,8 @@ //Project Name : Web //======================================================= +using System.Text.Json.Serialization; + using Auth0.ManagementApi; using Auth0.ManagementApi.Users; @@ -22,174 +24,200 @@ internal sealed class UserManagementHandler( IRequestHandler, IRequestHandler>> { -public async Task>> Handle( -GetUsersWithRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var result = new List(); -await foreach (var user in usersPager) -{ -var rolesPager = await client.Users.Roles.ListAsync( -user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager) -{ -roles.Add(role.Name ?? string.Empty); -} -result.Add(new UserWithRolesDto( -user.UserId ?? string.Empty, -user.Email ?? string.Empty, -user.Name ?? user.Email ?? string.Empty, -roles)); -} -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetUsersWithRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var usersPager = await client.Users.ListAsync(new ListUsersRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var result = new List(); + await foreach (var user in usersPager.ConfigureAwait(false)) + { + var rolesPager = await client.Users.Roles.ListAsync( + user.UserId ?? string.Empty, new ListUserRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager.ConfigureAwait(false)) + { + roles.Add(role.Name ?? string.Empty); + } + result.Add(new UserWithRolesDto( + user.UserId ?? string.Empty, + user.Email ?? string.Empty, + user.Name ?? user.Email ?? string.Empty, + roles)); + } + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.AssignAsync( -request.UserId, -new AssignUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(AssignRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.AssignAsync( + request.UserId, + new AssignUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -await client.Users.Roles.DeleteAsync( -request.UserId, -new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, -cancellationToken: cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(RemoveRoleCommand request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + await client.Users.Roles.DeleteAsync( + request.UserId, + new DeleteUserRolesRequestContent { Roles = [request.RoleId] }, + cancellationToken: cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) -{ -try -{ -var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); -var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); -var roles = new List(); -await foreach (var role in rolesPager) -{ -roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); -} -return Result.Ok>(roles); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} -catch (HttpRequestException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle(GetAvailableRolesQuery request, CancellationToken cancellationToken) + { + try + { + var client = await GetManagementClientAsync(cancellationToken).ConfigureAwait(false); + var rolesPager = await client.Roles.ListAsync(new ListRolesRequestParameters(), cancellationToken: cancellationToken).ConfigureAwait(false); + var roles = new List(); + await foreach (var role in rolesPager.ConfigureAwait(false)) + { + roles.Add(new RoleDto(role.Id ?? string.Empty, role.Name ?? string.Empty)); + } + return Result.Ok>(roles); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } + catch (HttpRequestException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -private async Task GetManagementClientAsync(CancellationToken cancellationToken) -{ -var domain = configuration["Auth0:ManagementApiDomain"] -?? throw new InvalidOperationException("Auth0:ManagementApiDomain not configured."); -var clientId = configuration["Auth0:ManagementApiClientId"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientId not configured."); -var clientSecret = configuration["Auth0:ManagementApiClientSecret"] -?? throw new InvalidOperationException("Auth0:ManagementApiClientSecret not configured."); + private async Task GetManagementClientAsync(CancellationToken cancellationToken) + { + var domain = GetRequiredManagementSetting("Auth0Management:Domain", "Auth0:ManagementApiDomain"); + var clientId = GetRequiredManagementSetting("Auth0Management:ClientId", "Auth0:ManagementApiClientId"); + var clientSecret = GetRequiredManagementSetting("Auth0Management:ClientSecret", "Auth0:ManagementApiClientSecret"); + var audience = GetOptionalManagementSetting("Auth0Management:Audience", "Auth0:ManagementApiAudience") + ?? $"https://{domain}/api/v2/"; -var httpClient = httpClientFactory.CreateClient(); -var tokenResponse = await httpClient.PostAsJsonAsync( -$"https://{domain}/oauth/token", -new -{ -client_id = clientId, -client_secret = clientSecret, -audience = $"https://{domain}/api/v2/", -grant_type = "client_credentials" -}, cancellationToken).ConfigureAwait(false); -tokenResponse.EnsureSuccessStatusCode(); -var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); -return new ManagementApiClient( -token: tokenData!.AccessToken, -clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); -} + using var httpClient = httpClientFactory.CreateClient(); + var tokenResponse = await httpClient.PostAsJsonAsync( + $"https://{domain}/oauth/token", + new + { + client_id = clientId, + client_secret = clientSecret, + audience, + grant_type = "client_credentials" + }, cancellationToken).ConfigureAwait(false); + tokenResponse.EnsureSuccessStatusCode(); + var tokenData = await tokenResponse.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(tokenData?.AccessToken)) + { + throw new InvalidOperationException("Auth0 Management API token response did not contain a valid access_token."); + } -private sealed class TokenResponse -{ -public string AccessToken { get; init; } = string.Empty; -} + return new ManagementApiClient( + token: tokenData.AccessToken, + clientOptions: new ClientOptions { BaseUrl = $"https://{domain}/api/v2" }); + } + + private string GetRequiredManagementSetting(string primaryKey, string legacyKey) + { + return GetOptionalManagementSetting(primaryKey, legacyKey) + ?? throw new InvalidOperationException( + $"{primaryKey} not configured. {legacyKey} not configured."); + } + + private string? GetOptionalManagementSetting(params string[] keys) + { + foreach (var key in keys) + { + var value = configuration[key]; + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; + } + + private sealed class TokenResponse + { + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } = string.Empty; + } } diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 77660b28..ebafbd1f 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -70,6 +70,7 @@ opts.Domain = auth0Domain; opts.ClientId = auth0ClientId; opts.ClientSecret = builder.Configuration["Auth0:ClientSecret"]; + opts.Scope = "openid profile email"; opts.CallbackPath = "/signin-auth0"; }); @@ -80,8 +81,7 @@ var existingOnTokenValidated = options.Events.OnTokenValidated; options.Events.OnTokenValidated = async context => { - if (existingOnTokenValidated is not null) - await existingOnTokenValidated(context); + await existingOnTokenValidated(context).ConfigureAwait(false); if (context.Principal?.Identity is not ClaimsIdentity identity) { @@ -120,7 +120,7 @@ // MediatR — scans Web assembly for all handlers builder.Services.AddMediatR(cfg => { - cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); + cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); }); // FluentValidation — scans Web assembly for all validators @@ -160,7 +160,7 @@ var props = new LoginAuthenticationPropertiesBuilder() .WithRedirectUri(safeReturn) .Build(); - await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props); + await ctx.ChallengeAsync(Auth0Constants.AuthenticationScheme, props).ConfigureAwait(false); }).AllowAnonymous(); app.MapGet("/Account/Logout", async ctx => @@ -168,8 +168,8 @@ var props = new LogoutAuthenticationPropertiesBuilder() .WithRedirectUri("/") .Build(); - await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props); - await ctx.SignOutAsync(); + await ctx.SignOutAsync(Auth0Constants.AuthenticationScheme, props).ConfigureAwait(false); + await ctx.SignOutAsync().ConfigureAwait(false); }).RequireAuthorization(); // Test-only login endpoint for E2E testing (Development/Testing environments only) @@ -205,11 +205,12 @@ static async Task MapTestLoginEndpoint(HttpContext ctx, string? role) await ctx.SignInAsync("Cookies", principal, new AuthenticationProperties { IsPersistent = true, - }); + }).ConfigureAwait(false); ctx.Response.Redirect("/"); } // Exclude the compiler-generated Program class (top-level bootstrap statements) from coverage. +[SuppressMessage("Design", "CA1515:Consider making public types internal", Justification = "WebApplicationFactory requires a public entry point for integration tests.")] [ExcludeFromCodeCoverage(Justification = "Application bootstrap entry-point — not business logic")] public partial class Program { } diff --git a/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs b/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs new file mode 100644 index 00000000..0cd625fe --- /dev/null +++ b/tests/Architecture.Tests/ProfileEmailAuthContractTests.cs @@ -0,0 +1,42 @@ +//======================================================= +//Copyright (c) 2026. All rights reserved. +//File Name : ProfileEmailAuthContractTests.cs +//Company : mpaulosky +//Author : Matthew Paulosky +//Solution Name : MyBlog +//Project Name : Architecture.Tests +//======================================================= + +using System.Text.RegularExpressions; + +namespace MyBlog.Architecture.Tests; + +public sealed class ProfileEmailAuthContractTests +{ + private static readonly string RepoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); + + [Fact] + public void ProgramShouldRequestEmailScopeForAuth0WebLogin() + { + // Arrange + var programSource = ReadRepoFile("src/Web/Program.cs"); + var configuresEmailScope = + Regex.IsMatch( + programSource, + @"\.Scope\s*=\s*""[^""]*\bemail\b[^""]*""", + RegexOptions.CultureInvariant) + || Regex.IsMatch( + programSource, + @"\.WithScope\s*\(\s*""[^""]*\bemail\b[^""]*""\s*\)", + RegexOptions.CultureInvariant); + + // Act / Assert + configuresEmailScope.Should().BeTrue( + because: "Auth0's ASP.NET Core SDK defaults to 'openid profile', so the web app must explicitly request the email scope if Profile.razor expects the signed-in principal to carry an email claim"); + } + + private static string ReadRepoFile(string relativePath) + { + return File.ReadAllText(Path.Combine(RepoRoot, relativePath)); + } +} diff --git a/tests/Web.Tests.Bunit/Features/ProfileTests.cs b/tests/Web.Tests.Bunit/Features/ProfileTests.cs index 8d0c8d69..139a93ab 100644 --- a/tests/Web.Tests.Bunit/Features/ProfileTests.cs +++ b/tests/Web.Tests.Bunit/Features/ProfileTests.cs @@ -39,6 +39,101 @@ public void ProfileRendersIdentityDetailsRolesPictureAndClaims() cut.Markup.Should().Contain("Engineering"); } + [Fact] + public void ProfileUsesOpenIdEmailClaimWhenFrameworkMappedEmailClaimIsMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|oidc-admin", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("email", "oidc-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("oidc-admin@example.com"); + } + + [Fact] + public void ProfileUsesPreferredUsernameAsEmailFallbackWhenDirectEmailClaimsAreMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|preferred-username", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("preferred_username", "preferred-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("preferred-admin@example.com"); + } + + [Fact] + public void ProfileUsesNamespacedEmailClaimTailWhenDirectEmailClaimsAreMissing() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|namespaced-email", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("https://schemas.example.com/email", "namespaced-admin@example.com") + ]); + + // Act + var cut = RenderForUser(principal); + var emailLine = cut.Find("section.card div.space-y-2 > p"); + + // Assert + emailLine.TextContent.Trim().Should().Be("namespaced-admin@example.com"); + } + + [Fact] + public void ProfileIgnoresNonStringEntriesInJsonEmailsClaimAndUsesFirstStringEmail() + { + // Arrange + var principal = CreatePrincipal( + name: "Admin User", + email: null, + userId: "auth0|json-emails", + pictureUrl: null, + rolesJson: null, + extraClaims: + [ + new Claim("emails", "[{\"value\":\"ignore-me\"},\"json-array@example.com\",42]") + ]); + + IRenderedComponent? cut = null; + Action act = () => cut = RenderForUser(principal); + + // Act + act.Should().NotThrow(); + + // Assert + cut.Should().NotBeNull(); + cut!.Find("section.card div.space-y-2 > p").TextContent.Trim().Should().Be("json-array@example.com"); + } + [Fact] public void ProfileUsesFallbackValuesWhenOptionalClaimsAreMissing() { @@ -81,10 +176,10 @@ public void ProfileAdminRoleBadgeHasRedColorClasses() .FirstOrDefault(span => span.TextContent.Trim() == "Admin" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-red-100")); + && cls.Contains("bg-red-100", StringComparison.Ordinal)); adminBadge.Should().NotBeNull("Admin role should render with red-100 background"); - adminBadge!.GetAttribute("class").Should().Contain("text-red-800"); + adminBadge.GetAttribute("class").Should().Contain("text-red-800"); } [Fact] @@ -107,10 +202,10 @@ public void ProfileNonAdminRoleBadgeHasGreenColorClasses() .FirstOrDefault(span => span.TextContent.Trim() == "Author" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-green-100")); + && cls.Contains("bg-green-100", StringComparison.Ordinal)); authorBadge.Should().NotBeNull("Non-admin role should render with green-100 background"); - authorBadge!.GetAttribute("class").Should().Contain("text-green-800"); + authorBadge.GetAttribute("class").Should().Contain("text-green-800"); } [Fact] @@ -133,7 +228,7 @@ public void ProfileAdminHeaderBadgeHasRedBackgroundClass() .FirstOrDefault(span => span.GetAttribute("title") == "Administrator" && span.GetAttribute("class") is { } cls - && cls.Contains("bg-red-600")); + && cls.Contains("bg-red-600", StringComparison.Ordinal)); headerBadge.Should().NotBeNull("Header Admin badge should render with bg-red-600"); } diff --git a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs index b82ad68e..df0d3571 100644 --- a/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs +++ b/tests/Web.Tests/Handlers/UserManagementHandlerTests.cs @@ -2,15 +2,19 @@ //======================================================= using System.Net; +using System.Text; +using System.Text.Json; using Microsoft.Extensions.Configuration; using MyBlog.Web.Features.UserManagement; -namespace Unit.Handlers; +namespace Web.Handlers; public class UserManagementHandlerTests { + private const string InvalidAccessTokenError = "Auth0 Management API token response did not contain a valid access_token."; + private readonly IConfiguration _config = Substitute.For(); private readonly IHttpClientFactory _httpFactory = Substitute.For(); private readonly UserManagementHandler _handler; @@ -24,7 +28,7 @@ public UserManagementHandlerTests() // ── Domain missing ────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_DomainMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesDomainMissingReturnsFailResult() { // Arrange (none) @@ -37,7 +41,7 @@ public async Task Handle_GetUsersWithRoles_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_AssignRole_DomainMissing_ReturnsFailResult() + public async Task HandleAssignRoleDomainMissingReturnsFailResult() { // Arrange (none) @@ -51,7 +55,7 @@ public async Task Handle_AssignRole_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_DomainMissing_ReturnsFailResult() + public async Task HandleRemoveRoleDomainMissingReturnsFailResult() { // Arrange (none) @@ -65,7 +69,7 @@ public async Task Handle_RemoveRole_DomainMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_DomainMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesDomainMissingReturnsFailResult() { // Arrange (none) @@ -80,7 +84,7 @@ public async Task Handle_GetAvailableRoles_DomainMissing_ReturnsFailResult() // ── ClientId missing ──────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_ClientIdMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -94,7 +98,7 @@ public async Task Handle_GetUsersWithRoles_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_AssignRole_ClientIdMissing_ReturnsFailResult() + public async Task HandleAssignRoleClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -109,7 +113,7 @@ public async Task Handle_AssignRole_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_ClientIdMissing_ReturnsFailResult() + public async Task HandleRemoveRoleClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -124,7 +128,7 @@ public async Task Handle_RemoveRole_ClientIdMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_ClientIdMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesClientIdMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientIdMissing(); @@ -140,7 +144,7 @@ public async Task Handle_GetAvailableRoles_ClientIdMissing_ReturnsFailResult() // ── ClientSecret missing ────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_ClientSecretMissing_ReturnsFailResult() + public async Task HandleGetUsersWithRolesClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -154,7 +158,7 @@ public async Task Handle_GetUsersWithRoles_ClientSecretMissing_ReturnsFailResult } [Fact] - public async Task Handle_AssignRole_ClientSecretMissing_ReturnsFailResult() + public async Task HandleAssignRoleClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -169,7 +173,7 @@ public async Task Handle_AssignRole_ClientSecretMissing_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_ClientSecretMissing_ReturnsFailResult() + public async Task HandleRemoveRoleClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -184,7 +188,7 @@ public async Task Handle_RemoveRole_ClientSecretMissing_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_ClientSecretMissing_ReturnsFailResult() + public async Task HandleGetAvailableRolesClientSecretMissingReturnsFailResult() { // Arrange var handler = BuildHandlerClientSecretMissing(); @@ -200,10 +204,12 @@ public async Task Handle_GetAvailableRoles_ClientSecretMissing_ReturnsFailResult // ── HTTP token endpoint fails ──────────────────────────────────────────────────────────────────── [Fact] - public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult() + public async Task HandleGetUsersWithRolesTokenEndpointFailsReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle(new GetUsersWithRolesQuery(), CancellationToken.None); @@ -214,10 +220,12 @@ public async Task Handle_GetUsersWithRoles_TokenEndpointFails_ReturnsFailResult( } [Fact] - public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() + public async Task HandleAssignRoleTokenEndpointFailsReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle( @@ -229,10 +237,12 @@ public async Task Handle_AssignRole_TokenEndpointFails_ReturnsFailResult() } [Fact] - public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() + public async Task HandleRemoveRoleTokenEndpointFailsReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle( @@ -244,10 +254,12 @@ public async Task Handle_RemoveRole_TokenEndpointFails_ReturnsFailResult() } [Fact] - public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult() + public async Task HandleGetAvailableRolesTokenEndpointFailsReturnsFailResult() { // Arrange - var handler = BuildHandlerHttpFail(HttpStatusCode.InternalServerError); + using var httpHandler = new StubHttpHandler(HttpStatusCode.InternalServerError); + using var httpClient = new HttpClient(httpHandler); + var handler = BuildHandlerHttpFail(new StaticHttpClientFactory(httpClient)); // Act var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); @@ -257,8 +269,101 @@ public async Task Handle_GetAvailableRoles_TokenEndpointFails_ReturnsFailResult( result.Error.Should().Contain("500"); } + // ── Management configuration/token contract ────────────────────────────────────────────── + + [Fact] + public async Task HandleGetAvailableRolesPrimaryManagementKeysUsePrimaryConfigAndConfiguredAudience() + { + // Arrange + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\" \"}"); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithPrimaryKeys(new StaticHttpClientFactory(httpClient), "https://api.example.com/"); + + // Act + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); + httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); + using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); + httpHandler.LastRequestUri.Should().Be(new Uri("https://primary.auth0.com/oauth/token")); + requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("primary-client-id"); + requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("primary-client-secret"); + requestBody.RootElement.GetProperty("audience").GetString().Should().Be("https://api.example.com/"); + requestBody.RootElement.GetProperty("grant_type").GetString().Should().Be("client_credentials"); + } + + [Fact] + public async Task HandleGetAvailableRolesWhitespacePrimaryManagementKeysFallBackToLegacyConfig() + { + // Arrange + using var httpHandler = new RecordingTokenHttpHandler("{\"access_token\":\"\"}"); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithLegacyFallback(new StaticHttpClientFactory(httpClient)); + + // Act + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); + httpHandler.LastRequestBody.Should().NotBeNullOrWhiteSpace(); + using var requestBody = JsonDocument.Parse(httpHandler.LastRequestBody!); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); + httpHandler.LastRequestUri.Should().Be(new Uri("https://legacy.auth0.com/oauth/token")); + requestBody.RootElement.GetProperty("client_id").GetString().Should().Be("legacy-client-id"); + requestBody.RootElement.GetProperty("client_secret").GetString().Should().Be("legacy-client-secret"); + requestBody.RootElement.GetProperty("audience").GetString().Should().Be("https://legacy.auth0.com/api/v2/"); + requestBody.RootElement.GetProperty("grant_type").GetString().Should().Be("client_credentials"); + } + + [Theory] + [InlineData("{\"access_token\":\"\"}")] + [InlineData("{\"access_token\":\" \"}")] + [InlineData("{}")] + public async Task HandleGetAvailableRolesBlankOrMissingAccessTokenReturnsExplicitFailure(string tokenResponseJson) + { + // Arrange + using var httpHandler = new RecordingTokenHttpHandler(tokenResponseJson); + using var httpClient = new HttpClient(httpHandler, disposeHandler: false); + var handler = BuildHandlerWithPrimaryKeys(new StaticHttpClientFactory(httpClient), "https://api.example.com/"); + + // Act + var result = await handler.Handle(new GetAvailableRolesQuery(), CancellationToken.None); + + // Assert + result.Failure.Should().BeTrue(); + result.Error.Should().Be(InvalidAccessTokenError); + } + // ── helpers ─────────────────────────────────────────────────────────────────────────────── + private static UserManagementHandler BuildHandlerWithPrimaryKeys(IHttpClientFactory httpFactory, string audience) + { + var config = Substitute.For(); + config["Auth0Management:Domain"].Returns("primary.auth0.com"); + config["Auth0Management:ClientId"].Returns("primary-client-id"); + config["Auth0Management:ClientSecret"].Returns("primary-client-secret"); + config["Auth0Management:Audience"].Returns(audience); + config["Auth0:ManagementApiDomain"].Returns("legacy.auth0.com"); + config["Auth0:ManagementApiClientId"].Returns("legacy-client-id"); + config["Auth0:ManagementApiClientSecret"].Returns("legacy-client-secret"); + return new UserManagementHandler(config, httpFactory); + } + + private static UserManagementHandler BuildHandlerWithLegacyFallback(IHttpClientFactory httpFactory) + { + var config = Substitute.For(); + config["Auth0Management:Domain"].Returns(" "); + config["Auth0Management:ClientId"].Returns("\t"); + config["Auth0Management:ClientSecret"].Returns(" "); + config["Auth0Management:Audience"].Returns(" "); + config["Auth0:ManagementApiDomain"].Returns("legacy.auth0.com"); + config["Auth0:ManagementApiClientId"].Returns("legacy-client-id"); + config["Auth0:ManagementApiClientSecret"].Returns("legacy-client-secret"); + return new UserManagementHandler(config, httpFactory); + } + private static UserManagementHandler BuildHandlerClientIdMissing() { var config = Substitute.For(); @@ -276,14 +381,12 @@ private static UserManagementHandler BuildHandlerClientSecretMissing() return new UserManagementHandler(config, Substitute.For()); } - private static UserManagementHandler BuildHandlerHttpFail(HttpStatusCode statusCode) + private static UserManagementHandler BuildHandlerHttpFail(IHttpClientFactory httpFactory) { var config = Substitute.For(); config["Auth0:ManagementApiDomain"].Returns("test.auth0.com"); config["Auth0:ManagementApiClientId"].Returns("test-client-id"); config["Auth0:ManagementApiClientSecret"].Returns("test-client-secret"); - var httpFactory = Substitute.For(); - httpFactory.CreateClient().Returns(new HttpClient(new StubHttpHandler(statusCode))); return new UserManagementHandler(config, httpFactory); } @@ -293,5 +396,31 @@ protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(statusCode)); } + + private sealed class StaticHttpClientFactory(HttpClient httpClient) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => httpClient; + } + + private sealed class RecordingTokenHttpHandler(string responseJson) : HttpMessageHandler + { + public Uri? LastRequestUri { get; private set; } + + public string? LastRequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri; + LastRequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }; + } + } } From 80a2ef15cecb3800b67bd0c2628ceb24b293c953 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sun, 10 May 2026 15:42:43 -0700 Subject: [PATCH 12/89] fix(build): clear remaining Release analyzer warnings (#283) ## Summary - remove the remaining Release analyzer warnings in the backend, infra, and test-project slice - keep the production diff focused to the warning fixes plus the final build log update - re-establish a zero-warning Release build baseline for this issue branch ## What changed - add `ConfigureAwait(false)` to the async warning hotspots in validation, repository, and cache paths - rename the ServiceDefaults extension container and add targeted null guards where analyzers required them - add centralized `[tests/**/*.cs]` analyzer suppressions in `.editorconfig` for repo-wide test-only xUnit naming and focused-sync-validator noise - document the final zero-warning baseline and verification pass in `docs/build-log.txt` ## Verification - `dotnet build MyBlog.slnx --configuration Release --no-restore` - `dotnet test tests/Architecture.Tests/Architecture.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Domain.Tests/Domain.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Web.Tests/Web.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Web.Tests.Integration/Web.Tests.Integration.csproj --configuration Release --no-build` Working as Boromir (DevOps / Infra) Closes #280 --------- Co-authored-by: Boromir Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: GitHub Copilot --- .editorconfig | 9 +- docs/build-log.txt | 87 +++++++++++++++++++ src/AppHost/AppHost.csproj | 6 ++ .../MongoDbResourceBuilderExtensions.cs | 10 +++ src/Domain/Abstractions/Result.cs | 6 ++ src/Domain/Behaviors/ValidationBehavior.cs | 4 +- src/ServiceDefaults/Extensions.cs | 4 +- src/Web/Data/BlogDbContext.cs | 5 ++ src/Web/Data/MongoDbBlogPostRepository.cs | 24 ++--- .../Caching/BlogPostCacheService.cs | 20 ++--- .../MongoSeedDataIntegrationTests.cs | 59 +++++++------ 11 files changed, 183 insertions(+), 51 deletions(-) diff --git a/.editorconfig b/.editorconfig index d0561928..ed0419c3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -401,4 +401,11 @@ dotnet_naming_style.camelcase.capitalization = camel_case dotnet_naming_style.s_camelcase.required_prefix = s_ dotnet_naming_style.s_camelcase.required_suffix = dotnet_naming_style.s_camelcase.word_separator = -dotnet_naming_style.s_camelcase.capitalization = camel_case \ No newline at end of file +dotnet_naming_style.s_camelcase.capitalization = camel_case + +[tests/**/*.cs] +# Tests intentionally use xUnit-style method names with underscores. +dotnet_diagnostic.CA1707.severity = none +# Test code deliberately exercises sync validators and uses xUnit-created fixtures. +dotnet_diagnostic.CA1849.severity = none +dotnet_diagnostic.CA1515.severity = none \ No newline at end of file diff --git a/docs/build-log.txt b/docs/build-log.txt index 4f7bee55..0970bf19 100644 --- a/docs/build-log.txt +++ b/docs/build-log.txt @@ -212,6 +212,93 @@ REMAINING BLOCKER None found for the SharpCompress / NU1902 issue after the package pin was applied and verified. +ADDENDUM: ISSUE #280 RELEASE ANALYZER WARNING CLEANUP +---------------------------------------------------- +Generated: 2026-05-10 +Branch: squad/280-cleanup-release-build-warnings +Scope: DevOps/infra-side warning cleanup after refreshing the preserved + issue branch with `dev`. + +BASELINE +-------- +- Preserved worktree branch was 4 commits ahead and 1 commit behind `dev`. + Merged `dev` into the worktree first so the baseline included PR #279 and + the SharpCompress transitive pin. +- Initial deduplicated Release analyzer baseline after the merge: + - 89 warnings + - 0 source-code errors + - 1 local worktree environment error: Tailwind CLI was not resolvable because + the sibling worktree lacked `node_modules` +- Top warning IDs before cleanup: + - CA2007 = 37 + - CA1707 = 34 + - CA1849 = 8 + - CA1515 = 4 + - CA2225 = 2 + - CA1062 = 2 + - CA1724 = 1 + - CA1865 = 1 +- Highest-leverage files before cleanup: + - tests/Domain.Tests/Behaviors/ValidationBehaviorTests.cs + - tests/Domain.Tests/Entities/BlogPostTests.cs + - tests/Domain.Tests/Abstractions/ResultTests.cs + - src/Web/Data/MongoDbBlogPostRepository.cs + - src/Web/Infrastructure/Caching/BlogPostCacheService.cs + - src/Web/Components/Theme/ThemeProvider.razor.cs + +FIXES APPLIED +------------- +- .editorconfig + - Added centralized `[tests/**/*.cs]` suppressions for CA1707, CA1849, and + CA1515 so test-only analyzer noise stays managed in one repo-wide location. + These warnings are xUnit naming / focused-sync-validator patterns in test + code, not production defects. +- src/ServiceDefaults/Extensions.cs + - Renamed the extension container type to `ServiceDefaultsExtensions` to + remove CA1724. + - Added `ArgumentNullException.ThrowIfNull(app)` to remove CA1062. +- src/Domain/Behaviors/ValidationBehavior.cs + - Added `ConfigureAwait(false)` to both async handler awaits. +- src/Web/Data/MongoDbBlogPostRepository.cs + - Added `ConfigureAwait(false)` to repository async calls. +- src/Web/Infrastructure/Caching/BlogPostCacheService.cs + - Added `ConfigureAwait(false)` to distributed-cache and fetch awaits. +- src/Web/Data/BlogDbContext.cs + - Added `ArgumentNullException.ThrowIfNull(modelBuilder)` for CA1062. + - Added a justified CA1515 suppression because the public DbContext type is + part of the composition root and shared test infrastructure. +- Local worktree-only environment fix + - Added an untracked local `node_modules` symlink back to the primary checkout + so Tailwind could build inside the sibling worktree. No tracked files + changed for this environment repair. + +RESULT +------ +- `dotnet build MyBlog.slnx --configuration Release --no-restore` + - Status: ✅ SUCCESS + - Final baseline: 0 warnings, 0 errors + +VERIFICATION +------------ +- `dotnet test tests/Architecture.Tests/Architecture.Tests.csproj --configuration Release --no-build` + - ✅ Passed 16/16 +- `dotnet test tests/Domain.Tests/Domain.Tests.csproj --configuration Release --no-build` + - ✅ Passed 42/42 +- `dotnet test tests/Web.Tests/Web.Tests.csproj --configuration Release --no-build` + - ✅ Passed 153/153 +- `dotnet test tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj --configuration Release --no-build` + - ✅ Passed 69/69 +- `dotnet test tests/Web.Tests.Integration/Web.Tests.Integration.csproj --configuration Release --no-build` + - ✅ Passed 12/12 +- `dotnet test tests/AppHost.Tests/AppHost.Tests.csproj --configuration Release --no-build` + - ✅ Passed 48/49, Skipped 1 documented AppHost theme skip + +REMAINING WARNINGS / HANDOFF +--------------------------- +- None in the final Release build baseline. +- No natural handoff to Legolas is required from this pass because the build is + warning-clean after the infra/backend and test-project cleanup. + ================================================================================ END OF BUILD LOG ================================================================================ diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index eb49f970..cc06c988 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -11,6 +11,12 @@ + + + <_Parameter1>AppHost.Tests + + + Exe net10.0 diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index a8b08d2e..2adf5d0e 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -22,6 +22,12 @@ internal static class MongoDbResourceBuilderExtensions // Shared semaphore — guards all three dev commands (Clear, Seed, Stats) so only one runs at a time. private static readonly SemaphoreSlim _dbMutex = new(1, 1); +/// +/// Test-only hook used by AppHost.Tests to hold the seed command inside the shared mutex +/// so overlapping invocations can be asserted deterministically. +/// +internal static Func? SeedCommandAfterMutexAcquiredAsync { get; set; } + public static IResourceBuilder WithMongoDbDevCommands( this IResourceBuilder builder, string databaseName) @@ -180,6 +186,10 @@ private static void WithSeedDataCommand( try { + var afterMutexAcquired = SeedCommandAfterMutexAcquiredAsync; + if (afterMutexAcquired is not null) + await afterMutexAcquired(context.CancellationToken); + context.Logger.LogInformation( "Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{Database}'.", context.ResourceName, databaseName); diff --git a/src/Domain/Abstractions/Result.cs b/src/Domain/Abstractions/Result.cs index c9bb72b8..324242cb 100644 --- a/src/Domain/Abstractions/Result.cs +++ b/src/Domain/Abstractions/Result.cs @@ -16,6 +16,8 @@ // Project Name : Domain // ======================================================= +using System.Diagnostics.CodeAnalysis; + namespace MyBlog.Domain.Abstractions; public enum ResultErrorCode @@ -140,6 +142,9 @@ public static Result FromValue(T? value) } #pragma warning restore CA1000 // Do not declare static members on generic types + // CA2225 does not recognize Result.ToValue()/FromValue() as valid alternates for + // these generic implicit conversions, so suppress the warning only on the operators. + [SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Result already exposes ToValue()/FromValue() named conversion APIs; the implicit conversions are kept intentionally for application ergonomics.")] public static implicit operator T?(Result? result) { if (result is null) @@ -152,6 +157,7 @@ public static Result FromValue(T? value) return result.Value; } + [SuppressMessage("Usage", "CA2225:Operator overloads have named alternates", Justification = "Result already exposes ToValue()/FromValue() named conversion APIs; the implicit conversions are kept intentionally for application ergonomics.")] public static implicit operator Result(T? value) { return Ok(value); diff --git a/src/Domain/Behaviors/ValidationBehavior.cs b/src/Domain/Behaviors/ValidationBehavior.cs index 6c62077c..878f947a 100644 --- a/src/Domain/Behaviors/ValidationBehavior.cs +++ b/src/Domain/Behaviors/ValidationBehavior.cs @@ -28,7 +28,7 @@ public async Task Handle( ArgumentNullException.ThrowIfNull(next); if (!validators.Any()) - return await next(cancellationToken); + return await next(cancellationToken).ConfigureAwait(false); var context = new ValidationContext(request); var failures = validators @@ -43,7 +43,7 @@ public async Task Handle( return (TResponse)CreateFailResult(typeof(TResponse), errorMessage); } - return await next(cancellationToken); + return await next(cancellationToken).ConfigureAwait(false); } private static object CreateFailResult(Type resultType, string errorMessage) diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs index e8b6dbd3..6636392d 100644 --- a/src/ServiceDefaults/Extensions.cs +++ b/src/ServiceDefaults/Extensions.cs @@ -26,7 +26,7 @@ namespace MyBlog.ServiceDefaults; // This project should be referenced by each service project in your solution. // To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults [ExcludeFromCodeCoverage(Justification = "Aspire infrastructure bootstrap — not business logic")] -public static class Extensions +public static class ServiceDefaultsExtensions { private const string HealthEndpointPath = "/health"; private const string AlivenessEndpointPath = "/alive"; @@ -121,6 +121,8 @@ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) w public static WebApplication MapDefaultEndpoints(this WebApplication app) { + ArgumentNullException.ThrowIfNull(app); + // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) diff --git a/src/Web/Data/BlogDbContext.cs b/src/Web/Data/BlogDbContext.cs index a49ccba9..e9c7232b 100644 --- a/src/Web/Data/BlogDbContext.cs +++ b/src/Web/Data/BlogDbContext.cs @@ -7,16 +7,21 @@ //Project Name : Web //======================================================= +using System.Diagnostics.CodeAnalysis; + using MongoDB.EntityFrameworkCore.Extensions; namespace MyBlog.Web.Data; +[SuppressMessage("Design", "CA1515:Consider making public types internal", Justification = "The DbContext type is part of the web composition root and shared test infrastructure.")] public sealed class BlogDbContext(DbContextOptions options) : DbContext(options) { public DbSet BlogPosts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + ArgumentNullException.ThrowIfNull(modelBuilder); + var entity = modelBuilder.Entity(); entity.ToCollection("blogposts"); entity.HasKey(p => p.Id); diff --git a/src/Web/Data/MongoDbBlogPostRepository.cs b/src/Web/Data/MongoDbBlogPostRepository.cs index e7bc4c79..0ffffb2d 100644 --- a/src/Web/Data/MongoDbBlogPostRepository.cs +++ b/src/Web/Data/MongoDbBlogPostRepository.cs @@ -14,45 +14,45 @@ internal sealed class MongoDbBlogPostRepository(IDbContextFactory { public async Task GetByIdAsync(Guid id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); return await ctx.BlogPosts.AsNoTracking() - .FirstOrDefaultAsync(p => p.Id == id, ct); + .FirstOrDefaultAsync(p => p.Id == id, ct).ConfigureAwait(false); } public async Task> GetAllAsync(CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); return await ctx.BlogPosts.AsNoTracking() .OrderByDescending(p => p.CreatedAt) - .ToListAsync(ct); + .ToListAsync(ct).ConfigureAwait(false); } public async Task AddAsync(BlogPost post, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - await ctx.BlogPosts.AddAsync(post, ct); - await ctx.SaveChangesAsync(ct); + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + await ctx.BlogPosts.AddAsync(post, ct).ConfigureAwait(false); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); } public async Task UpdateAsync(BlogPost post, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); var entry = ctx.Attach(post); // Version was incremented by post.Update(); the original value in the DB is Version - 1. // EF Core uses OriginalValue in the WHERE filter to detect concurrent modifications. entry.Property(p => p.Version).OriginalValue = post.Version - 1; entry.State = EntityState.Modified; - await ctx.SaveChangesAsync(ct); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) { - await using var ctx = await contextFactory.CreateDbContextAsync(ct); - var post = await ctx.BlogPosts.FindAsync([id], ct); + await using var ctx = await contextFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + var post = await ctx.BlogPosts.FindAsync([id], ct).ConfigureAwait(false); if (post is not null) { ctx.BlogPosts.Remove(post); - await ctx.SaveChangesAsync(ct); + await ctx.SaveChangesAsync(ct).ConfigureAwait(false); } } } diff --git a/src/Web/Infrastructure/Caching/BlogPostCacheService.cs b/src/Web/Infrastructure/Caching/BlogPostCacheService.cs index 897dd9c5..ec677684 100644 --- a/src/Web/Infrastructure/Caching/BlogPostCacheService.cs +++ b/src/Web/Infrastructure/Caching/BlogPostCacheService.cs @@ -34,7 +34,7 @@ public async ValueTask> GetOrFetchAllAsync( return cached; // L2 hit - var bytes = await distributedCache.GetAsync(BlogPostCacheKeys.All, ct); + var bytes = await distributedCache.GetAsync(BlogPostCacheKeys.All, ct).ConfigureAwait(false); if (bytes is not null) { try @@ -49,19 +49,19 @@ public async ValueTask> GetOrFetchAllAsync( catch (JsonException) { // Stale or corrupt bytes — remove and fall through to the DB - await distributedCache.RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); + await distributedCache.RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None).ConfigureAwait(false); } } // DB via caller-supplied fetch - var result = await fetch(); + var result = await fetch().ConfigureAwait(false); var list = result as List ?? result.ToList(); localCache.Set(BlogPostCacheKeys.All, list, LocalOpts); await distributedCache.SetAsync( BlogPostCacheKeys.All, JsonSerializer.SerializeToUtf8Bytes(list, JsonOpts), RedisOpts, - ct); + ct).ConfigureAwait(false); return result; } @@ -77,7 +77,7 @@ await distributedCache.SetAsync( return cached; // L2 hit - var bytes = await distributedCache.GetAsync(key, ct); + var bytes = await distributedCache.GetAsync(key, ct).ConfigureAwait(false); if (bytes is not null) { try @@ -92,12 +92,12 @@ await distributedCache.SetAsync( catch (JsonException) { // Stale or corrupt bytes — remove and fall through to the DB - await distributedCache.RemoveAsync(key, CancellationToken.None); + await distributedCache.RemoveAsync(key, CancellationToken.None).ConfigureAwait(false); } } // DB via caller-supplied fetch - var result = await fetch(); + var result = await fetch().ConfigureAwait(false); if (result is null) return null; @@ -106,7 +106,7 @@ await distributedCache.SetAsync( key, JsonSerializer.SerializeToUtf8Bytes(result, JsonOpts), RedisOpts, - ct); + ct).ConfigureAwait(false); return result; } @@ -114,7 +114,7 @@ public async Task InvalidateAllAsync(CancellationToken ct = default) { localCache.Remove(BlogPostCacheKeys.All); // CancellationToken.None: the DB write already committed — must not be cancelled - await distributedCache.RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None); + await distributedCache.RemoveAsync(BlogPostCacheKeys.All, CancellationToken.None).ConfigureAwait(false); } public async Task InvalidateByIdAsync(Guid id, CancellationToken ct = default) @@ -122,6 +122,6 @@ public async Task InvalidateByIdAsync(Guid id, CancellationToken ct = default) var key = BlogPostCacheKeys.ById(id); localCache.Remove(key); // CancellationToken.None: the DB write already committed — must not be cancelled - await distributedCache.RemoveAsync(key, CancellationToken.None); + await distributedCache.RemoveAsync(key, CancellationToken.None).ConfigureAwait(false); } } diff --git a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs index c1bbcc3c..748502ce 100644 --- a/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs +++ b/tests/AppHost.Tests/MongoSeedDataIntegrationTests.cs @@ -9,6 +9,8 @@ using AppHost.Tests.Infrastructure; +using Aspire.Hosting; + using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; @@ -80,36 +82,43 @@ public async Task SeedMyBlogData_Concurrent_Invocations_Allow_Only_One_Run() await db.CreateCollectionAsync("blogposts", cancellationToken: TestContext.Current.CancellationToken); var annotation = GetAnnotation(); - - // Act — dispatch both calls to thread-pool workers and open the gate at the same - // moment so they race to acquire _dbMutex. Without this the async lambda may run - // entirely synchronously (fast local MongoDB) and release the semaphore before the - // second call even starts, causing both to succeed (flake). var ct = TestContext.Current.CancellationToken; - using var startGate = new SemaphoreSlim(0, 2); + var enteredCriticalSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseCriticalSection = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var firstTask = Task.Run(async () => + MongoDbResourceBuilderExtensions.SeedCommandAfterMutexAcquiredAsync = async cancellationToken => { - await startGate.WaitAsync(ct); - return await annotation.ExecuteCommand(MakeContext()); - }, ct); + enteredCriticalSection.TrySetResult(true); + await releaseCriticalSection.Task.WaitAsync(cancellationToken); + }; - var secondTask = Task.Run(async () => + try { - await startGate.WaitAsync(ct); - return await annotation.ExecuteCommand(MakeContext()); - }, ct); - - startGate.Release(2); // open the gate — both workers race for _dbMutex - var results = await Task.WhenAll(firstTask, secondTask); - - // Assert - results.Count(static r => r.Success).Should().Be(1, - "the semaphore should allow only one seed operation to run at a time"); - results.Count(static r => !r.Success).Should().Be(1, - "the overlapping seed attempt should fail fast instead of queueing"); - results.Single(static r => !r.Success).Message.Should().Contain("already in progress", - "the operator needs immediate feedback when another database operation is in flight"); + // Act — hold the first seed invocation inside the shared mutex, then trigger the + // second invocation while the first is still in flight. This proves true overlap + // deterministically instead of relying on Task.Run scheduler timing. + var firstTask = annotation.ExecuteCommand(MakeContext()); + + await enteredCriticalSection.Task.WaitAsync(ct); + var secondResult = await annotation.ExecuteCommand(MakeContext()); + + releaseCriticalSection.TrySetResult(true); + var firstResult = await firstTask; + var results = new[] { firstResult, secondResult }; + + // Assert + results.Count(static r => r.Success).Should().Be(1, + "the semaphore should allow only one seed operation to run at a time"); + results.Count(static r => !r.Success).Should().Be(1, + "the overlapping seed attempt should fail fast instead of queueing"); + results.Single(static r => !r.Success).Message.Should().Contain("already in progress", + "the operator needs immediate feedback when another database operation is in flight"); + } + finally + { + releaseCriticalSection.TrySetResult(true); + MongoDbResourceBuilderExtensions.SeedCommandAfterMutexAcquiredAsync = null; + } } /// From f680a57940b8905be712d127892c96d815c88f7f Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sun, 10 May 2026 15:51:01 -0700 Subject: [PATCH 13/89] fix(headers): preserve original year when normalizing headers (#285) ## Summary - preserve the original copyright year when normalizing an existing header block - collapse duplicate top-of-file copyright headers into one canonical header - document the year-preservation rule in the header update prompt ## Validation - `dotnet build MyBlog.slnx --configuration Release --no-restore` - `dotnet test tests/Architecture.Tests/Architecture.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Domain.Tests/Domain.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Web.Tests/Web.Tests.csproj --configuration Release --no-build` - `dotnet test tests/Web.Tests.Bunit/Web.Tests.Bunit.csproj --configuration Release --no-build` - `dotnet test tests/Web.Tests.Integration/Web.Tests.Integration.csproj --configuration Release --no-build` - `dotnet test tests/AppHost.Tests/AppHost.Tests.csproj --configuration Release --no-build` Closes #284 Co-authored-by: Boromir --- .github/prompts/copyright-header-update-prompt.md | 12 ++++++------ src/Domain/Abstractions/Result.cs | 12 +----------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.github/prompts/copyright-header-update-prompt.md b/.github/prompts/copyright-header-update-prompt.md index 3c969e2c..6c434f4f 100644 --- a/.github/prompts/copyright-header-update-prompt.md +++ b/.github/prompts/copyright-header-update-prompt.md @@ -12,7 +12,7 @@ README files you write are appealing, informative, and easy to read. ## Plan -This plan details how to review and update copyright headers in C# files, supporting both single-file and solution-wide operations. It ensures every file has the correct header, updating or inserting as needed. +This plan details how to review and update copyright headers in C# files, supporting both single-file and solution-wide operations. It ensures every file has the correct header, preserves the original/earliest existing copyright year when normalizing an existing header, and collapses duplicate header blocks into one canonical header. **Header Example (as required):** @@ -31,14 +31,14 @@ This plan details how to review and update copyright headers in C# files, suppor **Steps:** 1. Identify all target `.cs` files, excluding those in `bin/` and `obj/` folders. -2. For each file, check for an existing header at the very first line. -3. If a header exists, update it with the correct format and metadata, ensuring every line starts with `//`. -4. If no header exists, insert the new header at the very first line of the file, with every line C# line commented (start with `//`). -5. Validate that the header is present, correctly formatted, and at the top of each file after editing. +2. For each file, check for one or more existing header-like comment blocks at the very first line. +3. If one or more header blocks exist, treat the entire leading header region as a single unit: preserve the original/earliest copyright year already present, normalize the metadata to the canonical format, and replace the region with exactly one header block. +4. If no header exists, insert the new header at the very first line of the file, with every line C# line commented (start with `//`). When there is no existing year to preserve, use the file's creation year. +5. Validate that exactly one canonical header is present, correctly formatted, and at the top of each file after editing. 6. Output a summary of the files reviewed and the files changed. **Open Questions:** 1. Should the header update logic support additional file types, or strictly `.cs` files? -2. Is there a preferred way to determine the file's creation year if metadata is unavailable? +2. If metadata is unavailable and no existing header year is present, is there a preferred fallback year source? 3. Should the solution/project name be parsed from the `.sln`/`.csproj` files or hardcoded? diff --git a/src/Domain/Abstractions/Result.cs b/src/Domain/Abstractions/Result.cs index 324242cb..3f17d19d 100644 --- a/src/Domain/Abstractions/Result.cs +++ b/src/Domain/Abstractions/Result.cs @@ -1,5 +1,5 @@ //======================================================= -//Copyright (c) 2026. All rights reserved. +//Copyright (c) 2025. All rights reserved. //File Name : Result.cs //Company : mpaulosky //Author : Matthew Paulosky @@ -7,17 +7,7 @@ //Project Name : Domain //======================================================= -// ======================================================= -// Copyright (c) 2025. All rights reserved. -// File Name : Result.cs -// Company : mpaulosky -// Author : Matthew Paulosky -// Solution Name : MyBlog -// Project Name : Domain -// ======================================================= - using System.Diagnostics.CodeAnalysis; - namespace MyBlog.Domain.Abstractions; public enum ResultErrorCode From 7541e68395eb9ff46820d25ce0fe8d2eed52d607 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sun, 10 May 2026 17:26:15 -0700 Subject: [PATCH 14/89] ci(hooks): add dotnet format gate before Release build (#290) - add dotnet format verification to the pre-push hook - document the renumbered hook gates and install output - include the required formatting cleanup so the new gate passes on merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/hooks/pre-push | 35 +- .squad/agents/boromir/history.md | 68 ++ .squad/decisions.md | 35 + .squad/playbooks/pre-push-process.md | 54 +- .squad/skills/pre-push-test-gate/SKILL.md | 7 +- scripts/install-hooks.sh | 9 +- .../MongoDbResourceBuilderExtensions.cs | 626 +++++++++--------- src/Domain/Entities/BlogPost.cs | 68 +- src/Domain/Interfaces/IBlogPostRepository.cs | 10 +- src/ServiceDefaults/Extensions.cs | 182 ++--- src/Web/Data/BlogPostDto.cs | 14 +- .../BlogPosts/Create/CreateBlogPostHandler.cs | 44 +- .../BlogPosts/Delete/DeleteBlogPostHandler.cs | 56 +- .../BlogPosts/Edit/EditBlogPostHandler.cs | 116 ++-- .../BlogPosts/List/GetBlogPostsHandler.cs | 52 +- src/Web/GlobalUsings.cs | 2 + src/Web/Security/RoleClaimsHelper.cs | 238 +++---- tests/AppHost.Tests/EnvVarTests.cs | 1 + .../Infrastructure/PlaywrightManager.cs | 3 +- .../AppHost.Tests/MongoDbClearCommandTests.cs | 310 ++++----- .../Tests/Layout/LayoutAuthenticatedTests.cs | 2 + .../Tests/Pages/HomePageTests.cs | 2 + .../Tests/Pages/NotFoundPageTests.cs | 2 + tests/AppHost.Tests/WebPlaywrightTests.cs | 1 + tests/Architecture.Tests/GlobalUsings.cs | 2 + tests/Domain.Tests/GlobalUsings.cs | 4 + tests/Web.Tests.Bunit/GlobalUsings.cs | 6 +- tests/Web.Tests/GlobalUsings.cs | 6 +- 28 files changed, 1068 insertions(+), 887 deletions(-) diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push index 138b97a0..4bb27f8a 100755 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Pre-push gate: mirrors CI checks to catch failures before they reach GitHub -# Runs: branch protection → untracked-file check → Release build → unit/architecture tests → integration tests +# Runs: branch protection → untracked-file check → dotnet format → Release build → unit/architecture tests → integration tests # NOTE: git provides refspecs on stdin; interactive prompts must use /dev/tty. set -uo pipefail @@ -49,7 +49,34 @@ if [[ -n "$UNTRACKED_SRC" ]]; then fi fi -# ── Gate 2: Release build (mirrors CI exactly) ───────────────────────────── +# ── Gate 2: dotnet format check ──────────────────────────────────────────── +echo -e "\n${CYAN}🎨 Checking code formatting (dotnet format --verify-no-changes)...${RESET}" +dotnet format MyBlog.slnx --verify-no-changes 2>&1 +FORMAT_EXIT=$? + +if [[ $FORMAT_EXIT -ne 0 ]]; then + echo -e "${RED}❌ Formatting issues detected — one or more files require formatting changes.${RESET}" + echo -e "${YELLOW} Fix: dotnet format MyBlog.slnx${RESET}" + echo -e "${YELLOW} Then: git add -u && git commit (or --amend), then re-push.${RESET}" + echo "" + printf "Auto-fix formatting now? Modified files must be staged and committed before re-pushing. [y/N] " >/dev/tty + read -r FORMAT_FIX ``` -3. **Release build passes locally** — Gate 2 runs Release (not Debug) +3. **Code is formatted** — Gate 2 runs `dotnet format --verify-no-changes` + + ```bash + dotnet format MyBlog.slnx --verify-no-changes + ``` + + If formatting issues are found, fix with: + + ```bash + dotnet format MyBlog.slnx + git add -u + git commit # or --amend + ``` + +4. **Release build passes locally** — Gate 3 runs Release (not Debug) ```bash dotnet build IssueTrackerApp.slnx --configuration Release @@ -49,7 +63,7 @@ Before running `git push`, verify: If build fails, run `.github/prompts/build-repair.prompt.md` to fix. -4. **Unit tests pass** — Gate 3 runs 6 test projects +5. **Unit tests pass** — Gate 4 runs 6 test projects ```bash dotnet test tests/Architecture.Tests/Architecture.Tests.csproj --configuration Release --no-build @@ -60,13 +74,13 @@ Before running `git push`, verify: dotnet test tests/Persistence.AzureStorage.Tests/Persistence.AzureStorage.Tests.csproj --configuration Release --no-build ``` -5. **Docker is running** — Gate 4 requires Docker for integration tests +6. **Docker is running** — Gate 5 requires Docker for integration tests ```bash docker info &>/dev/null && echo "Docker OK" || echo "Docker NOT running" ``` -## The 5 Gates (What the Hook Runs) +## The 6 Gates (What the Hook Runs) When you execute `git push`, the hook runs automatically: @@ -74,11 +88,12 @@ When you execute `git push`, the hook runs automatically: | ----- | ---------------------- | ------------------------------------------------------------------------ | | **0** | Branch protection | Current branch is `main` or `dev` | | **1** | Untracked source files | `.razor`/`.cs` files not staged (prompts y/N) | -| **2** | Release build | `dotnet build --configuration Release` fails (3 attempts) | -| **3** | Unit/Arch/bUnit tests | Any of 6 test projects fail (3 attempts) | -| **4** | Integration tests | Any of 4 integration test projects fail; Docker not running (3 attempts) | +| **2** | dotnet format | Any file requires formatting changes (prompts auto-fix y/N) | +| **3** | Release build | `dotnet build --configuration Release` fails (3 attempts) | +| **4** | Unit/Arch/bUnit tests | Any of 6 test projects fail (3 attempts) | +| **5** | Integration tests | Any of 4 integration test projects fail; Docker not running (3 attempts) | -### Gate 3 — Test Projects (Unit) +### Gate 4 — Test Projects (Unit) ```text tests/Architecture.Tests/Architecture.Tests.csproj @@ -89,7 +104,7 @@ tests/Web.Tests/Web.Tests.csproj tests/Persistence.AzureStorage.Tests/Persistence.AzureStorage.Tests.csproj ``` -### Gate 4 — Integration Test Projects (Docker Required) +### Gate 5 — Integration Test Projects (Docker Required) ```text tests/Persistence.MongoDb.Tests.Integration/Persistence.MongoDb.Tests.Integration.csproj @@ -102,7 +117,7 @@ These use Testcontainers (mongo:7.0, Azurite) and Aspire DCP. Docker daemon MUST ## Retry Behavior -The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: +The hook allows **3 attempts** for Gates 3, 4, and 5. Between attempts: - The hook pauses and prompts "Fix the errors and press Enter to retry, or Ctrl+C to abort" - Fix the failing code, then press Enter @@ -110,7 +125,15 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: ## Troubleshooting -### Build Failure (Gate 2) +### Formatting Failure (Gate 2) + +| Symptom | Fix | +| ------------------------ | ----------------------------------------------------------------- | +| Files differ from format | Run `dotnet format MyBlog.slnx`, then `git add -u && git commit` | +| Analyzer rule violation | Run `dotnet format MyBlog.slnx --diagnostics ` to debug | +| dotnet format not found | Install .NET SDK matching `global.json`; format ships with SDK | + +### Build Failure (Gate 3) | Symptom | Fix | | ------------------------ | ----------------------------------------------------- | @@ -120,7 +143,7 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: **Escalation:** Run `.github/prompts/build-repair.prompt.md` for automated fix. -### Test Failure (Gate 3) +### Test Failure (Gate 4) | Symptom | Fix | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | @@ -128,7 +151,7 @@ The hook allows **3 attempts** for Gates 2, 3, and 4. Between attempts: | bUnit test failure | Verify Blazor component rendering; check `Render()` not `RenderComponent()` (bUnit 2.x) | | DateTime equality failure | Assert individual fields, not whole-record equality (UtcNow varies between calls) | -### Integration Test Failure (Gate 4) +### Integration Test Failure (Gate 5) | Symptom | Fix | | ------------------------- | --------------------------------------------------------------- | @@ -148,6 +171,7 @@ chmod +x .git/hooks/pre-push ## Anti-Patterns - ❌ **Bypassing the hook** with `git push --no-verify` — CI will catch it, wasting time +- ❌ **Committing unformatted code** — Gate 2 blocks the push; run `dotnet format MyBlog.slnx` first - ❌ **Running Debug build only** — CI uses Release; Debug hides missing files - ❌ **Pushing without Docker** — Gate 4 will block; start Docker first - ❌ **Ignoring untracked files** — They're invisible to CI and will cause failures diff --git a/.squad/skills/pre-push-test-gate/SKILL.md b/.squad/skills/pre-push-test-gate/SKILL.md index c7dd12cf..87618de9 100644 --- a/.squad/skills/pre-push-test-gate/SKILL.md +++ b/.squad/skills/pre-push-test-gate/SKILL.md @@ -66,9 +66,10 @@ chmod +x .git/hooks/pre-push - Gate 0: Block direct push to `main` - Gate 1: Warn on untracked `.razor`/`.cs` files -- Gate 2: Release build (0 warnings, 0 errors) -- Gate 3: Unit + bUnit + Architecture tests (6 projects, no Docker) -- Gate 4: Integration + Playwright E2E — **AppHost.Tests included** (Docker required) +- Gate 2: `dotnet format --verify-no-changes` (formatting check; offers auto-fix) +- Gate 3: Release build (0 warnings, 0 errors) +- Gate 4: Unit + bUnit + Architecture tests (6 projects, no Docker) +- Gate 5: Integration + Playwright E2E — **AppHost.Tests included** (Docker required) **PowerShell (Windows):** diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index d1293273..9b29994d 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -81,12 +81,13 @@ echo "" echo "Pre-commit hook gates on every 'git commit':" echo " • Runs markdownlint on staged .md files (degrades gracefully if not installed)" echo "" -echo "Pre-push hook enforces 5 gates on every 'git push':" +echo "Pre-push hook enforces 6 gates on every 'git push':" echo " 0. Enforces branch naming — squad/{issue}-{slug} runs all gates;" echo " sprint/{N}-{slug} passes Gate 0 and exits (skips feature gates)" echo " 1. Warns about untracked .razor/.cs source files" -echo " 2. Release build (dotnet build MyBlog.slnx --configuration Release)" -echo " 3. Unit/arch tests (tests/Architecture.Tests, tests/Unit.Tests)" -echo " 4. Integration tests (tests/Integration.Tests — Docker required)" +echo " 2. dotnet format --verify-no-changes (formatting check; offers auto-fix)" +echo " 3. Release build (dotnet build MyBlog.slnx --configuration Release)" +echo " 4. Unit/arch tests (tests/Architecture.Tests, tests/Unit.Tests)" +echo " 5. Integration tests (tests/Integration.Tests — Docker required)" echo "" echo "To skip in an emergency: git commit --no-verify / git push --no-verify" diff --git a/src/AppHost/MongoDbResourceBuilderExtensions.cs b/src/AppHost/MongoDbResourceBuilderExtensions.cs index 2adf5d0e..3f64b163 100644 --- a/src/AppHost/MongoDbResourceBuilderExtensions.cs +++ b/src/AppHost/MongoDbResourceBuilderExtensions.cs @@ -19,199 +19,199 @@ namespace Aspire.Hosting; internal static class MongoDbResourceBuilderExtensions { -// Shared semaphore — guards all three dev commands (Clear, Seed, Stats) so only one runs at a time. -private static readonly SemaphoreSlim _dbMutex = new(1, 1); - -/// -/// Test-only hook used by AppHost.Tests to hold the seed command inside the shared mutex -/// so overlapping invocations can be asserted deterministically. -/// -internal static Func? SeedCommandAfterMutexAcquiredAsync { get; set; } - -public static IResourceBuilder WithMongoDbDevCommands( -this IResourceBuilder builder, -string databaseName) -{ -if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) -return builder; - -builder.WithClearDatabaseCommand(databaseName); -builder.WithSeedDataCommand(databaseName); -builder.WithShowStatsCommand(databaseName); -return builder; -} - -private static void WithClearDatabaseCommand( -this IResourceBuilder builder, -string databaseName) -{ -builder.WithCommand( -"clear-myblog-data", -"⚠️ Clear MyBlog Data", -executeCommand: async context => -{ -// AC2: Non-blocking acquire — return immediately if another clear is already in flight. -if (!await _dbMutex.WaitAsync(0)) -{ -context.Logger.LogWarning( -"Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", -context.ResourceName); - -return new ExecuteCommandResult -{ -Success = false, -Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." -}; -} - -try -{ -context.Logger.LogWarning( -"Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.", -context.ResourceName, databaseName); - -var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); -if (connectionString is null) -{ -context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); -return new ExecuteCommandResult -{ -Success = false, -Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" -}; -} - -var client = new MongoClient(connectionString); -var database = client.GetDatabase(databaseName); - -var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); -var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); - -var results = new List<(string Name, long Deleted)>(); -var warnings = new List(); - -foreach (var name in collectionNames) -{ -// Skip MongoDB internal system collections (e.g. system.views, system.users). -if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) -continue; - -try -{ -// AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, -// and the loop continues so remaining collections are still processed. -var collection = database.GetCollection(name); -var deleteResult = await collection.DeleteManyAsync( -FilterDefinition.Empty, -context.CancellationToken); - -results.Add((name, deleteResult.DeletedCount)); - -context.Logger.LogInformation( -"Collection '{Collection}': {Count} document(s) deleted.", -name, deleteResult.DeletedCount); -} -catch (Exception ex) when (ex is not OperationCanceledException) -{ -var warning = $"{name}: {ex.Message}"; -warnings.Add(warning); -context.Logger.LogWarning( -ex, -"Collection '{Collection}' could not be cleared — skipping and continuing.", -name); -} -} - -var totalDeleted = results.Sum(static r => r.Deleted); -var perCollection = results.Count == 0 -? "no non-system collections found" -: string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); - -context.Logger.LogWarning( -"Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", -totalDeleted, results.Count, warnings.Count); - -var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; -if (warnings.Count > 0) -message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; - -return new ExecuteCommandResult -{ -Success = true, -Message = message -}; -} -finally -{ -_dbMutex.Release(); -} -}, -new CommandOptions -{ -Description = "Permanently deletes all data from the myblog database. Local development only.", -ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", -IsHighlighted = true, -IconName = "DatabaseWarning", -// AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT -// check dependent resources (Web, etc.). Clearing is valid while the app is live against -// local Mongo; the Web app running is not a reason to disable the command. -UpdateState = ctx => -ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy -? ResourceCommandState.Enabled -: ResourceCommandState.Disabled -}); -} - -private static void WithSeedDataCommand( -this IResourceBuilder builder, -string databaseName) -{ -builder.WithCommand( -"seed-myblog-data", -"🌱 Seed MyBlog Data", -executeCommand: async context => -{ -if (!await _dbMutex.WaitAsync(0)) -{ -context.Logger.LogWarning( -"Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", -context.ResourceName); - -return new ExecuteCommandResult -{ -Success = false, -Message = "A database operation is already in progress. Wait for the current run to finish, then try again." -}; -} - -try -{ - var afterMutexAcquired = SeedCommandAfterMutexAcquiredAsync; - if (afterMutexAcquired is not null) - await afterMutexAcquired(context.CancellationToken); - -context.Logger.LogInformation( -"Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{Database}'.", -context.ResourceName, databaseName); - -var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); -if (connectionString is null) -{ -context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); -return new ExecuteCommandResult -{ -Success = false, -Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" -}; -} - -var client = new MongoClient(connectionString); -var database = client.GetDatabase(databaseName); -var collection = database.GetCollection("blogposts"); - -var now = DateTime.UtcNow; -var seedDocuments = new BsonDocument[] -{ + // Shared semaphore — guards all three dev commands (Clear, Seed, Stats) so only one runs at a time. + private static readonly SemaphoreSlim _dbMutex = new(1, 1); + + /// + /// Test-only hook used by AppHost.Tests to hold the seed command inside the shared mutex + /// so overlapping invocations can be asserted deterministically. + /// + internal static Func? SeedCommandAfterMutexAcquiredAsync { get; set; } + + public static IResourceBuilder WithMongoDbDevCommands( + this IResourceBuilder builder, + string databaseName) + { + if (!builder.ApplicationBuilder.ExecutionContext.IsRunMode) + return builder; + + builder.WithClearDatabaseCommand(databaseName); + builder.WithSeedDataCommand(databaseName); + builder.WithShowStatsCommand(databaseName); + return builder; + } + + private static void WithClearDatabaseCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "clear-myblog-data", + "⚠️ Clear MyBlog Data", + executeCommand: async context => + { + // AC2: Non-blocking acquire — return immediately if another clear is already in flight. + if (!await _dbMutex.WaitAsync(0)) + { + context.Logger.LogWarning( + "Clear MyBlog data skipped on {ResourceName} — a clear operation is already in progress.", + context.ResourceName); + + return new ExecuteCommandResult + { + Success = false, + Message = "A clear operation is already in progress. Wait for the current run to finish, then try again." + }; + } + + try + { + context.Logger.LogWarning( + "Clear MyBlog data invoked on {ResourceName} — enumerating collections in '{Database}'.", + context.ResourceName, databaseName); + + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); + if (connectionString is null) + { + context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); + return new ExecuteCommandResult + { + Success = false, + Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" + }; + } + + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + + var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); + var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); + + var results = new List<(string Name, long Deleted)>(); + var warnings = new List(); + + foreach (var name in collectionNames) + { + // Skip MongoDB internal system collections (e.g. system.views, system.users). + if (name.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) + continue; + + try + { + // AC3 (#249): Best-effort per collection — errors are caught, logged as warnings, + // and the loop continues so remaining collections are still processed. + var collection = database.GetCollection(name); + var deleteResult = await collection.DeleteManyAsync( + FilterDefinition.Empty, + context.CancellationToken); + + results.Add((name, deleteResult.DeletedCount)); + + context.Logger.LogInformation( + "Collection '{Collection}': {Count} document(s) deleted.", + name, deleteResult.DeletedCount); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var warning = $"{name}: {ex.Message}"; + warnings.Add(warning); + context.Logger.LogWarning( + ex, + "Collection '{Collection}' could not be cleared — skipping and continuing.", + name); + } + } + + var totalDeleted = results.Sum(static r => r.Deleted); + var perCollection = results.Count == 0 + ? "no non-system collections found" + : string.Join("; ", results.Select(static r => $"{r.Name}: {r.Deleted}")); + + context.Logger.LogWarning( + "Clear MyBlog data complete: {Total} document(s) removed across {Count} collection(s). Warnings: {WarnCount}.", + totalDeleted, results.Count, warnings.Count); + + var message = $"{results.Count} collection(s) cleared — {totalDeleted} total document(s) deleted. ({perCollection})"; + if (warnings.Count > 0) + message += $" ⚠️ {warnings.Count} collection(s) had errors: {string.Join("; ", warnings)}"; + + return new ExecuteCommandResult + { + Success = true, + Message = message + }; + } + finally + { + _dbMutex.Release(); + } + }, + new CommandOptions + { + Description = "Permanently deletes all data from the myblog database. Local development only.", + ConfirmationMessage = "This will permanently delete ALL data from the myblog database and cannot be undone. Confirm?", + IsHighlighted = true, + IconName = "DatabaseWarning", + // AC1 (#249): Gates only on the MongoDB resource's own health — intentionally does NOT + // check dependent resources (Web, etc.). Clearing is valid while the app is live against + // local Mongo; the Web app running is not a reason to disable the command. + UpdateState = ctx => + ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled + }); + } + + private static void WithSeedDataCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "seed-myblog-data", + "🌱 Seed MyBlog Data", + executeCommand: async context => + { + if (!await _dbMutex.WaitAsync(0)) + { + context.Logger.LogWarning( + "Seed MyBlog data skipped on {ResourceName} — a database operation is already in progress.", + context.ResourceName); + + return new ExecuteCommandResult + { + Success = false, + Message = "A database operation is already in progress. Wait for the current run to finish, then try again." + }; + } + + try + { + var afterMutexAcquired = SeedCommandAfterMutexAcquiredAsync; + if (afterMutexAcquired is not null) + await afterMutexAcquired(context.CancellationToken); + + context.Logger.LogInformation( + "Seed MyBlog data invoked on {ResourceName} — inserting seed data into '{Database}'.", + context.ResourceName, databaseName); + + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); + if (connectionString is null) + { + context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); + return new ExecuteCommandResult + { + Success = false, + Message = "Could not resolve MongoDB connection string. Is the MongoDB resource running?" + }; + } + + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + var collection = database.GetCollection("blogposts"); + + var now = DateTime.UtcNow; + var seedDocuments = new BsonDocument[] + { new() { ["_id"] = new BsonBinaryData(Guid.NewGuid(), GuidRepresentation.Standard), @@ -245,124 +245,124 @@ private static void WithSeedDataCommand( ["IsPublished"] = false, ["Version"] = 1, }, -}; - -await collection.InsertManyAsync(seedDocuments, cancellationToken: context.CancellationToken); - -context.Logger.LogInformation( -"Seed MyBlog data complete: {Count} blog post(s) inserted.", -seedDocuments.Length); - -return new ExecuteCommandResult -{ -Success = true, -Message = $"blogposts: {seedDocuments.Length} inserted (2 published, 1 draft)" -}; -} -finally -{ -_dbMutex.Release(); -} -}, -new CommandOptions -{ -Description = "Inserts seed blog posts into the myblog database. Local development only.", -IconName = "DatabaseArrowUp", -UpdateState = ctx => -ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy -? ResourceCommandState.Enabled -: ResourceCommandState.Disabled -}); -} - -private static void WithShowStatsCommand( -this IResourceBuilder builder, -string databaseName) -{ -builder.WithCommand( -"show-myblog-stats", -"📊 Show MyBlog Stats", -executeCommand: async context => -{ -if (!await _dbMutex.WaitAsync(0)) -{ -context.Logger.LogWarning( -"Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", -context.ResourceName); - -return CommandResults.Failure( -"A database operation is already in progress. Wait for the current run to finish, then try again."); -} - -try -{ -context.Logger.LogInformation( -"Show MyBlog stats invoked on {ResourceName} — querying '{Database}'.", -context.ResourceName, databaseName); - -var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); -if (connectionString is null) -{ -context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); -return CommandResults.Failure("Could not resolve MongoDB connection string. Is the MongoDB resource running?"); -} - -var client = new MongoClient(connectionString); -var database = client.GetDatabase(databaseName); - -var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); -var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); -var userCollections = collectionNames -.Where(static n => !n.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) -.ToList(); - -var sb = new StringBuilder(); -sb.AppendLine("| Collection | Document Count |"); -sb.AppendLine("| --- | --- |"); - -if (userCollections.Count == 0) -{ -sb.AppendLine("| *(no collections found)* | - |"); -} -else -{ -foreach (var name in userCollections) -{ -var col = database.GetCollection(name); -var count = await col.CountDocumentsAsync( -FilterDefinition.Empty, -cancellationToken: context.CancellationToken); -sb.AppendLine($"| {name} | {count} |"); -} -} - -var markdownTable = sb.ToString(); -context.Logger.LogInformation( -"Show MyBlog stats complete: {Count} collection(s) reported.", -userCollections.Count); - -return CommandResults.Success( -$"{userCollections.Count} collection(s) found in '{databaseName}'", -new CommandResultData -{ -Value = markdownTable, -Format = CommandResultFormat.Markdown, -DisplayImmediately = true -}); -} -finally -{ -_dbMutex.Release(); -} -}, -new CommandOptions -{ -Description = "Displays document counts per collection in the myblog database. Local development only.", -IconName = "ChartMultiple", -UpdateState = ctx => -ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy -? ResourceCommandState.Enabled -: ResourceCommandState.Disabled -}); -} + }; + + await collection.InsertManyAsync(seedDocuments, cancellationToken: context.CancellationToken); + + context.Logger.LogInformation( + "Seed MyBlog data complete: {Count} blog post(s) inserted.", + seedDocuments.Length); + + return new ExecuteCommandResult + { + Success = true, + Message = $"blogposts: {seedDocuments.Length} inserted (2 published, 1 draft)" + }; + } + finally + { + _dbMutex.Release(); + } + }, + new CommandOptions + { + Description = "Inserts seed blog posts into the myblog database. Local development only.", + IconName = "DatabaseArrowUp", + UpdateState = ctx => + ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled + }); + } + + private static void WithShowStatsCommand( + this IResourceBuilder builder, + string databaseName) + { + builder.WithCommand( + "show-myblog-stats", + "📊 Show MyBlog Stats", + executeCommand: async context => + { + if (!await _dbMutex.WaitAsync(0)) + { + context.Logger.LogWarning( + "Show MyBlog stats skipped on {ResourceName} — a database operation is already in progress.", + context.ResourceName); + + return CommandResults.Failure( + "A database operation is already in progress. Wait for the current run to finish, then try again."); + } + + try + { + context.Logger.LogInformation( + "Show MyBlog stats invoked on {ResourceName} — querying '{Database}'.", + context.ResourceName, databaseName); + + var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(context.CancellationToken); + if (connectionString is null) + { + context.Logger.LogError("Could not resolve MongoDB connection string for resource {ResourceName}.", context.ResourceName); + return CommandResults.Failure("Could not resolve MongoDB connection string. Is the MongoDB resource running?"); + } + + var client = new MongoClient(connectionString); + var database = client.GetDatabase(databaseName); + + var namesCursor = await database.ListCollectionNamesAsync(cancellationToken: context.CancellationToken); + var collectionNames = await namesCursor.ToListAsync(context.CancellationToken); + var userCollections = collectionNames + .Where(static n => !n.StartsWith("system.", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var sb = new StringBuilder(); + sb.AppendLine("| Collection | Document Count |"); + sb.AppendLine("| --- | --- |"); + + if (userCollections.Count == 0) + { + sb.AppendLine("| *(no collections found)* | - |"); + } + else + { + foreach (var name in userCollections) + { + var col = database.GetCollection(name); + var count = await col.CountDocumentsAsync( + FilterDefinition.Empty, + cancellationToken: context.CancellationToken); + sb.AppendLine($"| {name} | {count} |"); + } + } + + var markdownTable = sb.ToString(); + context.Logger.LogInformation( + "Show MyBlog stats complete: {Count} collection(s) reported.", + userCollections.Count); + + return CommandResults.Success( + $"{userCollections.Count} collection(s) found in '{databaseName}'", + new CommandResultData + { + Value = markdownTable, + Format = CommandResultFormat.Markdown, + DisplayImmediately = true + }); + } + finally + { + _dbMutex.Release(); + } + }, + new CommandOptions + { + Description = "Displays document counts per collection in the myblog database. Local development only.", + IconName = "ChartMultiple", + UpdateState = ctx => + ctx.ResourceSnapshot.HealthStatus == HealthStatus.Healthy + ? ResourceCommandState.Enabled + : ResourceCommandState.Disabled + }); + } } diff --git a/src/Domain/Entities/BlogPost.cs b/src/Domain/Entities/BlogPost.cs index 7e29098c..36964f18 100644 --- a/src/Domain/Entities/BlogPost.cs +++ b/src/Domain/Entities/BlogPost.cs @@ -11,43 +11,43 @@ namespace MyBlog.Domain.Entities; public sealed class BlogPost { - public Guid Id { get; private set; } - public string Title { get; private set; } = string.Empty; - public string Content { get; private set; } = string.Empty; - public string Author { get; private set; } = string.Empty; - public DateTime CreatedAt { get; private set; } - public DateTime? UpdatedAt { get; private set; } - public bool IsPublished { get; private set; } - public int Version { get; private set; } + public Guid Id { get; private set; } + public string Title { get; private set; } = string.Empty; + public string Content { get; private set; } = string.Empty; + public string Author { get; private set; } = string.Empty; + public DateTime CreatedAt { get; private set; } + public DateTime? UpdatedAt { get; private set; } + public bool IsPublished { get; private set; } + public int Version { get; private set; } - private BlogPost() { } + private BlogPost() { } - public static BlogPost Create(string title, string content, string author) - { - ArgumentException.ThrowIfNullOrWhiteSpace(title); - ArgumentException.ThrowIfNullOrWhiteSpace(content); - ArgumentException.ThrowIfNullOrWhiteSpace(author); + public static BlogPost Create(string title, string content, string author) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(content); + ArgumentException.ThrowIfNullOrWhiteSpace(author); - return new BlogPost - { - Id = Guid.NewGuid(), - Title = title, - Content = content, - Author = author, - CreatedAt = DateTime.UtcNow, - }; - } + return new BlogPost + { + Id = Guid.NewGuid(), + Title = title, + Content = content, + Author = author, + CreatedAt = DateTime.UtcNow, + }; + } - public void Update(string title, string content) - { - ArgumentException.ThrowIfNullOrWhiteSpace(title); - ArgumentException.ThrowIfNullOrWhiteSpace(content); - Title = title; - Content = content; - UpdatedAt = DateTime.UtcNow; - Version++; - } + public void Update(string title, string content) + { + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(content); + Title = title; + Content = content; + UpdatedAt = DateTime.UtcNow; + Version++; + } - public void Publish() => IsPublished = true; - public void Unpublish() => IsPublished = false; + public void Publish() => IsPublished = true; + public void Unpublish() => IsPublished = false; } diff --git a/src/Domain/Interfaces/IBlogPostRepository.cs b/src/Domain/Interfaces/IBlogPostRepository.cs index ecadec06..b784feb4 100644 --- a/src/Domain/Interfaces/IBlogPostRepository.cs +++ b/src/Domain/Interfaces/IBlogPostRepository.cs @@ -13,9 +13,9 @@ namespace MyBlog.Domain.Interfaces; public interface IBlogPostRepository { - Task GetByIdAsync(Guid id, CancellationToken ct = default); - Task> GetAllAsync(CancellationToken ct = default); - Task AddAsync(BlogPost post, CancellationToken ct = default); - Task UpdateAsync(BlogPost post, CancellationToken ct = default); - Task DeleteAsync(Guid id, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task AddAsync(BlogPost post, CancellationToken ct = default); + Task UpdateAsync(BlogPost post, CancellationToken ct = default); + Task DeleteAsync(Guid id, CancellationToken ct = default); } diff --git a/src/ServiceDefaults/Extensions.cs b/src/ServiceDefaults/Extensions.cs index 6636392d..559406a1 100644 --- a/src/ServiceDefaults/Extensions.cs +++ b/src/ServiceDefaults/Extensions.cs @@ -28,100 +28,100 @@ namespace MyBlog.ServiceDefaults; [ExcludeFromCodeCoverage(Justification = "Aspire infrastructure bootstrap — not business logic")] public static class ServiceDefaultsExtensions { - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - }); - - // Uncomment the following to restrict the allowed schemes for service discovery. - // builder.Services.Configure(options => - // { - // options.AllowedSchemes = ["https"]; - // }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - // Exclude health check requests from tracing - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath, System.StringComparison.OrdinalIgnoreCase) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath, System.StringComparison.OrdinalIgnoreCase) - ) - // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) - //.AddGrpcClientInstrumentation() - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - // Add a default liveness check to ensure app is responsive - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath, System.StringComparison.OrdinalIgnoreCase) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath, System.StringComparison.OrdinalIgnoreCase) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } public static WebApplication MapDefaultEndpoints(this WebApplication app) { - ArgumentNullException.ThrowIfNull(app); + ArgumentNullException.ThrowIfNull(app); // Adding health checks endpoints to applications in non-development environments has security implications. // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. diff --git a/src/Web/Data/BlogPostDto.cs b/src/Web/Data/BlogPostDto.cs index 77cd7b11..37943351 100644 --- a/src/Web/Data/BlogPostDto.cs +++ b/src/Web/Data/BlogPostDto.cs @@ -10,10 +10,10 @@ namespace MyBlog.Web.Data; internal sealed record BlogPostDto( - Guid Id, - string Title, - string Content, - string Author, - DateTime CreatedAt, - DateTime? UpdatedAt, - bool IsPublished); + Guid Id, + string Title, + string Content, + string Author, + DateTime CreatedAt, + DateTime? UpdatedAt, + bool IsPublished); diff --git a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs index b9d0a1f2..bc0cafbe 100644 --- a/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Create/CreateBlogPostHandler.cs @@ -16,28 +16,28 @@ internal sealed class CreateBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler> { -public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = BlogPost.Create(request.Title, request.Content, request.Author); -await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -return Result.Ok(post.Id); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = BlogPost.Create(request.Title, request.Content, request.Author); + await repo.AddAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + return Result.Ok(post.Id); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs index ec45bd13..2dfef6bd 100644 --- a/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Delete/DeleteBlogPostHandler.cs @@ -16,34 +16,34 @@ internal sealed class DeleteBlogPostHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler { -public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(DeleteBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + await repo.DeleteAsync(request.Id, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs index 88278245..e7cd4c25 100644 --- a/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs +++ b/src/Web/Features/BlogPosts/Edit/EditBlogPostHandler.cs @@ -18,67 +18,67 @@ internal sealed class EditBlogPostHandler( : IRequestHandler, IRequestHandler> { -public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) -{ -try -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -if (post is null) -return Result.Fail($"BlogPost {request.Id} not found."); -post.Update(request.Title, request.Content); -await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); -await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); -await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return Result.Ok(); -} -catch (DbUpdateConcurrencyException) -{ -return Result.Fail( -"This post was modified by another user. Please reload and try again.", -ResultErrorCode.Concurrency); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task Handle(EditBlogPostCommand request, CancellationToken cancellationToken) + { + try + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + if (post is null) + return Result.Fail($"BlogPost {request.Id} not found."); + post.Update(request.Title, request.Content); + await repo.UpdateAsync(post, cancellationToken).ConfigureAwait(false); + await cache.InvalidateAllAsync(cancellationToken).ConfigureAwait(false); + await cache.InvalidateByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return Result.Ok(); + } + catch (DbUpdateConcurrencyException) + { + return Result.Fail( + "This post was modified by another user. Please reload and try again.", + ResultErrorCode.Concurrency); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } -public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) -{ -try -{ -var dto = await cache.GetOrFetchByIdAsync( -request.Id, -async () => -{ -var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); -return post?.ToDto(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok(dto); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail(ex.Message); -} + public async Task> Handle(GetBlogPostByIdQuery request, CancellationToken cancellationToken) + { + try + { + var dto = await cache.GetOrFetchByIdAsync( + request.Id, + async () => + { + var post = await repo.GetByIdAsync(request.Id, cancellationToken).ConfigureAwait(false); + return post?.ToDto(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok(dto); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs index 18276f74..601be879 100644 --- a/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs +++ b/src/Web/Features/BlogPosts/List/GetBlogPostsHandler.cs @@ -16,32 +16,32 @@ internal sealed class GetBlogPostsHandler( IBlogPostRepository repo, IBlogPostCacheService cache) : IRequestHandler>> { -public async Task>> Handle( -GetBlogPostsQuery request, CancellationToken cancellationToken) -{ -try -{ -var result = await cache.GetOrFetchAllAsync( -async () => -{ -var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); -return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); -}, cancellationToken).ConfigureAwait(false); -return Result.Ok>(result); -} -catch (OperationCanceledException) -{ -throw; -} -catch (InvalidOperationException ex) -{ -return Result.Fail>(ex.Message); -} + public async Task>> Handle( + GetBlogPostsQuery request, CancellationToken cancellationToken) + { + try + { + var result = await cache.GetOrFetchAllAsync( + async () => + { + var all = await repo.GetAllAsync(cancellationToken).ConfigureAwait(false); + return (IReadOnlyList)all.Select(p => p.ToDto()).ToList(); + }, cancellationToken).ConfigureAwait(false); + return Result.Ok>(result); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidOperationException ex) + { + return Result.Fail>(ex.Message); + } #pragma warning disable CA1031 // Intentional: top-level handler converts unexpected failures to Result to keep UI stable -catch (Exception) -{ -return Result.Fail>("An unexpected error occurred."); -} + catch (Exception) + { + return Result.Fail>("An unexpected error occurred."); + } #pragma warning restore CA1031 -} + } } diff --git a/src/Web/GlobalUsings.cs b/src/Web/GlobalUsings.cs index 551ae07b..8e4b29eb 100644 --- a/src/Web/GlobalUsings.cs +++ b/src/Web/GlobalUsings.cs @@ -8,9 +8,11 @@ //======================================================= global using MediatR; + global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; diff --git a/src/Web/Security/RoleClaimsHelper.cs b/src/Web/Security/RoleClaimsHelper.cs index 02895781..22241270 100644 --- a/src/Web/Security/RoleClaimsHelper.cs +++ b/src/Web/Security/RoleClaimsHelper.cs @@ -14,123 +14,123 @@ namespace MyBlog.Web.Security; internal static class RoleClaimsHelper { - public static readonly string[] DefaultRoleClaimTypes = - [ - "https://myblog/roles", - "roles", - "role" - ]; - - public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) - { - var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); - - return configured is { Length: > 0 } - ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() - : DefaultRoleClaimTypes; - } - - public static bool IsRoleClaimType(string? claimType) - { - if (string.IsNullOrWhiteSpace(claimType)) - { - return false; - } - - if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) - || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) - || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var lastSlash = claimType.LastIndexOf('/'); - var lastColon = claimType.LastIndexOf(':'); - var separatorIndex = Math.Max(lastSlash, lastColon); - var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; - - return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) - || tail.Equals("role", StringComparison.OrdinalIgnoreCase); - } - - private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) - { - return (roleClaimTypes ?? DefaultRoleClaimTypes) - .Append(ClaimTypes.Role) - .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - } - - public static IReadOnlyList ExpandRoleValues(string? claimValue) - { - if (string.IsNullOrWhiteSpace(claimValue)) - { - return []; - } - - var trimmed = claimValue.Trim(); - - if (trimmed.StartsWith("[", StringComparison.Ordinal)) - { - try - { - using var document = JsonDocument.Parse(trimmed); - - if (document.RootElement.ValueKind == JsonValueKind.Array) - { - return document.RootElement - .EnumerateArray() - .Select(element => element.GetString()) - .Where(role => !string.IsNullOrWhiteSpace(role)) - .Cast() - .ToArray(); - } - } - catch (JsonException) - { - return []; - } - } - - if (trimmed.Contains(',', StringComparison.Ordinal)) - { - return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - } - - return [trimmed]; - } - - public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) - { - ArgumentNullException.ThrowIfNull(identity); - - foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) - { - foreach (var claim in identity.FindAll(roleClaimType).ToList()) - { - foreach (var role in ExpandRoleValues(claim.Value)) - { - if (!identity.HasClaim(ClaimTypes.Role, role)) - { - identity.AddClaim(new Claim(ClaimTypes.Role, role)); - } - } - } - } - } - - public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) - { - var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); - - return user.Claims - .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) - .SelectMany(claim => ExpandRoleValues(claim.Value)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(role => role) - .ToList(); - } + public static readonly string[] DefaultRoleClaimTypes = + [ + "https://myblog/roles", + "roles", + "role" + ]; + + public static IReadOnlyList GetRoleClaimTypes(IConfiguration configuration) + { + var configured = configuration.GetSection("Auth0:RoleClaimTypes").Get(); + + return configured is { Length: > 0 } + ? configured.Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + : DefaultRoleClaimTypes; + } + + public static bool IsRoleClaimType(string? claimType) + { + if (string.IsNullOrWhiteSpace(claimType)) + { + return false; + } + + if (claimType.Equals(ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) + || claimType.Equals("roles", StringComparison.OrdinalIgnoreCase) + || claimType.Equals("role", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var lastSlash = claimType.LastIndexOf('/'); + var lastColon = claimType.LastIndexOf(':'); + var separatorIndex = Math.Max(lastSlash, lastColon); + var tail = separatorIndex >= 0 ? claimType[(separatorIndex + 1)..] : claimType; + + return tail.Equals("roles", StringComparison.OrdinalIgnoreCase) + || tail.Equals("role", StringComparison.OrdinalIgnoreCase); + } + + private static string[] GetEffectiveRoleClaimTypes(IEnumerable claims, IEnumerable? roleClaimTypes) + { + return (roleClaimTypes ?? DefaultRoleClaimTypes) + .Append(ClaimTypes.Role) + .Concat(claims.Select(claim => claim.Type).Where(IsRoleClaimType)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public static IReadOnlyList ExpandRoleValues(string? claimValue) + { + if (string.IsNullOrWhiteSpace(claimValue)) + { + return []; + } + + var trimmed = claimValue.Trim(); + + if (trimmed.StartsWith("[", StringComparison.Ordinal)) + { + try + { + using var document = JsonDocument.Parse(trimmed); + + if (document.RootElement.ValueKind == JsonValueKind.Array) + { + return document.RootElement + .EnumerateArray() + .Select(element => element.GetString()) + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Cast() + .ToArray(); + } + } + catch (JsonException) + { + return []; + } + } + + if (trimmed.Contains(',', StringComparison.Ordinal)) + { + return trimmed.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + } + + return [trimmed]; + } + + public static void AddRoleClaims(ClaimsIdentity identity, IEnumerable roleClaimTypes) + { + ArgumentNullException.ThrowIfNull(identity); + + foreach (var roleClaimType in GetEffectiveRoleClaimTypes(identity.Claims, roleClaimTypes)) + { + foreach (var claim in identity.FindAll(roleClaimType).ToList()) + { + foreach (var role in ExpandRoleValues(claim.Value)) + { + if (!identity.HasClaim(ClaimTypes.Role, role)) + { + identity.AddClaim(new Claim(ClaimTypes.Role, role)); + } + } + } + } + } + + public static IReadOnlyList GetRoles(ClaimsPrincipal user, IEnumerable? roleClaimTypes = null) + { + var types = GetEffectiveRoleClaimTypes(user.Claims, roleClaimTypes); + + return user.Claims + .Where(claim => types.Contains(claim.Type, StringComparer.OrdinalIgnoreCase)) + .SelectMany(claim => ExpandRoleValues(claim.Value)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(role => role) + .ToList(); + } } diff --git a/tests/AppHost.Tests/EnvVarTests.cs b/tests/AppHost.Tests/EnvVarTests.cs index 3a391be1..a201fa7f 100644 --- a/tests/AppHost.Tests/EnvVarTests.cs +++ b/tests/AppHost.Tests/EnvVarTests.cs @@ -8,6 +8,7 @@ // ============================================= using Aspire.Hosting; + using FluentAssertions; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs b/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs index f9c982e6..c352ccdc 100644 --- a/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs +++ b/tests/AppHost.Tests/Infrastructure/PlaywrightManager.cs @@ -7,9 +7,10 @@ // Project Name : AppHost.Tests // ============================================= -using Microsoft.Playwright; using System.Diagnostics; +using Microsoft.Playwright; + namespace AppHost.Tests.Infrastructure; /// diff --git a/tests/AppHost.Tests/MongoDbClearCommandTests.cs b/tests/AppHost.Tests/MongoDbClearCommandTests.cs index ae6bf17f..832cd326 100644 --- a/tests/AppHost.Tests/MongoDbClearCommandTests.cs +++ b/tests/AppHost.Tests/MongoDbClearCommandTests.cs @@ -25,159 +25,159 @@ namespace AppHost.Tests; /// public sealed class MongoDbClearCommandTests { -private const string CommandName = "clear-myblog-data"; - -private static Task CreateBuilderAsync() => -DistributedApplicationTestingBuilder.CreateAsync( -args: [], -configureBuilder: static (options, _) => { options.DisableDashboard = true; }, -cancellationToken: TestContext.Current.CancellationToken); - -/// -/// Acceptance criterion #1: The mongodb resource exposes a "clear-myblog-data" operator action. -/// -[Fact] -public async Task MongoDb_Resource_Exposes_ClearMyBlogData_Command_Annotation() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); - -// Act -var annotation = mongoResource.Annotations -.OfType() -.SingleOrDefault(static a => a.Name == CommandName); - -// Assert -annotation.Should().NotBeNull( -"the mongodb resource must expose a 'clear-myblog-data' operator action per issue #247"); -} - -/// -/// Acceptance criterion #2: The clear-myblog-data action must be marked as destructive so the -/// Aspire dashboard renders it with a danger indicator. -/// -[Fact] -public async Task ClearMyBlogData_Command_IsHighlighted_Marks_It_As_Destructive() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -// Assert -annotation.IsHighlighted.Should().BeTrue( -"a destructive data-clearing command must set IsHighlighted = true so the Aspire dashboard warns the operator"); -} - -/// -/// Acceptance criterion #3: The action must require explicit y/n confirmation. -/// -/// Setting ConfirmationMessage on the annotation causes the Aspire dashboard to render -/// an OK/Cancel dialog before the execute callback is ever invoked. When the operator clicks -/// Cancel the callback is NOT called — this is the framework-managed no-op guarantee for a -/// declined confirmation. -/// -/// -[Fact] -public async Task ClearMyBlogData_Command_Has_ConfirmationMessage_Enabling_Yn_Prompt() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -// Assert -annotation.ConfirmationMessage.Should().NotBeNullOrEmpty( -"the command must define a ConfirmationMessage so Aspire shows a y/n dialog; " -+ "declining that dialog is the built-in no-op guarantee"); -} - -/// -/// Acceptance criterion #4: The action is enabled only when MongoDB is healthy. -/// -[Fact] -public async Task ClearMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -var snapshot = BuildSnapshot(HealthStatus.Healthy); - -var ctx = new UpdateCommandStateContext -{ -ResourceSnapshot = snapshot, -ServiceProvider = new ServiceCollection().BuildServiceProvider(), -}; - -// Act -var state = annotation.UpdateState(ctx); - -// Assert -state.Should().Be(ResourceCommandState.Enabled, -"the clear-myblog-data command must be available when MongoDB is healthy"); -} - -/// -/// Acceptance criterion #4 (corollary): The action must be disabled when MongoDB is not healthy -/// to prevent destructive operations on an unstable or stopped container. -/// -[Fact] -public async Task ClearMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() -{ -// Arrange -var builder = await CreateBuilderAsync(); -var annotation = GetClearMyBlogDataAnnotation(builder); - -var snapshot = BuildSnapshot(HealthStatus.Unhealthy); - -var ctx = new UpdateCommandStateContext -{ -ResourceSnapshot = snapshot, -ServiceProvider = new ServiceCollection().BuildServiceProvider(), -}; - -// Act -var state = annotation.UpdateState(ctx); - -// Assert -state.Should().Be(ResourceCommandState.Disabled, -"the clear-myblog-data command must be unavailable when MongoDB is unhealthy"); -} - - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -private static ResourceCommandAnnotation GetClearMyBlogDataAnnotation(IDistributedApplicationTestingBuilder builder) -{ -var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); - -return mongoResource.Annotations -.OfType() -.Single(static a => a.Name == CommandName); -} - -/// -/// Creates a with the given health status. -/// -/// has an internal init accessor and -/// is a private computed property — -/// both are inaccessible from external assemblies via normal C#. Reflection is required. -/// -/// -private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) -{ -var snapshot = new CustomResourceSnapshot -{ -ResourceType = "MongoDB.Server", -Properties = [], -}; - -var reports = ImmutableArray.Create( -new HealthReportSnapshot("ready", health, null, null)); - -var type = typeof(CustomResourceSnapshot); + private const string CommandName = "clear-myblog-data"; + + private static Task CreateBuilderAsync() => + DistributedApplicationTestingBuilder.CreateAsync( + args: [], + configureBuilder: static (options, _) => { options.DisableDashboard = true; }, + cancellationToken: TestContext.Current.CancellationToken); + + /// + /// Acceptance criterion #1: The mongodb resource exposes a "clear-myblog-data" operator action. + /// + [Fact] + public async Task MongoDb_Resource_Exposes_ClearMyBlogData_Command_Annotation() + { + // Arrange + var builder = await CreateBuilderAsync(); + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + // Act + var annotation = mongoResource.Annotations + .OfType() + .SingleOrDefault(static a => a.Name == CommandName); + + // Assert + annotation.Should().NotBeNull( + "the mongodb resource must expose a 'clear-myblog-data' operator action per issue #247"); + } + + /// + /// Acceptance criterion #2: The clear-myblog-data action must be marked as destructive so the + /// Aspire dashboard renders it with a danger indicator. + /// + [Fact] + public async Task ClearMyBlogData_Command_IsHighlighted_Marks_It_As_Destructive() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + // Assert + annotation.IsHighlighted.Should().BeTrue( + "a destructive data-clearing command must set IsHighlighted = true so the Aspire dashboard warns the operator"); + } + + /// + /// Acceptance criterion #3: The action must require explicit y/n confirmation. + /// + /// Setting ConfirmationMessage on the annotation causes the Aspire dashboard to render + /// an OK/Cancel dialog before the execute callback is ever invoked. When the operator clicks + /// Cancel the callback is NOT called — this is the framework-managed no-op guarantee for a + /// declined confirmation. + /// + /// + [Fact] + public async Task ClearMyBlogData_Command_Has_ConfirmationMessage_Enabling_Yn_Prompt() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + // Assert + annotation.ConfirmationMessage.Should().NotBeNullOrEmpty( + "the command must define a ConfirmationMessage so Aspire shows a y/n dialog; " + + "declining that dialog is the built-in no-op guarantee"); + } + + /// + /// Acceptance criterion #4: The action is enabled only when MongoDB is healthy. + /// + [Fact] + public async Task ClearMyBlogData_UpdateState_Returns_Enabled_When_MongoDB_Is_Healthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Healthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Enabled, + "the clear-myblog-data command must be available when MongoDB is healthy"); + } + + /// + /// Acceptance criterion #4 (corollary): The action must be disabled when MongoDB is not healthy + /// to prevent destructive operations on an unstable or stopped container. + /// + [Fact] + public async Task ClearMyBlogData_UpdateState_Returns_Disabled_When_MongoDB_Is_Unhealthy() + { + // Arrange + var builder = await CreateBuilderAsync(); + var annotation = GetClearMyBlogDataAnnotation(builder); + + var snapshot = BuildSnapshot(HealthStatus.Unhealthy); + + var ctx = new UpdateCommandStateContext + { + ResourceSnapshot = snapshot, + ServiceProvider = new ServiceCollection().BuildServiceProvider(), + }; + + // Act + var state = annotation.UpdateState(ctx); + + // Assert + state.Should().Be(ResourceCommandState.Disabled, + "the clear-myblog-data command must be unavailable when MongoDB is unhealthy"); + } + + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private static ResourceCommandAnnotation GetClearMyBlogDataAnnotation(IDistributedApplicationTestingBuilder builder) + { + var mongoResource = builder.Resources.Single(static r => r.Name == "mongodb"); + + return mongoResource.Annotations + .OfType() + .Single(static a => a.Name == CommandName); + } + + /// + /// Creates a with the given health status. + /// + /// has an internal init accessor and + /// is a private computed property — + /// both are inaccessible from external assemblies via normal C#. Reflection is required. + /// + /// + private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) + { + var snapshot = new CustomResourceSnapshot + { + ResourceType = "MongoDB.Server", + Properties = [], + }; + + var reports = ImmutableArray.Create( + new HealthReportSnapshot("ready", health, null, null)); + + var type = typeof(CustomResourceSnapshot); type .GetProperty("HealthReports")! .GetSetMethod(nonPublic: true)! @@ -187,6 +187,6 @@ private static CustomResourceSnapshot BuildSnapshot(HealthStatus health) .GetSetMethod(nonPublic: true)! .Invoke(snapshot, [(HealthStatus?)health]); -return snapshot; -} + return snapshot; + } } diff --git a/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs b/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs index 0a47403f..a43f0696 100644 --- a/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs +++ b/tests/AppHost.Tests/Tests/Layout/LayoutAuthenticatedTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs b/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs index b868b0fb..9d42069f 100644 --- a/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs +++ b/tests/AppHost.Tests/Tests/Pages/HomePageTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs b/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs index f06c00a6..2f3a1309 100644 --- a/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs +++ b/tests/AppHost.Tests/Tests/Pages/NotFoundPageTests.cs @@ -8,7 +8,9 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; + using Microsoft.Playwright; namespace AppHost.Tests; diff --git a/tests/AppHost.Tests/WebPlaywrightTests.cs b/tests/AppHost.Tests/WebPlaywrightTests.cs index 53028007..d42526fb 100644 --- a/tests/AppHost.Tests/WebPlaywrightTests.cs +++ b/tests/AppHost.Tests/WebPlaywrightTests.cs @@ -8,6 +8,7 @@ // ============================================= using AppHost.Tests.Infrastructure; + using FluentAssertions; namespace AppHost.Tests; diff --git a/tests/Architecture.Tests/GlobalUsings.cs b/tests/Architecture.Tests/GlobalUsings.cs index f1f7b3c8..bf75d1b4 100644 --- a/tests/Architecture.Tests/GlobalUsings.cs +++ b/tests/Architecture.Tests/GlobalUsings.cs @@ -8,5 +8,7 @@ //======================================================= global using FluentAssertions; + global using MyBlog.Domain.Entities; + global using NetArchTest.Rules; diff --git a/tests/Domain.Tests/GlobalUsings.cs b/tests/Domain.Tests/GlobalUsings.cs index 6194df10..dea26d27 100644 --- a/tests/Domain.Tests/GlobalUsings.cs +++ b/tests/Domain.Tests/GlobalUsings.cs @@ -8,10 +8,14 @@ //======================================================= global using FluentAssertions; + global using FluentValidation; global using FluentValidation.Results; + global using MediatR; + global using MyBlog.Domain.Abstractions; global using MyBlog.Domain.Behaviors; global using MyBlog.Domain.Entities; + global using NSubstitute; diff --git a/tests/Web.Tests.Bunit/GlobalUsings.cs b/tests/Web.Tests.Bunit/GlobalUsings.cs index 840408b5..c245cc6c 100644 --- a/tests/Web.Tests.Bunit/GlobalUsings.cs +++ b/tests/Web.Tests.Bunit/GlobalUsings.cs @@ -7,16 +7,20 @@ //Project Name : Web.Tests.Bunit //======================================================= +global using System.Security.Claims; + global using Bunit; global using FluentAssertions; + global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Components.Authorization; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; + global using NSubstitute; global using NSubstitute.ExceptionExtensions; -global using System.Security.Claims; diff --git a/tests/Web.Tests/GlobalUsings.cs b/tests/Web.Tests/GlobalUsings.cs index c88df296..88d19831 100644 --- a/tests/Web.Tests/GlobalUsings.cs +++ b/tests/Web.Tests/GlobalUsings.cs @@ -7,15 +7,19 @@ //Project Name : Web.Tests //======================================================= +global using System.Security.Claims; + global using FluentAssertions; + global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Components.Authorization; global using Microsoft.Extensions.Caching.Distributed; global using Microsoft.Extensions.Caching.Memory; + global using MyBlog.Domain.Entities; global using MyBlog.Domain.Interfaces; global using MyBlog.Web.Data; global using MyBlog.Web.Infrastructure.Caching; + global using NSubstitute; global using NSubstitute.ExceptionExtensions; -global using System.Security.Claims; From 158815ec4bbc1c258f43fc085edcdb56a0f55662 Mon Sep 17 00:00:00 2001 From: mpaulosky <60372079+mpaulosky@users.noreply.github.com> Date: Sun, 10 May 2026 17:32:15 -0700 Subject: [PATCH 15/89] feat(theme): centralise table, form, and alert CSS into input.css (#277) - centralise repeated table, form, alert, and secondary button styles in input.css - update Razor views to consume the shared classes - align Tailwind build scripts and the bUnit smoke assertion with the refactor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 3 +- src/Web/Components/Layout/MainLayout.razor | 2 +- src/Web/Components/Layout/NavMenu.razor | 6 +- .../Features/BlogPosts/Create/Create.razor | 26 +++--- src/Web/Features/BlogPosts/Edit/Edit.razor | 24 ++--- src/Web/Features/BlogPosts/List/Index.razor | 38 ++++---- .../Features/UserManagement/ManageRoles.razor | 28 +++--- src/Web/Styles/input.css | 89 +++++++++++++++++-- src/Web/Styles/themes.css | 85 +++++++++--------- src/Web/wwwroot/css/app.css | 4 +- .../Components/RazorSmokeTests.cs | 2 +- 11 files changed, 191 insertions(+), 116 deletions(-) diff --git a/package.json b/package.json index 5c53a417..83c1473a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "myblog", "private": true, "scripts": { - "tw:build": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --minify", + "tw:build": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css", + "tw:build:prod": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --minify", "tw:watch": "npx @tailwindcss/cli -i ./src/Web/Styles/input.css -o ./src/Web/wwwroot/css/tailwind.css --watch" }, "devDependencies": { diff --git a/src/Web/Components/Layout/MainLayout.razor b/src/Web/Components/Layout/MainLayout.razor index 219df93b..2834ba06 100644 --- a/src/Web/Components/Layout/MainLayout.razor +++ b/src/Web/Components/Layout/MainLayout.razor @@ -1,7 +1,7 @@ @inherits LayoutComponentBase -
+
diff --git a/src/Web/Components/Layout/NavMenu.razor b/src/Web/Components/Layout/NavMenu.razor index 11017fbc..e981096e 100644 --- a/src/Web/Components/Layout/NavMenu.razor +++ b/src/Web/Components/Layout/NavMenu.razor @@ -1,7 +1,7 @@