diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d0aee55..ef5640ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,9 @@ jobs: - name: Verify module .nupkgs contain static web assets run: node scripts/verify-nupkg-static-assets.mjs ./nupkgs + - name: Smoke-test packed nupkgs against a real consumer + run: node scripts/smoke-test-nupkg-consumer.mjs ./nupkgs --version 0.0.0-ci + docker: runs-on: ubuntu-latest needs: lint diff --git a/SimpleModule.slnx b/SimpleModule.slnx index 8462e602..ddce6f58 100644 --- a/SimpleModule.slnx +++ b/SimpleModule.slnx @@ -9,6 +9,7 @@ + diff --git a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs index b162176b..e87e1573 100644 --- a/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs +++ b/cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs @@ -47,6 +47,7 @@ public override int Execute(CommandContext context, NewModuleSettings settings) Plan(Path.Combine(moduleDir, $"{moduleName}.csproj")); Plan(Path.Combine(moduleDir, $"{moduleName}Module.cs")); Plan(Path.Combine(moduleDir, $"{moduleName}Constants.cs")); + Plan(Path.Combine(moduleDir, $"{moduleName}Permissions.cs")); Plan(Path.Combine(moduleDir, $"{moduleName}DbContext.cs")); Plan(Path.Combine(moduleDir, $"{singularName}Service.cs")); Plan(Path.Combine(endpointsDir, "GetAllEndpoint.cs")); @@ -108,6 +109,10 @@ public override int Execute(CommandContext context, NewModuleSettings settings) Path.Combine(moduleDir, $"{moduleName}Constants.cs"), templates.ConstantsClass(moduleName, singularName) ); + File.WriteAllText( + Path.Combine(moduleDir, $"{moduleName}Permissions.cs"), + templates.PermissionsClass(moduleName, singularName) + ); File.WriteAllText( Path.Combine(moduleDir, $"{moduleName}DbContext.cs"), templates.DbContextClass(moduleName, singularName) diff --git a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs index c0f510ab..b313424c 100644 --- a/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs +++ b/cli/SimpleModule.Cli/Templates/ModuleTemplates.cs @@ -200,6 +200,23 @@ public string ConstantsClass(string moduleName, string singularName) ); } + public string PermissionsClass(string moduleName, string singularName) + { + var refPath = RefModulePath($"{_refModule}Permissions.cs"); + if (refPath is null) + { + return FallbackPermissionsClass(moduleName); + } + + return TemplateExtractor.ReadAndTransform( + refPath, + _refModule!, + _refSingular!, + moduleName, + singularName + ); + } + public string GlobalUsings() { var refPath = RefTestPath("GlobalUsings.cs"); @@ -988,6 +1005,24 @@ public static class {{moduleName}}Constants } """; + private static string FallbackPermissionsClass(string moduleName) => + $$""" + using SimpleModule.Core.Authorization; + + namespace SimpleModule.{{moduleName}}; + + // Permissions are auto-discovered by the source generator. Use them on endpoints via + // `.RequirePermission({{moduleName}}Permissions.View)` or the `[RequirePermission]` attribute. + // Delete this file if the module has no protected endpoints. + public sealed class {{moduleName}}Permissions : IModulePermissions + { + public const string View = "{{moduleName}}.View"; + public const string Create = "{{moduleName}}.Create"; + public const string Update = "{{moduleName}}.Update"; + public const string Delete = "{{moduleName}}.Delete"; + } + """; + private static string FallbackDbContextClass(string moduleName, string singularName) => $$""" using Microsoft.EntityFrameworkCore; diff --git a/docs/CONSTITUTION.md b/docs/CONSTITUTION.md index 66db73d9..3b56edab 100644 --- a/docs/CONSTITUTION.md +++ b/docs/CONSTITUTION.md @@ -306,7 +306,8 @@ Override `ConfigureEndpoints` on the module class for non-standard routes. ### Registering Permissions -- Override `ConfigurePermissions` on the module class and call `builder.AddPermissions()`. +- Auto-discovered by the source generator. Any class implementing `IModulePermissions` is registered automatically — no `ConfigurePermissions` override required. +- For manual control (rare), override `ConfigurePermissions` on the module class and call `builder.AddPermissions()`. ### Applying Permissions diff --git a/framework/.allowed-projects b/framework/.allowed-projects index b91f2315..18b8c98d 100644 --- a/framework/.allowed-projects +++ b/framework/.allowed-projects @@ -15,3 +15,4 @@ SimpleModule.Storage SimpleModule.Storage.Azure SimpleModule.Storage.Local SimpleModule.Storage.S3 +SimpleModule.Testing diff --git a/framework/SimpleModule.Hosting/ModuleGraphValidator.cs b/framework/SimpleModule.Hosting/ModuleGraphValidator.cs new file mode 100644 index 00000000..20a00cd0 --- /dev/null +++ b/framework/SimpleModule.Hosting/ModuleGraphValidator.cs @@ -0,0 +1,125 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SimpleModule.Hosting; + +/// +/// Boot-time validator that catches the most common "missing peer module" failure mode: +/// a module's SimpleModule.X.Contracts assembly is loaded (because some other +/// module references it) but no service satisfies the contract interface, meaning the +/// implementing SimpleModule.X package was never installed. +/// +#pragma warning disable CA1812 // Instantiated by DI as IHostedService +internal sealed class ModuleGraphValidator : IHostedService +#pragma warning restore CA1812 +{ + private const string SimpleModulePrefix = "SimpleModule."; + private const string ContractsSuffix = ".Contracts"; + + private readonly IServiceProviderIsService _isService; + private readonly ILogger _logger; + private readonly SimpleModuleOptions _options; + private readonly IHostEnvironment _environment; + + public ModuleGraphValidator( + IServiceProviderIsService isService, + ILogger logger, + SimpleModuleOptions options, + IHostEnvironment environment + ) + { + _isService = isService; + _logger = logger; + _options = options; + _environment = environment; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.ValidateModuleGraph) + { + return Task.CompletedTask; + } + + // In Production this check is too noisy — downstream apps may legitimately + // ship a contracts assembly without the impl (e.g. their own implementation). + if (_environment.IsProduction()) + { + return Task.CompletedTask; + } + + var unsatisfied = FindUnsatisfiedContracts(); + if (unsatisfied.Count == 0) + { + return Task.CompletedTask; + } + + foreach (var (contractFullName, suspectedModulePackage) in unsatisfied) + { + _logger.LogWarning( + "Module graph: contract {Contract} is referenced but no implementation is registered. The module providing it ({Package}) appears to be missing — add a PackageReference (or call its registration extension) to fix runtime resolution failures.", + contractFullName, + suspectedModulePackage + ); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private List<(string Contract, string Package)> FindUnsatisfiedContracts() + { + var unsatisfied = new List<(string Contract, string Package)>(); + + foreach (var assembly in EnumerateContractsAssemblies()) + { + var assemblyName = assembly.GetName().Name!; + var implPackage = assemblyName[..^ContractsSuffix.Length]; + + foreach (var iface in EnumerateContractInterfaces(assembly)) + { + if (!_isService.IsService(iface)) + { + unsatisfied.Add((iface.FullName ?? iface.Name, implPackage)); + } + } + } + + return unsatisfied; + } + + private static IEnumerable EnumerateContractsAssemblies() + { + return AppDomain + .CurrentDomain.GetAssemblies() + .Where(a => + { + var name = a.GetName().Name; + return name is not null + && name.StartsWith(SimpleModulePrefix, StringComparison.Ordinal) + && name.EndsWith(ContractsSuffix, StringComparison.Ordinal); + }); + } + + private static IEnumerable EnumerateContractInterfaces(Assembly assembly) + { + Type[] exported; + try + { + exported = assembly.GetExportedTypes(); + } + catch (ReflectionTypeLoadException) + { + return []; + } + + return exported.Where(t => + t.IsInterface + && t.Name.StartsWith('I') + && t.Name.EndsWith("Contracts", StringComparison.Ordinal) + ); + } +} diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index 09f6ed36..7c6339cf 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -134,6 +134,11 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddDevTools(); } + if (options.ValidateModuleGraph) + { + builder.Services.AddHostedService(); + } + return builder; } diff --git a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs index 5cdadce9..a3893d49 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleOptions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleOptions.cs @@ -14,6 +14,16 @@ public class SimpleModuleOptions public bool EnableDevTools { get; set; } = true; + /// + /// Logs a warning at startup for every SimpleModule contract interface + /// (e.g. IUsersContracts) that is in the assembly graph but has no + /// registered implementation — the typical signal that a peer module's + /// package is missing. + /// Defaults to true in non-Production environments and false otherwise so + /// production logs aren't polluted by intentionally-absent peers. + /// + public bool ValidateModuleGraph { get; set; } = true; + /// /// Content Security Policy overrides. Modules can append extra origins for /// directives like connect-src, img-src, etc. diff --git a/framework/SimpleModule.Testing/README.md b/framework/SimpleModule.Testing/README.md new file mode 100644 index 00000000..c31ce809 --- /dev/null +++ b/framework/SimpleModule.Testing/README.md @@ -0,0 +1,57 @@ +# SimpleModule.Testing + +Reusable integration-test helpers for SimpleModule applications. + +## What's in the box + +- `TestAuthHandler` — header-based test auth handler that turns a + semicolon-separated list of `type=value` claims into an authenticated + `ClaimsPrincipal`. +- `TestAuthDefaults` — scheme name (`TestScheme`) and header name + (`X-Test-Claims`) constants. +- `services.AddTestAuthentication()` — registers the handler as the default + authenticate/challenge scheme. +- `factory.CreateAuthenticatedClient(params Claim[])` and + `factory.CreateAuthenticatedClient(string[] permissions, params Claim[])` + extensions on `WebApplicationFactory` that produce HTTP clients + with the appropriate `X-Test-Claims` header. + +## Usage + +```csharp +using SimpleModule.Testing; + +public sealed class MyAppFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.AddTestAuthentication(); + }); + } +} + +public sealed class MyEndpointTests(MyAppFactory factory) : IClassFixture +{ + [Fact] + public async Task Authenticated_request_succeeds() + { + var client = factory.CreateAuthenticatedClient( + new Claim("permission", "Things.View") + ); + + var response = await client.GetAsync("/api/things"); + + response.EnsureSuccessStatusCode(); + } +} +``` + +This package is intentionally narrow: it ships only the auth-related plumbing. +Wire your own `WebApplicationFactory` for module-specific setup (DB swap, +hosted-service removal, etc.). + +## License + +MIT diff --git a/framework/SimpleModule.Testing/SimpleModule.Testing.csproj b/framework/SimpleModule.Testing/SimpleModule.Testing.csproj new file mode 100644 index 00000000..d2f75fd8 --- /dev/null +++ b/framework/SimpleModule.Testing/SimpleModule.Testing.csproj @@ -0,0 +1,14 @@ + + + net10.0 + Library + Reusable integration-test helpers for SimpleModule applications: header-based test auth scheme, TestAuthHandler, and CreateAuthenticatedClient extensions for WebApplicationFactory. + + + + + + + + + diff --git a/framework/SimpleModule.Testing/TestAuthDefaults.cs b/framework/SimpleModule.Testing/TestAuthDefaults.cs new file mode 100644 index 00000000..3e1a72be --- /dev/null +++ b/framework/SimpleModule.Testing/TestAuthDefaults.cs @@ -0,0 +1,7 @@ +namespace SimpleModule.Testing; + +public static class TestAuthDefaults +{ + public const string AuthenticationScheme = "TestScheme"; + public const string ClaimsHeader = "X-Test-Claims"; +} diff --git a/framework/SimpleModule.Testing/TestAuthExtensions.cs b/framework/SimpleModule.Testing/TestAuthExtensions.cs new file mode 100644 index 00000000..9124cc8e --- /dev/null +++ b/framework/SimpleModule.Testing/TestAuthExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace SimpleModule.Testing; + +public static class TestAuthExtensions +{ + /// + /// Registers the under + /// and makes it the + /// default authenticate/challenge scheme. Intended for use inside + /// WebApplicationFactory.ConfigureWebHost. + /// + public static AuthenticationBuilder AddTestAuthentication(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services + .AddAuthentication(options => + { + options.DefaultAuthenticateScheme = TestAuthDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = TestAuthDefaults.AuthenticationScheme; + }) + .AddScheme( + TestAuthDefaults.AuthenticationScheme, + _ => { } + ); + } +} diff --git a/framework/SimpleModule.Testing/TestAuthHandler.cs b/framework/SimpleModule.Testing/TestAuthHandler.cs new file mode 100644 index 00000000..8c1e0d59 --- /dev/null +++ b/framework/SimpleModule.Testing/TestAuthHandler.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace SimpleModule.Testing; + +/// +/// Authentication handler for integration tests. Reads claims from the +/// header (semicolon-separated +/// type=value pairs) and produces an authenticated . +/// Returns when the header is absent so +/// other authentication schemes still get a chance to run. +/// +public class TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder +) : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(TestAuthDefaults.ClaimsHeader, out var claimsHeader)) + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + + var claims = new List(); + foreach (var part in claimsHeader.ToString().Split(';')) + { + var kvp = part.Split('=', 2); + if (kvp.Length == 2) + { + claims.Add(new Claim(kvp[0], kvp[1])); + } + } + + var identity = new ClaimsIdentity(claims, TestAuthDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, TestAuthDefaults.AuthenticationScheme); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs new file mode 100644 index 00000000..aebb1abb --- /dev/null +++ b/framework/SimpleModule.Testing/WebApplicationFactoryAuthExtensions.cs @@ -0,0 +1,65 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Mvc.Testing; +using SimpleModule.Core.Authorization; + +namespace SimpleModule.Testing; + +public static class WebApplicationFactoryAuthExtensions +{ + /// + /// Creates an that carries the supplied claims as a + /// header, which the + /// turns into an authenticated principal. + /// A default is added if missing. + /// + public static HttpClient CreateAuthenticatedClient( + this WebApplicationFactory factory, + params Claim[] claims + ) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(claims); + + var client = factory.CreateClient(); + ApplyClaims(client, claims); + return client; + } + + /// + /// Convenience overload that adds each entry of + /// as a claim before applying any + /// . + /// + public static HttpClient CreateAuthenticatedClient( + this WebApplicationFactory factory, + string[] permissions, + params Claim[] additionalClaims + ) + where TEntryPoint : class + { + ArgumentNullException.ThrowIfNull(permissions); + ArgumentNullException.ThrowIfNull(additionalClaims); + + var combined = new List(additionalClaims.Length + permissions.Length); + combined.AddRange(additionalClaims); + foreach (var permission in permissions) + { + combined.Add(new Claim(WellKnownClaims.Permission, permission)); + } + return factory.CreateAuthenticatedClient(combined.ToArray()); + } + + private static void ApplyClaims(HttpClient client, IEnumerable claims) + { + var list = new List(claims); + if (!list.Exists(c => c.Type == ClaimTypes.NameIdentifier)) + { + list.Add(new Claim(ClaimTypes.NameIdentifier, "test-user-id")); + } + + var headerValue = string.Join(";", list.Select(c => $"{c.Type}={c.Value}")); + client.DefaultRequestHeaders.Remove(TestAuthDefaults.ClaimsHeader); + client.DefaultRequestHeaders.Add(TestAuthDefaults.ClaimsHeader, headerValue); + } +} diff --git a/modules/Permissions/src/SimpleModule.Permissions/README.md b/modules/Permissions/src/SimpleModule.Permissions/README.md index 9c3bb9b7..ec04eb1c 100644 --- a/modules/Permissions/src/SimpleModule.Permissions/README.md +++ b/modules/Permissions/src/SimpleModule.Permissions/README.md @@ -22,9 +22,56 @@ Or via .NET CLI: dotnet add package SimpleModule.Permissions ``` +## Defining Permissions in Your Own Modules + +Any module — including modules **you** write in a downstream app — can contribute +permissions. Permission classes are auto-discovered by the SimpleModule source +generator: you do not need to call `AddPermissions()` yourself. + +The convention is one sealed class per module implementing `IModulePermissions`, +containing only `public const string` fields named `Module.Action`: + +```csharp +using SimpleModule.Core.Authorization; + +namespace MyApp.Customers; + +public sealed class CustomersPermissions : IModulePermissions +{ + public const string View = "Customers.View"; + public const string Create = "Customers.Create"; + public const string Update = "Customers.Update"; + public const string Delete = "Customers.Delete"; +} +``` + +Apply them on endpoints using the `RequirePermission` extension method or the +`[RequirePermission]` attribute: + +```csharp +public sealed class CreateCustomerEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) => + app.MapPost("/", (CreateCustomerRequest request) => /* ... */) + .RequirePermission(CustomersPermissions.Create); +} +``` + +The role-edit UI groups permissions by the prefix before the first dot +(`Customers.View` → "Customers" group), so your custom permissions appear +alongside framework permissions automatically. + +If you scaffold modules with `sm new module `, a starter +`Permissions.cs` is generated for you. + +See the [Permissions guide](https://github.com/antosubash/SimpleModule/blob/main/docs/site/guide/permissions.md) +for the full authorization model (claims transformation, wildcard matching, +testing patterns). + ## Usage -The module is auto-discovered by the SimpleModule framework. Use `IPermissionsContracts` to check permissions from other modules. +The module is auto-discovered by the SimpleModule framework. Use +`IPermissionsContracts` to check permissions from other modules. ## License diff --git a/scripts/smoke-test-nupkg-consumer.mjs b/scripts/smoke-test-nupkg-consumer.mjs new file mode 100755 index 00000000..4767d4ea --- /dev/null +++ b/scripts/smoke-test-nupkg-consumer.mjs @@ -0,0 +1,198 @@ +#!/usr/bin/env node +// Real-consumer smoke test for packed module .nupkgs. +// +// Builds a throwaway ASP.NET Core app that PackageReferences the packed +// module .nupkgs from a local NuGet feed and asserts the consumer build +// receives the modules' static web assets through MSBuild props/targets. +// +// This catches the regression class where the .nupkg shipped fine on disk +// but the consumer's wwwroot pipeline didn't pick the assets up — invisible +// to the in-repo `template/SimpleModule.Host` because it consumes modules +// via ProjectReference. +// +// Usage: node scripts/smoke-test-nupkg-consumer.mjs [--version 0.0.0-ci] + +import { execFileSync, spawnSync } from 'node:child_process'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); + +const args = process.argv.slice(2); +const nupkgDir = args[0]; +if (!nupkgDir) { + console.error('Usage: smoke-test-nupkg-consumer.mjs [--version ]'); + process.exit(2); +} +const versionArgIdx = args.indexOf('--version'); +const packageVersion = versionArgIdx >= 0 ? args[versionArgIdx + 1] : '0.0.0-ci'; + +const absoluteNupkgDir = resolve(nupkgDir); +if (!existsSync(absoluteNupkgDir)) { + console.error(`nupkg dir not found: ${absoluteNupkgDir}`); + process.exit(2); +} + +// Discover UI-shipping modules the same way verify-nupkg-static-assets.mjs does. +const modulesDir = join(repoRoot, 'modules'); +const moduleDirs = readdirSync(modulesDir) + .map((name) => join(modulesDir, name, 'src')) + .filter((p) => existsSync(p) && statSync(p).isDirectory()) + .flatMap((srcDir) => + readdirSync(srcDir) + .map((name) => join(srcDir, name)) + .filter((p) => statSync(p).isDirectory()), + ); + +const uiModules = moduleDirs + .filter((dir) => { + const csproj = readdirSync(dir).find((f) => f.endsWith('.csproj')); + return ( + csproj && + existsSync(join(dir, 'package.json')) && + existsSync(join(dir, 'Pages', 'index.ts')) + ); + }) + .map((dir) => basename(dir)); + +if (uiModules.length === 0) { + console.error('No UI-shipping modules discovered under modules/.'); + process.exit(2); +} + +const consumerDir = mkdtempSync(join(tmpdir(), 'sm-smoke-consumer-')); +// Keep the global-packages folder *outside* the project tree so the SDK's +// default **/*.cs glob can't reach into it (e.g. ImTools ships content/*.cs +// that would otherwise duplicate-compile into the consumer). +const packagesDir = mkdtempSync(join(tmpdir(), 'sm-smoke-packages-')); +const cleanup = () => { + rmSync(consumerDir, { recursive: true, force: true }); + rmSync(packagesDir, { recursive: true, force: true }); +}; +process.on('exit', cleanup); +process.on('SIGINT', () => { + cleanup(); + process.exit(130); +}); + +console.log(`Smoke consumer at ${consumerDir}`); +console.log(`Local feed: ${absoluteNupkgDir}`); +console.log(`Package version: ${packageVersion}`); +console.log(`UI modules: ${uiModules.join(', ')}\n`); + +const packageRefs = uiModules + .map((id) => ` `) + .join('\n'); + +writeFileSync( + join(consumerDir, 'Consumer.csproj'), + ` + + net10.0 + enable + enable + + $(NoWarn);SM0001;SM0002;SM0003;SM0025;SM0028 + + +${packageRefs} + + +`, +); + +writeFileSync( + join(consumerDir, 'Program.cs'), + `var builder = WebApplication.CreateBuilder(args); +var app = builder.Build(); +app.MapGet("/", () => "ok"); +app.Run(); +`, +); + +writeFileSync( + join(consumerDir, 'nuget.config'), + ` + + + + + + + + + + +`, +); + +// Empty Directory.Build.props/targets to prevent inheriting repo-level config. +writeFileSync(join(consumerDir, 'Directory.Build.props'), '\n'); +writeFileSync(join(consumerDir, 'Directory.Build.targets'), '\n'); + +const run = (cmd, runArgs, opts = {}) => { + const result = spawnSync(cmd, runArgs, { + cwd: consumerDir, + stdio: 'inherit', + ...opts, + }); + if (result.status !== 0) { + console.error(`\nFAIL: ${cmd} ${runArgs.join(' ')} (exit ${result.status})`); + process.exit(result.status ?? 1); + } +}; + +console.log('--- dotnet restore ---'); +run('dotnet', ['restore', '--verbosity', 'minimal']); + +console.log('\n--- dotnet build ---'); +run('dotnet', ['build', '-c', 'Release', '--no-restore', '--verbosity', 'minimal']); + +// Verify the consumer's static web assets manifest references each module's pages.js. +const candidates = [ + join(consumerDir, 'obj', 'Release', 'net10.0', 'staticwebassets.build.json'), + join(consumerDir, 'obj', 'Release', 'net10.0', 'staticwebassets.build.endpoints.json'), + join(consumerDir, 'bin', 'Release', 'net10.0', 'Consumer.staticwebassets.runtime.json'), +]; + +const manifests = candidates.filter((p) => existsSync(p)); +if (manifests.length === 0) { + console.error('\nFAIL: no static-web-assets manifest produced by consumer build.'); + console.error(`Looked for:\n ${candidates.join('\n ')}`); + process.exit(1); +} + +const manifestText = manifests.map((p) => readFileSync(p, 'utf8')).join('\n'); + +let failures = 0; +for (const id of uiModules) { + const needle = `_content/${id}/${id}.pages.js`; + if (manifestText.includes(needle)) { + console.log(`OK ${id} → ${needle}`); + } else { + console.error(`FAIL ${id}: ${needle} not in any manifest`); + failures++; + } +} + +if (failures > 0) { + console.error(`\n${failures}/${uiModules.length} module(s) missing static asset entries in consumer manifest.`); + process.exit(1); +} + +console.log(`\nVerified ${uiModules.length} module package(s) consumed by a real PackageReference build.`); diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs index e71a8b88..ea615a87 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.Auth.cs @@ -1,8 +1,5 @@ using System.Security.Claims; -using System.Text.Encodings.Web; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; +using SimpleModule.Testing; namespace SimpleModule.Tests.Shared.Fixtures; @@ -13,77 +10,17 @@ public HttpClient CreateAuthenticatedClient( params Claim[] additionalClaims ) { - var claims = new List(additionalClaims); - foreach (var permission in permissions) - { - claims.Add(new Claim("permission", permission)); - } - return CreateAuthenticatedClient(claims.ToArray()); + EnsureDatabasesInitialized(); + return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient( + this, + permissions, + additionalClaims + ); } public HttpClient CreateAuthenticatedClient(params Claim[] claims) { EnsureDatabasesInitialized(); - var client = CreateClient(); - var claimsList = new List(claims); - - // Ensure there's always a Subject claim - if (!claimsList.Exists(c => c.Type == ClaimTypes.NameIdentifier)) - { - claimsList.Add(new Claim(ClaimTypes.NameIdentifier, "test-user-id")); - } - - // Encode claims as a header the test handler will read - var claimsValue = string.Join(";", claimsList.Select(c => $"{c.Type}={c.Value}")); - client.DefaultRequestHeaders.Add("X-Test-Claims", claimsValue); - - return client; - } -} - -public class TestAuthHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder -) : AuthenticationHandler(options, logger, encoder) -{ - protected override Task HandleAuthenticateAsync() - { - // Authenticate only test requests that include explicit test claims. - if (!Request.Headers.ContainsKey("X-Test-Claims")) - { - return Task.FromResult(AuthenticateResult.NoResult()); - } - - var claims = new List - { - new(ClaimTypes.NameIdentifier, "test-user-id"), - new(ClaimTypes.Name, "Test User"), - new(ClaimTypes.Email, "test@example.com"), - }; - - // Parse custom claims from header - if (Request.Headers.TryGetValue("X-Test-Claims", out var claimsHeader)) - { - claims.Clear(); - var parts = claimsHeader.ToString().Split(';'); - foreach (var part in parts) - { - var kvp = part.Split('=', 2); - if (kvp.Length == 2) - { - claims.Add(new Claim(kvp[0], kvp[1])); - } - } - } - - var identity = new ClaimsIdentity(claims, SimpleModuleWebApplicationFactory.TestAuthScheme); - var principal = new ClaimsPrincipal(identity); - var ticket = new AuthenticationTicket( - principal, - SimpleModuleWebApplicationFactory.TestAuthScheme - ); - - return Task.FromResult(AuthenticateResult.Success(ticket)); + return WebApplicationFactoryAuthExtensions.CreateAuthenticatedClient(this, claims); } } diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 3c4f4ed0..81a33157 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -23,14 +23,13 @@ using SimpleModule.RateLimiting; using SimpleModule.Settings; using SimpleModule.Tenants; +using SimpleModule.Testing; using SimpleModule.Users; namespace SimpleModule.Tests.Shared.Fixtures; public partial class SimpleModuleWebApplicationFactory : WebApplicationFactory { - public const string TestAuthScheme = "TestScheme"; - // Shared in-memory SQLite connection kept open for the lifetime of the factory private readonly SqliteConnection _connection = new("Data Source=:memory:"); @@ -93,13 +92,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ); // Add test authentication scheme that bypasses OpenIddict validation - services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = TestAuthScheme; - options.DefaultChallengeScheme = TestAuthScheme; - }) - .AddScheme(TestAuthScheme, _ => { }); + services.AddTestAuthentication(); services.PostConfigure( AuthConstants.SmartAuthPolicy, @@ -108,8 +101,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) var fallbackSelector = options.ForwardDefaultSelector; options.ForwardDefaultSelector = context => { - if (context.Request.Headers.ContainsKey("X-Test-Claims")) - return TestAuthScheme; + if (context.Request.Headers.ContainsKey(TestAuthDefaults.ClaimsHeader)) + return TestAuthDefaults.AuthenticationScheme; return fallbackSelector?.Invoke(context); }; diff --git a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj index b5bfbef2..73c9c7c6 100644 --- a/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj +++ b/tests/SimpleModule.Tests.Shared/SimpleModule.Tests.Shared.csproj @@ -13,6 +13,9 @@ + + +