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
37 changes: 37 additions & 0 deletions .claude/commands/ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
Run all CI checks locally, mirroring the GitHub Actions CI pipeline. This catches issues before creating a PR.

Execute these steps in order, stopping on first failure. Report results as a summary table at the end.

## Step 1: Lint & Format Check

Run `npm run check` from the project root. This runs biome lint, format check, page validation, and i18n validation.

## Step 2: Frontend Build

Run `npm run build` from the project root. This builds all module frontend assets via Vite in production mode.

## Step 3: .NET Build

Run `dotnet build` from the project root. This compiles all .NET projects including source generators.

## Step 4: .NET Tests

Run `dotnet test --no-build` from the project root. This runs all unit and integration tests (excluding load tests which are filtered out in CI).

## Step 5: E2E Smoke Tests

Run `npm run test:smoke -w tests/e2e` to execute Playwright smoke tests. Playwright browsers are already installed.

## Reporting

After all steps complete (or on first failure), print a summary table:

| Step | Status |
|------|--------|
| Lint & Format | pass/fail |
| Frontend Build | pass/fail |
| .NET Build | pass/fail |
| .NET Tests | pass/fail |
| E2E Smoke Tests | pass/fail |

If any step fails, show the relevant error output and suggest a fix.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -427,5 +427,8 @@ template/SimpleModule.Host/Styles/_scan/

*.stamp

# k6 load test output
tests/k6/results/

# Website build output
website/dist/
86 changes: 85 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,90 @@ test-e2e-report: ## View Playwright HTML test report
.PHONY: test-all
test-all: test test-e2e ## Run all .NET and e2e tests

# ─── Load Testing (k6) ──────────────────────────

K6_DIR := tests/k6
K6 := k6 run --compatibility-mode=experimental_enhanced

.PHONY: k6-smoke
k6-smoke: ## Run k6 smoke test (health endpoints)
$(K6) $(K6_DIR)/scenarios/health.ts

.PHONY: k6-auth
k6-auth: ## Run k6 auth load test
$(K6) $(K6_DIR)/scenarios/auth.ts

.PHONY: k6-products
k6-products: ## Run k6 products CRUD load test
K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/products.ts

.PHONY: k6-orders
k6-orders: ## Run k6 orders CRUD load test
K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/orders.ts

.PHONY: k6-pages
k6-pages: ## Run k6 page builder load test
K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/pages.ts

.PHONY: k6-page-lifecycle
k6-page-lifecycle: ## Run k6 full page lifecycle test (publish, tags, templates)
$(K6) $(K6_DIR)/scenarios/page-lifecycle.ts

.PHONY: k6-settings
k6-settings: ## Run k6 settings and menu management load test
$(K6) $(K6_DIR)/scenarios/settings.ts

.PHONY: k6-users
k6-users: ## Run k6 user management CRUD load test
$(K6) $(K6_DIR)/scenarios/users.ts

.PHONY: k6-audit-logs
k6-audit-logs: ## Run k6 audit logs query, stats, and export test
$(K6) $(K6_DIR)/scenarios/audit-logs.ts

.PHONY: k6-files
k6-files: ## Run k6 file upload/download load test
$(K6) $(K6_DIR)/scenarios/file-storage.ts

.PHONY: k6-marketplace
k6-marketplace: ## Run k6 marketplace API load test (anonymous)
$(K6) $(K6_DIR)/scenarios/marketplace.ts

.PHONY: k6-jobs
k6-jobs: ## Run k6 background jobs load test
$(K6) $(K6_DIR)/scenarios/background-jobs.ts

.PHONY: k6-feature-flags
k6-feature-flags: ## Run k6 feature flags CRUD load test
$(K6) $(K6_DIR)/scenarios/feature-flags.ts

.PHONY: k6-tenants
k6-tenants: ## Run k6 tenants CRUD load test
$(K6) $(K6_DIR)/scenarios/tenants.ts

.PHONY: k6-mixed
k6-mixed: ## Run k6 mixed traffic load test (realistic simulation)
$(K6) $(K6_DIR)/scenarios/mixed.ts

.PHONY: k6-hotspots
k6-hotspots: ## Run k6 hotspot detection (all endpoints, sorted by latency)
@mkdir -p $(K6_DIR)/results
$(K6) $(K6_DIR)/scenarios/hotspots.ts

.PHONY: k6-stress
k6-stress: ## Run k6 stress test (mixed scenario, high load)
K6_PROFILE=stress $(K6) $(K6_DIR)/scenarios/mixed.ts

.PHONY: k6-spike
k6-spike: ## Run k6 spike test (sudden traffic burst)
K6_PROFILE=spike $(K6) $(K6_DIR)/scenarios/mixed.ts

.PHONY: k6-all
k6-all: k6-smoke k6-auth k6-products k6-orders k6-pages k6-page-lifecycle k6-settings k6-users k6-audit-logs k6-files k6-marketplace k6-jobs k6-feature-flags k6-tenants k6-mixed ## Run all k6 load test scenarios

.PHONY: load
load: k6-all ## Alias: run all k6 load tests

# ─── Database ────────────────────────────────────

