Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions SimpleModule.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<Project Path="framework/SimpleModule.Generator/SimpleModule.Generator.csproj" />
<Project Path="framework/SimpleModule.Database/SimpleModule.Database.csproj" />
<Project Path="framework/SimpleModule.Hosting/SimpleModule.Hosting.csproj" />
<Project Path="framework/SimpleModule.Testing/SimpleModule.Testing.csproj" />
<Project Path="framework/SimpleModule.Storage/SimpleModule.Storage.csproj" />
<Project Path="framework/SimpleModule.Storage.Local/SimpleModule.Storage.Local.csproj" />
<Project Path="framework/SimpleModule.Storage.Azure/SimpleModule.Storage.Azure.csproj" />
Expand Down
5 changes: 5 additions & 0 deletions cli/SimpleModule.Cli/Commands/New/NewModuleCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions cli/SimpleModule.Cli/Templates/ModuleTemplates.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`.
- 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<T>()`.

### Applying Permissions

Expand Down
1 change: 1 addition & 0 deletions framework/.allowed-projects
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ SimpleModule.Storage
SimpleModule.Storage.Azure
SimpleModule.Storage.Local
SimpleModule.Storage.S3
SimpleModule.Testing
125 changes: 125 additions & 0 deletions framework/SimpleModule.Hosting/ModuleGraphValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace SimpleModule.Hosting;

/// <summary>
/// Boot-time validator that catches the most common "missing peer module" failure mode:
/// a module's <c>SimpleModule.X.Contracts</c> assembly is loaded (because some other
/// module references it) but no service satisfies the contract interface, meaning the
/// implementing <c>SimpleModule.X</c> package was never installed.
/// </summary>
#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<ModuleGraphValidator> _logger;
private readonly SimpleModuleOptions _options;
private readonly IHostEnvironment _environment;

public ModuleGraphValidator(
IServiceProviderIsService isService,
ILogger<ModuleGraphValidator> 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<Assembly> 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<Type> 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)
);
}
}
5 changes: 5 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddDevTools();
}

if (options.ValidateModuleGraph)
{
builder.Services.AddHostedService<ModuleGraphValidator>();
}

return builder;
}

Expand Down
10 changes: 10 additions & 0 deletions framework/SimpleModule.Hosting/SimpleModuleOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ public class SimpleModuleOptions

public bool EnableDevTools { get; set; } = true;

/// <summary>
/// Logs a warning at startup for every SimpleModule contract interface
/// (e.g. <c>IUsersContracts</c>) 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.
/// </summary>
public bool ValidateModuleGraph { get; set; } = true;

/// <summary>
/// Content Security Policy overrides. Modules can append extra origins for
/// directives like <c>connect-src</c>, <c>img-src</c>, etc.
Expand Down
57 changes: 57 additions & 0 deletions framework/SimpleModule.Testing/README.md
Original file line number Diff line number Diff line change
@@ -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<TEntryPoint>` that produce HTTP clients
with the appropriate `X-Test-Claims` header.

## Usage

```csharp
using SimpleModule.Testing;

public sealed class MyAppFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
services.AddTestAuthentication();
});
}
}

public sealed class MyEndpointTests(MyAppFactory factory) : IClassFixture<MyAppFactory>
{
[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
14 changes: 14 additions & 0 deletions framework/SimpleModule.Testing/SimpleModule.Testing.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<Description>Reusable integration-test helpers for SimpleModule applications: header-based test auth scheme, TestAuthHandler, and CreateAuthenticatedClient extensions for WebApplicationFactory.</Description>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleModule.Core\SimpleModule.Core.csproj" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions framework/SimpleModule.Testing/TestAuthDefaults.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace SimpleModule.Testing;

public static class TestAuthDefaults
{
public const string AuthenticationScheme = "TestScheme";
public const string ClaimsHeader = "X-Test-Claims";
}
29 changes: 29 additions & 0 deletions framework/SimpleModule.Testing/TestAuthExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;

namespace SimpleModule.Testing;

public static class TestAuthExtensions
{
/// <summary>
/// Registers the <see cref="TestAuthHandler"/> under
/// <see cref="TestAuthDefaults.AuthenticationScheme"/> and makes it the
/// default authenticate/challenge scheme. Intended for use inside
/// <c>WebApplicationFactory.ConfigureWebHost</c>.
/// </summary>
public static AuthenticationBuilder AddTestAuthentication(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

return services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = TestAuthDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = TestAuthDefaults.AuthenticationScheme;
})
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthDefaults.AuthenticationScheme,
_ => { }
);
}
}
Loading
Loading