Skip to content

Automated tests

Inventory of what the unit and integration suites pin. Every entry is at least one file under src/dotnet/Modgud.Tests.Unit/ or src/dotnet/Modgud.Api.Tests/.

Two test projects

ProjectPurposeRun timeNeeds Docker?
Modgud.Tests.UnitPure logic — pinning behaviour of helpers, evaluators, aggregates, flavors. No web host, no Marten, no Wolverine.~1 s test execution, ~3 s wall-clock with buildno
Modgud.Api.TestsIntegration — full WebApplicationFactory against a real Testcontainers Postgres. End-to-end HTTP through the actual middleware stack.~90 syes

Run commands:

bash
cd src/dotnet

# Fast feedback — recommended default during development
dotnet test Modgud.Tests.Unit

# Full integration suite (Docker must be running)
dotnet test Modgud.Api.Tests

# Both
dotnet test

Unit-test inventory

944 tests in Modgud.Tests.Unit (verified by dotnet test Modgud.Tests.Unit/, 2026-05-25 — current as of the OAuthApi- credential cut). The per-area numbers below are snapshots from earlier waves; if a row's count drifts, trust the file's own [Fact] count over this table.

Authorization slice

AreaFile(s)TestsWhat's pinned
Permission evaluationAuthorization/PermissionEvaluatorTests.cs152-segment <resource>:<action> matching inside an App's catalog, realm:admin realm-wide bypass, <resource>:admin resource-wide bypass, no cross-app/cross-resource leak, no substring match (oauth:admin does NOT cover oauth-client:read), null/empty argument guards
Resource registryResources/ResourceRegistryTests.cs16(appSlug, resource)-keyed registration, permission listing, case-sensitive lookup
Person principalAuthorization/Principals/PersonTests.cs12DisplayName fallback chain (Acronym → Name → AccountName → Id), whitespace-only-fields filter
Group principalAuthorization/Principals/GroupTests.cs15GetEmailsAsync over Shared / ExpandToMembers / Shared-without-Email-fallback, inactive/deleted/dangling-member skips, nested recursion, cycle detection (this test caught a real production bug — see commit b6b2dc3)
ServiceAccount principalAuthorization/Principals/ServiceAccountTests.cs4type discriminator, DisplayName, capability-interface set
App aggregate + projectionAuthorization/Apps/*Tests.cs11Create / Setters / Delete / Replay; IsSystem flag for the seeded modgud app
App slug rulesAuthorization/Apps/AppSlugRulesTests.csreserved set (realm, *, modgud), 3-63 chars, lowercase + digits + hyphens, no leading/trailing hyphen

Realms

AreaFile(s)TestsWhat's pinned
Realm slug grammarRealms/RealmSlugRulesTests.cs33length 3–63, leading letter, trailing letter/digit, lowercase + digits + hyphen, reserved-set with case-insensitive checks
Realm cache lookupRealms/RealmCacheLookupTests.cs13exact host match, localhost fallback to single active realm, multi-realm safety

OAuth domain + wire format

AreaFile(s)TestsWhat's pinned
OAuth Application aggregateOAuth/OAuthApplicationAggregateTests.cs14Create / Setters / Delete / Replay, AppIds n:m semantics + legacy-AppIdChanged replay
OAuth Scope aggregateOAuth/OAuthScopeAggregateTests.cs12Create / Setters / Delete / Replay; AppId null-vs-set
OAuth Api aggregateOAuth/OAuthApiAggregateTests.cs12Create / Setters / Enable+Disable / Delete / Replay; AppId 1:1 link
LoginProvider aggregateIdentity/LoginProviderAggregateTests.cs14Create / Setters / Delete / Replay (incl. Configuration defensive copy)
StandardScopes constant setOAuth/StandardScopesTests.cs7the seeded built-in scopes are stable
OAuth wire-format constantsOAuth/OAuthApplicationKeysTests.cs (25), OAuth/OAuthConstantsTests.cs (32), OAuth/ScopePropertyKeysTests.cs (7)64every permission prefix (scp:/gt:/rst:/ept:), grant-type strings (incl. RFC-8628 device-code URN), client/consent types, cocoar: setting + property keys, distinctness across namespaces
OAuthAdminMapping (extracted)Application/OAuthAdminMappingTests.cs70+BuildClientPermissions, grant-type round-trip, BuildClient* defaults + property survival, MapClient/MapScope, MapApiState (id-stringification, defensive list copies), MergeClientSettings/MergeClientProperties partial-PATCH semantics (omit-preserve / value-overwrite / list-replace / no-mutation), BCrypt hash+verify round-trip and malformed-hash safety
OAuth *StateProjection (3) + LoginProviderInfrastructure/Persistence/Marten/Projections/OAuth/*Tests.cs + LoginProviders/...Tests.cs54Create + every Apply + replay (incl. AccessTokenType case-sensitive parse bug pinning, AppIds n:m projection, AppId set/null/created-default for Scope + Api)

ClaimsTransformation library

AreaFile(s)TestsWhat's pinned
ModgudClaimsTransformationClient/AspNetCore/ModgudClaimsTransformationTests.cs12per-app role flattening from resource_access[<app>].roles to ClaimTypes.Role, cross-app isolation, malformed JSON tolerance, idempotence, anonymous short-circuit, AppSlug configuration validation

ExternalAuth (OIDC IdP federation)

AreaFile(s)TestsWhat's pinned
EntraId flavorExternalAuth/EntraIdFlavorTests.cs15identity, config schema, v2-authority shape, common multi-tenant alias, throws on missing TenantId
Generic OIDC flavorExternalAuth/GenericOidcFlavorTests.cs15identity, config schema, well-known suffix-strip incl. Keycloak realm path
Flavor registryExternalAuth/FlavorRegistryTests.cs10case-insensitive Get/TryGet, KeyNotFoundException with key listing, duplicate-key construction throws

Authentication slice

AreaFile(s)TestsWhat's pinned
Domain typesAuthentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ApplicationUser}Tests.cs51OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, session expiry, ApplicationUser default state
ExtensionsAuthentication/ExtensionMethods/{HttpContextExtensions, HttpRequestExtensions, ErrorOrExtensions}Tests.cs25tenant accessor on HttpContext, source-IP resolution incl. the X-Forwarded-For pinning bug, ErrorOr → ProblemDetails mapping
TwoFactorEnforcementMiddlewareAuthentication/Account/TwoFactorEnforcementMiddlewareTests.cs23whitelist paths, federated-MFA AMR detection, early-exit branches; DB branches unit-untested by design
Sessions / SessionTrackerAuthentication/Sessions/SessionTrackerTests.cs5best-effort tracking, swallows failures from ISessionService
Device info parsingSessions/DeviceInfoServiceTests.cs8Wangkanai.Detection mapping pins driven by a fake IDetectionService: browser/platform/device → DeviceInfo, "Others" collapse to "Unknown", version-zero collapse to null, defensive throw-swallow. Mac-Safari-as-Mobile pin gone (fix landed with the swap)
EmailOtpConfigurationAuthentication/Identity/EmailOtpConfigurationTests.cs2default values
TwoFactorHelper (extracted)Authentication/Account/Services/TwoFactorHelperTests.cs10BuildMethodsList order/conditions (TOTP/email-with-address-required/passkey count), TryExpireSetupGrace exempt-bypass + DueAt overwrite

Infrastructure + Api glue

AreaFile(s)TestsWhat's pinned
Email templates / In-memory serviceInfrastructure/Email/{EmailTemplateStore, InMemoryEmailService}Tests.cs20placeholder substitution + every template enum value, in-memory capture/recall/Clear
UserView mapper + projectionInfrastructure/Persistence/Marten/Mappers/UserViewMapperTests.cs (6) + .../Projections/Users/UserViewTests.cs (8)14DTO mapping, ShortGuid encoding, GetDisplayLabel fallback (incl. whitespace-pinning bug)
ViewRef recordInfrastructure/Persistence/Marten/Projections/ViewRefTests.cs5record value-equality
TenantConstantsInfrastructure/Persistence/Tenancy/TenantConstantsTests.cs3wire-format string contract: "system", "TenantId" HttpContext key
Tenant context middlewareApi/TenantContextMiddlewareTests.cs5sets IMessageBus.TenantId from HttpContext.Items["TenantId"], falls back to system, ignores non-string values
SignalR side-effect messagesInfrastructure/Events/SignalRSideEffectMessagesTests.cs6record shape + enum integer values (over-the-wire format)
ProjectionSideEffectsInfrastructure/Events/ProjectionSideEffectsTests.cs1smoke

Api features (extracted helpers)

AreaFile(s)TestsWhat's pinned
Consent-URL helperApi/Features/Auth/OAuth/ConsentUrlHelperTests.cs13ParseAuthorizationUrl on /connect/authorize URLs (params, missing, malformed-URI → null+[], NRE on null bubbles up to guard against catch-widening regressions), AppendErrorToUrl with proper URL encoding
Authorization-endpoint claim helpersApi/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs16GetDisplayName fallback chain (Firstname Lastname → UserName → Email), GetDestinations claim-type→token-target switch — name/preferred_username/given_name/family_name (profile scope), email/email_verified (email scope), role (roles scope), SecurityStamp suppressed, unknown-claim default to AccessToken
PaginationRequest.WithDefaultsApplication/PaginationRequestTests.cs6non-positive raw page/pageSize clamped to 1/20, valid passthrough, parameterless-ctor and clamp targets agree
Group-cycle detectorApi/Features/Admin/GroupCycleDetectorTests.cs10DetectCycles on linear / branching / no-cycle / self-loop / 2-node / 3-node cycles
Realms endpoint MapToDto + filterApi/Features/Admin/RealmsEndpointsTests.cs6RealmDto mapping, RequireControlPlaneFilter 404-on-missing
Auto-membership sync paths + ShouldSyncApi/Features/Groups/AutoMembershipSyncHandlersTests.cs18PrincipalPaths constants pinning, ShouldSync per handler (UserCreated, UserUpdated, UserDeleted, UserActivated/Deactivated, GroupCreated/Updated/Deleted)

Integration-test inventory

162 tests in Modgud.Api.Tests, all green against Testcontainers PostgreSQL (~3.5 min, Docker required; verified 2026-05-25 after the OAuthApi-credential cut). The per-folder breakdown below is a snapshot; if it drifts, dotnet test Modgud.Api.Tests/ --list-tests is authoritative.

FolderFilesWhat's covered
Users/1UserCRUD via the singular /api/user endpoint (not /api/admin/users)
Security/6AuthEnforcement (grace period, whitelist), MFA (TOTP), EmailOtp, MagicLink, ProfileSelfService (UserChangeRequest), OWASP Top 10 (see below)
Authorization/1PermissionResolutionTests — end-to-end gate tests against GET /api/user: BoundTo on/off/wildcard/wrong-app, role-AppSlug filter, bypass cascade (resource-admin, realm-admin), cross-app no-leak
ExternalAuth/6OIDC IdpConfig CRUD, ExternalLoginProcessor (JIT account creation + linking), DynamicOidcSchemeManager, FlavorRegistry, ExternalIdentityLink aggregate, UserUpdateScriptRunner (JsEval)
Principals/1PrincipalEmailResolver (group expansion)

OWASP Top 10 (2021)

Security/OwaspTop10Tests.cs, 12 tests, tagged with the xUnit trait OWASP=Top10. Run a focused pass with dotnet test --filter "OWASP=Top10".

CategoryTestsWhat's pinned
A01 Broken Access Control3All admin endpoints require auth (anon → 401), authentication alone is not enough (403 without permission), no horizontal escalation (regular user cannot read another user's sessions)
A02 Cryptographic Failures3Auth cookie is HttpOnly, no PasswordHash field in any user-detail response, login does not reveal user existence (identical 401 + body for unknown user vs. wrong password on existing user)
A03 Injection1SQL-injection payload in login username returns vanilla 401 — never crashes (Marten parameterises every query)
A05 Security Misconfiguration1Public-facing error responses do not leak .NET stack-trace markers or DB-driver internals
A07 Identification and Authentication Failures4Brute-force lockout after 5 failed attempts, weak passwords rejected by Identity, deactivated user cannot sign in (same 401 as unknown), forgot-password always returns 200 with byte-identical body for known and unknown users

A04 (Insecure Design), A06 (Vulnerable Components), A08 (Software and Data Integrity), A09 (Logging and Monitoring), A10 (SSRF) are addressed at the architecture / dependency / event-sourcing level rather than via assertable HTTP contracts; see the file's class-level comment for the rationale.

What we deliberately do NOT unit-test

These are listed so we don't have the same "should we test this?" conversation again.

  • Pure DTOs / records with no logicLoginProviderState, OAuth*State, Realm, SessionDtos, ProfileUpdateDto, UserChangeRequest, StoredPasskeyCredential, IdpConfig, ExternalIdentityLink, AuthLogDocument, UserDeletionState. The compiler is the test.
  • Pure enums + interfacesEmailMode, MembershipMode, IdpFlavor, IPrincipal, all IAuthSettings / IMagicLinkConfiguration / IServerConfiguration interfaces, IEmailService, IGlobalStore, IMasterConnectionString, ITenantSessionFactory, ISessionService, IDeviceInfoService. Nothing to assert.
  • Mapperly-generated mappers — generated code, no behaviour of ours.
  • External librariesCocoar.Json.Mutable's MutableJsonMerge, Cocoar.JsEval, BCrypt, Wangkanai.Detection. They have their own tests; we test our use of them, not them.
  • Heavy services with DB / JsEval / HTTP / DIOAuthAdminService (after full helper extraction in waves 2 + 4, the only remaining instance method is MapApiAsync which is a one-line DB-load wrapper around the pure MapApiState helper), MembershipEvaluator (Jint.Engine + JsExpressionTranslator on the membership-script path), RealmProvisioningService, RealmCache (lookup logic already extracted to RealmCacheLookup and tested), SmtpEmailService, PostmarkEmailService, AdminNotifier, EventSourcedUserStore, EmailOtpService, AuthLogPersistenceService, RecoveryCli. These belong in integration tests; they don't survive the no-Docker contract.
  • OpenIddict pipeline handlersAccessTokenTypeHandler, RealmIssuerHandler. Need OpenIddict server pipeline-context types not constructible without server DI.
  • Marten OpenIddict stores (×4) and OpenIddictExtensions, OAuthMartenSetup, OAuthRealmSeeder, MartenConfiguration, TenantedSessionFactory. Marten/DI heavy by design.
  • Setup / DI registrationDependencyInjection.cs, DependencyInjectionExtensions.cs, MartenStoreOptionsExtensions.cs. Wiring, no logic.
  • Minimal-API endpoint files (*Endpoints.cs) — need WebApplicationFactory. Tested at integration level if at all.

Refactors made for testability

These are pure-extractions made to enable unit-testing. None changed behaviour.

SourceWhat was extractedTest file
Modgud.Authorization/Services/PermissionService.csbypass logic → PermissionEvaluator.Evaluate(grants, permission) (static class), now 3-segment + 3 bypass tiersAuthorization/PermissionEvaluatorTests.cs
Modgud.Infrastructure/Realms/RealmProvisioningService.csslug regex + reserved set → Modgud.Domain.Realms.RealmSlugRulesRealms/RealmSlugRulesTests.cs
Modgud.Infrastructure/Realms/RealmCache.cshost-matching + localhost-fallback → Modgud.Infrastructure.Realms.RealmCacheLookupRealms/RealmCacheLookupTests.cs
Modgud.Application/Services/OAuthAdminService.cs16 private static helpers (mapping, permission building, BCrypt wrappers) → internal static OAuthAdminMapping. Service shrunk by 262 LoC. Wave 4 added MapApiState, MergeClientSettings/MergeClientProperties.Application/OAuthAdminMappingTests.cs
Modgud.Authentication/Api/Account/TwoFactorEnforcementMiddleware.csIsWhitelisted, HasFederatedMfa, FederatedMfaAmrValues lifted from private static to internal staticAuthentication/Account/TwoFactorEnforcementMiddlewareTests.cs
Modgud.Api/Features/Auth/OAuth/ConsentEndpoints.csParseAuthorizationUrl, AppendErrorToUrlinternal static ConsentUrlHelperApi/Features/Auth/OAuth/ConsentUrlHelperTests.cs
Modgud.Api/Features/Auth/OAuth/AuthorizationEndpoints.csGetDisplayName(user), GetDestinations(claim)internal static AuthorizationEndpointHelpersApi/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs
Modgud.Api/Features/Admin/ProjectionEndpoints.csDetectCycles, HasCycle, GroupRef, CycleReportinternal static GroupCycleDetectorApi/Features/Admin/GroupCycleDetectorTests.cs
Modgud.Api/Features/Admin/RealmsEndpoints.csMapToDto private→internalApi/Features/Admin/RealmsEndpointsTests.cs
Modgud.Authentication/Api/Account/Services/TwoFactorHelper.csBuildMethodsList(user, passkeyCount) and TryExpireSetupGrace(security, now)Authentication/Account/Services/TwoFactorHelperTests.cs

Production bugs found and fixed during the test sweep

The pattern: a test exposes the bug, the fix lands in the same wave, the pinning test is flipped to assert the corrected behaviour.

Wave 3 — Api/Features bug-fix pass

  • AuthorizationEndpoints.GetDestinations did not route given_name/family_name/email_verified into the id_token. The OIDC profile scope is supposed to deliver given_name / family_name in the id_token; the email scope is supposed to deliver email_verified. The principal-builder set those claims, but GetDestinations had no explicit cases for them — they fell into the default branch (AccessToken only) and never reached the id_token. Added explicit allow-listed cases for all three; three new pinning tests cover the new behaviour.
  • ProjectionEndpoints.MapPost("rebuild") race on a process-wide static. ProjectionSideEffects.Enabled is mutable static; two concurrent rebuilds could capture each other's interim false and permanently disable side effects. Now serialised behind a SemaphoreSlim(1,1) — the second caller gets a 409 Conflict.
  • ConsentUrlHelper.ParseAuthorizationUrl swallowed all exceptions. The bare catch masked programming errors (NRE, OOM, …) by turning them into "bad request" responses. Narrowed to catch (UriFormatException). New regression-guard test asserts NRE on null input bubbles up.

Wave 2 — Authorization / Authentication / Infrastructure / Api sweep

  • HttpRequestExtensions.FindSourceIp crashed on standard X-Forwarded-For comma-list (commit a2a4a61). Now splits on ,, trims, TryParses; silently skips garbage entries.
  • OAuthApplicationStateProjection parsed AccessTokenType case-sensitively (commit dab1883). Operator writing "jwt" silently fell back to Reference. Now ignoreCase: true.
  • Group.MemberIds interface accessor returned the live backing list (commit f676947). Defensive .ToArray() snapshot now; the result cannot be downcast to mutate the backing list.
  • UserView.GetDisplayLabel returned whitespace verbatim (commit bc5968f). Falls through to <no name> placeholder when nothing visible is set.

Polish from the same passes

  • UserSecurityData.RotateSecurityStamp() renamed → RotateAllStamps() (commit 9253771). The old name lied: it rotated both stamps. Both stamp-rotation methods got proper XML docs.
  • TwoFactorEnforcementMiddleware.HasFederatedMfa doc completed (commit 9253771). Now lists all seven recognised AMR values.
  • OAuthApplicationTypes constants centralised (commit 1b294e8). "web" / "native" now live alongside the sister classes OAuthClientTypes, OAuthConsentTypes. Sweep of bare literals is its own backlog item.
  • PaginationRequest.WithDefaults(page, pageSize) factory extracted. OAuthClientsEndpoints and OAuthApisEndpoints were inlining the same <= 0 ? default clamp logic. Helper now lives on the DTO; both endpoints call it; six new tests pin the clamp + that the parameterless ctor and the clamp targets agree.
  • RequireControlPlaneFilter now logs each early-return. 404 used to be silent — a future misrouted realm would look like a missing route. Now Log.Debug carries the reason ("no tenant info" / "realm '{Slug}' is not a management realm").
  • AutoMembershipOnUserUpdatedHandler.ShouldSync trade-off documented. The deliberate "trigger on Optional.HasValue even when the value didn't change" is now a code comment so a future cleanup doesn't optimise it back the wrong way.

Released under the Apache-2.0 License.