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
| Project | Purpose | Run time | Needs Docker? |
|---|---|---|---|
Modgud.Tests.Unit | Pure logic — pinning behaviour of helpers, evaluators, aggregates, flavors. No web host, no Marten, no Wolverine. | ~1 s test execution, ~3 s wall-clock with build | no |
Modgud.Api.Tests | Integration — full WebApplicationFactory against a real Testcontainers Postgres. End-to-end HTTP through the actual middleware stack. | ~6-7 min | yes |
Run commands:
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 testUnit-test inventory
1283 tests in Modgud.Tests.Unit (dotnet test Modgud.Tests.Unit). The per-area numbers below are snapshots and will drift as tests are added; if a row's count is off, trust the file's own [Fact] count over this table.
Authorization slice
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Permission evaluation | Authorization/PermissionEvaluatorTests.cs | 15 | 2-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 registry | Resources/ResourceRegistryTests.cs | 16 | (appSlug, resource)-keyed registration, permission listing, case-sensitive lookup |
| Person principal | Authorization/Principals/PersonTests.cs | 12 | DisplayName fallback chain (Acronym → Name → AccountName → Id), whitespace-only-fields filter |
| Group principal | Authorization/Principals/GroupTests.cs | 15 | GetEmailsAsync over Shared / ExpandToMembers / Shared-without-Email-fallback, inactive/deleted/dangling-member skips, nested recursion, cycle detection (a group graph with a cycle must not infinite-loop or stack-overflow) |
| ServiceAccount principal | Authorization/Principals/ServiceAccountTests.cs | 4 | type discriminator, DisplayName, capability-interface set |
| App aggregate + projection | Authorization/Apps/*Tests.cs | 11 | Create / Setters / Delete / Replay; IsSystem flag for the seeded modgud app |
| App slug rules | Authorization/Apps/AppSlugRulesTests.cs | reserved set (realm, *, modgud), 3-63 chars, lowercase + digits + hyphens, no leading/trailing hyphen | |
| Membership-script JsEval security (adversarial) | Authorization/MembershipSecurityTests.cs | 30+ | JsEval threat-model suite, six test groups mirroring attacker classes A1-A6: A1 resource exhaustion/DoS (depth/length caps, transpile+eval timeouts), A2 native-host escape (eval/Function/globalThis/require/import rejected at translation, dangerous globals removed, prototype pollution neutralized, DocumentSession not reachable), A3 type confusion (BigInt/NaN/overflow/negative-zero), A4 cross-tenant probe (unknown property/identifier rejected at translation), A5 SQL-injection via LINQ (metacharacters stay literal constants, malformed GUID input rejected), A6 information disclosure (translator errors never leak Acornima internals or stack traces) |
Realms
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Realm slug grammar | Realms/RealmSlugRulesTests.cs | 33 | length 3–63, leading letter, trailing letter/digit, lowercase + digits + hyphen, reserved-set with case-insensitive checks |
| Realm cache lookup | Realms/RealmCacheLookupTests.cs | 13 | exact host match, localhost fallback to single active realm, multi-realm safety |
OAuth domain + wire format
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| OAuth Application aggregate | OAuth/OAuthApplicationAggregateTests.cs | 14 | Create / Setters / Delete / Replay, AppIds n:m semantics + legacy-AppIdChanged replay |
| OAuth Scope aggregate | OAuth/OAuthScopeAggregateTests.cs | 12 | Create / Setters / Delete / Replay; AppId null-vs-set |
| OAuth Api aggregate | OAuth/OAuthApiAggregateTests.cs | 12 | Create / Setters / Enable+Disable / Delete / Replay; AppId 1:1 link |
| LoginProvider aggregate | Identity/LoginProviderAggregateTests.cs | 14 | Create / Setters / Delete / Replay (incl. Configuration defensive copy) |
| StandardScopes constant set | OAuth/StandardScopesTests.cs | 7 | the seeded built-in scopes are stable |
| OAuth wire-format constants | OAuth/OAuthApplicationKeysTests.cs (25), OAuth/OAuthConstantsTests.cs (32), OAuth/ScopePropertyKeysTests.cs (7) | 64 | every 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.cs | 70+ | 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) + LoginProvider | Infrastructure/Persistence/Marten/Projections/OAuth/*Tests.cs + LoginProviders/...Tests.cs | 54 | Create + every Apply + replay (incl. AccessTokenType case-sensitive parse bug pinning, AppIds n:m projection, AppId set/null/created-default for Scope + Api) |
Resource-server library
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Scheme-local claims projection | ResourceServer/ModgudClaimsProjectorTests.cs, ResourceServerRegistrationTests.cs | — | audience isolation, role/permission projection, malformed JSON tolerance, idempotence, simultaneous JWT + introspection registration, startup validation, and absence of global claims transformation |
| Permission metadata | ResourceServer/ModgudPermissionExtensionsTests.cs | — | exact permission policy on endpoints and route groups |
| Reference-token introspection | ResourceServer/IntrospectionHandlerTests.cs | — | active/audience checks, claim construction, malformed response rejection, and scheme-local projection |
ExternalAuth (OIDC IdP federation)
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| EntraId flavor | ExternalAuth/EntraIdFlavorTests.cs | 15 | identity, config schema, v2-authority shape, common multi-tenant alias, throws on missing TenantId |
| Generic OIDC flavor | ExternalAuth/GenericOidcFlavorTests.cs | 15 | identity, config schema, well-known suffix-strip incl. Keycloak realm path |
| Flavor registry | ExternalAuth/FlavorRegistryTests.cs | 10 | case-insensitive Get/TryGet, KeyNotFoundException with key listing, duplicate-key construction throws |
Authentication slice
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Domain types | Authentication/Domain/{EmailOtpChallenge, MagicLinkChallenge, UserSecurityData, UserSession, ClientSession, ApplicationUser}Tests.cs | — | OTP/Magic-Link expiry + match semantics, security-stamp rotation asymmetry, browser/native session expiry, ApplicationUser default state |
| Extensions | Authentication/ExtensionMethods/{HttpContextExtensions, HttpRequestExtensions, ErrorOrExtensions}Tests.cs | 25 | tenant accessor on HttpContext, source-IP resolution incl. the X-Forwarded-For pinning bug, ErrorOr → ProblemDetails mapping |
| TwoFactorEnforcementMiddleware | Authentication/Account/TwoFactorEnforcementMiddlewareTests.cs | 23 | whitelist paths, federated-MFA AMR detection, early-exit branches; DB branches unit-untested by design |
| Session policy + mapping | Applications/EffectiveSettingsTests.cs, Application/OAuthAdminMappingTests.cs | — | realm/application/client lifetime precedence, bounds, and API mapping |
| Device info parsing | Sessions/DeviceInfoServiceTests.cs | 8 | Wangkanai.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) |
| EmailOtpConfiguration | Authentication/Identity/EmailOtpConfigurationTests.cs | 2 | default values |
| TwoFactorHelper (extracted) | Authentication/Account/Services/TwoFactorHelperTests.cs | 10 | BuildMethodsList order/conditions (TOTP/email-with-address-required/passkey count), TryExpireSetupGrace exempt-bypass + DueAt overwrite |
Infrastructure + Api glue
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Email templates / In-memory service | Infrastructure/Email/{EmailTemplateStore, InMemoryEmailService}Tests.cs | 20 | placeholder substitution + every template enum value, in-memory capture/recall/Clear |
| UserView mapper + projection | Infrastructure/Persistence/Marten/Mappers/UserViewMapperTests.cs (6) + .../Projections/Users/UserViewTests.cs (8) | 14 | DTO mapping, ShortGuid encoding, GetDisplayLabel fallback (incl. whitespace-pinning bug) |
| ViewRef record | Infrastructure/Persistence/Marten/Projections/ViewRefTests.cs | 5 | record value-equality |
| TenantConstants | Infrastructure/Persistence/Tenancy/TenantConstantsTests.cs | 3 | wire-format string contract: "system", "TenantId" HttpContext key |
| Tenant context middleware | Api/TenantContextMiddlewareTests.cs | 5 | sets IMessageBus.TenantId from HttpContext.Items["TenantId"], falls back to system, ignores non-string values |
| SignalR side-effect messages | Infrastructure/Events/SignalRSideEffectMessagesTests.cs | 6 | record shape + enum integer values (over-the-wire format) |
| ProjectionSideEffects | Infrastructure/Events/ProjectionSideEffectsTests.cs | 1 | smoke |
Api features (extracted helpers)
| Area | File(s) | Tests | What's pinned |
|---|---|---|---|
| Consent-URL helper | Api/Features/Auth/OAuth/ConsentUrlHelperTests.cs | 13 | ParseAuthorizationUrl 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 helpers | Api/Features/Auth/OAuth/AuthorizationEndpointHelpersTests.cs | 16 | GetDisplayName 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.WithDefaults | Application/PaginationRequestTests.cs | 6 | non-positive raw page/pageSize clamped to 1/20, valid passthrough, parameterless-ctor and clamp targets agree |
| Group-cycle detector | Api/Features/Admin/GroupCycleDetectorTests.cs | 10 | DetectCycles on linear / branching / no-cycle / self-loop / 2-node / 3-node cycles |
| Realms endpoint MapToDto + filter | Api/Features/Admin/RealmsEndpointsTests.cs | 6 | RealmDto mapping, RequireControlPlaneFilter 404-on-missing |
| Auto-membership sync paths + ShouldSync | Api/Features/Groups/AutoMembershipSyncHandlersTests.cs | 18 | PrincipalPaths constants pinning, ShouldSync per handler (UserCreated, UserUpdated, UserDeleted, UserActivated/Deactivated, GroupCreated/Updated/Deleted) |
Integration-test inventory
485 tests in Modgud.Api.Tests, all green against Testcontainers PostgreSQL (~6-7 min, Docker required). The per-folder breakdown below is a snapshot and will drift as tests are added; dotnet test Modgud.Api.Tests --list-tests is authoritative.
| Folder | Files | What's covered |
|---|---|---|
Users/ | 5 | User CRUD via the singular /api/user endpoint, account lifecycle (soft-delete/recycle-bin/sweep), GDPR deletion settings, configurable required registration fields |
Security/ | 20 | AuthEnforcement (grace period, whitelist), MFA (TOTP), EmailOtp, MagicLink, ProfileSelfService (UserChangeRequest), OWASP Top 10 (see below), security-stamp/session revocation on password/2FA/credential changes, control-plane transfer + separation, passkey hardening, service-account revocation |
Authorization/ | 37 | End-to-end permission gating (PermissionResolutionTests), plus the newer feature surfaces: invite-code self-registration, per-realm auth rate limits, CIMD, native cookieless grants (OTP/magic-link/passkey, incl. per-client WebAuthn RP-ID), the device authorization flow, dynamic client registration, OIDC federation (issuer anchoring, first-signal consistency), application settings + the settings cascade, signing-key rotation, roles/groups endpoint robustness |
ColdStart/ | 15 | Full-process boot + declarative realm provisioning: cold-start bootstrap, login/magic-link/passkey contracts, realm create/hard-delete, manifest export/apply/parity, the provisioning test kit, recovery CLI commands |
ExternalAuth/ | 14 | OIDC/SAML LoginProvider CRUD and logout boundaries, ExternalLoginProcessor (JIT account creation + linking), dynamic provider managers, flavor registries, ExternalIdentityLink lifecycle, UserUpdateScriptRunner (JsEval), federation |
Admin/ | 1 | Projection-rebuild endpoint |
Audit/ | 5 | Audit endpoint, GDPR erasure survival in the audit trail, auth-audit-view projection, login-failure-streak emission, security audit store |
Observability/ | 1 | OpenTelemetry log redaction |
Principals/ | 1 | PrincipalEmailResolver (group expansion) |
Infrastructure/ | 10 | Shared test fixtures and harnesses (Testcontainers Postgres fixture, cold-start web-application factory, CLI harness, software WebAuthn authenticator) — not test classes themselves |
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".
| Category | Tests | What's pinned |
|---|---|---|
| A01 Broken Access Control | 3 | All 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 Failures | 3 | Auth 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 Injection | 1 | SQL-injection payload in login username returns vanilla 401 — never crashes (Marten parameterises every query) |
| A05 Security Misconfiguration | 1 | Public-facing error responses do not leak .NET stack-trace markers or DB-driver internals |
| A07 Identification and Authentication Failures | 4 | Device-aware throttling after repeated failures (strangers exhaust the untrusted pool, the owner's trusted browser keeps working), 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 logic —
LoginProviderState,OAuth*State,Realm,SessionDtos,ProfileUpdateDto,UserChangeRequest,StoredPasskeyCredential,IdpConfig,ExternalIdentityLink,AuthLogDocument,UserDeletionState. The compiler is the test. - Pure enums + interfaces —
EmailMode,MembershipMode,IdpFlavor,IPrincipal, allIAuthSettings/IMagicLinkConfiguration/IServerConfigurationinterfaces,IEmailService,IGlobalStore,IMasterConnectionString,ITenantSessionFactory,ISessionService,IDeviceInfoService. Nothing to assert. - Mapperly-generated mappers — generated code, no behaviour of ours.
- External libraries —
Cocoar.Json.Mutable'sMutableJsonMerge,Cocoar.JsEval, BCrypt, Wangkanai.Detection. They have their own tests; we test our use of them, not them. - Heavy services with DB / JsEval / HTTP / DI —
OAuthAdminService(after the pure-helper extraction above, the only remaining instance method isMapApiAsync, a one-line DB-load wrapper around the pureMapApiStatehelper),MembershipEvaluator(Jint.Engine + JsExpressionTranslator on the membership-script path),RealmProvisioningService,RealmCache(lookup logic already extracted toRealmCacheLookupand tested),SmtpEmailService,PostmarkEmailService,AdminNotifier,EventSourcedUserStore,EmailOtpService,AuthLogPersistenceService,RecoveryCli. These belong in integration tests; they don't survive the no-Docker contract. - OpenIddict pipeline handlers —
AccessTokenTypeHandler,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 registration —
DependencyInjection.cs,DependencyInjectionExtensions.cs,MartenStoreOptionsExtensions.cs. Wiring, no logic. - Minimal-API endpoint files (
*Endpoints.cs) — needWebApplicationFactory. Tested at integration level if at all.
Several of the source files listed above (PermissionEvaluator, RealmSlugRules, RealmCacheLookup, OAuthAdminMapping, ConsentUrlHelper, AuthorizationEndpointHelpers, GroupCycleDetector, TwoFactorHelper, …) started life as private helpers inside a larger service and were pulled out to internal static so they could be unit-tested directly, with no behaviour change. That's why the test-file column above often names a small static helper class rather than the endpoint or service file itself.