Imported from jmbilizi/real-estate-platform (
apps/services/account-service/AGENTS.md). Install upstream withnpx skills add jmbilizi/real-estate-platform --skill account-service. Copyright stays with the author.
Account Service (account-service)
ASP.NET Core Minimal APIs + ASP.NET Identity + PostgreSQL/EF Core — identity, auth, RBAC, API keys,
audited profiles for the whole platform. Nx project name: account-service. See its README.md
for service-specific detail.
Commands
pnpm run account:serve # nx serve account-service
pnpm exec nx test account-service # Runs companion Tests/ project
pnpm exec nx build account-service # Also: lint, type-check, format
Notes
-
Endpoints live in
Routes/(Minimal APIs), EF Core migrations inMigrations/, entities inModels/, DTOs inDtos/. -
Tests use the companion
Tests/subfolder convention (.Tests.csprojinside this project dir, not a separate Nx project) — see rootAGENTS.md§ .NET Companion Test Convention. -
Auth schemes (PRD §11.1): cookie (
Identity.Application), opaque bearer (Identity.Bearer, not JWT), and SHA-256-hashed API keys. -
Six-tier staff RBAC is hierarchical; domain roles are additive facets — never model them as exclusive personas (PRD §11.2).
-
Only a verified code creates an account.
POST /account/signup/completeis the only endpoint that creates anApplicationUser, and it creates it confirmed. Identity's/register,/confirmEmail,/resendConfirmationEmail,/forgotPasswordand/resetPassworddo not exist (404, absent from OpenAPI). Do not add a route that creates a user.Tests/Integration/AccountCreationSurfaceTests.cslists every endpoint, fails on an unlisted one, probes each with a registration body, and checks that those five paths answer404. -
Identity keeps
login,refreshandmanage/*. They stayMapIdentityApi's own handlers.Routes/IdentityEndpoints.csmaps Identity into a detached route builder and hides the five retired routes before the app sees them. Hand-mapping the kept endpoints would copy Identity's cookie and bearer handlers.POST /manage/infowith a new email answers400: an email change runs by code (see Email change).POST /manage/infowith a new password runsPasswordChangeServicethroughPasswordChangeFilter, not the framework branch. The current password is required and counts toward the lock, and the new stamp ends every other session. -
The flows are code flows. Each one sends a 6-digit code by email (
Helpers/EmailCodeService.cs) and answers the same for every address.Flow Endpoints Sign-up /account/signup/{start,verify,resend,change-email,complete}Sign-in step /account/identify, then/account/loginor the sign-up flowReset /account/password/reset/{start,verify,complete}Email change /account/email/change/{start,verify}Secure POST /account/secureEach flow has its own section below.
-
Security notices. After a reset, a password change or an email change,
Helpers/SecurityNoticeService.csmails the address the account had BEFORE the change. It skips a suppressed address, never blocks the action, and logs the kind only. Each notice carries a single-useSecureAccountTokenstoken (hash stored, 7 day life) for the "This wasn't me" link. The link isAccountRecovery:WebBaseUrl+AccountRecovery:SecureAccountPath. -
What this service adds on top of Identity's handlers. Identity ships
loginwith no timing equalisation.Helpers/AccountRecoveryThrottleFilter.csis one endpoint filter over the Identity group and over every code route. It discriminates on the bound argument type (LoginRequest,SignUpStartRequestand so on) rather than the path:GET /manage/infohas no DTO, so an index-0 cast would throw there. Policy lives inConfiguration/AccountRecoveryOptions.cs(AccountRecoverysection).Models/AppUserManager.csreports a soft-deleted account as unconfirmed, andClearLockoutAsyncreleases a lockout after a reset. -
Every code flow answers the same for registered and unregistered addresses, including the timing. The body parity is in the services. The floor
AccountRecovery:MinimumResponseDurationis in the filter. Anything added to these paths has to keep both. Each flow's test file has the parity tests. -
Mail sends through Postmark. Sending is queued, not inline:
Helpers/PostmarkDeliveryQueue.csis both theIOutboundEmailSenderevery composed message goes through and the background service that drains it, so no response time depends on Postmark, and no retry blocks a request.PostmarkOptions.IsConfiguredis the fail-closed check: the committed secret placeholder (infra/k8s/base/secrets/postmark.secret.yaml) reads as "not configured", and the queue discards the message with a loud warning (event1370) rather than attempting delivery. Accepted, rejected and exhausted-retry outcomes log events1371/1372/1373, keyed byEmailKind, never the code, the link or the body. -
A soft-deleted account cannot sign in, and the refusal is central.
Models/AppSignInManager.csoverridesCanSignInAsync(every sign-in path) andValidateSecurityStampAsync(cookie and/account/refresh). Do not move this behindRequireConfirmedAccount, which is a separate switch. Tests:Tests/Integration/DeletedAccountSignInTests.cs. -
CPM: versionless
<PackageReference>; runpnpm run nx:resetafter project structure changes.
Identity surface (login, refresh, manage)
- Identity's kept endpoints under
/accountstay the framework's. Do not wrap them. Behaviour attaches as filters over the group (Helpers/AccountRecoveryThrottleFilter.cs,Helpers/IdentityResponseShapingFilter.cs) and as options. No setting turns email confirmation on or off: every account is born confirmed. - Delivery seam:
Helpers/IOutboundEmailSender.cs(implemented byPostmarkDeliveryQueue) is the one transport for the code emails, the notices and the already-registered notice. Every message composes throughHelpers/IdentityEmailComposer.cs.Helpers/PostmarkEmailSender.csis theIEmailSender<ApplicationUser>thatMapIdentityApirequires. Every member throws, because nothing may send an Identity link or code. - Sender identity (section
Email): from"Cribstop.com" <no-reply@cribstop.com>, reply-tocontact@cribstop.com. Startup validation refuses a reply-to equal to the from address.Email:BrokerageDisclosureends every body (PRD §6). It is configuration, so a jurisdiction change needs no code change. - Non-enumeration is a product requirement, not stock Identity. Do not "fix" it back:
/loginanswersNotAllowedandLockedoutasFailed.RequiresTwoFactorstays. - Rate limits live in
Helpers/AccountRecoveryRateLimiter.cs, consulted before any account lookup. Each code flow has a send limit and a verify limit per client address, plus per-email limits from the code engine. Caller identity isX-Real-IP(see #143).429withRetry-After. - Timing floor
AccountRecovery:MinimumResponseDuration(250ms) applies to/loginand to every code endpoint. - Tests:
Tests/Integration/IdentityLoginEndpointTests.cs(login).AccountRecoveryFactorygives one host per test and records sent messages. The base factory lifts the limits and the floor.AuthHelper.SeedUserAsynccreates a confirmed account for a test.
Email code engine (#650)
Helpers/EmailCodeService.csissues, verifies and voids 6-digit codes. Sign-up calls it (see Sign-up below). Purposes:EmailCodePurpose(SignUp,PasswordReset,EmailChangeNew,EmailChangeOld). Policy:Configuration/EmailCodeOptions.cs(EmailCodessection).- Tables:
EmailCodes(keyed HMAC of the code, never the code) andEmailCodeThrottles(wrong tries and lock per email and purpose). The throttle row is separate so a resend does not reset it. - Resend caps count
EmailCodesrows. The purge keeps a spent row for 24 hours for that reason. - Limits return a result with
RetryAfterSeconds, never an exception. A caller maps them to429. - The HMAC key is
ACCOUNT_SERVICE_EMAIL_CODE_HMAC_KEY(flat env var). With no usable key, every call returnsUnavailableand startup logs event1380. Only Development and Testing use a fixed dev key. - The code message logs by
EmailKindonly.PostmarkDeliveryQueueredacts the recipient for it. - A striped in-process lock serializes one email and purpose. Two replicas can still race.
Sign-up before an account exists (#652)
- Endpoints (anonymous,
Routes/SignUp.cs):POST /account/signup/start,/verify,/resend,/change-email. The gateway catch-all/account/{everything}already routes them. - Logic is in
Helpers/SignUpService.cs. A pending sign-up is aPendingRegistrationsrow: no password, noApplicationUser, expiry 30 minutes after the last code (SignUpoptions section).PendingRegistrationPurgeServicedeletes expired rows. #654 creates the account. /verifyreturnssignupProof: 32 random bytes, stored only as a SHA-256 hash on the row, 15 minute life, bound to the email.SignUpService.TryConsumeProofAsyncuses it up once. #654 must call it before it creates the account.- Wrong code:
400withattemptsLeft. Lock or limit:429withRetry-After. No engine key:503. - Same answer for every address.
/startfor an address with an account sends the already-registered notice and answers like a new address./resendand/verifydo nothing for an address with no open sign-up. The engine counts no wrong try without an open code, soAccountRecoveryRateLimiter.TryDecoyWrongTrycounts it in memory: the same tries left, then the same lock. Do not remove it. Without it,attemptsLefttells an existing address from a new one. - Limits run in
AccountRecoveryThrottleFilter, before any lookup. Send (start, resend, new address of a change): per client address, plus per email cooldown, hour and day fromEmailCodes. Verify: per client address, which caps how many emails one address can lock. Change-email also counts the old email. The address counter runs first, so an exhausted address cannot spend a victim's email budget. The timing floorAccountRecovery:MinimumResponseDurationapplies to all four endpoints. - A row with a live, unused proof is protected.
/startleaves it alone and/change-emaildoes not drop it. Without this, anyone who knows the address could void the owner's proof. - Known limits: counters are per process (see the limiter). Anyone who knows an address that waits
for a code can drop it with
/change-email, or lock it with five wrong tries. Both are bounded by the limits above. After a lock,/startanswers429for a new address and for an existing one alike. In every deployed environment the engine answers503until #669 suppliesACCOUNT_SERVICE_EMAIL_CODE_HMAC_KEY. - Email key: trim and upper case only. Plus tags and dots stay (
Helpers/SignUpEmail.cs). - Tests:
Tests/Integration/SignUpEndpointTests.cs. Gateway:AccountSignUpRoutesTests.
Identify (#653)
POST /account/identify(anonymous,Routes/Identify.cs, logic inHelpers/IdentifyService.cs) answers{ next, resendAfterSeconds, expiresInSeconds }. A confirmed, active account getsnext: "password"with both numbers0. Every other valid address getsnext: "code"afterSignUpService.StartAsync. Bad address:400. No engine key:503.- Decision (stakeholder default, #653): the route is revealed. The route is the only thing the
endpoint reveals. Both routes have one JSON shape and run under the timing floor. A cooldown or
lock in the code engine answers
next: "code"with the wait inresendAfterSeconds, not429. So a pending, locked or undeliverable address looks like a new one. The service has no suppression store, so there is noundeliverableanswer. Do not add one. - Soft-deleted and unconfirmed accounts get
code./startsends them the already-registered notice, not a code, so an unconfirmed account has no way forward from identify (known gap). #654 refuses a soft-deleted account at the set-password step. - The code route spends the
/signup/startsend budget (TrySignUpSend) before it callsStartAsync. A refusal there answerscodewith the wait and the full code life, not429. - The per-email limit counts every route, so anyone can make one address answer
429for the window. The ticket requires it. - No engine key:
503for every address, so the status shows no route. - Limits (
AccountRecovery:IdentifiesPerAddress20,IdentifiesPerEmail5, perRequestWindow):AccountRecoveryRateLimiter.TryIdentify, in the throttle filter. The address counter runs first. A bad address counts against the address only.429withRetry-After. - Bot challenge (Turnstile) is not built. Add it if the logs show identify abuse: sustained
429from many client addresses. - Tests:
Tests/Integration/IdentifyEndpointTests.cs.
Staff roles (#628)
Agentis a role facet, never a persona. Introspection returnsroles(array),emailandemailConfirmedonly whenisValidis true./internal/*has no gateway route. The gateway testInternalRoutesNotExposedTestsguards it.- Every grant and removal in
Routes/Admin.cswrites aRoleGrantAuditsrow. The README says how a human grants the firstSuperAdmin.
Contact lookup (#689)
POST /internal/account/contacts(Routes/ContactLookup.cs) takes{ accountIds }(1 to 100 UUIDs). It returns{ contacts: [{ accountId, displayName, email, emailConfirmed }] }. Unknown and soft-deleted ids are omitted. An empty or over-limit batch returns400.- Same trust as introspection: in-cluster only, no gateway route, hidden from OpenAPI. Any hardening of introspection (#97) applies here too. The handler never logs an email or a display name.
- Not a directory: no search, no listing.
- Tests:
Tests/Integration/ContactLookupEndpointTests.cs.
Complete sign-up and password policy (#654)
POST /account/signup/complete(Routes/SignUpComplete.cs,Helpers/SignUpCompletion.cs) takes{ email, signupProof, password }. It uses up the proof, creates a confirmedApplicationUserthroughUserManager.CreateAsync(default role, password check), deletes the pending row, and signs in like/login. QueryuseCookiesanduseSessionCookiespick cookie or bearer.- Answers:
200session,401 invalid_proof,400 password_rejectedwitherrors(stable codes),409 email_unavailable(an account, a soft-deleted account, or a lost race: one answer). - A rejected password gives the proof back (
SignUpService.ReleaseProofAsync). The proof check runs first, so the breach check is not an oracle for a caller without a proof. - The new account, its role and the pending-row delete share one transaction on a relational
provider. The unique index on
NormalizedUserName(the address) is the final guard. - Policy:
Configuration/PasswordPolicyOptions.cs, sectionPasswordPolicy.MinLength8,PassphraseLength15,MaxLength128. A password is valid with 8+ characters and an uppercase letter, a lowercase letter, a number and a symbol, or with 15+ characters and no other rule (#819).Helpers/PasswordPolicyValidator.csis the onlyIPasswordValidator, so register, change and reset follow it. IdentityRequire*flags stay false. Codes:too_short,missing_upper,missing_lower,missing_digit,missing_symbol,too_long,breached. The web mirror islib/password-rules.tsand/api/account/password-policy. The password is never trimmed. Old shorter passwords still log in. - Breach check:
Helpers/PwnedPasswordsClient.cs(HIBP range API, SHA-1 prefix,Add-Padding, 2 second timeout). A timeout or error lets the password pass and logs event1390. The HTTP client callsRemoveAllLoggers()because the framework logs the request URI, which holds the prefix. Never log the password, the hash or the exception text there. - Tests use
FakePwnedPasswordsClient(AccountServiceFactory.Breaches). No test reaches the API.
Password reset by code (#658)
POST /account/password/reset/{start,verify,complete},Routes/PasswordReset.cs,Helpers/PasswordResetService.cs.startandverifyanswer the same for every address. Only a confirmed, live account gets aPasswordResetcode. The decoy and send counters use scopepwreset, so a reset never spends a sign-up counter.verifyreturnsresetProof(single use, 15 minutes). ThePasswordResetProofsrow holds a SHA-256 hash and the account id.completeuses the proof up first. A refused password gives the proof back.completetakes{ email, resetProof, newPassword }. It runs the same validators as sign-up, sets the hash, rotates the security stamp (ends every cookie and bearer session), clears the lockout and writes anAccountSecurityEventsrow in one transaction. It signs nobody in: the response is204, and the client sends the user to sign in.AccountSecurityEventsis append-only, with no FK to the account.ClientAddressHashis an HMAC of the client address under the code key, never the address. Tests:Tests/Integration/PasswordResetEndpointTests.cs.
Email change (#660)
POST /account/email/change/{start,verify},Routes/EmailChange.cs,Helpers/EmailChangeService.cs. Both need a cookie or bearer session. An API key gets403.starttakes{ newEmail, currentPassword }or{ newEmail, oldEmailCode }. With neither it sends anEmailChangeOldcode to the old address and answers{ stepUp: "oldEmailCode" }. A wrong password counts toward the Identity lockout (429once locked). A wrong old code counts in the code engine. Both answer403 step_up_failed.- After step-up,
startstores onePendingEmailChangesrow per account (30 minutes). It stores the row for a taken address too, and sends theEmailChangeNewcode only to a free address. A taken address, a free address and the caller's own address get the same answer.verifycounts wrong tries for a taken address in the rate limiter (scopeemailchange), as sign-up does. verifytakes{ code }. The swap re-checks the address inside the transaction, then sets email and user name, keepsEmailConfirmed, rotates the stamp and writes theAccountSecurityEventsrow (EmailChanged, keyed hashes of the old and new address) and theEmailChangeRestoresrow. The unique index onNormalizedUserNameis the final guard. A lost race answers400 invalid_codewith noattemptsLeft.- The new stamp ends every other session. The route signs the caller in again, as the same kind of session (cookie keeps its persistence, bearer gets a new token body).
EmailChangeRestoresholds the old address for 7 days.POST /account/securereads it. #661 owns the notice to the old address.
Secure account (#662)
POST /account/secure,Routes/SecureAccount.cs,Helpers/SecureAccountService.cs. Anonymous. It sharesAccountRecoveryThrottleFilter(scopesecure). A bad, used or expired token gives one400 invalid_token. A GET does nothing.- One transaction: use the token (
Versionconcurrency token, one winner), restore the old email, nullPasswordHash, drop pending email changes, rotate the stamp, write aSecureAccountevent. - Restore only for an
EmailChangedtoken, an unusedEmailChangeRestoresrow of that change that has not passedRestoreUntil, and an address no other account holds. The check runs inside the transaction. A lost race on the user-name index retries the call without the restore. - No password hash means the owner must set a password by emailed code (#658). The call does not send that code. The web page opens the reset flow.
- Purge:
PendingRegistrationPurgeServicealso runsEmailChangeService.PurgeAsync. - Data keyed on email (checked for #660): waitlist interests key on
UserId(FK, composite key). API keys key onUserId. Property-service leads (listing_inquiries) key the account link onaccount_id. Theiremailcolumn is a contact snapshot from the inquiry. It stays as it was and does not change with the account. The staff duplicate check groups leads by that snapshot, so one person's leads from before and after a change do not merge. Nothing orphans. Account-service tables keyed on an address (EmailCodes,EmailCodeThrottles,PendingRegistrations) hold short-lived state. - Known limits: counters are per process. Any signed-in caller can lock the
EmailChangeNewpurpose of an address by five wrong tries, which bounds as in sign-up. The in-memory test provider has no unique index, so the database guard has no test. - Tests:
Tests/Integration/EmailChangeEndpointTests.cs.
Email code on a new device (#818)
- Off by default.
ApplicationUser.RequireDeviceCodeis the setting. A password sign-in from an unrecognized device then needs an emailedNewDeviceSignIncode (EmailCodeService, same cooldown, caps and lock as every code flow). Helpers/DeviceSignInFilter.csruns over Identity's/login. Only a correct password gets the challenge:401 { error: "device_code_required", resendAfterSeconds, expiresInSeconds, reason }. A wrong password, a lock and a setting that is off fall through to Identity, so lockout counts and answers stay the framework's. With no way to send a code the filter answers503(fails closed).POST /account/login/device { email, password, code }checks the password again, then the code, then signs in like/login(useCookies,useSessionCookies)./login/device/resendneeds the password too. Both shareAccountRecoveryThrottleFilter(scopedevicecode).- Trusted devices:
TrustedDevicesholds the SHA-256 hash of 32 random bytes, never the token. The token isbase64url(bytes).base64url(HMAC). The HMAC key derives fromACCOUNT_SERVICE_EMAIL_CODE_HMAC_KEY, so no new secret exists. SectionDeviceTrust:Lifetime(30 days),MaxDevices(20). The purge service deletes expired rows. - The web app owns the cookie (
device_token, httpOnly, SameSite=Lax, Secure in production). It sends the value inX-Device-Tokenand a short name inX-Device-Label. This service answers with the new token inX-Device-Token-Issuedand its life inX-Device-Max-Age. GET|PUT /account/security/new-device,DELETE /account/security/new-device/devices/{id}. A cookie or bearer session is required.PUTneedscurrentPassword(step-up: a wrong password counts toward the sign-in lock). It refusesenabled: truewith503when codes cannot send. Turning it on trusts the calling device. Turning it off removes every device.- Tests:
Tests/Integration/DeviceSignInEndpointTests.cs.
Undeliverable email (#664)
EmailSuppressionsholds one row per normalized address that Postmark will not deliver to (HardBounce,SpamComplaint,ManualSuppression,InactiveRecipient).EmailSuppressionServiceis the only writer. Every call is idempotent.POST /account/webhooks/postmark(Routes/PostmarkWebhook.cs) takes the Bounce (Inactive: trueonly), SpamComplaint and SubscriptionChange events.SuppressSending: falseremoves the row. It needs HTTP Basic credentials:ACCOUNT_SERVICE_POSTMARK_WEBHOOK_USERandACCOUNT_SERVICE_POSTMARK_WEBHOOK_PASSWORD. Without both, every call gets401. An event on another message stream is ignored. An event the service does not act on gets200, so Postmark does not retry it.- The path is not under
/internalon purpose. The gateway forbids a route to/internal(InternalRoutesNotExposedTests). Postmark reaches the endpoint through its own gateway route (account-service-routes.json, 120 per minute). Basic auth is the gate. - The sign-up paths (
/signup/start,/resend,/change-email,/identify) answer422{ "error": "undeliverable" }for a suppressed address or a domain with no MX, A or AAAA record. Password reset stays neutral. A Postmark406on send writes anInactiveRecipientrow after the response, so the next step shows it. DnsMailDomainResolver(DnsClient) checks the domain. A timeout or DNS error fails open. It does not block disposable domains. Tests useFakeMailDomainResolverand never query DNS.- Nothing logs the address or the body.
Notification consent (#694, #783, #769)
- Tables
NotificationPreferences(key AccountId, Channel, Category) andNotificationPreferenceAudits(append-only). The ticket names themnotification_preferences. The service keeps its PascalCase table names. Neither table has a FK or an address column. - A missing row means not opted in. Email
non_transactionalandsmsdefault to OFF. - One source of truth: the consent row. The legacy
EmailNotificationsEnabledflag mirrors it. Every email opt-in or opt-out, an unsubscribe and the profile toggle write throughNotificationPreferenceService, which sets the flag. An SMS opt-out clearsSmsNotificationsEnabled.PUT /account/profilewithemailNotificationsEnabled: trueanswers400unless the account already opted in. An opt-in needs the wording, so it goes through the preferences endpoint. - The row is the consent record:
Enabled,Source(user,unsubscribe_link),UpdatedAt,ConsentWordingId,ConsentWordingVersionandConsentedAt. The server holds the wording text (Models/ConsentWordings.cs). The client sends only the id. A new text needs a new version.GETreturns the current wording inconsentWording. Each change writes an audit row. A repeat that changes nothing writes none.DELETE /account/profiledeletes the account's preference and audit rows. GETandPUT /account/notification-preferences(Routes/NotificationPreferences.cs). Cookie or bearer session. The gateway catch-all carries both.PUTtakes{ items: [{ channel, category, enabled, consentWordingId }] }, 1 to 4 items, all or nothing. Errors:400transactional_not_storable,invalid_channel,invalid_category,consent_wording_required,unknown_consent_wording.409sms_requires_consentfor an SMS opt-in.- Categories (
Models/NotificationCategories.cs):non_transactionalis the only stored category. The policy lookup also acceptsmarketingandalertand maps both to it.transactionalhas no row and always answersallowed: true. Align these names with@events/contracts(#791). POST /notifications/unsubscribe?t=<token>(RFC 8058 one-click). No login. The token is the only credential. Own gateway route with a rate limit. A valid token answers200, also on a repeat call and for a deleted account. A bad token answers400 invalid_token.- Token (
Helpers/UnsubscribeTokenService.cs):base64url(v1|accountId|category).base64url(HMAC). No address inside. No expiry, because CAN-SPAM needs an old link to work. The category is bound: onlynon_transactionalis valid, and the link opts out of exactly that category. A token for any other category answers400. The key isACCOUNT_SERVICE_UNSUBSCRIBE_HMAC_KEY(its own key). Only Development and Testing use a fixed dev key. With no key, no token is signed and the lookup answersallowed: falsefor email that needs consent. - The token sits in the query string. The gateway and this service log requests at Warning only
(
Microsoft.AspNetCore,Ocelot,System.Net.Http.HttpClient). The gateway redactstfrom the OTel URL tags (SensitiveQueryRedactor). Do not raise those levels. POST /internal/account/notification-policy(Routes/NotificationPolicy.cs). Body{ items: [{ accountId, channel, category, includeUnsubscribeToken }] }, 1 to 100 items. It needs theX-Internal-Keyheader. The key isACCOUNT_SERVICE_INTERNAL_KEY, compared in constant time. With no key set, every call answers403. The Notification Service (#696, #769) is the caller. An unknown channel or category fails the call with400. Unknown and soft-deleted accounts are omitted. Each answer holdsallowed,suppressed(the #664 list, email only),emailConfirmedandunsubscribeToken(only when asked, and only for email that needs consent). No gateway route. Hidden from OpenAPI.- Tests:
Tests/Integration/NotificationPreferencesEndpointTests.cs,NotificationConsentEndpointTests.cs,Tests/Helpers/UnsubscribeTokenServiceTests.cs,UnsubscribeTokenEdgeCaseTests.cs. Gateway:AccountNotificationRoutesTests,SensitiveQueryRedactorTests.
