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 @@
+
+
+