From 8eaac902e7daf9df07710d3ec1787028508c3f93 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 9 Jun 2026 21:45:29 +0200 Subject: [PATCH 1/7] fix: resurrect the page-registry guard on both ends (CI and runtime) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate-pages.mjs scanned modules//src/, but the real layout is src/SimpleModule. — it validated zero C# files and printed success in CI and 'npm run check' since the layout change. Fix the path by scanning every project dir under src/ (skipping obj/bin/wwwroot, whose stale artifacts contain Render calls for deleted pages), resolve Inertia.Render arguments that are same-file string consts (UnlockAccountEndpoint pattern), and add a self-check that fails the run when zero C# files or zero endpoints are found repo-wide so path drift can never silently disable the guard again. The runtime fallback was equally dead: app.tsx called showErrorToast(), which does not exist (ReferenceError instead of an error toast on failed page loads). Root cause it survived: ClientApp had no tsconfig.json and was excluded from scripts/typecheck.mjs. Add the tsconfig, wire ClientApp into typecheck (with a hard failure if its tsconfig ever disappears), and fix the two latent bugs the new checking exposed: router.on('exception') is not an Inertia v3 event (the network-error toast never fired — the event is 'networkError'), and the page resolver must return the component, not the module wrapper. --- .../components/layouts/layout-provider.tsx | 4 +- scripts/typecheck.mjs | 23 ++- template/SimpleModule.Host/ClientApp/app.tsx | 15 +- .../SimpleModule.Host/ClientApp/tsconfig.json | 10 ++ .../ClientApp/validate-pages.mjs | 145 ++++++++++++++---- 5 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 template/SimpleModule.Host/ClientApp/tsconfig.json diff --git a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx index 5def956a..3594be15 100644 --- a/packages/SimpleModule.UI/components/layouts/layout-provider.tsx +++ b/packages/SimpleModule.UI/components/layouts/layout-provider.tsx @@ -24,7 +24,9 @@ function AutoLayout({ children }: { children: React.ReactNode }) { } interface PageModule { - default: { layout?: (content: React.ReactNode) => React.ReactNode }; + default: React.ComponentType & { + layout?: (content: React.ReactNode) => React.ReactNode; + }; } export function resolveLayout(page: PageModule) { diff --git a/scripts/typecheck.mjs b/scripts/typecheck.mjs index 214766bc..52391ad5 100644 --- a/scripts/typecheck.mjs +++ b/scripts/typecheck.mjs @@ -3,9 +3,9 @@ /** * typecheck.mjs * - * Runs `tsc --noEmit` in every module and package that has a tsconfig.json. - * Each project is checked independently so @/* path aliases resolve correctly. - * All checks run in parallel for speed. + * Runs `tsc --noEmit` in every module and package that has a tsconfig.json, + * plus the Host ClientApp. Each project is checked independently so @/* path + * aliases resolve correctly. All checks run in parallel for speed. * * Exit codes: * 0 = All projects pass type checking @@ -68,9 +68,26 @@ function checkProject(dir) { }); } +// The Host ClientApp must always be in this list — it is the Inertia +// bootstrap that every module page loads through. It went unchecked once +// (no tsconfig.json, not listed here) and shipped a ReferenceError in its +// page-load error handler. +const clientAppDir = path.join( + projectRoot, + 'template', + 'SimpleModule.Host', + 'ClientApp', +); + +if (!fs.existsSync(path.join(clientAppDir, 'tsconfig.json'))) { + console.error(`Missing tsconfig.json in ${clientAppDir} — ClientApp must be type-checked.`); + process.exit(1); +} + const projects = [ ...findProjects(modulesDir, 'nested'), ...findProjects(packagesDir, 'flat'), + clientAppDir, ]; const results = await Promise.all(projects.map(checkProject)); diff --git a/template/SimpleModule.Host/ClientApp/app.tsx b/template/SimpleModule.Host/ClientApp/app.tsx index ef498e03..93b80714 100644 --- a/template/SimpleModule.Host/ClientApp/app.tsx +++ b/template/SimpleModule.Host/ClientApp/app.tsx @@ -120,7 +120,10 @@ router.on('httpException', (event) => { showToast({ variant: 'error', title: 'Error', message, autoDismissMs: 8000 }); }); -router.on('exception', (event) => { +// Inertia v3 renamed this event — 'exception' no longer exists, so the old +// router.on('exception', ...) handler was dead code and network failures +// surfaced nothing. +router.on('networkError', (event) => { event.preventDefault(); showToast({ variant: 'error', @@ -222,14 +225,18 @@ const ERROR_PAGES: Record = { createInertiaApp({ resolve: async (name) => { if (name in ERROR_PAGES) { - return ERROR_PAGES[name]; + return ERROR_PAGES[name].default; } try { const page = await resolvePage(name); - return resolveLayout(page); + return resolveLayout(page).default; } catch (err) { - showErrorToast(`Failed to load page "${name}". Try refreshing the page.`); + showToast({ + variant: 'error', + title: 'Error', + message: `Failed to load page "${name}". Try refreshing the page.`, + }); throw err; } }, diff --git a/template/SimpleModule.Host/ClientApp/tsconfig.json b/template/SimpleModule.Host/ClientApp/tsconfig.json new file mode 100644 index 00000000..f468292a --- /dev/null +++ b/template/SimpleModule.Host/ClientApp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@simplemodule/tsconfig/base", + "compilerOptions": { + "types": ["vite/client"], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["**/*.ts", "**/*.tsx"] +} diff --git a/template/SimpleModule.Host/ClientApp/validate-pages.mjs b/template/SimpleModule.Host/ClientApp/validate-pages.mjs index feafdb43..efc99ae6 100644 --- a/template/SimpleModule.Host/ClientApp/validate-pages.mjs +++ b/template/SimpleModule.Host/ClientApp/validate-pages.mjs @@ -7,15 +7,22 @@ * between C# endpoints and TypeScript Pages/index.ts files. * * This script: - * 1. Scans all C# files in each module's src/{ModuleName} directory + * 1. Scans all C# files in each module's src/ directory (the implementation + * project lives at src/SimpleModule.{ModuleName}; obj/bin/wwwroot are skipped) * 2. Finds all Inertia.Render("ComponentName/...") calls * 3. Scans the module's Pages/index.ts file * 4. Finds all keys in the pages object export * 5. Compares the two lists and reports mismatches * + * Self-check: if zero C# files or zero Inertia.Render endpoints are found + * across the whole repo, the script fails. A layout change must never be able + * to silently turn this guard into a no-op again (it did once: the script + * scanned src/{ModuleName} while the real layout is src/SimpleModule.{ModuleName}, + * so it validated zero files and always reported success). + * * Exit codes: * 0 = All modules have valid registrations - * 1 = Mismatches found + * 1 = Mismatches found, or self-check failed */ import fs from 'node:fs'; @@ -26,6 +33,10 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '../../..'); const modulesDir = path.resolve(projectRoot, 'modules'); +// Build-output and vendor directories that must never be scanned — stale +// artifacts in obj/bin can contain Inertia.Render strings for deleted pages. +const SKIPPED_DIRS = new Set(['obj', 'bin', 'node_modules', 'wwwroot', 'dist']); + /** * Recursively find all .cs files in a directory */ @@ -39,6 +50,7 @@ function findCSharpFiles(dir) { const fullPath = path.join(currentPath, entry.name); if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name)) continue; walk(fullPath); } else if (entry.isFile() && entry.name.endsWith('.cs')) { files.push(fullPath); @@ -54,19 +66,40 @@ function findCSharpFiles(dir) { } /** - * Extract all Inertia.Render component names from a C# file - * Pattern: Inertia\.Render\s*\(\s*"([^"]+)" + * Extract all Inertia.Render component names from a C# file. + * Handles both inline literals — Inertia.Render("Module/Page", ...) — and + * identifiers resolved against `const string Name = "Module/Page";` + * declarations in the same file (the ComponentName pattern). + * Returns { names, unresolved } where unresolved lists identifier arguments + * that could not be resolved to a string in this file. */ function findCSharpEndpoints(content) { - const pattern = /Inertia\.Render\s*\(\s*"([^"]+)"/g; - const matches = new Set(); - let match = pattern.exec(content); + const names = new Set(); + const unresolved = new Set(); + + const literalPattern = /Inertia\.Render\s*\(\s*"([^"]+)"/g; + let match = literalPattern.exec(content); while (match !== null) { - matches.add(match[1]); - match = pattern.exec(content); + names.add(match[1]); + match = literalPattern.exec(content); } - return matches; + const identifierPattern = /Inertia\.Render\s*\(\s*([A-Za-z_][A-Za-z0-9_.]*)\s*[,)]/g; + match = identifierPattern.exec(content); + while (match !== null) { + const identifier = match[1]; + const constName = identifier.split('.').pop(); + const constPattern = new RegExp(`const\\s+string\\s+${constName}\\s*=\\s*"([^"]+)"`); + const constMatch = constPattern.exec(content); + if (constMatch) { + names.add(constMatch[1]); + } else { + unresolved.add(identifier); + } + match = identifierPattern.exec(content); + } + + return { names, unresolved }; } /** @@ -100,32 +133,55 @@ function findTypeScriptPages(content) { */ function validateModule(modulePath) { const moduleName = path.basename(modulePath); - const srcPath = path.join(modulePath, 'src', moduleName); + const srcRoot = path.join(modulePath, 'src'); + + // Implementation projects live at src/SimpleModule.{ModuleName} (plus a + // Contracts sibling). Scan every project directory under src/ rather than + // hard-coding one name, so a layout rename cannot silently skip files. + const projectDirs = fs.existsSync(srcRoot) + ? fs + .readdirSync(srcRoot, { withFileTypes: true }) + .filter((e) => e.isDirectory() && !SKIPPED_DIRS.has(e.name)) + .map((e) => path.join(srcRoot, e.name)) + : []; // Find all C# endpoints - const csharpFiles = findCSharpFiles(srcPath); const csharpEndpoints = new Set(); + const unresolvedRenders = []; + let csharpFileCount = 0; + + for (const projectDir of projectDirs) { + for (const filePath of findCSharpFiles(projectDir)) { + csharpFileCount += 1; + const content = fs.readFileSync(filePath, 'utf-8'); + const { names, unresolved } = findCSharpEndpoints(content); - for (const filePath of csharpFiles) { - const content = fs.readFileSync(filePath, 'utf-8'); - const endpoints = findCSharpEndpoints(content); + for (const endpoint of names) { + csharpEndpoints.add(endpoint); + } - for (const endpoint of endpoints) { - csharpEndpoints.add(endpoint); + for (const identifier of unresolved) { + unresolvedRenders.push(`${path.relative(modulePath, filePath)}: ${identifier}`); + } } } - // Find all TS pages - const pagesIndexPath = path.join(srcPath, 'Pages', 'index.ts'); - let tsPages = new Set(); + // Find all TS pages (Pages/index.ts in the implementation project) + const tsPages = new Set(); let hasPages = false; - try { - const content = fs.readFileSync(pagesIndexPath, 'utf-8'); - tsPages = findTypeScriptPages(content); - hasPages = true; - } catch (err) { - if (err.code !== 'ENOENT') throw err; // Re-throw non-file-not-found errors + for (const projectDir of projectDirs) { + const pagesIndexPath = path.join(projectDir, 'Pages', 'index.ts'); + + try { + const content = fs.readFileSync(pagesIndexPath, 'utf-8'); + for (const page of findTypeScriptPages(content)) { + tsPages.add(page); + } + hasPages = true; + } catch (err) { + if (err.code !== 'ENOENT') throw err; // Re-throw non-file-not-found errors + } } // Compare @@ -135,9 +191,12 @@ function validateModule(modulePath) { return { moduleName, hasPages, + csharpFileCount, + endpointCount: csharpEndpoints.size, missing, extra, - isValid: missing.length === 0 && extra.length === 0, + unresolvedRenders, + isValid: missing.length === 0 && extra.length === 0 && unresolvedRenders.length === 0, }; } @@ -164,10 +223,30 @@ function main() { // Print results console.log('\n=== Pages Registry Validation ===\n'); + // Self-check: this guard once silently validated nothing because the module + // layout changed underneath it. If the scan finds no C# files or no + // Inertia.Render endpoints at all, the paths are wrong — fail loudly. + const totalCsFiles = results.reduce((sum, r) => sum + r.csharpFileCount, 0); + const totalEndpoints = results.reduce((sum, r) => sum + r.endpointCount, 0); + + if (totalCsFiles === 0) { + console.error('❌ Self-check failed: scanned 0 C# files across all modules.'); + console.error(' The module source layout has likely changed — update validate-pages.mjs.\n'); + process.exit(1); + } + + if (totalEndpoints === 0) { + console.error('❌ Self-check failed: found 0 Inertia.Render endpoints across all modules.'); + console.error(' The module source layout has likely changed — update validate-pages.mjs.\n'); + process.exit(1); + } + const invalid = results.filter((r) => !r.isValid); if (invalid.length === 0) { - console.log('✅ All modules have valid Pages/index.ts registrations\n'); + console.log( + `✅ All modules valid (${totalEndpoints} endpoints across ${totalCsFiles} C# files)\n`, + ); process.exit(0); } @@ -182,6 +261,16 @@ function main() { console.log(` Extra in Pages/index.ts: ${result.extra.join(', ')}`); } + if (result.unresolvedRenders.length > 0) { + console.log( + ' Inertia.Render arguments that could not be resolved to a string ' + + '(use a literal or a same-file const):', + ); + for (const entry of result.unresolvedRenders) { + console.log(` ${entry}`); + } + } + console.log(); } From ae4ae8ae882ee02aab842091998466e3de1f9acf Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 9 Jun 2026 21:45:44 +0200 Subject: [PATCH 2/7] fix: remaining #242-class DbContext races and lost-event outbox gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sequentialize the three surviving Task.WhenAll-over-scoped-DbContext sites (AdminService.GetAdminOverviewAsync, Admin UsersEditEndpoint, TenantFeatureHelper.GetOverridesForTenantAsync — the last threw whenever 2+ active feature flags existed). EF Core forbids concurrent operations on one context instance, so these surfaced as intermittent HTTP 500s under load, exactly like the UsersEndpoint instance fixed in #244. Route TenantService.CreateTenantAsync and FileStorageService UploadFileAsync / DeleteFileAsync through IDbContextOutbox instead of SaveChangesAsync followed by bus.PublishAsync — the old pattern lost the event when the process died between the two calls, and DeleteFileAsync additionally skipped the event whenever blob deletion failed after the DB commit. Create flows need the database-generated id in the event payload, so they save inside an explicit transaction and let SaveChangesAndFlushMessagesAsync persist the envelope, commit, and only then flush — FakeDbContextOutbox now mirrors that commit-current-transaction behavior. --- .../src/SimpleModule.Admin/AdminService.cs | 18 ++++++----- .../Pages/Admin/UsersEditEndpoint.cs | 17 ++++++----- .../FileStorageService.cs | 30 +++++++++++++------ .../FileStorageServiceTests.cs | 4 +-- .../TenantFeatures/TenantFeatureHelper.cs | 30 ++++++++++++------- .../src/SimpleModule.Tenants/TenantService.cs | 13 ++++++-- .../Unit/TenantServiceTests.cs | 19 +++++------- .../Fakes/FakeDbContextOutbox.cs | 8 +++++ 8 files changed, 87 insertions(+), 52 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/AdminService.cs b/modules/Admin/src/SimpleModule.Admin/AdminService.cs index 9844495d..57378cb6 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminService.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminService.cs @@ -10,14 +10,16 @@ public async Task GetAdminOverviewAsync( CancellationToken cancellationToken = default ) { - var usersTask = userAdmin.GetUsersPagedAsync(null, 1, 1); - var activeTask = userAdmin.GetUsersPagedAsync(null, 1, 1, filterStatus: "active"); - var rolesTask = roleAdmin.GetAllRolesAsync(); - await Task.WhenAll(usersTask, activeTask, rolesTask).ConfigureAwait(false); - - var usersPage = await usersTask.ConfigureAwait(false); - var activePage = await activeTask.ConfigureAwait(false); - var roles = await rolesTask.ConfigureAwait(false); + // Await sequentially, not via Task.WhenAll: IUserAdminContracts and + // IRoleAdminContracts are both backed by the same scoped UsersDbContext, + // and EF Core forbids concurrent operations on one DbContext instance. + // Parallel awaits here intermittently threw "A second operation was + // started on this context instance..." → HTTP 500 (same class as #242). + var usersPage = await userAdmin.GetUsersPagedAsync(null, 1, 1).ConfigureAwait(false); + var activePage = await userAdmin + .GetUsersPagedAsync(null, 1, 1, filterStatus: "active") + .ConfigureAwait(false); + var roles = await roleAdmin.GetAllRolesAsync().ConfigureAwait(false); return new AdminOverviewDto { diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs index 66d21398..3e330f54 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEditEndpoint.cs @@ -35,14 +35,15 @@ public void Map(IEndpointRouteBuilder app) if (user is null) return TypedResults.NotFound(); - var rolesTask = roleAdmin.GetAllRolesAsync(); - var permsTask = permissionContracts.GetPermissionsForUserAsync(UserId.From(id)); - var sessionsTask = sessionContracts.GetActiveSessionsForUserAsync(id); - await Task.WhenAll(rolesTask, permsTask, sessionsTask); - - var allRoles = await rolesTask; - var userPermissions = (await permsTask).ToList(); - var activeSessions = await sessionsTask; + // Await sequentially, not via Task.WhenAll: these contracts can + // resolve services backed by the same scoped DbContext, and EF Core + // forbids concurrent operations on one context instance. Parallel + // awaits intermittently surfaced as HTTP 500 (same class as #242). + var allRoles = await roleAdmin.GetAllRolesAsync(); + var userPermissions = ( + await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) + ).ToList(); + var activeSessions = await sessionContracts.GetActiveSessionsForUserAsync(id); var permissionsByModule = permissionRegistry.ByModule.ToDictionary( kvp => kvp.Key, diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs index 28ebff02..23e93dde 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/FileStorageService.cs @@ -3,14 +3,14 @@ using SimpleModule.FileStorage.Contracts; using SimpleModule.FileStorage.Contracts.Events; using SimpleModule.Storage; -using Wolverine; +using Wolverine.EntityFrameworkCore; namespace SimpleModule.FileStorage; public sealed partial class FileStorageService( FileStorageDbContext db, IStorageProvider storageProvider, - IMessageBus bus, + IDbContextOutbox outbox, ILogger logger ) : IFileStorageContracts { @@ -77,11 +77,16 @@ public async Task UploadFileAsync( }; db.StoredFiles.Add(storedFile); - await db.SaveChangesAsync(); - - LogFileUploaded(logger, storedFile.Id, storedFile.FileName); - await bus.PublishAsync( + // FileStorageId is database-generated, so the row must be saved before + // the event can carry it. The explicit transaction keeps the row and the + // outbox envelope atomic: SaveChangesAndFlushMessagesAsync persists the + // envelope, commits the open transaction, and only then releases the + // event to the bus. The previous SaveChanges-then-PublishAsync pattern + // lost the event when the process died between the two calls. + await using var transaction = await db.Database.BeginTransactionAsync(); + await db.SaveChangesAsync(); + await outbox.PublishAsync( new FileUploadedEvent( storedFile.Id, storedFile.FileName, @@ -89,6 +94,9 @@ await bus.PublishAsync( storedFile.ContentType ) ); + await outbox.SaveChangesAndFlushMessagesAsync(); + + LogFileUploaded(logger, storedFile.Id, storedFile.FileName); return storedFile; } @@ -113,7 +121,13 @@ public async Task DeleteFileAsync(StoredFile file) var storagePath = file.StoragePath; db.StoredFiles.Remove(file); - await db.SaveChangesAsync(); + + // Publish through the outbox so the delete and the event commit atomically. + // Previously the event was published only after blob deletion succeeded, so + // a crash — or a failed blob delete — after the DB commit silently dropped + // FileDeletedEvent even though the file was gone from the system of record. + await outbox.PublishAsync(new FileDeletedEvent(file.Id, file.FileName)); + await outbox.SaveChangesAndFlushMessagesAsync(); try { @@ -128,8 +142,6 @@ public async Task DeleteFileAsync(StoredFile file) } LogFileDeleted(logger, file.Id, file.FileName); - - await bus.PublishAsync(new FileDeletedEvent(file.Id, file.FileName)); } public async Task DownloadFileAsync(FileStorageId id) diff --git a/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs b/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs index 96ca0575..bd2d2bfc 100644 --- a/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs +++ b/modules/FileStorage/tests/SimpleModule.FileStorage.Tests/FileStorageServiceTests.cs @@ -38,7 +38,7 @@ public FileStorageServiceTests() _service = new FileStorageService( _db, _storageProvider, - new TestMessageBus(), + new FakeDbContextOutbox(_db), NullLogger.Instance ); } @@ -229,7 +229,7 @@ public async Task UploadFileAsync_Cleans_Up_Storage_On_DB_Failure() var failingService = new FileStorageService( _db, failingProvider, - new TestMessageBus(), + new FakeDbContextOutbox(_db), NullLogger.Instance ); diff --git a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs index 0061ce39..71a531f0 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/Endpoints/TenantFeatures/TenantFeatureHelper.cs @@ -14,16 +14,24 @@ TenantId tenantId var tenantIdStr = tenantId.Value.ToString( System.Globalization.CultureInfo.InvariantCulture ); - var overrideTasks = flags - .Where(f => !f.IsDeprecated) - .Select(f => featureFlags.GetOverridesAsync(f.Name)); - var allOverrides = await Task.WhenAll(overrideTasks); - return allOverrides - .SelectMany(o => o) - .Where(o => - o.OverrideType == OverrideType.Tenant - && string.Equals(o.OverrideValue, tenantIdStr, StringComparison.Ordinal) - ) - .ToList(); + + // Await sequentially, not via Task.WhenAll: every GetOverridesAsync call + // hits the same scoped FeatureFlagsDbContext, and EF Core forbids + // concurrent operations on one context instance. With 2+ active flags + // the parallel version reliably threw "A second operation was started + // on this context instance..." → HTTP 500 (same class as #242). + var result = new List(); + foreach (var flag in flags.Where(f => !f.IsDeprecated)) + { + var overrides = await featureFlags.GetOverridesAsync(flag.Name); + result.AddRange( + overrides.Where(o => + o.OverrideType == OverrideType.Tenant + && string.Equals(o.OverrideValue, tenantIdStr, StringComparison.Ordinal) + ) + ); + } + + return result; } } diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs index 09cfe0b7..b5509436 100644 --- a/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs +++ b/modules/Tenants/src/SimpleModule.Tenants/TenantService.cs @@ -3,14 +3,12 @@ using SimpleModule.Core.Exceptions; using SimpleModule.Tenants.Contracts; using SimpleModule.Tenants.Contracts.Events; -using Wolverine; using Wolverine.EntityFrameworkCore; namespace SimpleModule.Tenants; public sealed partial class TenantService( TenantsDbContext db, - IMessageBus bus, IDbContextOutbox outbox, ILogger logger ) : ITenantContracts @@ -81,10 +79,19 @@ public async Task CreateTenantAsync(CreateTenantRequest request) } db.Tenants.Add(entity); + + // TenantId is database-generated, so the entity must be saved before the + // event can carry it. The explicit transaction keeps the tenant row and + // the outbox envelope atomic: SaveChangesAndFlushMessagesAsync persists + // the envelope, commits the open transaction, and only then releases the + // event to the bus. The previous SaveChanges-then-PublishAsync pattern + // lost the event when the process died between the two calls. + await using var transaction = await db.Database.BeginTransactionAsync(); await db.SaveChangesAsync(); + await outbox.PublishAsync(new TenantCreatedEvent(entity.Id, entity.Name, entity.Slug)); + await outbox.SaveChangesAndFlushMessagesAsync(); LogTenantCreated(logger, entity.Id, entity.Name); - await bus.PublishAsync(new TenantCreatedEvent(entity.Id, entity.Name, entity.Slug)); return MapToDto(entity); } diff --git a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs index 88e27898..9159970e 100644 --- a/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs +++ b/modules/Tenants/tests/SimpleModule.Tenants.Tests/Unit/TenantServiceTests.cs @@ -2,13 +2,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; -using NSubstitute; using SimpleModule.Core.Exceptions; using SimpleModule.Database; using SimpleModule.Tenants; using SimpleModule.Tenants.Contracts; using SimpleModule.Tests.Shared.Fakes; -using Wolverine; namespace Tenants.Tests.Unit; @@ -16,7 +14,6 @@ public sealed class TenantServiceTests : IDisposable { private readonly TenantsDbContext _db; private readonly TenantService _sut; - private readonly IMessageBus _bus = Substitute.For(); private readonly FakeDbContextOutbox _outbox; public TenantServiceTests() @@ -37,7 +34,7 @@ public TenantServiceTests() _db.Database.OpenConnection(); _db.Database.EnsureCreated(); _outbox = new FakeDbContextOutbox(_db); - _sut = new TenantService(_db, _bus, _outbox, NullLogger.Instance); + _sut = new TenantService(_db, _outbox, NullLogger.Instance); } public void Dispose() => _db.Dispose(); @@ -89,10 +86,12 @@ public async Task CreateTenantAsync_CreatesAndReturnsTenant() tenant.Status.Should().Be(TenantStatus.Active); tenant.Hosts.Should().HaveCount(1); tenant.Hosts[0].HostName.Should().Be("new.localhost"); - await _bus.Received(1) - .PublishAsync( - Arg.Any(), - Arg.Any() + _outbox + .PublishedMessages.Should() + .ContainSingle(m => + m is SimpleModule.Tenants.Contracts.Events.TenantCreatedEvent + && ((SimpleModule.Tenants.Contracts.Events.TenantCreatedEvent)m).TenantId + == tenant.Id ); } @@ -105,9 +104,7 @@ public async Task UpdateTenantAsync_WithValidData_UpdatesTenant() updated.Name.Should().Be("Updated Acme"); _outbox .PublishedMessages.Should() - .ContainSingle(m => - m is SimpleModule.Tenants.Contracts.Events.TenantUpdatedEvent - ); + .ContainSingle(m => m is SimpleModule.Tenants.Contracts.Events.TenantUpdatedEvent); } [Fact] diff --git a/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs b/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs index a8860961..407ee289 100644 --- a/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs +++ b/tests/SimpleModule.Tests.Shared/Fakes/FakeDbContextOutbox.cs @@ -44,6 +44,14 @@ public ValueTask SendAsync(T message, DeliveryOptions? options = null) public async Task SaveChangesAndFlushMessagesAsync(CancellationToken token = default) { await DbContext.SaveChangesAsync(token); + + // Mirror the production outbox, which commits an externally-started + // transaction before flushing messages (services that need a + // database-generated id save inside an explicit transaction first). + if (DbContext.Database.CurrentTransaction is not null) + { + await DbContext.Database.CommitTransactionAsync(token); + } } public Task FlushOutgoingMessagesAsync() => Task.CompletedTask; From 21eb449605c241be0f51c8eedd8748a38ce5267d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 9 Jun 2026 21:46:06 +0200 Subject: [PATCH 3/7] fix(security): harden the default deployment posture A default deployment was one HTTP request away from an admin token: the compiled-in admin@simplemodule.dev / Admin123! fallback was seeded in every environment (with only a log warning), the shipped docker-compose ran in Development which turns on the ROPC password grant, and the host cleared KnownProxies/KnownIPNetworks so X-Forwarded-For spoofing bypassed the per-IP rate limiter that would slow brute force. Closing all links in the chain: - UserSeedService now fails host startup outside Development when Seed:AdminPassword is unset (SeedConfigurationException escapes the tolerate-DB-errors catch) instead of seeding the published default; the demo user is skipped entirely unless Seed:UserPassword is provided. - New OpenIddictProductionGuard fails startup in Production when OpenIddict:AllowPasswordGrant is enabled or when signing/encryption certificates are missing (ephemeral keys regenerate per restart and invalidate every issued token). - Forwarded-header trust is now explicit: loopback by default, with ForwardedHeaders:KnownProxies / KnownNetworks / TrustAllProxies config instead of unconditionally trusting any client-supplied header. - docker-compose.yml pins OpenIddict__AllowPasswordGrant=false, requires SEED_ADMIN_PASSWORD via .env, and documents the Production switches. - UploadEndpoint actually enforces FileStorageModuleOptions: 413 above MaxFileSizeMb, 400 for extensions outside AllowedExtensions (both were defined but never consulted). --- docker-compose.yml | 18 ++++++- .../SimpleModuleHostExtensions.cs | 35 ++++++++++-- .../Endpoints/Files/UploadEndpoint.cs | 36 ++++++++++++- .../OpenIddictModule.cs | 11 +++- .../Services/OpenIddictProductionGuard.cs | 53 +++++++++++++++++++ .../Services/SeedConfigurationException.cs | 19 +++++++ .../Services/UserSeedService.cs | 50 ++++++++++++----- 7 files changed, 199 insertions(+), 23 deletions(-) create mode 100644 modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictProductionGuard.cs create mode 100644 modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs diff --git a/docker-compose.yml b/docker-compose.yml index 97f4ed4c..34939a5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,28 @@ services: ports: - "8080:8080" environment: - # Development enables auto-migration. For production, apply migrations - # externally and set to Production. + # Development enables auto-migration and ephemeral signing keys for a + # local quickstart. Do NOT expose this stack to the internet as-is: set + # ASPNETCORE_ENVIRONMENT=Production (which additionally enforces signing + # certificates and refuses the password grant), apply migrations + # externally, and configure ForwardedHeaders__KnownProxies for your + # reverse proxy. ASPNETCORE_ENVIRONMENT: Development Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}" Database__Provider: PostgreSQL # Set to your public URL so OpenIddict registers correct redirect URIs. # Examples: https://app.simplemodule.dev, http://localhost:8080 OpenIddict__BaseUrl: ${APP_BASE_URL:-http://localhost:8080} + # The ROPC password grant stays off even in this Development quickstart — + # combined with seeded credentials it would hand out fully-privileged + # tokens with a single POST /connect/token. + OpenIddict__AllowPasswordGrant: "false" + # Required: password for the seeded admin account. There is deliberately + # no default — put SEED_ADMIN_PASSWORD in your .env file. + Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env} + # Optional: password for the seeded demo user (outside Development the + # demo user is skipped entirely when this is unset). + Seed__UserPassword: ${SEED_USER_PASSWORD:-} # api stays in Producer mode — it enqueues jobs but never runs them. # All IModuleJob execution lives in the worker service below. BackgroundJobs__WorkerMode: Producer diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 6ef608bf..092ac44a 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -57,9 +57,33 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( { fhOptions.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - // Allow any proxy in containerized/cloud environments - fhOptions.KnownIPNetworks.Clear(); - fhOptions.KnownProxies.Clear(); + + // Proxy trust must be explicit. The previous behavior cleared + // KnownProxies/KnownIPNetworks, which let any client spoof + // X-Forwarded-For and bypass per-IP rate limiting. By default only + // loopback is trusted (the ASP.NET Core default); deployments behind + // a reverse proxy list it under ForwardedHeaders:KnownProxies / + // ForwardedHeaders:KnownNetworks, or — for closed networks where the + // proxy address is not static — opt into + // ForwardedHeaders:TrustAllProxies. + var section = builder.Configuration.GetSection("ForwardedHeaders"); + + if (section.GetValue("TrustAllProxies")) + { + fhOptions.KnownIPNetworks.Clear(); + fhOptions.KnownProxies.Clear(); + return; + } + + foreach (var proxy in section.GetSection("KnownProxies").Get() ?? []) + { + fhOptions.KnownProxies.Add(System.Net.IPAddress.Parse(proxy)); + } + + foreach (var network in section.GetSection("KnownNetworks").Get() ?? []) + { + fhOptions.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(network)); + } }); builder.Services.AddProblemDetails(); @@ -139,7 +163,10 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( // cleared by `sm up`. Resolved as singleton because it caches state // for a short interval. builder.Services.Configure(_ => { }); - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton< + IMaintenanceStateProvider, + FileSystemMaintenanceStateProvider + >(); if (options.EnableHealthChecks) { diff --git a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs index 11e6ce89..c29fd2d9 100644 --- a/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs +++ b/modules/FileStorage/src/SimpleModule.FileStorage/Endpoints/Files/UploadEndpoint.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Extensions; @@ -20,7 +21,8 @@ async Task ( IFormFile? file, string? folder, HttpContext context, - IFileStorageContracts files + IFileStorageContracts files, + IOptions options ) => { if (file is null || file.Length == 0) @@ -28,6 +30,38 @@ IFileStorageContracts files return TypedResults.BadRequest("A file is required."); } + var maxBytes = options.Value.MaxFileSizeMb * 1024L * 1024L; + if (file.Length > maxBytes) + { + return TypedResults.Problem( + detail: $"File exceeds the maximum allowed size of " + + $"{options.Value.MaxFileSizeMb} MB.", + statusCode: StatusCodes.Status413PayloadTooLarge + ); + } + + // An empty AllowedExtensions list means "no restriction". + var allowedExtensions = options + .Value.AllowedExtensions.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries + ) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var extension = Path.GetExtension(file.FileName); + if ( + allowedExtensions.Count > 0 + && ( + string.IsNullOrEmpty(extension) + || !allowedExtensions.Contains(extension) + ) + ) + { + return TypedResults.BadRequest( + $"File type '{extension}' is not allowed. Allowed extensions: " + + $"{options.Value.AllowedExtensions}" + ); + } + var userId = context.User.GetUserId(); await using var stream = file.OpenReadStream(); var storedFile = await files.UploadFileAsync( diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs index e100a4b9..82bca845 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs @@ -66,7 +66,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.AllowRefreshTokenFlow(); - // Enable password grant in Development for load testing (k6, etc.) + // Enable password grant in Development for load testing (k6, etc.). + // OpenIddictProductionGuard fails host startup if this is ever + // turned on in Production. if (configuration.GetValue("OpenIddict:AllowPasswordGrant")) { options.AllowPasswordFlow(); @@ -103,7 +105,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config } else { - // Development/Testing: use ephemeral keys (avoids macOS keychain issues) + // Development/Testing: use ephemeral keys (avoids macOS keychain + // issues). OpenIddictProductionGuard fails host startup if + // Production runs without certificates. options.AddEphemeralEncryptionKey().AddEphemeralSigningKey(); } @@ -127,6 +131,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.UseAspNetCore(); }); + // Refuses unsafe Production configurations (password grant, ephemeral keys) + services.AddHostedService(); + // Seed service services.AddHostedService(); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictProductionGuard.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictProductionGuard.cs new file mode 100644 index 00000000..b02350af --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictProductionGuard.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.OpenIddict.Services; + +/// +/// Fails host startup when the OpenIddict configuration is unsafe for Production: +/// the ROPC password grant must stay off (it lets anyone exchange leaked or default +/// credentials for a fully-privileged token in a single request), and token +/// signing/encryption must use real certificates — ephemeral keys are regenerated +/// on every restart, invalidating all issued tokens, and signal a copy-pasted +/// Development configuration. +/// +public sealed class OpenIddictProductionGuard( + IConfiguration configuration, + IHostEnvironment environment +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + if (!environment.IsProduction()) + { + return Task.CompletedTask; + } + + if (configuration.GetValue("OpenIddict:AllowPasswordGrant")) + { + throw new InvalidOperationException( + "'OpenIddict:AllowPasswordGrant' must not be enabled in Production. " + + "The ROPC password grant exists for local load testing only." + ); + } + + var encryptionCertPath = configuration[ConfigKeys.OpenIddictEncryptionCertPath]; + var signingCertPath = configuration[ConfigKeys.OpenIddictSigningCertPath]; + + if (string.IsNullOrEmpty(encryptionCertPath) || string.IsNullOrEmpty(signingCertPath)) + { + throw new InvalidOperationException( + $"'{ConfigKeys.OpenIddictSigningCertPath}' and " + + $"'{ConfigKeys.OpenIddictEncryptionCertPath}' must be configured in " + + "Production. Without certificates OpenIddict falls back to ephemeral " + + "keys that are regenerated on every restart, invalidating all issued " + + "tokens." + ); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs b/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs new file mode 100644 index 00000000..48665495 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/SeedConfigurationException.cs @@ -0,0 +1,19 @@ +namespace SimpleModule.Users.Services; + +/// +/// Thrown when seeding cannot proceed safely — e.g. a required seed password is +/// missing outside Development. Unlike transient database errors (which the seed +/// service logs and tolerates), this exception is allowed to escape +/// so host startup fails loudly instead +/// of seeding a publicly known default credential. +/// +public sealed class SeedConfigurationException : InvalidOperationException +{ + public SeedConfigurationException() { } + + public SeedConfigurationException(string message) + : base(message) { } + + public SeedConfigurationException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs index e42b05b2..54ba1894 100644 --- a/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs +++ b/modules/Users/src/SimpleModule.Users/Services/UserSeedService.cs @@ -43,7 +43,8 @@ await SeedUserAsync( SeedConstants.AdminDisplayName, ConfigKeys.SeedAdminPassword, SeedConstants.DefaultAdminPassword, - SeedConstants.AdminRole + SeedConstants.AdminRole, + requiredOutsideDevelopment: true ); await SeedUserAsync( userManager, @@ -51,11 +52,12 @@ await SeedUserAsync( SeedConstants.UserDisplayName, ConfigKeys.SeedUserPassword, SeedConstants.DefaultUserPassword, - SeedConstants.UserRole + SeedConstants.UserRole, + requiredOutsideDevelopment: false ); } #pragma warning disable CA1031 // Seed service must not crash the host on database errors - catch (Exception ex) + catch (Exception ex) when (ex is not SeedConfigurationException) #pragma warning restore CA1031 { LogSeedError(logger, ex.Message); @@ -99,12 +101,39 @@ private async Task SeedUserAsync( string displayName, string passwordConfigKey, string defaultPassword, - string role + string role, + bool requiredOutsideDevelopment ) { if (await userManager.FindByEmailAsync(email) is not null) return; + // The compiled-in default passwords are a Development convenience only. + // Outside Development, creating the admin account with a published + // default password would leave the deployment one POST /connect/token + // away from a fully-privileged token — fail host startup instead. + // The optional test user is simply skipped when no password is set. + var password = configuration[passwordConfigKey]; + if (string.IsNullOrEmpty(password)) + { + if (!environment.IsDevelopment()) + { + if (requiredOutsideDevelopment) + { + throw new SeedConfigurationException( + $"'{passwordConfigKey}' must be configured outside the Development " + + $"environment. Refusing to create '{email}' with the compiled-in " + + "default password." + ); + } + + LogSkippingSeedUser(logger, email, passwordConfigKey); + return; + } + + password = defaultPassword; + } + LogSeedingUser(logger, email); var user = new ApplicationUser @@ -116,9 +145,6 @@ string role CreatedAt = DateTime.UtcNow, }; - var password = configuration[passwordConfigKey] ?? defaultPassword; - if (password == defaultPassword && !environment.IsDevelopment()) - LogDefaultPasswordWarning(logger, email, passwordConfigKey); var result = await userManager.CreateAsync(user, password); if (result.Succeeded) { @@ -140,14 +166,10 @@ string role private static partial void LogSeedingUser(ILogger logger, string email); [LoggerMessage( - Level = LogLevel.Warning, - Message = "Seeding {Email} with default password. Set '{ConfigKey}' in configuration before deploying to production." + Level = LogLevel.Information, + Message = "Skipping seed user {Email}: '{ConfigKey}' is not configured and default passwords are disabled outside Development." )] - private static partial void LogDefaultPasswordWarning( - ILogger logger, - string email, - string configKey - ); + private static partial void LogSkippingSeedUser(ILogger logger, string email, string configKey); [LoggerMessage(Level = LogLevel.Error, Message = "Seed error: {ErrorDescription}")] private static partial void LogSeedError(ILogger logger, string errorDescription); From f4f072b9d65e3abb089e721c78c48a750e1f1411 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 9 Jun 2026 21:46:21 +0200 Subject: [PATCH 4/7] docs+ci: reconcile docs with the tracked codebase; add security scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md and the Constitution described a system that is not on main: 11 load-test scenarios (mostly against Products/Orders/Marketplace/PageBuilder modules that exist only as untracked artifacts), benchmarks 'for every module', and a dual-provider CI that never existed. Rewrite the testing sections around what is actually tracked (6 NBomber scenarios, 5 benchmark classes), switch the page-registry examples from the phantom Products module to the real Tenants module — the old example also showed the wrong src/ layout, which is exactly the path bug that silently disabled validate-pages — and state plainly that the PostgreSQL CI leg is a known gap rather than claiming it runs. CI gains the security scanning an auth framework published to NuGet should have had: a CodeQL workflow (C# + JS/TS), Dependabot for nuget/npm/actions, and a vulnerable-package audit job that fails on known-vulnerable NuGet dependencies (including transitive). --- .github/dependabot.yml | 26 +++++++ .github/workflows/ci.yml | 20 ++++++ .github/workflows/codeql.yml | 40 +++++++++++ CLAUDE.md | 42 ++++++----- docs/CONSTITUTION.md | 7 +- tasks/todo.md | 133 ++++++++++++++++------------------- 6 files changed, 169 insertions(+), 99 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..e7c1f136 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: / + schedule: + interval: weekly + groups: + nuget-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + npm-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef5640ed..471f4f05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,26 @@ jobs: - name: Test run: dotnet test --no-build + vulnerable-packages: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - run: dotnet restore + + - name: Audit NuGet packages for known vulnerabilities + run: | + dotnet list package --vulnerable --include-transitive 2>&1 | tee vulnerable.txt + if grep -q "has the following vulnerable packages" vulnerable.txt; then + echo "::error::Vulnerable NuGet packages found — see log above" + exit 1 + fi + e2e: runs-on: ubuntu-latest needs: lint diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..445e6dd0 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '24 5 * * 1' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: csharp + build-mode: none + - language: javascript-typescript + build-mode: none + steps: + - uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' diff --git a/CLAUDE.md b/CLAUDE.md index 33307a11..8c5ef3d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,34 +87,32 @@ dotnet test --filter "FullyQualifiedName~ClassName" # single test class dotnet test --filter "FullyQualifiedName~MethodName" # single test method ``` -Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. SQLite in-memory for unit tests, PostgreSQL for integration tests in CI. +Test stack: xUnit.v3, FluentAssertions, Bogus, Microsoft.AspNetCore.Mvc.Testing. SQLite in-memory for unit and integration tests (a PostgreSQL CI leg is a known gap — the test factory is SQLite-only today). ### Benchmarks (BenchmarkDotNet) ```bash dotnet run -c Release --project tests/SimpleModule.Benchmarks # all benchmarks -dotnet run -c Release --project tests/SimpleModule.Benchmarks -- --filter "*Products*" # specific module +dotnet run -c Release --project tests/SimpleModule.Benchmarks -- --filter "*Users*" # specific module ``` -Micro-benchmarks for every module: endpoint latency (CRUD operations via in-process TestServer), JSON serialization/deserialization of DTOs. Uses `SimpleModuleWebApplicationFactory` with test auth headers for low-overhead measurement. +Micro-benchmarks for Admin, AuditLogs, FileStorage, Settings, and Users: endpoint latency (CRUD operations via in-process TestServer), JSON serialization/deserialization of DTOs. Uses `SimpleModuleWebApplicationFactory` with test auth headers for low-overhead measurement. ### Load Tests (NBomber) ```bash -dotnet test tests/SimpleModule.LoadTests # all 11 scenarios (50 concurrent, ~5 min) -dotnet test tests/SimpleModule.LoadTests --filter "Products_Crud" # single scenario +dotnet test tests/SimpleModule.LoadTests # all scenarios +dotnet test tests/SimpleModule.LoadTests --filter "Users_Crud" # single scenario ``` -HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant) from OpenIddict. Runs against the full ASP.NET pipeline with file-based SQLite in WAL mode. 11 scenarios covering all modules at 50 concurrent copies: +HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant) from OpenIddict. Runs against the full ASP.NET pipeline with file-based SQLite in WAL mode. Six scenarios (`tests/SimpleModule.LoadTests/Scenarios/`), each runnable individually or combined via `All_Scenarios`: -- **Products, Orders, Users** — full CRUD lifecycle (create → read → update → delete) -- **Settings** — read operations (settings, definitions, menus, available pages) -- **AuditLogs, FileStorage** — read operations (list, get by ID, folders) -- **PageBuilder** — full lifecycle (create → get → update → publish → unpublish → delete + tags/templates) -- **Admin** — role create/delete (handles 302 redirects) -- **FeatureFlags** — get all flags, check flag status -- **Marketplace** — search and browse (anonymous) -- **Mixed Realistic** — weighted workload (70% reads, 20% creates, 10% updates) +- **Users** (`Users_Crud`) — full CRUD lifecycle +- **Settings** (`Settings_Ops`) — read operations +- **AuditLogs** (`AuditLogs_Read`) — read operations +- **FileStorage** (`Files_Ops`) — file operations +- **Admin** (`Admin_Ops`) — role create/delete (handles 302 redirects) +- **FeatureFlags** (`FeatureFlags_Ops`) — get all flags, check flag status **Key infrastructure:** - `LoadTestWebApplicationFactory` — extends `WebApplicationFactory` with file-based SQLite + WAL, seeds OAuth client/user/permissions, acquires Bearer tokens via `/connect/token` @@ -131,13 +129,13 @@ HTTP load tests using real OAuth Bearer tokens acquired via ROPC (password grant ### Frontend (React + Inertia.js) -- **ClientApp** (`template/SimpleModule.Host/ClientApp/app.tsx`) — Inertia bootstrap. Resolves pages by splitting route name (e.g., `Products/Browse` → imports `/_content/Products/Products.pages.js`). +- **ClientApp** (`template/SimpleModule.Host/ClientApp/app.tsx`) — Inertia bootstrap. Resolves pages by splitting route name (e.g., `Tenants/Browse` → imports `/_content/Tenants/Tenants.pages.js`). - **Module pages** — Each module builds its React pages via Vite in library mode → `{ModuleName}.pages.js` in module's `wwwroot/`. Entry point: `Pages/index.ts` exporting a `pages` record mapping route names to components. - **Type generation** — `[Dto]` types → source generator embeds TS interfaces → `scripts/extract-ts-types.mjs` writes `.ts` files to `ClientApp/types/`. ### Request Flow -1. ASP.NET route handler calls `Inertia.Render("Products/Browse", props)` +1. ASP.NET route handler calls `Inertia.Render("Tenants/Browse", props)` 2. Inertia middleware renders static HTML shell with embedded JSON props 3. React ClientApp dynamically imports module's `pages.js` bundle 4. Component hydrates with server-provided props @@ -177,15 +175,15 @@ When you add a new `IViewEndpoint`, you **must** register it in your module's `P **Pattern:** ```typescript -// modules/Products/src/Products/Pages/index.ts -export const pages: Record = { - "Products/Browse": () => import("./Browse"), - "Products/Manage": () => import("./Manage"), - "Products/Create": () => import("./Create"), +// modules/Tenants/src/SimpleModule.Tenants/Pages/index.ts +export const pages: Record = { + 'Tenants/Browse': () => import('./Browse'), + 'Tenants/Manage': () => import('./Manage'), + 'Tenants/Create': () => import('./Create'), }; ``` -**The Rule:** For every `IViewEndpoint` with `Inertia.Render("Products/Something", ...)`, add a matching entry in `pages`. The component name in Inertia.Render (e.g., `"Products/Manage"`) is your key. +**The Rule:** For every `IViewEndpoint` with `Inertia.Render("Tenants/Something", ...)`, add a matching entry in `pages`. The component name in Inertia.Render (e.g., `"Tenants/Manage"`) is your key. **Validation:** After adding endpoints, run: diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index c22dcc2f..7a90f636 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -431,8 +431,8 @@ xUnit.v3, FluentAssertions, Bogus, `SimpleModuleWebApplicationFactory`. ### Database -- SQLite in-memory locally, PostgreSQL in CI. -- Both providers tested in CI. +- SQLite in-memory for unit and integration tests (shared connection per test factory). +- A PostgreSQL CI test leg is planned but not yet implemented — `SimpleModuleWebApplicationFactory` currently supports SQLite only. ### Rules @@ -600,7 +600,8 @@ All SM diagnostics are emitted by the Roslyn source generator at compile time. ` - `TreatWarningsAsErrors` is enabled globally via `Directory.Build.props`. - `AnalysisLevel=latest-all`, `AnalysisMode=All`. - Suppressed rules live in `.editorconfig`. -- Tests run against both SQLite and PostgreSQL in CI. +- CI tests run against SQLite. A PostgreSQL leg (postgres service + provider switch in the test factory) is a known gap. +- CodeQL, Dependabot, and a vulnerable-package audit run in CI for security scanning. --- diff --git a/tasks/todo.md b/tasks/todo.md index 9ad3a546..e5d7e141 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,78 +1,63 @@ -# Task: Design-system consistency pass across all module pages - -Goal: every page uses the design system consistently → run /qa → open PR with screenshots. - -Scope: 13 tracked modules with `.tsx` source. The 8 untracked dirs (Agents, Chat, Datasets, -Map, Marketplace, Orders, PageBuilder, Products) are stale build artifacts with NO source — left untouched, flagged to user. - -Out of scope (noted, not changed): i18n hardcoded-string gaps; the intentional centered-card -auth-page layout (only token/control bugs inside auth pages are fixed, not forced into PageShell). - -## Fixes (from parallel line-level audit) - -### HIGH — color tokens breaking dark mode / raw controls -- [ ] Tenants/tenantStatus.ts — raw palette → Badge variant map (success/warning/danger) -- [ ] Tenants/Browse.tsx, Manage.tsx — status span → -- [ ] Tenants/Features.tsx — text-green/red-600 → -- [ ] BackgroundJobs/Dashboard.tsx — text-red-500, border-red-200, hover:bg-red-50 → semantic -- [ ] BackgroundJobs/Detail.tsx — text-red-600, border-red-200, bg-red-50/text-red-800 → semantic -- [ ] FeatureFlags/Manage.tsx:264 — raw → -- [ ] RateLimiting/components/RulesTable.tsx — text-muted-foreground (undefined) → text-text-muted -- [ ] Email/History.tsx — text-destructive (undefined) → text-danger -- [ ] OpenIddict/OAuthCallback.tsx — text-muted → text-text-muted -- [ ] Dashboard/Home.tsx — text-white → text-text-inverse -- [ ] Users/Login.tsx, Register.tsx — text-white + inline var → bg-primary text-text-inverse; raw checkbox → Checkbox -- [ ] Users/LoginWith2fa.tsx — raw checkbox → Checkbox - -### MEDIUM — hand-rolled layout → PageShell; custom markup → DS components -- [ ] Settings/UserSettings.tsx — Container+h1 → PageShell; error banner → Alert -- [ ] Settings/AdminSettings.tsx — error banner → Alert -- [ ] Admin/RolesCreate, RolesEdit, UsersCreate, UsersEdit — Container+Breadcrumb+h1 → PageShell -- [ ] Admin/Roles.tsx — raw