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
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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 }}'
42 changes: 20 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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
Expand Down Expand Up @@ -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<string, any> = {
"Products/Browse": () => import("./Browse"),
"Products/Manage": () => import("./Manage"),
"Products/Create": () => import("./Create"),
// modules/Tenants/src/SimpleModule.Tenants/Pages/index.ts
export const pages: Record<string, unknown> = {
'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:

Expand Down
27 changes: 25 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,30 @@ 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 trust your reverse proxy via a comma-separated
# ForwardedHeaders__KnownProxies=10.0.0.5,10.0.0.6 (or
# ForwardedHeaders__KnownNetworks=10.0.0.0/8). Without it X-Forwarded-For
# is ignored and per-IP rate limiting keys every client to the proxy IP.
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
Expand Down Expand Up @@ -39,6 +55,13 @@ services:
Database__DefaultConnection: "Host=postgres;Port=5432;Database=simplemodule;Username=simplemodule;Password=${POSTGRES_PASSWORD:-simplemodule}"
Database__Provider: PostgreSQL
BackgroundJobs__WorkerMode: Consumer
# The worker runs the same module set as the api, including the user
# seeder. It must see the SAME seed passwords — otherwise whichever
# process wins the startup race seeds the admin account, and a worker
# without these would silently seed the compiled-in default password,
# defeating the api's required SEED_ADMIN_PASSWORD.
Seed__AdminPassword: ${SEED_ADMIN_PASSWORD:?Set SEED_ADMIN_PASSWORD in .env}
Seed__UserPassword: ${SEED_USER_PASSWORD:-}
volumes:
- storage_data:/app/storage
depends_on:
Expand Down
7 changes: 4 additions & 3 deletions docs/CONSTITUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

---

Expand Down
34 changes: 34 additions & 0 deletions framework/SimpleModule.Core/Hosting/HostEnvironmentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Hosting;

namespace SimpleModule.Core.Hosting;

/// <summary>
/// Shared environment classification for the framework's fail-fast guards.
/// </summary>
public static class HostEnvironmentExtensions
{
/// <summary>
/// The well-known name for the test environment used by the in-process
/// <c>WebApplicationFactory</c> harnesses.
/// </summary>
public const string TestingEnvironmentName = "Testing";

/// <summary>
/// True for the developer-machine and CI/test environments — Development and
/// Testing — where compiled-in defaults (seed passwords, ephemeral signing
/// keys, the ROPC password grant) are acceptable conveniences.
/// <para>
/// Every other environment (Staging, QA, Production, or any custom name) is
/// treated as a real deployment: the security guards require explicit
/// configuration and refuse the unsafe defaults. Using a single predicate
/// keeps <c>UserSeedService</c> and <c>OpenIddictProductionGuard</c>
/// consistent — a deployment is never hardened by one guard and waved
/// through by the other.
/// </para>
/// </summary>
public static bool IsLocalOrTest(this IHostEnvironment environment)
{
ArgumentNullException.ThrowIfNull(environment);
return environment.IsDevelopment() || environment.IsEnvironment(TestingEnvironmentName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ public static partial class SimpleModuleHostExtensions
private const string ModuleContentPathPrefix = "/_content/";
private const string ModuleScriptExtension = ".mjs";

/// <summary>
/// Reads a config list that may be expressed either as a JSON/indexed array
/// (<c>ForwardedHeaders:KnownProxies:0</c>) or as a single comma-separated
/// scalar (<c>ForwardedHeaders__KnownProxies=10.0.0.5,10.0.0.6</c>, the form
/// natural for environment variables). Binding only the array form silently
/// dropped scalar env vars, leaving the proxy untrusted.
/// </summary>
private static string[] ReadConfigList(IConfigurationSection section, string key)
{
var array = section.GetSection(key).Get<string[]>();
if (array is { Length: > 0 })
{
return array;
}

var scalar = section[key];
return string.IsNullOrWhiteSpace(scalar)
? []
: scalar.Split(
',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
);
}

private static IResult RenderErrorPage(int statusCode)
{
var (title, message) = statusCode switch
Expand Down
51 changes: 47 additions & 4 deletions framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,49 @@ 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<bool>("TrustAllProxies"))
{
fhOptions.KnownIPNetworks.Clear();
fhOptions.KnownProxies.Clear();
return;
}

foreach (var proxy in ReadConfigList(section, "KnownProxies"))
{
if (!System.Net.IPAddress.TryParse(proxy, out var address))
{
throw new InvalidOperationException(
$"ForwardedHeaders:KnownProxies contains '{proxy}', which is not a valid "
+ "IP address."
);
}

fhOptions.KnownProxies.Add(address);
}

foreach (var network in ReadConfigList(section, "KnownNetworks"))
{
if (!System.Net.IPNetwork.TryParse(network, out var ipNetwork))
{
throw new InvalidOperationException(
$"ForwardedHeaders:KnownNetworks contains '{network}', which is not a valid "
+ "CIDR network (e.g. 10.0.0.0/8)."
);
}

fhOptions.KnownIPNetworks.Add(ipNetwork);
}
});

builder.Services.AddProblemDetails();
Expand Down Expand Up @@ -139,7 +179,10 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
// cleared by `sm up`. Resolved as singleton because it caches state
// for a short interval.
builder.Services.Configure<MaintenanceModeOptions>(_ => { });
builder.Services.TryAddSingleton<IMaintenanceStateProvider, FileSystemMaintenanceStateProvider>();
builder.Services.TryAddSingleton<
IMaintenanceStateProvider,
FileSystemMaintenanceStateProvider
>();

if (options.EnableHealthChecks)
{
Expand Down
Loading
Loading