.PHONY: db-reset
Expand Down Expand Up @@ -237,7 +321,7 @@ endef
help: ## Show this help
@printf "$(HELP_HEADER)"
@awk ' \
/^# .+ Setup|^# .+ Build|^# .+ Run|^# .+ Test|^# .+ Database|^# .+ Code Qual|^# .+ Code Gen|^# .+ CI|^# .+ Docker|^# .+ Clean|^# .+ Help/ { \
/^# .+ Setup|^# .+ Build|^# .+ Run|^# .+ Test|^# .+ Load Test|^# .+ Database|^# .+ Code Qual|^# .+ Code Gen|^# .+ CI|^# .+ Docker|^# .+ Clean|^# .+ Help/ { \
section = $$0; \
gsub(/^# [^A-Z]*/, "", section); \
gsub(/ [^A-Z]*$$/, "", section); \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
using System.Security.Claims;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using SimpleModule.Core;
using SimpleModule.OpenIddict.Contracts;
using SimpleModule.Permissions.Contracts;
using SimpleModule.Users.Contracts;
using static OpenIddict.Abstractions.OpenIddictConstants;

namespace SimpleModule.OpenIddict.Endpoints.Connect;

public class TokenEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app)
{
app.MapPost(ConnectRouteConstants.ConnectToken, (Delegate)HandleAsync)
.ExcludeFromDescription()
.AllowAnonymous();
}

private static async Task<IResult> HandleAsync(HttpContext context)
{
var request =
context.GetOpenIddictServerRequest()
?? throw new InvalidOperationException(AuthErrorMessages.OpenIdConnectRequestMissing);

if (!request.IsPasswordGrantType())
{
// Let OpenIddict handle non-password grants (auth code, refresh token)
return Results.Empty;
}

var userManager = context.RequestServices.GetRequiredService<
UserManager<ApplicationUser>
>();

var user = await userManager.FindByEmailAsync(request.Username!);
if (user is null || !await userManager.CheckPasswordAsync(user, request.Password!))
{
return Results.Forbid(
authenticationSchemes: [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme]
);
}

var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);

var userIdString = await userManager.GetUserIdAsync(user);
identity
.SetClaim(Claims.Subject, userIdString)
.SetClaim(Claims.Email, await userManager.GetEmailAsync(user) ?? string.Empty)
.SetClaim(Claims.Name, user.DisplayName);

var roles = await userManager.GetRolesAsync(user);
foreach (var role in roles)
{
identity.AddClaim(Claims.Role, role);
}

// Load permissions
var permissionContracts =
context.RequestServices.GetRequiredService<IPermissionContracts>();
var userContracts = context.RequestServices.GetRequiredService<IUserContracts>();
var userId = UserId.From(userIdString);

var roleIdMap = await userContracts.GetRoleIdsByNamesAsync(roles);

var allPermissions = await permissionContracts.GetAllPermissionsForUserAsync(
userId,
roleIdMap.Values.Select(id => RoleId.From(id))
);

foreach (var permission in allPermissions)
{
identity.AddClaim("permission", permission);
}

identity.SetScopes(
Scopes.OpenId,
Scopes.Profile,
Scopes.Email,
AuthConstants.RolesScope
);

foreach (var claim in identity.Claims)
{
claim.SetDestinations(GetDestinations(claim, identity));
}

var principal = new ClaimsPrincipal(identity);

return Results.SignIn(
principal,
authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme
);
}

private static IEnumerable<string> GetDestinations(Claim claim, ClaimsIdentity identity)
{
switch (claim.Type)
{
case Claims.Name:
yield return Destinations.AccessToken;
if (identity.HasScope(Scopes.Profile))
yield return Destinations.IdentityToken;
yield break;

case Claims.Email:
yield return Destinations.AccessToken;
if (identity.HasScope(Scopes.Email))
yield return Destinations.IdentityToken;
yield break;

case Claims.Role:
yield return Destinations.AccessToken;
if (identity.HasScope(AuthConstants.RolesScope))
yield return Destinations.IdentityToken;
yield break;

case Claims.Subject:
yield return Destinations.AccessToken;
yield return Destinations.IdentityToken;
yield break;

case "permission":
yield return Destinations.AccessToken;
yield break;

default:
yield return Destinations.AccessToken;
yield break;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config

options.AllowRefreshTokenFlow();

// Enable password grant in Development for load testing (k6, etc.)
if (configuration.GetValue<bool>("OpenIddict:AllowPasswordGrant"))
{
options.AllowPasswordFlow();
}

options
.SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize)
.SetTokenEndpointUris(ConnectRouteConstants.ConnectToken)
Expand Down Expand Up @@ -87,6 +93,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
options
.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableEndSessionEndpointPassthrough()
.EnableUserInfoEndpointPassthrough();
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ is not null
Requirements = { OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange },
};

// Allow password grant in Development for load testing (k6, etc.)
if (configuration.GetValue<bool>("OpenIddict:AllowPasswordGrant"))
{
descriptor.Permissions.Add(
OpenIddictConstants.Permissions.GrantTypes.Password
);
}

// Allow additional redirect URIs from configuration
var additionalRedirects = configuration
.GetSection(ConfigKeys.OpenIddictAdditionalRedirectUris)
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"packages/SimpleModule.UI",
"template/SimpleModule.Host/ClientApp",
"tests/e2e",
"tests/k6",
"docs/site",
"website"
],
Expand Down
3 changes: 3 additions & 0 deletions template/SimpleModule.Host/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"Database": {
"DefaultConnection": "Data Source=app.db"
},
"OpenIddict": {
"AllowPasswordGrant": true
},
"Logging": {
"LogLevel": {
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
Expand Down
Loading
Loading