diff --git a/CHANGELOG.md b/CHANGELOG.md index e0dba0a..1f8e422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to the netrock generator will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [0.10.0] - 2026-08-17 + +### Added + +- Machine-readable error codes on every ProblemDetails response: `ErrorMessages` entries are now `Error` records (stable snake_case `code` + message), `Result.Failure()` takes an `Error`, and `ProblemFactory` writes the code to the `code` extension (framework-generated bodies get `validation_failed` or a snake_case reason phrase such as `not_found`). New `ProblemDetailsSchemaTransformer` documents the extension in OpenAPI, and `ErrorMessagesTests` enforce code naming and uniqueness +- Hangfire-backed email delivery: with both `email` and `jobs` enabled, transactional emails are queued as `EmailDeliveryJob` via `BackgroundEmailService` and retried automatically (5 attempts, 30s to 1h backoff) instead of being lost on SMTP failure. Without `jobs`, `SmtpEmailService` still sends inline +- New API tests (`ProblemFactoryTests`, `ProblemDetailsCodeTests`, `ProblemDetailsAssert` fixture) and component tests for the email job pipeline +- Frontend: `getErrorCode()` and `getErrorMessage(error, fallback, messagesByCode)` translate by backend code; login form and OAuth callback page map on codes instead of English `detail` text + +### Fixed + +- Frontend API proxy buffers request bodies so backend 401 responses pass through instead of surfacing as 502 on Node 24 (undici streaming issue) + ## [0.9.5] - 2026-08-17 ### Changed diff --git a/packages/core/package.json b/packages/core/package.json index 158929d..13548b4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@netrock/core", - "version": "0.9.5", + "version": "0.10.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/core/src/manifests/auth.ts b/packages/core/src/manifests/auth.ts index 2689d00..ea3f3c1 100644 --- a/packages/core/src/manifests/auth.ts +++ b/packages/core/src/manifests/auth.ts @@ -438,6 +438,14 @@ export function registerAuthManifest(): void { path: 'src/backend/tests/MyProject.Api.Tests/Fixtures/TestAuthHandler.cs', templated: false }, + { + path: 'src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs', + templated: false + }, + { + path: 'src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs', + templated: false + }, { path: 'src/backend/tests/MyProject.Api.Tests/Contracts/ResponseContracts.cs', templated: false @@ -446,6 +454,10 @@ export function registerAuthManifest(): void { path: 'src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs', templated: false }, + { + path: 'src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs', + templated: true + }, { path: 'src/backend/tests/MyProject.Api.Tests/Validation/CorsOptionsValidationTests.cs', templated: false diff --git a/packages/core/src/manifests/core.ts b/packages/core/src/manifests/core.ts index c531397..72251d7 100644 --- a/packages/core/src/manifests/core.ts +++ b/packages/core/src/manifests/core.ts @@ -15,6 +15,7 @@ export function registerCoreManifest(): void { { path: 'src/backend/MyProject.Domain/MyProject.Domain.csproj', templated: false }, // Shared + { path: 'src/backend/MyProject.Shared/Error.cs', templated: false }, { path: 'src/backend/MyProject.Shared/ErrorMessages.cs', templated: true }, { path: 'src/backend/MyProject.Shared/ErrorType.cs', templated: false }, { path: 'src/backend/MyProject.Shared/MyProject.Shared.csproj', templated: false }, @@ -121,6 +122,10 @@ export function registerCoreManifest(): void { path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs', templated: false }, + { + path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs', + templated: false + }, { path: 'src/backend/MyProject.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs', templated: false diff --git a/packages/core/src/manifests/email.ts b/packages/core/src/manifests/email.ts index 701edc8..384f9bb 100644 --- a/packages/core/src/manifests/email.ts +++ b/packages/core/src/manifests/email.ts @@ -26,12 +26,20 @@ export function registerEmailManifest(): void { // Infrastructure - Email services and configuration { path: 'src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs', - templated: false + templated: true + }, + { + path: 'src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs', + templated: true }, { path: 'src/backend/MyProject.Infrastructure/Features/Email/Options/EmailOptions.cs', templated: false }, + { + path: 'src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs', + templated: true + }, { path: 'src/backend/MyProject.Infrastructure/Features/Email/Services/FluidEmailTemplateRenderer.cs', templated: true @@ -56,6 +64,18 @@ export function registerEmailManifest(): void { }, // Tests - Email services + { + path: 'src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs', + templated: true + }, + { + path: 'src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs', + templated: true + }, + { + path: 'src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs', + templated: true + }, { path: 'src/backend/tests/MyProject.Component.Tests/Services/FluidEmailTemplateRendererTests.cs', templated: false diff --git a/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap b/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap index 6fca5f8..d7c0dbb 100644 --- a/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap +++ b/packages/core/tests/integration/__snapshots__/snapshot.test.ts.snap @@ -145,13 +145,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > core-only 1`] = ` "namespace SnapshotApp.Shared; /// -/// User-facing error messages organized by domain area. -/// Constants are used in Result.Failure() calls so that messages remain consistent, -/// greppable, and easy to extract into translation keys later. +/// User-facing errors organized by domain area. Each entry is an pairing a stable, +/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls +/// so that messages remain consistent, greppable, and translatable by clients via the code. /// -/// All client-facing messages must be static constants - never interpolate runtime values +/// Codes follow {nested_class}_{field_name} in snake_case and are verified by +/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers. +/// +/// +/// All client-facing messages must be static - never interpolate runtime values /// (role names, user IDs, framework error descriptions) into error responses. -/// Log runtime details server-side via ILogger instead. +/// Log runtime details server-side via ILogger instead. The only exceptions are +/// entries explicitly documented as carrying a dynamic message (password policy feedback, +/// rate-limit retry hints); those keep their stable code and override +/// with a with expression. /// /// public static class ErrorMessages @@ -163,8 +170,8 @@ public static class ErrorMessages /// public static class Pagination { - public const string InvalidPage = "Page number must be positive."; - public const string InvalidPageSize = "Page size must be positive."; + public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive."); + public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive."); } /// @@ -172,7 +179,13 @@ public static class ErrorMessages /// public static class Server { - public const string InternalError = "An internal error occurred."; + public static readonly Error InternalError = new("server_internal_error", "An internal error occurred."); + + /// + /// Request rejected by the rate limiter. The message is overridden with the retry hint + /// (seconds until the window resets). + /// + public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later."); } @@ -181,19 +194,20 @@ public static class ErrorMessages /// public static class Security { - public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed."; + public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed."); } + /// /// Generic entity operation error messages (repository layer). /// public static class Entity { - public const string AddFailed = "Failed to add entity."; - public const string NotFound = "Entity not found."; - public const string NotDeleted = "Entity could not be deleted."; + public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity."); + public static readonly Error NotFound = new("entity_not_found", "Entity not found."); + public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted."); } } " @@ -203,13 +217,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > full preset 1`] = ` "namespace SnapshotApp.Shared; /// -/// User-facing error messages organized by domain area. -/// Constants are used in Result.Failure() calls so that messages remain consistent, -/// greppable, and easy to extract into translation keys later. +/// User-facing errors organized by domain area. Each entry is an pairing a stable, +/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls +/// so that messages remain consistent, greppable, and translatable by clients via the code. /// -/// All client-facing messages must be static constants - never interpolate runtime values +/// Codes follow {nested_class}_{field_name} in snake_case and are verified by +/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers. +/// +/// +/// All client-facing messages must be static - never interpolate runtime values /// (role names, user IDs, framework error descriptions) into error responses. -/// Log runtime details server-side via ILogger instead. +/// Log runtime details server-side via ILogger instead. The only exceptions are +/// entries explicitly documented as carrying a dynamic message (password policy feedback, +/// rate-limit retry hints); those keep their stable code and override +/// with a with expression. /// /// public static class ErrorMessages @@ -219,25 +240,37 @@ public static class ErrorMessages /// public static class Auth { - public const string LoginInvalidCredentials = "Invalid username or password."; - public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator."; - public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator."; - public const string TokenMissing = "Refresh token is missing."; - public const string TokenNotFound = "Refresh token not found."; - public const string TokenInvalidated = "Refresh token has been invalidated."; - public const string TokenReused = "Invalid refresh token."; - public const string TokenExpired = "Refresh token has expired."; - public const string TokenUserNotFound = "Token owner not found."; - public const string NotAuthenticated = "User is not authenticated."; - public const string InsufficientPermissions = "You do not have the required permissions for this action."; - public const string UserNotFound = "User not found."; - public const string PasswordIncorrect = "Current password is incorrect."; - public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used."; - public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token."; - public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used."; - public const string EmailAlreadyVerified = "Email address is already verified."; - public const string PasswordSameAsCurrent = "New password must be different from your current password."; - public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again."; + public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password."); + public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator."); + public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator."); + public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing."); + public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found."); + public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated."); + public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token."); + public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired."); + public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found."); + public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated."); + public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action."); + public static readonly Error UserNotFound = new("auth_user_not_found", "User not found."); + public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect."); + public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used."); + public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token."); + public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used."); + public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified."); + public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password."); + public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again."); + + /// + /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is + /// overridden with the Identity error descriptions so users get actionable feedback. + /// + public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details."); + + /// + /// New password rejected by the password policy. The message is overridden with the + /// Identity error descriptions so users get actionable feedback. + /// + public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements."); } /// @@ -245,15 +278,15 @@ public static class ErrorMessages /// public static class TwoFactor { - public const string SetupFailed = "Failed to set up two-factor authentication."; - public const string VerificationFailed = "The verification code is invalid. Please try again."; - public const string AlreadyEnabled = "Two-factor authentication is already enabled."; - public const string NotEnabled = "Two-factor authentication is not enabled."; - public const string DisableFailed = "Failed to disable two-factor authentication."; - public const string ChallengeNotFound = "Two-factor challenge not found or expired."; - public const string ChallengeLocked = "Too many failed attempts. Please log in again."; - public const string RecoveryCodeInvalid = "The recovery code is invalid."; - public const string InvalidCode = "The two-factor code is invalid."; + public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication."); + public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again."); + public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled."); + public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled."); + public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication."); + public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired."); + public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again."); + public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid."); + public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid."); } /// @@ -261,13 +294,13 @@ public static class ErrorMessages /// public static class User { - public const string NotAuthenticated = "User is not authenticated."; - public const string NotFound = "User not found."; - public const string DeleteInvalidPassword = "Invalid password."; - public const string PhoneNumberTaken = "This phone number is already in use."; - public const string UpdateFailed = "Failed to update profile."; - public const string DeleteFailed = "Failed to delete account."; - public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser."; + public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated."); + public static readonly Error NotFound = new("user_not_found", "User not found."); + public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password."); + public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use."); + public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile."); + public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account."); + public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser."); } /// @@ -275,31 +308,31 @@ public static class ErrorMessages /// public static class Admin { - public const string UserNotFound = "User not found."; - public const string HierarchyInsufficient = "You do not have sufficient privileges to manage this user."; - public const string RoleAssignAboveRank = "Cannot assign a role at or above your own rank."; - public const string RoleRemoveAboveRank = "Cannot remove a role at or above your own rank."; - public const string RoleSelfRemove = "Cannot remove a role from your own account."; - public const string LockSelfAction = "Cannot lock your own account."; - public const string DeleteSelfAction = "Cannot delete your own account."; - public const string EmailVerificationRequired = "User must have a verified email address before being assigned this role."; - public const string EmailAlreadyRegistered = "A user with this email address already exists."; - public const string RoleAssignEscalation = "Cannot assign a role that grants permissions you do not hold."; - public const string RoleNotFound = "Role not found."; - public const string RoleAlreadyAssigned = "User already has this role."; - public const string RoleNotAssigned = "User does not have this role."; - public const string LastRoleHolder = "Cannot remove this role - this is the last user holding it."; - public const string RoleAssignFailed = "Failed to assign role."; - public const string RoleRemoveFailed = "Failed to remove role."; - public const string LockFailed = "Failed to lock user account."; - public const string UnlockFailed = "Failed to unlock user account."; - public const string DeleteFailed = "Failed to delete user account."; - public const string EmailVerificationFailed = "Failed to verify email address."; - public const string CreateUserFailed = "Failed to create user account."; - public const string LastSuperuserCannotDelete = "Cannot delete this user - they are the last superuser."; - public const string TwoFactorNotEnabled = "Two-factor authentication is not enabled for this user."; - public const string DisableTwoFactorSelfAction = "You cannot disable your own two-factor authentication from the admin panel."; - public const string DisableTwoFactorFailed = "Failed to disable two-factor authentication."; + public static readonly Error UserNotFound = new("admin_user_not_found", "User not found."); + public static readonly Error HierarchyInsufficient = new("admin_hierarchy_insufficient", "You do not have sufficient privileges to manage this user."); + public static readonly Error RoleAssignAboveRank = new("admin_role_assign_above_rank", "Cannot assign a role at or above your own rank."); + public static readonly Error RoleRemoveAboveRank = new("admin_role_remove_above_rank", "Cannot remove a role at or above your own rank."); + public static readonly Error RoleSelfRemove = new("admin_role_self_remove", "Cannot remove a role from your own account."); + public static readonly Error LockSelfAction = new("admin_lock_self_action", "Cannot lock your own account."); + public static readonly Error DeleteSelfAction = new("admin_delete_self_action", "Cannot delete your own account."); + public static readonly Error EmailVerificationRequired = new("admin_email_verification_required", "User must have a verified email address before being assigned this role."); + public static readonly Error EmailAlreadyRegistered = new("admin_email_already_registered", "A user with this email address already exists."); + public static readonly Error RoleAssignEscalation = new("admin_role_assign_escalation", "Cannot assign a role that grants permissions you do not hold."); + public static readonly Error RoleNotFound = new("admin_role_not_found", "Role not found."); + public static readonly Error RoleAlreadyAssigned = new("admin_role_already_assigned", "User already has this role."); + public static readonly Error RoleNotAssigned = new("admin_role_not_assigned", "User does not have this role."); + public static readonly Error LastRoleHolder = new("admin_last_role_holder", "Cannot remove this role - this is the last user holding it."); + public static readonly Error RoleAssignFailed = new("admin_role_assign_failed", "Failed to assign role."); + public static readonly Error RoleRemoveFailed = new("admin_role_remove_failed", "Failed to remove role."); + public static readonly Error LockFailed = new("admin_lock_failed", "Failed to lock user account."); + public static readonly Error UnlockFailed = new("admin_unlock_failed", "Failed to unlock user account."); + public static readonly Error DeleteFailed = new("admin_delete_failed", "Failed to delete user account."); + public static readonly Error EmailVerificationFailed = new("admin_email_verification_failed", "Failed to verify email address."); + public static readonly Error CreateUserFailed = new("admin_create_user_failed", "Failed to create user account."); + public static readonly Error LastSuperuserCannotDelete = new("admin_last_superuser_cannot_delete", "Cannot delete this user - they are the last superuser."); + public static readonly Error TwoFactorNotEnabled = new("admin_two_factor_not_enabled", "Two-factor authentication is not enabled for this user."); + public static readonly Error DisableTwoFactorSelfAction = new("admin_disable_two_factor_self_action", "You cannot disable your own two-factor authentication from the admin panel."); + public static readonly Error DisableTwoFactorFailed = new("admin_disable_two_factor_failed", "Failed to disable two-factor authentication."); } /// @@ -307,18 +340,18 @@ public static class ErrorMessages /// public static class Roles { - public const string SystemRoleCannotBeDeleted = "System roles cannot be deleted."; - public const string SystemRoleCannotBeRenamed = "System roles cannot be renamed."; - public const string RoleNotFound = "Role not found."; - public const string RoleNameTaken = "A role with this name already exists."; - public const string RoleHasUsers = "Cannot delete a role that has users assigned to it."; - public const string InvalidPermission = "One or more permission values are invalid."; - public const string SystemRoleNameReserved = "This name is reserved for a system role."; - public const string SuperuserPermissionsFixed = "Superuser permissions cannot be modified."; - public const string CannotGrantUnheldPermission = "Cannot grant permissions that you do not hold."; - public const string CreateFailed = "Failed to create role."; - public const string UpdateFailed = "Failed to update role."; - public const string DeleteFailed = "Failed to delete role."; + public static readonly Error SystemRoleCannotBeDeleted = new("roles_system_role_cannot_be_deleted", "System roles cannot be deleted."); + public static readonly Error SystemRoleCannotBeRenamed = new("roles_system_role_cannot_be_renamed", "System roles cannot be renamed."); + public static readonly Error RoleNotFound = new("roles_role_not_found", "Role not found."); + public static readonly Error RoleNameTaken = new("roles_role_name_taken", "A role with this name already exists."); + public static readonly Error RoleHasUsers = new("roles_role_has_users", "Cannot delete a role that has users assigned to it."); + public static readonly Error InvalidPermission = new("roles_invalid_permission", "One or more permission values are invalid."); + public static readonly Error SystemRoleNameReserved = new("roles_system_role_name_reserved", "This name is reserved for a system role."); + public static readonly Error SuperuserPermissionsFixed = new("roles_superuser_permissions_fixed", "Superuser permissions cannot be modified."); + public static readonly Error CannotGrantUnheldPermission = new("roles_cannot_grant_unheld_permission", "Cannot grant permissions that you do not hold."); + public static readonly Error CreateFailed = new("roles_create_failed", "Failed to create role."); + public static readonly Error UpdateFailed = new("roles_update_failed", "Failed to update role."); + public static readonly Error DeleteFailed = new("roles_delete_failed", "Failed to delete role."); } /// @@ -326,8 +359,8 @@ public static class ErrorMessages /// public static class Pagination { - public const string InvalidPage = "Page number must be positive."; - public const string InvalidPageSize = "Page size must be positive."; + public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive."); + public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive."); } /// @@ -335,7 +368,13 @@ public static class ErrorMessages /// public static class Server { - public const string InternalError = "An internal error occurred."; + public static readonly Error InternalError = new("server_internal_error", "An internal error occurred."); + + /// + /// Request rejected by the rate limiter. The message is overridden with the retry hint + /// (seconds until the window resets). + /// + public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later."); } /// @@ -343,9 +382,9 @@ public static class ErrorMessages /// public static class Jobs { - public const string NotFound = "Job not found."; - public const string TriggerFailed = "Failed to trigger job."; - public const string RestoreFailed = "Failed to restore jobs."; + public static readonly Error NotFound = new("jobs_not_found", "Job not found."); + public static readonly Error TriggerFailed = new("jobs_trigger_failed", "Failed to trigger job."); + public static readonly Error RestoreFailed = new("jobs_restore_failed", "Failed to restore jobs."); } /// @@ -353,7 +392,7 @@ public static class ErrorMessages /// public static class Security { - public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed."; + public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed."); } /// @@ -361,10 +400,21 @@ public static class ErrorMessages /// public static class Avatar { - public const string FileTooLarge = "The file exceeds the maximum allowed size of 5 MB."; - public const string UnsupportedFormat = "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF."; - public const string ProcessingFailed = "Failed to process the avatar image."; - public const string NotFound = "Avatar not found."; + public static readonly Error FileTooLarge = new("avatar_file_too_large", "The file exceeds the maximum allowed size of 5 MB."); + public static readonly Error UnsupportedFormat = new("avatar_unsupported_format", "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF."); + public static readonly Error ProcessingFailed = new("avatar_processing_failed", "Failed to process the avatar image."); + public static readonly Error NotFound = new("avatar_not_found", "Avatar not found."); + } + + /// + /// File storage (S3-compatible) error messages. + /// + public static class FileStorage + { + public static readonly Error UploadFailed = new("file_storage_upload_failed", "Failed to upload file to storage."); + public static readonly Error DownloadFailed = new("file_storage_download_failed", "Failed to retrieve file from storage."); + public static readonly Error DeleteFailed = new("file_storage_delete_failed", "Failed to delete file from storage."); + public static readonly Error NotFound = new("file_storage_not_found", "File not found."); } /// @@ -372,23 +422,23 @@ public static class ErrorMessages /// public static class ExternalAuth { - public const string ProviderNotConfigured = "The requested authentication provider is not configured."; - public const string InvalidState = "Invalid or missing OAuth state token."; - public const string StateExpired = "OAuth state token has expired. Please try again."; - public const string EmailNotVerified = "Your email address must be verified before linking an external account. Please verify your email first."; - public const string AlreadyLinkedToOtherUser = "This external account is already linked to another user."; - public const string ProviderNotLinked = "This provider is not linked to your account."; - public const string CannotUnlinkLastMethod = "Cannot unlink this provider because it is your only sign-in method. Set a password first."; - public const string CodeExchangeFailed = "Failed to exchange the authorization code with the provider."; - public const string ProviderError = "The external authentication provider returned an error."; - public const string InvalidRedirectUri = "The provided redirect URI is not allowed."; - public const string PasswordAlreadySet = "A password is already set for this account."; - public const string PasswordSetFailed = "Failed to set the password. Please try again."; - public const string UnknownProvider = "The specified authentication provider is not recognized."; - public const string ClientSecretRequired = "A client secret is required when enabling a provider that has no existing secret."; - public const string TestConnectionInvalidCredentials = "The provider rejected the credentials. Verify the client ID and secret are correct."; - public const string TestConnectionProviderUnreachable = "Could not reach the authentication provider. Please try again later."; - public const string TestConnectionNotConfigured = "No credentials are configured for this provider."; + public static readonly Error ProviderNotConfigured = new("external_auth_provider_not_configured", "The requested authentication provider is not configured."); + public static readonly Error InvalidState = new("external_auth_invalid_state", "Invalid or missing OAuth state token."); + public static readonly Error StateExpired = new("external_auth_state_expired", "OAuth state token has expired. Please try again."); + public static readonly Error EmailNotVerified = new("external_auth_email_not_verified", "Your email address must be verified before linking an external account. Please verify your email first."); + public static readonly Error AlreadyLinkedToOtherUser = new("external_auth_already_linked_to_other_user", "This external account is already linked to another user."); + public static readonly Error ProviderNotLinked = new("external_auth_provider_not_linked", "This provider is not linked to your account."); + public static readonly Error CannotUnlinkLastMethod = new("external_auth_cannot_unlink_last_method", "Cannot unlink this provider because it is your only sign-in method. Set a password first."); + public static readonly Error CodeExchangeFailed = new("external_auth_code_exchange_failed", "Failed to exchange the authorization code with the provider."); + public static readonly Error ProviderError = new("external_auth_provider_error", "The external authentication provider returned an error."); + public static readonly Error InvalidRedirectUri = new("external_auth_invalid_redirect_uri", "The provided redirect URI is not allowed."); + public static readonly Error PasswordAlreadySet = new("external_auth_password_already_set", "A password is already set for this account."); + public static readonly Error PasswordSetFailed = new("external_auth_password_set_failed", "Failed to set the password. Please try again."); + public static readonly Error UnknownProvider = new("external_auth_unknown_provider", "The specified authentication provider is not recognized."); + public static readonly Error ClientSecretRequired = new("external_auth_client_secret_required", "A client secret is required when enabling a provider that has no existing secret."); + public static readonly Error TestConnectionInvalidCredentials = new("external_auth_test_connection_invalid_credentials", "The provider rejected the credentials. Verify the client ID and secret are correct."); + public static readonly Error TestConnectionProviderUnreachable = new("external_auth_test_connection_provider_unreachable", "Could not reach the authentication provider. Please try again later."); + public static readonly Error TestConnectionNotConfigured = new("external_auth_test_connection_not_configured", "No credentials are configured for this provider."); } /// @@ -396,9 +446,9 @@ public static class ErrorMessages /// public static class Entity { - public const string AddFailed = "Failed to add entity."; - public const string NotFound = "Entity not found."; - public const string NotDeleted = "Entity could not be deleted."; + public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity."); + public static readonly Error NotFound = new("entity_not_found", "Entity not found."); + public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted."); } } " @@ -408,13 +458,20 @@ exports[`convergence file snapshots > ErrorMessages.cs > minimal (core + auth) 1 "namespace SnapshotApp.Shared; /// -/// User-facing error messages organized by domain area. -/// Constants are used in Result.Failure() calls so that messages remain consistent, -/// greppable, and easy to extract into translation keys later. +/// User-facing errors organized by domain area. Each entry is an pairing a stable, +/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls +/// so that messages remain consistent, greppable, and translatable by clients via the code. /// -/// All client-facing messages must be static constants - never interpolate runtime values +/// Codes follow {nested_class}_{field_name} in snake_case and are verified by +/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers. +/// +/// +/// All client-facing messages must be static - never interpolate runtime values /// (role names, user IDs, framework error descriptions) into error responses. -/// Log runtime details server-side via ILogger instead. +/// Log runtime details server-side via ILogger instead. The only exceptions are +/// entries explicitly documented as carrying a dynamic message (password policy feedback, +/// rate-limit retry hints); those keep their stable code and override +/// with a with expression. /// /// public static class ErrorMessages @@ -424,25 +481,37 @@ public static class ErrorMessages /// public static class Auth { - public const string LoginInvalidCredentials = "Invalid username or password."; - public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator."; - public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator."; - public const string TokenMissing = "Refresh token is missing."; - public const string TokenNotFound = "Refresh token not found."; - public const string TokenInvalidated = "Refresh token has been invalidated."; - public const string TokenReused = "Invalid refresh token."; - public const string TokenExpired = "Refresh token has expired."; - public const string TokenUserNotFound = "Token owner not found."; - public const string NotAuthenticated = "User is not authenticated."; - public const string InsufficientPermissions = "You do not have the required permissions for this action."; - public const string UserNotFound = "User not found."; - public const string PasswordIncorrect = "Current password is incorrect."; - public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used."; - public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token."; - public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used."; - public const string EmailAlreadyVerified = "Email address is already verified."; - public const string PasswordSameAsCurrent = "New password must be different from your current password."; - public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again."; + public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password."); + public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator."); + public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator."); + public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing."); + public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found."); + public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated."); + public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token."); + public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired."); + public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found."); + public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated."); + public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action."); + public static readonly Error UserNotFound = new("auth_user_not_found", "User not found."); + public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect."); + public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used."); + public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token."); + public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used."); + public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified."); + public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password."); + public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again."); + + /// + /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is + /// overridden with the Identity error descriptions so users get actionable feedback. + /// + public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details."); + + /// + /// New password rejected by the password policy. The message is overridden with the + /// Identity error descriptions so users get actionable feedback. + /// + public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements."); } /// @@ -450,15 +519,15 @@ public static class ErrorMessages /// public static class TwoFactor { - public const string SetupFailed = "Failed to set up two-factor authentication."; - public const string VerificationFailed = "The verification code is invalid. Please try again."; - public const string AlreadyEnabled = "Two-factor authentication is already enabled."; - public const string NotEnabled = "Two-factor authentication is not enabled."; - public const string DisableFailed = "Failed to disable two-factor authentication."; - public const string ChallengeNotFound = "Two-factor challenge not found or expired."; - public const string ChallengeLocked = "Too many failed attempts. Please log in again."; - public const string RecoveryCodeInvalid = "The recovery code is invalid."; - public const string InvalidCode = "The two-factor code is invalid."; + public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication."); + public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again."); + public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled."); + public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled."); + public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication."); + public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired."); + public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again."); + public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid."); + public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid."); } /// @@ -466,13 +535,13 @@ public static class ErrorMessages /// public static class User { - public const string NotAuthenticated = "User is not authenticated."; - public const string NotFound = "User not found."; - public const string DeleteInvalidPassword = "Invalid password."; - public const string PhoneNumberTaken = "This phone number is already in use."; - public const string UpdateFailed = "Failed to update profile."; - public const string DeleteFailed = "Failed to delete account."; - public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser."; + public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated."); + public static readonly Error NotFound = new("user_not_found", "User not found."); + public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password."); + public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use."); + public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile."); + public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account."); + public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser."); } @@ -481,8 +550,8 @@ public static class ErrorMessages /// public static class Pagination { - public const string InvalidPage = "Page number must be positive."; - public const string InvalidPageSize = "Page size must be positive."; + public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive."); + public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive."); } /// @@ -490,7 +559,13 @@ public static class ErrorMessages /// public static class Server { - public const string InternalError = "An internal error occurred."; + public static readonly Error InternalError = new("server_internal_error", "An internal error occurred."); + + /// + /// Request rejected by the rate limiter. The message is overridden with the retry hint + /// (seconds until the window resets). + /// + public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later."); } @@ -499,19 +574,20 @@ public static class ErrorMessages /// public static class Security { - public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed."; + public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed."); } + /// /// Generic entity operation error messages (repository layer). /// public static class Entity { - public const string AddFailed = "Failed to add entity."; - public const string NotFound = "Entity not found."; - public const string NotDeleted = "Entity could not be deleted."; + public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity."); + public static readonly Error NotFound = new("entity_not_found", "Entity not found."); + public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted."); } } " @@ -593,6 +669,7 @@ using SnapshotApp.Infrastructure.Persistence.Extensions; using SnapshotApp.WebApi.Extensions; using SnapshotApp.WebApi.Features.OpenApi.Extensions; using SnapshotApp.WebApi.Middlewares; +using SnapshotApp.WebApi.Shared; using Serilog; using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions; @@ -652,6 +729,7 @@ try options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; + ProblemFactory.EnsureCode(context.ProblemDetails); }; }); @@ -783,6 +861,7 @@ using SnapshotApp.WebApi.Extensions; using SnapshotApp.WebApi.Features.OpenApi.Extensions; using SnapshotApp.WebApi.Middlewares; using SnapshotApp.WebApi.Routing; +using SnapshotApp.WebApi.Shared; using Serilog; using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions; @@ -872,6 +951,7 @@ try options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; + ProblemFactory.EnsureCode(context.ProblemDetails); }; }); @@ -1002,6 +1082,7 @@ using SnapshotApp.WebApi.Authorization; using SnapshotApp.WebApi.Extensions; using SnapshotApp.WebApi.Features.OpenApi.Extensions; using SnapshotApp.WebApi.Middlewares; +using SnapshotApp.WebApi.Shared; using Serilog; using LoggerConfigurationExtensions = SnapshotApp.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions; @@ -1076,6 +1157,7 @@ try options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; + ProblemFactory.EnsureCode(context.ProblemDetails); }; }); @@ -1705,6 +1787,7 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -1778,6 +1861,7 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs", @@ -1813,9 +1897,12 @@ exports[`file list snapshots > core + auth + 2fa + oauth file list 1`] = ` "src/backend/tests/SnapshotApp.Api.Tests/Controllers/OAuthProvidersControllerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Controllers/UsersControllerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs", "src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs", "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj", "src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs", @@ -1908,6 +1995,7 @@ exports[`file list snapshots > core + jobs file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -1930,6 +2018,7 @@ exports[`file list snapshots > core + jobs file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs", "src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs", @@ -2001,6 +2090,7 @@ exports[`file list snapshots > core-only file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -2019,6 +2109,7 @@ exports[`file list snapshots > core-only file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs", "src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs", @@ -2165,6 +2256,7 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] = "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -2232,6 +2324,7 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] = "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs", @@ -2267,9 +2360,12 @@ exports[`file list snapshots > frontend + admin (no jobs/oauth) file list 1`] = "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs", "src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs", "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj", "src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs", @@ -2849,6 +2945,7 @@ exports[`file list snapshots > frontend full file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -2953,6 +3050,7 @@ exports[`file list snapshots > frontend full file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs", @@ -2996,9 +3094,12 @@ exports[`file list snapshots > frontend full file list 1`] = ` "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs", "src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs", "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj", "src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs", @@ -3556,6 +3657,7 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -3601,6 +3703,7 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs", @@ -3633,9 +3736,12 @@ exports[`file list snapshots > frontend minimal (core + auth + frontend) file li "src/backend/tests/SnapshotApp.Api.Tests/Controllers/AuthControllerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Controllers/UsersControllerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs", "src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs", "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj", "src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs", @@ -4245,6 +4351,7 @@ exports[`file list snapshots > full preset file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -4349,6 +4456,7 @@ exports[`file list snapshots > full preset file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequest.cs", "src/backend/SnapshotApp.WebApi/Features/Users/Dtos/DeleteAccount/DeleteAccountRequestValidator.cs", @@ -4392,9 +4500,12 @@ exports[`file list snapshots > full preset file list 1`] = ` "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/AdminMapperPiiTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Features/Admin/PiiMaskerTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/CustomWebApplicationFactory.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/ProblemDetailsAssert.cs", "src/backend/tests/SnapshotApp.Api.Tests/Fixtures/TestAuthHandler.cs", "src/backend/tests/SnapshotApp.Api.Tests/GlobalUsings.cs", "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs", + "src/backend/tests/SnapshotApp.Api.Tests/Shared/ProblemFactoryTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/SnapshotApp.Api.Tests.csproj", "src/backend/tests/SnapshotApp.Api.Tests/Validation/CorsOptionsValidationTests.cs", "src/backend/tests/SnapshotApp.Api.Tests/Validation/RateLimitingOptionsValidationTests.cs", @@ -4490,6 +4601,7 @@ exports[`file list snapshots > minimal preset (core + auth) file list 1`] = ` "src/backend/SnapshotApp.Infrastructure/SnapshotApp.Infrastructure.csproj", "src/backend/SnapshotApp.ServiceDefaults/Extensions.cs", "src/backend/SnapshotApp.ServiceDefaults/SnapshotApp.ServiceDefaults.csproj", + "src/backend/SnapshotApp.Shared/Error.cs", "src/backend/SnapshotApp.Shared/ErrorMessages.cs", "src/backend/SnapshotApp.Shared/ErrorType.cs", "src/backend/SnapshotApp.Shared/PhoneNumberHelper.cs", @@ -4508,6 +4620,7 @@ exports[`file list snapshots > minimal preset (core + auth) file list 1`] = ` "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/CleanupDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/EnumSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/NumericSchemaTransformer.cs", + "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs", "src/backend/SnapshotApp.WebApi/Features/OpenApi/Transformers/ProjectDocumentTransformer.cs", "src/backend/SnapshotApp.WebApi/Middlewares/ExceptionHandlingMiddleware.cs", "src/backend/SnapshotApp.WebApi/Middlewares/OriginValidationMiddleware.cs", diff --git a/packages/web/package.json b/packages/web/package.json index a9521a4..828fc4e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,7 +1,7 @@ { "name": "@netrock/web", "private": true, - "version": "0.9.5", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/templates/.claude/agents/backend-reviewer.md b/templates/.claude/agents/backend-reviewer.md index 08649ee..80d9058 100644 --- a/templates/.claude/agents/backend-reviewer.md +++ b/templates/.claude/agents/backend-reviewer.md @@ -18,7 +18,7 @@ WebApi -> Application <- Infrastructure All layers reference Shared (Result, ErrorType, ErrorMessages) ``` -- **Shared**: `Result`/`Result`, `ErrorType`, `ErrorMessages`. Zero deps. +- **Shared**: `Result`/`Result`, `Error` (code + message), `ErrorType`, `ErrorMessages`. Zero deps. - **Domain**: Entities (`BaseEntity`). Zero deps. - **Application**: Interfaces, DTOs (Input/Output), service contracts. - **Infrastructure**: EF Core, Identity, services. All implementations `internal`. @@ -28,7 +28,7 @@ All layers reference Shared (Result, ErrorType, ErrorMessages) ### Result Pattern - All fallible operations return `Result`/`Result` - never throw for business logic -- `Result.Failure(ErrorMessages.*, ErrorType.*)` with centralized constants +- `Result.Failure(ErrorMessages.*, ErrorType.*)` with centralized `Error` entries (stable snake_case `code` + message; never rename a code) - Runtime values in logs, never in Result messages - Controllers use `ProblemFactory.Create(result.Error, result.ErrorType)` for failures diff --git a/templates/.claude/rules/backend-api.md b/templates/.claude/rules/backend-api.md index 9dcc06f..5f898aa 100644 --- a/templates/.claude/rules/backend-api.md +++ b/templates/.claude/rules/backend-api.md @@ -5,7 +5,7 @@ Extends CLAUDE.md Hard Rules with implementation patterns. ## Error Handling - `ProblemFactory.Create(result.Error, result.ErrorType)` for error responses - never `NotFound()`, `BadRequest()`, or anonymous objects -- Client-facing error messages: `ErrorMessages.*` constants only - runtime values go in `ILogger`, never in `Result.Failure()` +- Client-facing errors: `ErrorMessages.*` entries only (`Error` = stable snake_case `code` + message) - runtime values go in `ILogger`, never in `Result.Failure()`. Every ProblemDetails carries `code`; renaming a code is a breaking change ## Controllers diff --git a/templates/.claude/rules/frontend-svelte.md b/templates/.claude/rules/frontend-svelte.md index 2327ed1..0018a32 100644 --- a/templates/.claude/rules/frontend-svelte.md +++ b/templates/.claude/rules/frontend-svelte.md @@ -8,7 +8,7 @@ Extends CLAUDE.md Hard Rules with implementation patterns. ## API Client - File uploads: native `fetch()` with `FormData` - not `browserClient` (openapi-fetch breaks with multipart) -- Error handling: `getErrorMessage(error, fallback)` for simple errors, `handleMutationError()` for forms with validation +- Error handling: `getErrorMessage(error, fallback)` for simple errors, `getErrorMessage(error, fallback, messagesByCode)` / `getErrorCode()` to translate by backend `code` (never match `detail` text), `handleMutationError()` for forms with validation ## Styling - `h-dvh` not `h-screen` for full-height layouts diff --git a/templates/.claude/skills/add-background-job/SKILL.md b/templates/.claude/skills/add-background-job/SKILL.md index 925e622..cbf83e7 100644 --- a/templates/.claude/skills/add-background-job/SKILL.md +++ b/templates/.claude/skills/add-background-job/SKILL.md @@ -60,35 +60,42 @@ services.AddScoped(sp => sp.GetRequiredService +Note: transactional emails are already queued automatically - `ITemplatedEmailSender.SendSafeAsync()` routes through `BackgroundEmailService` -> `EmailDeliveryJob` (`Features/Email/Jobs/`) whenever email and job scheduling are both enabled. Do not wrap email sends in another job. + + +**1. Create the job class** in `src/backend/MyProject.Infrastructure/Features/{Feature}/Jobs/` (or `Features/Jobs/` for cross-cutting jobs): ```csharp -internal sealed class WelcomeEmailJob( - ITemplatedEmailSender templatedEmailSender, - ILogger logger) +internal sealed class ReportGenerationJob( + IReportService reportService, + ILogger logger) { - public async Task ExecuteAsync(string userId, string email) + [AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])] + public async Task ExecuteAsync(Guid reportId, CancellationToken cancellationToken) { - await templatedEmailSender.SendSafeAsync("welcome", new WelcomeModel(email), email, default); - logger.LogInformation("Sent welcome email to user '{UserId}'", userId); + await reportService.GenerateAsync(reportId, cancellationToken); + logger.LogInformation("Generated report '{ReportId}'", reportId); } } ``` -All parameters must be **JSON-serializable** (Hangfire persists them). Never pass `IServiceProvider`, `HttpContext`, or `DbContext` as arguments. +All parameters must be **JSON-serializable** (Hangfire persists them). Never pass `IServiceProvider`, `HttpContext`, or `DbContext` as arguments. A trailing `CancellationToken` parameter is injected by Hangfire (server shutdown) - pass `CancellationToken.None` in the enqueue expression. + +Let exceptions propagate - `[AutomaticRetry]` only retries when the method throws. Retries re-run the whole method, so keep it idempotent. -**2. Register:** `services.AddScoped();` +**2. Register:** `services.AddScoped();` **3. Enqueue:** ```csharp // Fire-and-forget -backgroundJobClient.Enqueue(job => job.ExecuteAsync(user.Id, user.Email)); +backgroundJobClient.Enqueue(job => job.ExecuteAsync(reportId, CancellationToken.None)); // Delayed -backgroundJobClient.Schedule(job => job.ExecuteAsync(user.Id, user.Email), TimeSpan.FromMinutes(30)); +backgroundJobClient.Schedule(job => job.ExecuteAsync(reportId, CancellationToken.None), TimeSpan.FromMinutes(30)); ``` diff --git a/templates/.claude/skills/backend-conventions/SKILL.md b/templates/.claude/skills/backend-conventions/SKILL.md index 7c059f9..44ea2b7 100644 --- a/templates/.claude/skills/backend-conventions/SKILL.md +++ b/templates/.claude/skills/backend-conventions/SKILL.md @@ -9,7 +9,7 @@ user-invocable: false ``` src/backend/ -├── MyProject.Shared/ # Result, ErrorType, ErrorMessages (zero deps) +├── MyProject.Shared/ # Result, Error, ErrorType, ErrorMessages (zero deps) ├── MyProject.Domain/Entities/ # Business entities (BaseEntity) ├── MyProject.Application/ # Interfaces, DTOs, service contracts │ ├── Features/{Feature}/I{Feature}Service.cs @@ -98,7 +98,7 @@ dotnet ef migrations add {Name} \ // Success return Result.Success(entity.Id); -// Static message - prefer ErrorMessages constants +// Static error (code + message) - always an ErrorMessages entry return Result.Failure(ErrorMessages.Admin.UserNotFound, ErrorType.NotFound); // Runtime values go in server-side logs, never in client responses @@ -113,7 +113,7 @@ return Result.Failure(ErrorMessages.Admin.DeleteFailed); | `ErrorType.Forbidden` | 403 | Authenticated but insufficient privileges | | `ErrorType.NotFound` | 404 | Entity not found | -Controller: `ProblemFactory.Create(result.Error, result.ErrorType)` for failures. +Controller: `ProblemFactory.Create(result.Error, result.ErrorType)` for failures. `result.Error` is an `Error` record (`Code`, `Message`); the factory writes `Message` to `detail` and `Code` to the `code` extension. ## Service Pattern @@ -157,13 +157,18 @@ FluentValidation auto-discovered from WebApi assembly. Co-locate validators with | URL fields | `Uri.TryCreate` + restrict to `http`/`https` schemes | | Shared patterns | Extract to `ValidationConstants.cs` | -## Error Messages +## Error Messages and Codes -- Client-facing messages are centralized as `const string` in `ErrorMessages.cs` nested classes +- Client-facing errors are centralized as `static readonly Error` entries in `ErrorMessages.cs` nested classes. `Error(Code, Message)` pairs a stable, machine-readable snake_case code with the human-readable message. +- Codes are derived from the declaring location: `{nested_class}_{field_name}` in snake_case (`ErrorMessages.ExternalAuth.StateExpired` -> `external_auth_state_expired`). `ErrorMessagesTests` enforces the pattern and global uniqueness. +- Every `ProblemDetails` response carries the code in the `code` extension (`ProblemFactory` for controllers/middleware, `ProblemFactory.EnsureCode` in `AddProblemDetails` for framework-generated bodies: `validation_failed` for model validation, snake_case reason phrase such as `not_found` otherwise). `ProblemDetailsSchemaTransformer` documents it in OpenAPI. +- Codes are a public contract: adding one is additive, renaming or removing one is a breaking change for API consumers (frontend maps on codes, not on `detail` text). - Runtime values (role names, user IDs, framework errors): log server-side via `ILogger`, never in `Result.Failure()` -- Identity errors: log `.Description` server-side, return a static `ErrorMessages` constant to the client -- Exception: password validation errors (registration, change, reset) are forwarded as-is -- To add: create `const string` in `ErrorMessages.cs` nested class. Dynamic values go in logs, not in Result. +# @feature auth +- Identity errors: log `.Description` server-side, return a static `ErrorMessages` entry to the client +- Exception: password policy / registration feedback keeps its stable code and overrides only the message: `ErrorMessages.Auth.PasswordPolicyViolation with { Message = errors }` +# @end +- To add: create `public static readonly Error X = new("{class}_{x}", "...")` in the matching `ErrorMessages.cs` nested class. Dynamic values go in logs, not in Result. # @feature auth ## Authorization diff --git a/templates/.claude/skills/new-entity/SKILL.md b/templates/.claude/skills/new-entity/SKILL.md index e7c3184..2a5b223 100644 --- a/templates/.claude/skills/new-entity/SKILL.md +++ b/templates/.claude/skills/new-entity/SKILL.md @@ -24,7 +24,7 @@ Use these as starting points - fill in the specifics from context: 1. Create `src/backend/MyProject.Domain/Entities/{Entity}.cs`: - Extend `BaseEntity`, private setters, protected parameterless ctor, public ctor with `Id = Guid.NewGuid()` 2. If enums: create alongside entity with explicit integer values -3. Add error messages to `src/backend/MyProject.Shared/ErrorMessages.cs` +3. Add `Error` entries (snake_case code + message) to `src/backend/MyProject.Shared/ErrorMessages.cs` **Infrastructure:** diff --git a/templates/.claude/skills/review-pr/references/conventions-summary.md b/templates/.claude/skills/review-pr/references/conventions-summary.md index 9737788..c0c6334 100644 --- a/templates/.claude/skills/review-pr/references/conventions-summary.md +++ b/templates/.claude/skills/review-pr/references/conventions-summary.md @@ -34,7 +34,7 @@ ## Error Flow -Backend `ErrorMessages.*` -> `Result.Failure()` -> `ProblemFactory.Create()` -> `ProblemDetails.detail` +Backend `ErrorMessages.*` (`Error`: code + message) -> `Result.Failure()` -> `ProblemFactory.Create()` -> `ProblemDetails.detail` + `code` ## Dockerfile diff --git a/templates/FILEMAP.md b/templates/FILEMAP.md index 0d29ff5..82366c2 100644 --- a/templates/FILEMAP.md +++ b/templates/FILEMAP.md @@ -28,8 +28,14 @@ Quick-reference for "when you change X, also update Y" and "where does X live?" |---|---| | **Domain entity** (add/rename property) | EF configuration, migration, Application DTOs, WebApi DTOs, mapper | | **Domain entity** (add enum property) | EF config (`.HasComment()`), `EnumSchemaTransformer` handles the rest automatically | -| **`ErrorMessages.cs`** (Shared - add/rename constant) | Service that uses it | -| **`Result.cs`** (Shared - change pattern) | Every service + every controller that matches on `Result` | +| **`ErrorMessages.cs`** (Shared - add/rename `Error` entry) | Service that uses it; `ErrorMessagesTests` (code must be `{nested_class}_{field_name}` snake_case); renaming a code is a breaking API change | +| **`Result.cs`** / **`Error.cs`** (Shared - change pattern) | Every service + every controller that matches on `Result`; `ProblemFactory` | +# @feature auth +| **`ProblemFactory`** (WebApi - ProblemDetails shape, `code` extension) | `ProblemDetailsSchemaTransformer` (OpenAPI), `ExceptionHandlingMiddleware`, `OriginValidationMiddleware`, `RateLimiterExtensions`, `ProblemDetailsAuthorizationHandler`, `Program.cs` (`EnsureCode`), `ProblemDetailsAssert` + `ProblemDetailsCodeTests` (Api.Tests) | +# @end +# @feature !auth +| **`ProblemFactory`** (WebApi - ProblemDetails shape, `code` extension) | `ProblemDetailsSchemaTransformer` (OpenAPI), `ExceptionHandlingMiddleware`, `OriginValidationMiddleware`, `RateLimiterExtensions`, `Program.cs` (`EnsureCode`) | +# @end | **Application interface** (change signature) | Infrastructure service implementation, controller calling the service | | **Application DTO** (add/rename/remove field) | Infrastructure service, WebApi mapper, WebApi request/response DTO | | **Infrastructure EF config** (change mapping) | Run new migration | @@ -52,8 +58,17 @@ Quick-reference for "when you change X, also update Y" and "where does X live?" | **`FileStorageOptions`** (change S3/MinIO config) | `appsettings.json`, `MyProject.AppHost/Program.cs` (`.WithEnvironment()`), `appsettings.Testing.json` | # @end # @feature auth +# @feature jobs +| **`EmailOptions`** (change config shape) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `ServiceCollectionExtensions` (email DI), `EmailOptionsValidationTests`, `EmailServiceRegistrationTests` | +| **`IEmailService`** (change sending contract) | `NoOpEmailService`, `SmtpEmailService`, `BackgroundEmailService`, `EmailDeliveryJob`, `CustomWebApplicationFactory` | +| **`SmtpEmailService`** (change SMTP delivery) | `EmailDeliveryJob` (job executor), `SmtpEmailServiceTests`, `EmailDeliveryJobTests` | +| **`EmailDeliveryJob`** (change job signature/retry policy) | `BackgroundEmailService` (enqueue expression), `EmailDeliveryJobTests`, `BackgroundEmailServiceTests` | +| **Email DI routing** (`AddEmailServices` - background vs direct vs no-op) | `EmailServiceRegistrationTests` | +# @end +# @feature !jobs | **`EmailOptions`** (change config shape) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `ServiceCollectionExtensions` (email DI), `EmailOptionsValidationTests` | | **`IEmailService`** (change sending contract) | `NoOpEmailService`, `SmtpEmailService`, `CustomWebApplicationFactory` | +# @end | **`IEmailTemplateRenderer`** (change rendering contract) | `FluidEmailTemplateRenderer`, `TemplatedEmailSender`, `FluidEmailTemplateRendererTests` | | **`ITemplatedEmailSender`** (change send-safe contract) | `TemplatedEmailSender`, all services calling `SendSafeAsync()`, `TemplatedEmailSenderTests` | | **`EmailTemplateModels.cs`** (add/rename model record) | Matching `.liquid` templates, `FluidEmailTemplateRenderer.CreateOptions()`, services that construct the model, `FluidEmailTemplateRendererTests` | @@ -95,6 +110,9 @@ Quick-reference for "when you change X, also update Y" and "where does X live?" # @feature jobs | **`IRecurringJobDefinition`** (add new job) | Register in `ServiceCollectionExtensions.AddJobScheduling()`, job auto-discovered at startup | | **Job scheduling config** (`ServiceCollectionExtensions.AddJobScheduling`) | `Program.cs` must call `AddJobScheduling()` and `UseJobScheduling()` | +# @feature auth +| **`JobSchedulingOptions`** (change `Enabled` semantics) | Email DI (`AddEmailServices` reads `JobScheduling:Enabled` to pick `BackgroundEmailService`), `EmailServiceRegistrationTests` | +# @end # @end | **`RateLimitPolicies.cs`** (add/rename constant) | `RateLimiterExtensions.cs` policy registration, `RateLimitingOptions.cs` config class, `appsettings.json` section, `[EnableRateLimiting]` attribute on controllers | | **`RateLimitingOptions.cs`** (add/rename option class) | `RateLimiterExtensions.cs`, `appsettings.json`, `appsettings.Development.json` | @@ -109,7 +127,7 @@ Quick-reference for "when you change X, also update Y" and "where does X live?" | **Connection string config** (change format/name) | Verify `MyProject.AppHost/Program.cs` environment variable mapping still works | | **`MyProject.ServiceDefaults/Extensions.cs`** | All projects referencing ServiceDefaults, `Program.cs` `AddServiceDefaults()` call | | **`MyProject.AppHost/Program.cs`** | Verify resource names match `ConnectionStrings:*` and `WithEnvironment` keys match `appsettings.json` option paths | -| **`ProblemDetailsAuthorizationHandler`** | `ProblemDetails` shape, `ErrorMessages.Auth` constants, `Program.cs` registration | +| **`ProblemDetailsAuthorizationHandler`** | `ProblemFactory.CreateProblemDetails`, `ErrorMessages.Auth` entries, `Program.cs` registration | | **OpenAPI transformers** | Regenerate types to verify; check Scalar UI | # @feature captcha | **`CaptchaOptions`** (Infrastructure - Captcha config) | `appsettings.json`, `appsettings.Development.json`, `appsettings.Testing.json`, `TurnstileCaptchaService`, `ServiceCollectionExtensions` | @@ -126,7 +144,7 @@ Files that are frequently referenced in impact tables above. For anything not li ``` src/backend/MyProject.{Layer}/ - Shared: Result.cs, ErrorType.cs, ErrorMessages.cs, PhoneNumberHelper.cs + Shared: Result.cs, Error.cs, ErrorType.cs, ErrorMessages.cs, PhoneNumberHelper.cs Domain: Entities/{Entity}.cs Application: Features/{Feature}/I{Feature}Service.cs Features/{Feature}/Dtos/{Operation}Input.cs, {Entity}Output.cs @@ -154,7 +172,8 @@ src/backend/MyProject.{Layer}/ Authorization/ProblemDetailsAuthorizationHandler.cs # @end Routing/{Name}RouteConstraint.cs - Shared/RateLimitPolicies.cs + Shared/RateLimitPolicies.cs, ProblemFactory.cs + Features/OpenApi/Transformers/{Name}SchemaTransformer.cs Program.cs ``` @@ -172,8 +191,17 @@ src/backend/MyProject.Application/Features/Email/ src/backend/MyProject.Infrastructure/Features/Email/ Services/FluidEmailTemplateRenderer.cs Fluid-based renderer (singleton, cached) Services/TemplatedEmailSender.cs Render+send wrapper (swallows failures) +# @feature jobs + Services/BackgroundEmailService.cs Queues EmailDeliveryJob (Enabled + JobScheduling:Enabled) + Services/SmtpEmailService.cs MailKit SMTP sender (direct when jobs disabled; job executor otherwise) +# @end +# @feature !jobs Services/SmtpEmailService.cs MailKit SMTP sender (when Enabled) +# @end Services/NoOpEmailService.cs Dev/test no-op sender (when disabled) +# @feature jobs + Jobs/EmailDeliveryJob.cs Hangfire job: SMTP send with [AutomaticRetry] backoff +# @end Templates/_base.liquid Shared HTML email layout (header, card, footer) Templates/{name}.liquid HTML body fragment Templates/{name}.text.liquid Plain text variant (optional) @@ -219,6 +247,9 @@ src/backend/tests/ MyProject.Api.Tests/ Fixtures/CustomWebApplicationFactory.cs WebApplicationFactory config Fixtures/TestAuthHandler.cs Fake auth handler + Fixtures/ProblemDetailsAssert.cs Shared ProblemDetails (status/detail/code) assertions + Shared/ProblemFactoryTests.cs ProblemFactory + code fallback tests + Middlewares/ProblemDetailsCodeTests.cs End-to-end `code` presence across the pipeline Contracts/ResponseContracts.cs Frozen response shapes for contract testing Controllers/{Controller}Tests.cs HTTP integration tests Validators/{Validator}Tests.cs FluentValidation tests @@ -234,7 +265,7 @@ src/backend/tests/ |---|---| | `src/backend/MyProject.WebApi/Program.cs` | DI wiring, middleware pipeline | | `src/backend/MyProject.Infrastructure/Persistence/MyProjectDbContext.cs` | DbSets, migrations | -| `src/backend/MyProject.Shared/ErrorMessages.cs` | All static error strings | +| `src/backend/MyProject.Shared/ErrorMessages.cs` | All client-facing errors (`Error` = stable snake_case code + message) | # @feature auth | `src/backend/MyProject.Application/Identity/Constants/AppRoles.cs` | Role definitions | | `src/backend/MyProject.Application/Identity/Constants/AppPermissions.cs` | Permission definitions (reflection-discovered) | diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs index 0862786..9d00bd3 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/Admin/Services/AdminService.cs @@ -626,7 +626,7 @@ await auditService.LogAsync(AuditActions.AdminCreateUser, userId: callerUserId, /// /// Verifies that the caller has a strictly higher role rank than the target user. - /// Returns if the hierarchy check fails. + /// Returns if the hierarchy check fails. /// private async Task EnforceHierarchyAsync(Guid callerUserId, ApplicationUser targetUser) { diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs index 920ae3a..a14c835 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/AuthenticationService.cs @@ -230,7 +230,7 @@ public async Task> Register(RegisterInput input, CancellationToken if (!result.Succeeded) { var errors = string.Join(", ", result.Errors.Select(e => e.Description)); - return Result.Failure(errors); + return Result.Failure(ErrorMessages.Auth.RegistrationInvalid with { Message = errors }); } var roleResult = await userManager.AddToRoleAsync(user, AppRoles.User); @@ -300,7 +300,7 @@ public async Task ChangePasswordAsync(ChangePasswordInput input, Cancell if (!changeResult.Succeeded) { var errors = string.Join(", ", changeResult.Errors.Select(e => e.Description)); - return Result.Failure(errors); + return Result.Failure(ErrorMessages.Auth.PasswordPolicyViolation with { Message = errors }); } await tokenSessionService.RevokeUserTokensAsync(userId.Value, cancellationToken); @@ -372,7 +372,7 @@ public async Task ResetPasswordAsync(ResetPasswordInput input, Cancellat return Result.Failure(ErrorMessages.Auth.ResetPasswordTokenInvalid); } - return Result.Failure(string.Join(" ", errors)); + return Result.Failure(ErrorMessages.Auth.PasswordPolicyViolation with { Message = string.Join(" ", errors) }); } emailToken.IsUsed = true; diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs index 9cd48c4..ed5c89c 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/Authentication/Services/TokenSessionService.cs @@ -195,13 +195,13 @@ public async Task> RefreshTokenAsync( return Result.Success( new AuthenticationOutput(AccessToken: newAccessToken, RefreshToken: newRefreshTokenString)); - Result Fail(string message) + Result Fail(Error error) { if (useCookies) { DeleteAuthCookies(); } - return Result.Failure(message, ErrorType.Unauthorized); + return Result.Failure(error, ErrorType.Unauthorized); } } diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs index 90e8075..ea0b454 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Extensions/ServiceCollectionExtensions.cs @@ -1,8 +1,14 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MyProject.Application.Features.Email; +// @feature jobs +using MyProject.Infrastructure.Features.Email.Jobs; +// @end using MyProject.Infrastructure.Features.Email.Options; using MyProject.Infrastructure.Features.Email.Services; +// @feature jobs +using MyProject.Infrastructure.Features.Jobs.Options; +// @end namespace MyProject.Infrastructure.Features.Email.Extensions; @@ -15,8 +21,19 @@ public static class ServiceCollectionExtensions { /// /// Registers email options, the template rendering pipeline, and the email service. + // @feature jobs + /// + /// Email:Enabled and JobScheduling:Enabled both true: + /// queues sends as Hangfire jobs executed by + /// (SMTP with automatic retries). + /// Email:Enabled only: sends inline. + /// Email:Enabled is false: (log only). + /// + // @end + // @feature !jobs /// When Email:Enabled is true, registers ; /// otherwise registers (log only). + // @end /// /// The application configuration for reading email options. /// The service collection for chaining. @@ -31,7 +48,22 @@ public IServiceCollection AddEmailServices(IConfiguration configuration) .GetSection(EmailOptions.SectionName) .Get() ?? new EmailOptions(); + // @feature jobs + var jobSchedulingEnabled = configuration + .GetSection(JobSchedulingOptions.SectionName) + .Get()?.Enabled ?? new JobSchedulingOptions().Enabled; + + if (options.Enabled && jobSchedulingEnabled) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } + else if (options.Enabled) + // @end + // @feature !jobs if (options.Enabled) + // @end { services.AddScoped(); } diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs new file mode 100644 index 0000000..f7b41dc --- /dev/null +++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Jobs/EmailDeliveryJob.cs @@ -0,0 +1,40 @@ +// @feature jobs +using Hangfire; +using MyProject.Application.Features.Email; +using MyProject.Infrastructure.Features.Email.Services; + +namespace MyProject.Infrastructure.Features.Email.Jobs; + +/// +/// Hangfire job that delivers a single rendered email via SMTP. +/// Enqueued by so that transient SMTP failures +/// are retried automatically instead of being silently lost. Failed deliveries remain +/// in Hangfire storage (visible in the development dashboard), where they can be retried manually. +/// +/// The rendered (including any verification or reset links) is persisted +/// as the job payload until the job expires, so Hangfire storage must be treated as sensitive data. +/// +/// +internal sealed class EmailDeliveryJob(SmtpEmailService smtpEmailService) +{ + /// + /// Maximum number of automatic retries after the first failed attempt. + /// + public const int RetryAttempts = 5; + + /// + /// Sends the given message via SMTP. Any delivery exception is propagated so Hangfire + /// records the failure (with the exception details) and schedules the next retry. + /// + /// is JSON-serialized by Hangfire and persisted with the job; + /// the is injected by Hangfire and signals server shutdown. + /// + /// + /// The rendered email message to deliver. + /// Hangfire-injected token signalling server shutdown. + [AutomaticRetry(Attempts = RetryAttempts, DelaysInSeconds = [30, 120, 600, 1800, 3600], + OnAttemptsExceeded = AttemptsExceededAction.Fail)] + public Task ExecuteAsync(EmailMessage message, CancellationToken cancellationToken) => + smtpEmailService.SendEmailAsync(message, cancellationToken); +} +// @end diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs new file mode 100644 index 0000000..da4fb75 --- /dev/null +++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/BackgroundEmailService.cs @@ -0,0 +1,33 @@ +// @feature jobs +using Hangfire; +using Microsoft.Extensions.Logging; +using MyProject.Application.Features.Email; +using MyProject.Infrastructure.Features.Email.Jobs; + +namespace MyProject.Infrastructure.Features.Email.Services; + +/// +/// Queues emails as Hangfire background jobs instead of sending them inline. +/// Delivery is performed by with automatic retries, +/// so a transient SMTP outage delays the email rather than losing it. +/// Registered when both Email:Enabled and JobScheduling:Enabled are true. +/// +internal class BackgroundEmailService( + IBackgroundJobClient backgroundJobClient, + ILogger logger) : IEmailService +{ + /// + public Task SendEmailAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var jobId = backgroundJobClient.Enqueue(job => + job.ExecuteAsync(message, CancellationToken.None)); + + logger.LogInformation("Email to {To} queued as job {JobId} | Subject: {Subject}", + message.To, jobId, message.Subject); + + return Task.CompletedTask; + } +} +// @end diff --git a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs index c1a2c07..0af2916 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/Email/Services/TemplatedEmailSender.cs @@ -4,9 +4,10 @@ namespace MyProject.Infrastructure.Features.Email.Services; /// -/// Renders an email template and sends the result, swallowing both rendering -/// and delivery failures. Transient provider outages (quota, auth, network) -/// and template errors are logged but never propagate to the caller. +/// Renders an email template and hands the result to , swallowing both +/// rendering and hand-off failures. Depending on configuration the hand-off is a Hangfire enqueue +/// (, retried by EmailDeliveryJob) or a direct SMTP send; +/// either way, failures are logged but never propagate to the caller. /// is re-thrown to respect cooperative cancellation. /// internal class TemplatedEmailSender( diff --git a/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs b/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs index f545956..04eb5f3 100644 --- a/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs +++ b/templates/src/backend/MyProject.Infrastructure/Features/FileStorage/Services/S3FileStorageService.cs @@ -47,12 +47,12 @@ public async Task UploadAsync(string key, byte[] data, string contentTyp catch (AmazonS3Exception ex) { logger.LogError(ex, "Failed to upload object '{Key}' to bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to upload file to storage."); + return Result.Failure(ErrorMessages.FileStorage.UploadFailed); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Unexpected error uploading object '{Key}' to bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to upload file to storage."); + return Result.Failure(ErrorMessages.FileStorage.UploadFailed); } } @@ -76,17 +76,17 @@ public async Task> DownloadAsync(string key, Cancella } catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { - return Result.Failure("File not found.", ErrorType.NotFound); + return Result.Failure(ErrorMessages.FileStorage.NotFound, ErrorType.NotFound); } catch (AmazonS3Exception ex) { logger.LogError(ex, "Failed to download object '{Key}' from bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to retrieve file from storage."); + return Result.Failure(ErrorMessages.FileStorage.DownloadFailed); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Unexpected error downloading object '{Key}' from bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to retrieve file from storage."); + return Result.Failure(ErrorMessages.FileStorage.DownloadFailed); } } @@ -109,12 +109,12 @@ public async Task DeleteAsync(string key, CancellationToken ct) catch (AmazonS3Exception ex) { logger.LogError(ex, "Failed to delete object '{Key}' from bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to delete file from storage."); + return Result.Failure(ErrorMessages.FileStorage.DeleteFailed); } catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Unexpected error deleting object '{Key}' from bucket '{Bucket}'", key, _bucketName); - return Result.Failure("Failed to delete file from storage."); + return Result.Failure(ErrorMessages.FileStorage.DeleteFailed); } } diff --git a/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs b/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs index d5e0c92..4bab9c4 100644 --- a/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs +++ b/templates/src/backend/MyProject.Infrastructure/Identity/Services/UserService.cs @@ -408,7 +408,7 @@ private async Task DeleteUser(ApplicationUser user) { logger.LogWarning("DeleteAsync failed for user '{UserId}': {Errors}", user.Id, string.Join(", ", result.Errors.Select(e => e.Description))); - throw new InvalidOperationException(ErrorMessages.User.DeleteFailed); + throw new InvalidOperationException(ErrorMessages.User.DeleteFailed.Message); } } diff --git a/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs b/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs index 30312a0..53387de 100644 --- a/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs +++ b/templates/src/backend/MyProject.Infrastructure/Persistence/Extensions/PaginationExtensions.cs @@ -21,11 +21,11 @@ public static class PaginationExtensions /// Thrown when page number is less than or equal to 0, or when page size is less than or equal to 0 public static IQueryable Paginate(this IQueryable ts, int pageNumber, int pageSize) { - if (pageNumber <= 0) throw new PaginationException(nameof(pageNumber), ErrorMessages.Pagination.InvalidPage); + if (pageNumber <= 0) throw new PaginationException(nameof(pageNumber), ErrorMessages.Pagination.InvalidPage.Message); pageSize = pageSize switch { - <= 0 => throw new PaginationException(nameof(pageSize), ErrorMessages.Pagination.InvalidPageSize), + <= 0 => throw new PaginationException(nameof(pageSize), ErrorMessages.Pagination.InvalidPageSize.Message), > MaxPageSize => MaxPageSize, _ => pageSize }; diff --git a/templates/src/backend/MyProject.Shared/Error.cs b/templates/src/backend/MyProject.Shared/Error.cs new file mode 100644 index 0000000..6f9a058 --- /dev/null +++ b/templates/src/backend/MyProject.Shared/Error.cs @@ -0,0 +1,22 @@ +namespace MyProject.Shared; + +/// +/// A client-facing error consisting of a stable, machine-readable and a +/// human-readable . +/// +/// +/// +/// Instances are declared once in and passed to Result.Failure(). +/// The code surfaces as the code extension of every ProblemDetails response so that +/// clients can branch on it (or use it as a translation key) instead of matching English text. +/// +/// +/// Codes are snake_case and derived from the declaring location: {NestedClass}_{FieldName} +/// (for example ErrorMessages.ExternalAuth.StateExpired is external_auth_state_expired). +/// Treat codes as a public contract - renaming one is a breaking change for API consumers. +/// +/// Pattern documented in .claude/skills/backend-conventions/SKILL.md. +/// +/// The stable, snake_case, machine-readable identifier of the error condition. +/// The human-readable, client-facing description of the error. +public sealed record Error(string Code, string Message); diff --git a/templates/src/backend/MyProject.Shared/ErrorMessages.cs b/templates/src/backend/MyProject.Shared/ErrorMessages.cs index b0433cc..ffda4fe 100644 --- a/templates/src/backend/MyProject.Shared/ErrorMessages.cs +++ b/templates/src/backend/MyProject.Shared/ErrorMessages.cs @@ -1,13 +1,20 @@ namespace MyProject.Shared; /// -/// User-facing error messages organized by domain area. -/// Constants are used in Result.Failure() calls so that messages remain consistent, -/// greppable, and easy to extract into translation keys later. +/// User-facing errors organized by domain area. Each entry is an pairing a stable, +/// machine-readable code with a human-readable message. Entries are used in Result.Failure() calls +/// so that messages remain consistent, greppable, and translatable by clients via the code. /// -/// All client-facing messages must be static constants - never interpolate runtime values +/// Codes follow {nested_class}_{field_name} in snake_case and are verified by +/// ErrorMessagesTests. Renaming a code is a breaking change for API consumers. +/// +/// +/// All client-facing messages must be static - never interpolate runtime values /// (role names, user IDs, framework error descriptions) into error responses. -/// Log runtime details server-side via ILogger instead. +/// Log runtime details server-side via ILogger instead. The only exceptions are +/// entries explicitly documented as carrying a dynamic message (password policy feedback, +/// rate-limit retry hints); those keep their stable code and override +/// with a with expression. /// /// public static class ErrorMessages @@ -18,25 +25,37 @@ public static class ErrorMessages /// public static class Auth { - public const string LoginInvalidCredentials = "Invalid username or password."; - public const string LoginAccountLocked = "Account is temporarily locked. Please try again later or contact an administrator."; - public const string RegisterRoleAssignFailed = "Account was created but role assignment failed. Please contact an administrator."; - public const string TokenMissing = "Refresh token is missing."; - public const string TokenNotFound = "Refresh token not found."; - public const string TokenInvalidated = "Refresh token has been invalidated."; - public const string TokenReused = "Invalid refresh token."; - public const string TokenExpired = "Refresh token has expired."; - public const string TokenUserNotFound = "Token owner not found."; - public const string NotAuthenticated = "User is not authenticated."; - public const string InsufficientPermissions = "You do not have the required permissions for this action."; - public const string UserNotFound = "User not found."; - public const string PasswordIncorrect = "Current password is incorrect."; - public const string ResetPasswordFailed = "Password reset failed. The link may have expired or already been used."; - public const string ResetPasswordTokenInvalid = "Invalid or expired password reset token."; - public const string EmailVerificationFailed = "Email verification failed. The link may have expired or already been used."; - public const string EmailAlreadyVerified = "Email address is already verified."; - public const string PasswordSameAsCurrent = "New password must be different from your current password."; - public const string CaptchaInvalid = "CAPTCHA verification failed. Please try again."; + public static readonly Error LoginInvalidCredentials = new("auth_login_invalid_credentials", "Invalid username or password."); + public static readonly Error LoginAccountLocked = new("auth_login_account_locked", "Account is temporarily locked. Please try again later or contact an administrator."); + public static readonly Error RegisterRoleAssignFailed = new("auth_register_role_assign_failed", "Account was created but role assignment failed. Please contact an administrator."); + public static readonly Error TokenMissing = new("auth_token_missing", "Refresh token is missing."); + public static readonly Error TokenNotFound = new("auth_token_not_found", "Refresh token not found."); + public static readonly Error TokenInvalidated = new("auth_token_invalidated", "Refresh token has been invalidated."); + public static readonly Error TokenReused = new("auth_token_reused", "Invalid refresh token."); + public static readonly Error TokenExpired = new("auth_token_expired", "Refresh token has expired."); + public static readonly Error TokenUserNotFound = new("auth_token_user_not_found", "Token owner not found."); + public static readonly Error NotAuthenticated = new("auth_not_authenticated", "User is not authenticated."); + public static readonly Error InsufficientPermissions = new("auth_insufficient_permissions", "You do not have the required permissions for this action."); + public static readonly Error UserNotFound = new("auth_user_not_found", "User not found."); + public static readonly Error PasswordIncorrect = new("auth_password_incorrect", "Current password is incorrect."); + public static readonly Error ResetPasswordFailed = new("auth_reset_password_failed", "Password reset failed. The link may have expired or already been used."); + public static readonly Error ResetPasswordTokenInvalid = new("auth_reset_password_token_invalid", "Invalid or expired password reset token."); + public static readonly Error EmailVerificationFailed = new("auth_email_verification_failed", "Email verification failed. The link may have expired or already been used."); + public static readonly Error EmailAlreadyVerified = new("auth_email_already_verified", "Email address is already verified."); + public static readonly Error PasswordSameAsCurrent = new("auth_password_same_as_current", "New password must be different from your current password."); + public static readonly Error CaptchaInvalid = new("auth_captcha_invalid", "CAPTCHA verification failed. Please try again."); + + /// + /// Registration rejected by ASP.NET Identity (password policy, duplicate email). The message is + /// overridden with the Identity error descriptions so users get actionable feedback. + /// + public static readonly Error RegistrationInvalid = new("auth_registration_invalid", "Registration failed. Please check the provided details."); + + /// + /// New password rejected by the password policy. The message is overridden with the + /// Identity error descriptions so users get actionable feedback. + /// + public static readonly Error PasswordPolicyViolation = new("auth_password_policy_violation", "The new password does not meet the password requirements."); } /// @@ -44,15 +63,15 @@ public static class Auth /// public static class TwoFactor { - public const string SetupFailed = "Failed to set up two-factor authentication."; - public const string VerificationFailed = "The verification code is invalid. Please try again."; - public const string AlreadyEnabled = "Two-factor authentication is already enabled."; - public const string NotEnabled = "Two-factor authentication is not enabled."; - public const string DisableFailed = "Failed to disable two-factor authentication."; - public const string ChallengeNotFound = "Two-factor challenge not found or expired."; - public const string ChallengeLocked = "Too many failed attempts. Please log in again."; - public const string RecoveryCodeInvalid = "The recovery code is invalid."; - public const string InvalidCode = "The two-factor code is invalid."; + public static readonly Error SetupFailed = new("two_factor_setup_failed", "Failed to set up two-factor authentication."); + public static readonly Error VerificationFailed = new("two_factor_verification_failed", "The verification code is invalid. Please try again."); + public static readonly Error AlreadyEnabled = new("two_factor_already_enabled", "Two-factor authentication is already enabled."); + public static readonly Error NotEnabled = new("two_factor_not_enabled", "Two-factor authentication is not enabled."); + public static readonly Error DisableFailed = new("two_factor_disable_failed", "Failed to disable two-factor authentication."); + public static readonly Error ChallengeNotFound = new("two_factor_challenge_not_found", "Two-factor challenge not found or expired."); + public static readonly Error ChallengeLocked = new("two_factor_challenge_locked", "Too many failed attempts. Please log in again."); + public static readonly Error RecoveryCodeInvalid = new("two_factor_recovery_code_invalid", "The recovery code is invalid."); + public static readonly Error InvalidCode = new("two_factor_invalid_code", "The two-factor code is invalid."); } /// @@ -60,13 +79,13 @@ public static class TwoFactor /// public static class User { - public const string NotAuthenticated = "User is not authenticated."; - public const string NotFound = "User not found."; - public const string DeleteInvalidPassword = "Invalid password."; - public const string PhoneNumberTaken = "This phone number is already in use."; - public const string UpdateFailed = "Failed to update profile."; - public const string DeleteFailed = "Failed to delete account."; - public const string LastSuperuserCannotDelete = "Cannot delete your account while you are the last superuser."; + public static readonly Error NotAuthenticated = new("user_not_authenticated", "User is not authenticated."); + public static readonly Error NotFound = new("user_not_found", "User not found."); + public static readonly Error DeleteInvalidPassword = new("user_delete_invalid_password", "Invalid password."); + public static readonly Error PhoneNumberTaken = new("user_phone_number_taken", "This phone number is already in use."); + public static readonly Error UpdateFailed = new("user_update_failed", "Failed to update profile."); + public static readonly Error DeleteFailed = new("user_delete_failed", "Failed to delete account."); + public static readonly Error LastSuperuserCannotDelete = new("user_last_superuser_cannot_delete", "Cannot delete your account while you are the last superuser."); } // @end @@ -76,31 +95,31 @@ public static class User /// public static class Admin { - public const string UserNotFound = "User not found."; - public const string HierarchyInsufficient = "You do not have sufficient privileges to manage this user."; - public const string RoleAssignAboveRank = "Cannot assign a role at or above your own rank."; - public const string RoleRemoveAboveRank = "Cannot remove a role at or above your own rank."; - public const string RoleSelfRemove = "Cannot remove a role from your own account."; - public const string LockSelfAction = "Cannot lock your own account."; - public const string DeleteSelfAction = "Cannot delete your own account."; - public const string EmailVerificationRequired = "User must have a verified email address before being assigned this role."; - public const string EmailAlreadyRegistered = "A user with this email address already exists."; - public const string RoleAssignEscalation = "Cannot assign a role that grants permissions you do not hold."; - public const string RoleNotFound = "Role not found."; - public const string RoleAlreadyAssigned = "User already has this role."; - public const string RoleNotAssigned = "User does not have this role."; - public const string LastRoleHolder = "Cannot remove this role - this is the last user holding it."; - public const string RoleAssignFailed = "Failed to assign role."; - public const string RoleRemoveFailed = "Failed to remove role."; - public const string LockFailed = "Failed to lock user account."; - public const string UnlockFailed = "Failed to unlock user account."; - public const string DeleteFailed = "Failed to delete user account."; - public const string EmailVerificationFailed = "Failed to verify email address."; - public const string CreateUserFailed = "Failed to create user account."; - public const string LastSuperuserCannotDelete = "Cannot delete this user - they are the last superuser."; - public const string TwoFactorNotEnabled = "Two-factor authentication is not enabled for this user."; - public const string DisableTwoFactorSelfAction = "You cannot disable your own two-factor authentication from the admin panel."; - public const string DisableTwoFactorFailed = "Failed to disable two-factor authentication."; + public static readonly Error UserNotFound = new("admin_user_not_found", "User not found."); + public static readonly Error HierarchyInsufficient = new("admin_hierarchy_insufficient", "You do not have sufficient privileges to manage this user."); + public static readonly Error RoleAssignAboveRank = new("admin_role_assign_above_rank", "Cannot assign a role at or above your own rank."); + public static readonly Error RoleRemoveAboveRank = new("admin_role_remove_above_rank", "Cannot remove a role at or above your own rank."); + public static readonly Error RoleSelfRemove = new("admin_role_self_remove", "Cannot remove a role from your own account."); + public static readonly Error LockSelfAction = new("admin_lock_self_action", "Cannot lock your own account."); + public static readonly Error DeleteSelfAction = new("admin_delete_self_action", "Cannot delete your own account."); + public static readonly Error EmailVerificationRequired = new("admin_email_verification_required", "User must have a verified email address before being assigned this role."); + public static readonly Error EmailAlreadyRegistered = new("admin_email_already_registered", "A user with this email address already exists."); + public static readonly Error RoleAssignEscalation = new("admin_role_assign_escalation", "Cannot assign a role that grants permissions you do not hold."); + public static readonly Error RoleNotFound = new("admin_role_not_found", "Role not found."); + public static readonly Error RoleAlreadyAssigned = new("admin_role_already_assigned", "User already has this role."); + public static readonly Error RoleNotAssigned = new("admin_role_not_assigned", "User does not have this role."); + public static readonly Error LastRoleHolder = new("admin_last_role_holder", "Cannot remove this role - this is the last user holding it."); + public static readonly Error RoleAssignFailed = new("admin_role_assign_failed", "Failed to assign role."); + public static readonly Error RoleRemoveFailed = new("admin_role_remove_failed", "Failed to remove role."); + public static readonly Error LockFailed = new("admin_lock_failed", "Failed to lock user account."); + public static readonly Error UnlockFailed = new("admin_unlock_failed", "Failed to unlock user account."); + public static readonly Error DeleteFailed = new("admin_delete_failed", "Failed to delete user account."); + public static readonly Error EmailVerificationFailed = new("admin_email_verification_failed", "Failed to verify email address."); + public static readonly Error CreateUserFailed = new("admin_create_user_failed", "Failed to create user account."); + public static readonly Error LastSuperuserCannotDelete = new("admin_last_superuser_cannot_delete", "Cannot delete this user - they are the last superuser."); + public static readonly Error TwoFactorNotEnabled = new("admin_two_factor_not_enabled", "Two-factor authentication is not enabled for this user."); + public static readonly Error DisableTwoFactorSelfAction = new("admin_disable_two_factor_self_action", "You cannot disable your own two-factor authentication from the admin panel."); + public static readonly Error DisableTwoFactorFailed = new("admin_disable_two_factor_failed", "Failed to disable two-factor authentication."); } /// @@ -108,18 +127,18 @@ public static class Admin /// public static class Roles { - public const string SystemRoleCannotBeDeleted = "System roles cannot be deleted."; - public const string SystemRoleCannotBeRenamed = "System roles cannot be renamed."; - public const string RoleNotFound = "Role not found."; - public const string RoleNameTaken = "A role with this name already exists."; - public const string RoleHasUsers = "Cannot delete a role that has users assigned to it."; - public const string InvalidPermission = "One or more permission values are invalid."; - public const string SystemRoleNameReserved = "This name is reserved for a system role."; - public const string SuperuserPermissionsFixed = "Superuser permissions cannot be modified."; - public const string CannotGrantUnheldPermission = "Cannot grant permissions that you do not hold."; - public const string CreateFailed = "Failed to create role."; - public const string UpdateFailed = "Failed to update role."; - public const string DeleteFailed = "Failed to delete role."; + public static readonly Error SystemRoleCannotBeDeleted = new("roles_system_role_cannot_be_deleted", "System roles cannot be deleted."); + public static readonly Error SystemRoleCannotBeRenamed = new("roles_system_role_cannot_be_renamed", "System roles cannot be renamed."); + public static readonly Error RoleNotFound = new("roles_role_not_found", "Role not found."); + public static readonly Error RoleNameTaken = new("roles_role_name_taken", "A role with this name already exists."); + public static readonly Error RoleHasUsers = new("roles_role_has_users", "Cannot delete a role that has users assigned to it."); + public static readonly Error InvalidPermission = new("roles_invalid_permission", "One or more permission values are invalid."); + public static readonly Error SystemRoleNameReserved = new("roles_system_role_name_reserved", "This name is reserved for a system role."); + public static readonly Error SuperuserPermissionsFixed = new("roles_superuser_permissions_fixed", "Superuser permissions cannot be modified."); + public static readonly Error CannotGrantUnheldPermission = new("roles_cannot_grant_unheld_permission", "Cannot grant permissions that you do not hold."); + public static readonly Error CreateFailed = new("roles_create_failed", "Failed to create role."); + public static readonly Error UpdateFailed = new("roles_update_failed", "Failed to update role."); + public static readonly Error DeleteFailed = new("roles_delete_failed", "Failed to delete role."); } // @end @@ -128,8 +147,8 @@ public static class Roles /// public static class Pagination { - public const string InvalidPage = "Page number must be positive."; - public const string InvalidPageSize = "Page size must be positive."; + public static readonly Error InvalidPage = new("pagination_invalid_page", "Page number must be positive."); + public static readonly Error InvalidPageSize = new("pagination_invalid_page_size", "Page size must be positive."); } /// @@ -137,7 +156,13 @@ public static class Pagination /// public static class Server { - public const string InternalError = "An internal error occurred."; + public static readonly Error InternalError = new("server_internal_error", "An internal error occurred."); + + /// + /// Request rejected by the rate limiter. The message is overridden with the retry hint + /// (seconds until the window resets). + /// + public static readonly Error TooManyRequests = new("server_too_many_requests", "Too many requests. Please try again later."); } // @feature jobs @@ -146,9 +171,9 @@ public static class Server /// public static class Jobs { - public const string NotFound = "Job not found."; - public const string TriggerFailed = "Failed to trigger job."; - public const string RestoreFailed = "Failed to restore jobs."; + public static readonly Error NotFound = new("jobs_not_found", "Job not found."); + public static readonly Error TriggerFailed = new("jobs_trigger_failed", "Failed to trigger job."); + public static readonly Error RestoreFailed = new("jobs_restore_failed", "Failed to restore jobs."); } // @end @@ -157,7 +182,7 @@ public static class Jobs /// public static class Security { - public const string CrossOriginRequestBlocked = "Cross-origin requests are not allowed."; + public static readonly Error CrossOriginRequestBlocked = new("security_cross_origin_request_blocked", "Cross-origin requests are not allowed."); } // @feature avatars @@ -166,10 +191,23 @@ public static class Security /// public static class Avatar { - public const string FileTooLarge = "The file exceeds the maximum allowed size of 5 MB."; - public const string UnsupportedFormat = "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF."; - public const string ProcessingFailed = "Failed to process the avatar image."; - public const string NotFound = "Avatar not found."; + public static readonly Error FileTooLarge = new("avatar_file_too_large", "The file exceeds the maximum allowed size of 5 MB."); + public static readonly Error UnsupportedFormat = new("avatar_unsupported_format", "Unsupported image format. Allowed formats: JPEG, PNG, WebP, GIF."); + public static readonly Error ProcessingFailed = new("avatar_processing_failed", "Failed to process the avatar image."); + public static readonly Error NotFound = new("avatar_not_found", "Avatar not found."); + } + // @end + + // @feature file-storage + /// + /// File storage (S3-compatible) error messages. + /// + public static class FileStorage + { + public static readonly Error UploadFailed = new("file_storage_upload_failed", "Failed to upload file to storage."); + public static readonly Error DownloadFailed = new("file_storage_download_failed", "Failed to retrieve file from storage."); + public static readonly Error DeleteFailed = new("file_storage_delete_failed", "Failed to delete file from storage."); + public static readonly Error NotFound = new("file_storage_not_found", "File not found."); } // @end @@ -179,23 +217,23 @@ public static class Avatar /// public static class ExternalAuth { - public const string ProviderNotConfigured = "The requested authentication provider is not configured."; - public const string InvalidState = "Invalid or missing OAuth state token."; - public const string StateExpired = "OAuth state token has expired. Please try again."; - public const string EmailNotVerified = "Your email address must be verified before linking an external account. Please verify your email first."; - public const string AlreadyLinkedToOtherUser = "This external account is already linked to another user."; - public const string ProviderNotLinked = "This provider is not linked to your account."; - public const string CannotUnlinkLastMethod = "Cannot unlink this provider because it is your only sign-in method. Set a password first."; - public const string CodeExchangeFailed = "Failed to exchange the authorization code with the provider."; - public const string ProviderError = "The external authentication provider returned an error."; - public const string InvalidRedirectUri = "The provided redirect URI is not allowed."; - public const string PasswordAlreadySet = "A password is already set for this account."; - public const string PasswordSetFailed = "Failed to set the password. Please try again."; - public const string UnknownProvider = "The specified authentication provider is not recognized."; - public const string ClientSecretRequired = "A client secret is required when enabling a provider that has no existing secret."; - public const string TestConnectionInvalidCredentials = "The provider rejected the credentials. Verify the client ID and secret are correct."; - public const string TestConnectionProviderUnreachable = "Could not reach the authentication provider. Please try again later."; - public const string TestConnectionNotConfigured = "No credentials are configured for this provider."; + public static readonly Error ProviderNotConfigured = new("external_auth_provider_not_configured", "The requested authentication provider is not configured."); + public static readonly Error InvalidState = new("external_auth_invalid_state", "Invalid or missing OAuth state token."); + public static readonly Error StateExpired = new("external_auth_state_expired", "OAuth state token has expired. Please try again."); + public static readonly Error EmailNotVerified = new("external_auth_email_not_verified", "Your email address must be verified before linking an external account. Please verify your email first."); + public static readonly Error AlreadyLinkedToOtherUser = new("external_auth_already_linked_to_other_user", "This external account is already linked to another user."); + public static readonly Error ProviderNotLinked = new("external_auth_provider_not_linked", "This provider is not linked to your account."); + public static readonly Error CannotUnlinkLastMethod = new("external_auth_cannot_unlink_last_method", "Cannot unlink this provider because it is your only sign-in method. Set a password first."); + public static readonly Error CodeExchangeFailed = new("external_auth_code_exchange_failed", "Failed to exchange the authorization code with the provider."); + public static readonly Error ProviderError = new("external_auth_provider_error", "The external authentication provider returned an error."); + public static readonly Error InvalidRedirectUri = new("external_auth_invalid_redirect_uri", "The provided redirect URI is not allowed."); + public static readonly Error PasswordAlreadySet = new("external_auth_password_already_set", "A password is already set for this account."); + public static readonly Error PasswordSetFailed = new("external_auth_password_set_failed", "Failed to set the password. Please try again."); + public static readonly Error UnknownProvider = new("external_auth_unknown_provider", "The specified authentication provider is not recognized."); + public static readonly Error ClientSecretRequired = new("external_auth_client_secret_required", "A client secret is required when enabling a provider that has no existing secret."); + public static readonly Error TestConnectionInvalidCredentials = new("external_auth_test_connection_invalid_credentials", "The provider rejected the credentials. Verify the client ID and secret are correct."); + public static readonly Error TestConnectionProviderUnreachable = new("external_auth_test_connection_provider_unreachable", "Could not reach the authentication provider. Please try again later."); + public static readonly Error TestConnectionNotConfigured = new("external_auth_test_connection_not_configured", "No credentials are configured for this provider."); } // @end @@ -204,8 +242,8 @@ public static class ExternalAuth /// public static class Entity { - public const string AddFailed = "Failed to add entity."; - public const string NotFound = "Entity not found."; - public const string NotDeleted = "Entity could not be deleted."; + public static readonly Error AddFailed = new("entity_add_failed", "Failed to add entity."); + public static readonly Error NotFound = new("entity_not_found", "Entity not found."); + public static readonly Error NotDeleted = new("entity_not_deleted", "Entity could not be deleted."); } } diff --git a/templates/src/backend/MyProject.Shared/Result.cs b/templates/src/backend/MyProject.Shared/Result.cs index 615eb68..86b3fbc 100644 --- a/templates/src/backend/MyProject.Shared/Result.cs +++ b/templates/src/backend/MyProject.Shared/Result.cs @@ -17,9 +17,9 @@ public class Result public bool IsFailure => !IsSuccess; /// - /// Gets the error message if the operation failed. + /// Gets the error (machine-readable code plus human-readable message) if the operation failed. /// - public string? Error { get; } + public Error? Error { get; } /// /// Gets the error category for the failure, or null when not specified (defaults to 400). @@ -30,7 +30,7 @@ public class Result /// /// Initializes a new instance of the class. /// - protected Result(bool isSuccess, string? error = null, ErrorType? errorType = null) + protected Result(bool isSuccess, Error? error = null, ErrorType? errorType = null) { IsSuccess = isSuccess; Error = error; @@ -47,23 +47,23 @@ public static Result Success() } /// - /// Creates a failed result with the specified error message. + /// Creates a failed result with the specified error. /// Defaults to 400 (Validation) at the controller layer. /// - /// The error message. - /// A failed result containing the error message. - public static Result Failure(string error) + /// The error, typically an entry. + /// A failed result containing the error. + public static Result Failure(Error error) { return new Result(false, error, Shared.ErrorType.Validation); } /// - /// Creates a failed result with the specified error message and error category. + /// Creates a failed result with the specified error and error category. /// - /// The error message. + /// The error, typically an entry. /// The error category for HTTP status code mapping. - /// A failed result containing the error message and error type. - public static Result Failure(string error, ErrorType errorType) + /// A failed result containing the error and error type. + public static Result Failure(Error error, ErrorType errorType) { return new Result(false, error, errorType); } @@ -88,7 +88,7 @@ public class Result : Result ? _value! : throw new InvalidOperationException("Cannot access Value on a failed result."); - private Result(bool isSuccess, string? error, T? value, ErrorType? errorType = null) + private Result(bool isSuccess, Error? error, T? value, ErrorType? errorType = null) : base(isSuccess, error, errorType) { _value = value; @@ -105,23 +105,23 @@ public static Result Success(T value) } /// - /// Creates a failed result with the specified error message. + /// Creates a failed result with the specified error. /// Defaults to 400 (Validation) at the controller layer. /// - /// The error message. - /// A failed result containing the error message. - public new static Result Failure(string error) + /// The error, typically an entry. + /// A failed result containing the error. + public new static Result Failure(Error error) { return new Result(false, error, default, Shared.ErrorType.Validation); } /// - /// Creates a failed result with the specified error message and error category. + /// Creates a failed result with the specified error and error category. /// - /// The error message. + /// The error, typically an entry. /// The error category for HTTP status code mapping. - /// A failed result containing the error message and error type. - public new static Result Failure(string error, ErrorType errorType) + /// A failed result containing the error and error type. + public new static Result Failure(Error error, ErrorType errorType) { return new Result(false, error, default, errorType); } diff --git a/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs b/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs index 12ed2d5..7aee666 100644 --- a/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs +++ b/templates/src/backend/MyProject.WebApi/Authorization/ProblemDetailsAuthorizationHandler.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization.Policy; using Microsoft.AspNetCore.Mvc; using MyProject.Shared; +using MyProject.WebApi.Shared; namespace MyProject.WebApi.Authorization; @@ -28,11 +29,8 @@ public async Task HandleAsync( await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = httpContext, - ProblemDetails = new ProblemDetails - { - Status = StatusCodes.Status401Unauthorized, - Detail = ErrorMessages.Auth.NotAuthenticated - } + ProblemDetails = ProblemFactory.CreateProblemDetails( + ErrorMessages.Auth.NotAuthenticated, StatusCodes.Status401Unauthorized) }); return; } @@ -44,11 +42,8 @@ await problemDetailsService.WriteAsync(new ProblemDetailsContext await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = httpContext, - ProblemDetails = new ProblemDetails - { - Status = StatusCodes.Status403Forbidden, - Detail = ErrorMessages.Auth.InsufficientPermissions - } + ProblemDetails = ProblemFactory.CreateProblemDetails( + ErrorMessages.Auth.InsufficientPermissions, StatusCodes.Status403Forbidden) }); return; } diff --git a/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs b/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs index 3568cf6..7acc232 100644 --- a/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs +++ b/templates/src/backend/MyProject.WebApi/Extensions/RateLimiterExtensions.cs @@ -2,6 +2,7 @@ using System.Threading.RateLimiting; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using MyProject.Shared; using MyProject.WebApi.Options; using MyProject.WebApi.Shared; @@ -92,15 +93,15 @@ private static void ConfigureOnRejected(RateLimiterOptions options) context.HttpContext.Response.Headers.RetryAfter = retryAfterSeconds.ToString(CultureInfo.InvariantCulture); var problemDetailsService = context.HttpContext.RequestServices.GetRequiredService(); + var error = ErrorMessages.Server.TooManyRequests with + { + Message = $"Too many requests. Please try again in {retryAfterSeconds} seconds." + }; + await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = context.HttpContext, - ProblemDetails = new ProblemDetails - { - Status = StatusCodes.Status429TooManyRequests, - Title = "Too Many Requests", - Detail = $"Too many requests. Please try again in {retryAfterSeconds} seconds." - } + ProblemDetails = ProblemFactory.CreateProblemDetails(error, StatusCodes.Status429TooManyRequests) }); }; } diff --git a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs index 78d596d..1ddc9aa 100644 --- a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs +++ b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Extensions/WebApplicationBuilderExtensions.cs @@ -23,6 +23,7 @@ public static WebApplicationBuilder AddOpenApiSpecification(this WebApplicationB opt.AddOperationTransformer(); opt.AddSchemaTransformer(); opt.AddSchemaTransformer(); + opt.AddSchemaTransformer(); }); return builder; diff --git a/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs new file mode 100644 index 0000000..be22552 --- /dev/null +++ b/templates/src/backend/MyProject.WebApi/Features/OpenApi/Transformers/ProblemDetailsSchemaTransformer.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OpenApi; +using Microsoft.OpenApi; +using MyProject.WebApi.Shared; + +namespace MyProject.WebApi.Features.OpenApi.Transformers; + +/// +/// Documents the code extension on every -derived schema. +/// The extension is written at runtime via ProblemDetails.Extensions, which the schema +/// generator cannot see, so it is declared here to keep generated clients (frontend v1.d.ts) accurate. +/// +/// See .claude/skills/backend-conventions/SKILL.md for the error code convention. +internal sealed class ProblemDetailsSchemaTransformer : IOpenApiSchemaTransformer +{ + /// + public Task TransformAsync( + OpenApiSchema schema, + OpenApiSchemaTransformerContext context, + CancellationToken cancellationToken) + { + if (!typeof(ProblemDetails).IsAssignableFrom(context.JsonTypeInfo.Type)) + { + return Task.CompletedTask; + } + + schema.Properties ??= new Dictionary(); + schema.Properties[ProblemFactory.CodeExtensionKey] = new OpenApiSchema + { + Type = JsonSchemaType.String, + Description = "Stable, machine-readable error code (snake_case). Use it to branch on the error " + + "or as a translation key instead of matching the human-readable detail." + }; + + return Task.CompletedTask; + } +} diff --git a/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs b/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs index 533fe01..945eee3 100644 --- a/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs +++ b/templates/src/backend/MyProject.WebApi/Middlewares/ExceptionHandlingMiddleware.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using MyProject.Infrastructure.Persistence.Exceptions; using MyProject.Shared; +using MyProject.WebApi.Shared; namespace MyProject.WebApi.Middlewares; @@ -31,13 +32,13 @@ public async Task Invoke(HttpContext context) { logger.LogWarning(keyNotFoundEx, "A KeyNotFoundException occurred."); await HandleExceptionAsync(context, keyNotFoundEx, HttpStatusCode.NotFound, - customMessage: ErrorMessages.Entity.NotFound); + ErrorMessages.Entity.NotFound); } catch (PaginationException paginationEx) { logger.LogWarning(paginationEx, "A PaginationException occurred."); await HandleExceptionAsync(context, paginationEx, HttpStatusCode.BadRequest, - customMessage: paginationEx.ParamName is "pageSize" + paginationEx.ParamName is "pageSize" ? ErrorMessages.Pagination.InvalidPageSize : ErrorMessages.Pagination.InvalidPage); } @@ -45,7 +46,7 @@ await HandleExceptionAsync(context, paginationEx, HttpStatusCode.BadRequest, { logger.LogError(e, "An unhandled exception occurred."); await HandleExceptionAsync(context, e, HttpStatusCode.InternalServerError, - customMessage: ErrorMessages.Server.InternalError); + ErrorMessages.Server.InternalError); } } @@ -53,16 +54,12 @@ private async Task HandleExceptionAsync( HttpContext context, Exception exception, HttpStatusCode statusCode, - string? customMessage = null) + Error error) { var status = (int)statusCode; context.Response.StatusCode = status; - var problemDetails = new ProblemDetails - { - Status = status, - Detail = customMessage ?? ErrorMessages.Server.InternalError - }; + var problemDetails = ProblemFactory.CreateProblemDetails(error, status); if (env.IsDevelopment() && exception.StackTrace is not null) { diff --git a/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs b/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs index 6c59509..f9a8fb9 100644 --- a/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs +++ b/templates/src/backend/MyProject.WebApi/Middlewares/OriginValidationMiddleware.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using MyProject.Shared; +using MyProject.WebApi.Shared; using CorsOptions = MyProject.WebApi.Options.CorsOptions; namespace MyProject.WebApi.Middlewares; @@ -74,11 +74,8 @@ public async Task Invoke(HttpContext context) await problemDetailsService.WriteAsync(new ProblemDetailsContext { HttpContext = context, - ProblemDetails = new ProblemDetails - { - Status = StatusCodes.Status403Forbidden, - Detail = ErrorMessages.Security.CrossOriginRequestBlocked - } + ProblemDetails = ProblemFactory.CreateProblemDetails( + ErrorMessages.Security.CrossOriginRequestBlocked, StatusCodes.Status403Forbidden) }); } } diff --git a/templates/src/backend/MyProject.WebApi/Program.cs b/templates/src/backend/MyProject.WebApi/Program.cs index 7de07ec..a28abec 100644 --- a/templates/src/backend/MyProject.WebApi/Program.cs +++ b/templates/src/backend/MyProject.WebApi/Program.cs @@ -50,6 +50,7 @@ // @end // @end // @end +using MyProject.WebApi.Shared; using Serilog; using LoggerConfigurationExtensions = MyProject.Infrastructure.Logging.Extensions.LoggerConfigurationExtensions; @@ -165,6 +166,7 @@ options.CustomizeProblemDetails = context => { context.ProblemDetails.Instance = context.HttpContext.Request.Path; + ProblemFactory.EnsureCode(context.ProblemDetails); }; }); diff --git a/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs b/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs index 8545e90..47dfbc2 100644 --- a/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs +++ b/templates/src/backend/MyProject.WebApi/Shared/ProblemFactory.cs @@ -5,30 +5,90 @@ namespace MyProject.WebApi.Shared; /// -/// Creates -based action results from atomic error values. -/// Produces a consistent body with Title set from the -/// status code's reason phrase. +/// Creates bodies and action results from values. +/// Every body carries the human-readable message in detail and the stable, machine-readable +/// error code in the code extension so clients never have to match on English text. /// internal static class ProblemFactory { /// - /// Returns a response with the specified detail and error type. + /// Name of the entry that carries the machine-readable error code. /// - /// The error detail message. + public const string CodeExtensionKey = "code"; + + /// + /// Code applied to framework-generated validation failures (), + /// whose field-level details live in errors. + /// + public const string ValidationFailedCode = "validation_failed"; + + /// + /// Returns a action result for the specified error and error type. + /// + /// The error to report. Null is tolerated for defensive call sites and yields no detail or code. /// The error category. Defaults to 400 Bad Request when null. - public static ObjectResult Create(string? detail, ErrorType? errorType = null) + public static ObjectResult Create(Error? error, ErrorType? errorType = null) { - var code = ToStatusCode(errorType); + var statusCode = ToStatusCode(errorType); var problemDetails = new ProblemDetails { - Status = code, - Detail = detail, - Title = ReasonPhrases.GetReasonPhrase(code), - Type = $"https://tools.ietf.org/html/rfc9110#section-15.5.{code - 399}" + Status = statusCode, + Title = ReasonPhrases.GetReasonPhrase(statusCode), + Type = $"https://tools.ietf.org/html/rfc9110#section-15.5.{statusCode - 399}" + }; + + if (error is not null) + { + problemDetails.Detail = error.Message; + problemDetails.Extensions[CodeExtensionKey] = error.Code; + } + + return new ObjectResult(problemDetails) { StatusCode = statusCode }; + } + + /// + /// Creates a bare body (status, detail, code) for middleware and handlers + /// that write through , which fills in title and type defaults. + /// + /// The error to report. + /// The HTTP status code of the response. + public static ProblemDetails CreateProblemDetails(Error error, int statusCode) + { + return new ProblemDetails + { + Status = statusCode, + Detail = error.Message, + Extensions = { [CodeExtensionKey] = error.Code } }; + } + + /// + /// Ensures a code extension is present on framework-generated problem details + /// (model validation, status code pages, unmatched routes). Bodies that already carry a code are left untouched. + /// Validation failures get ; everything else falls back to the + /// snake_case reason phrase of the status (for example not_found, method_not_allowed). + /// + /// The problem details being written. + public static void EnsureCode(ProblemDetails problemDetails) + { + if (problemDetails.Extensions.ContainsKey(CodeExtensionKey)) + { + return; + } + + problemDetails.Extensions[CodeExtensionKey] = problemDetails is HttpValidationProblemDetails + ? ValidationFailedCode + : ToFallbackCode(problemDetails.Status ?? StatusCodes.Status500InternalServerError); + } + + private static string ToFallbackCode(int statusCode) + { + var reasonPhrase = ReasonPhrases.GetReasonPhrase(statusCode); - return new ObjectResult(problemDetails) { StatusCode = code }; + return string.IsNullOrEmpty(reasonPhrase) + ? $"http_{statusCode}" + : reasonPhrase.Replace(' ', '_').Replace('-', '_').ToLowerInvariant(); } private static int ToStatusCode(ErrorType? errorType) => errorType switch diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs index 5b5a916..ae1396a 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerDisableTwoFactorTests.cs @@ -1,7 +1,6 @@ // @feature 2fa using System.Net; using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Fixtures; using MyProject.Application.Identity.Constants; using MyProject.Shared; @@ -29,16 +28,6 @@ private static HttpRequestMessage Post(string url, string auth, HttpContent? con return request; } - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } [Fact] public async Task DisableTwoFactor_WithPermission_Returns204() @@ -93,7 +82,7 @@ public async Task DisableTwoFactor_NotFound_Returns404() JsonContent.Create(new { Reason = (string?)null }))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound); } [Fact] @@ -110,7 +99,7 @@ public async Task DisableTwoFactor_TwoFactorNotEnabled_Returns400() JsonContent.Create(new { Reason = (string?)null }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.TwoFactorNotEnabled); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.TwoFactorNotEnabled); } [Fact] diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs index da46c66..eb8e200 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AdminControllerTests.cs @@ -52,16 +52,6 @@ private static HttpRequestMessage Delete(string url, string auth) return request; } - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } #region ListUsers @@ -155,7 +145,7 @@ public async Task GetUser_NotFound_Returns404WithProblemDetails() Get($"/api/v1/admin/users/{userId}", TestAuth.WithPermissions(AppPermissions.Users.View))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound); } [Fact] @@ -331,7 +321,7 @@ public async Task AssignRole_ServiceFailure_Returns400WithProblemDetails() JsonContent.Create(new { Role = "Admin" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.RoleAssignAboveRank); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.RoleAssignAboveRank); } [Fact] @@ -348,7 +338,7 @@ public async Task AssignRole_CustomRoleEscalation_Returns403() JsonContent.Create(new { Role = "PrivilegedRole" }))); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - await AssertProblemDetailsAsync(response, 403, ErrorMessages.Admin.RoleAssignEscalation); + await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Admin.RoleAssignEscalation); } [Fact] @@ -365,7 +355,7 @@ public async Task AssignRole_EmailNotVerified_Returns400() JsonContent.Create(new { Role = "User" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.EmailVerificationRequired); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.EmailVerificationRequired); } #endregion @@ -467,7 +457,7 @@ public async Task DeleteUser_NotFound_Returns404WithProblemDetails() Delete($"/api/v1/admin/users/{userId}", TestAuth.WithPermissions(AppPermissions.Users.Manage))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound); } [Fact] @@ -516,7 +506,7 @@ public async Task VerifyEmail_NotFound_Returns404() Post($"/api/v1/admin/users/{userId}/verify-email", TestAuth.WithPermissions(AppPermissions.Users.Manage))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound); } [Fact] @@ -530,7 +520,7 @@ public async Task VerifyEmail_AlreadyVerified_Returns400() Post($"/api/v1/admin/users/{userId}/verify-email", TestAuth.WithPermissions(AppPermissions.Users.Manage))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified); } #endregion @@ -570,7 +560,7 @@ public async Task SendPasswordReset_NotFound_Returns404() Post($"/api/v1/admin/users/{userId}/send-password-reset", TestAuth.WithPermissions(AppPermissions.Users.Manage))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Admin.UserNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Admin.UserNotFound); } #endregion @@ -616,7 +606,7 @@ public async Task CreateUser_DuplicateEmail_Returns400() JsonContent.Create(new { Email = "existing@test.com" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Admin.EmailAlreadyRegistered); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Admin.EmailAlreadyRegistered); } #endregion @@ -682,7 +672,7 @@ public async Task GetRole_NotFound_Returns404WithProblemDetails() Get($"/api/v1/admin/roles/{roleId}", TestAuth.WithPermissions(AppPermissions.Roles.View))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Roles.RoleNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Roles.RoleNotFound); } [Fact] @@ -806,7 +796,7 @@ public async Task SetPermissions_CallerLacksPermission_Returns403() JsonContent.Create(new { Permissions = new[] { "users.view" } }))); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - await AssertProblemDetailsAsync(response, 403, ErrorMessages.Roles.CannotGrantUnheldPermission); + await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Roles.CannotGrantUnheldPermission); } [Fact] @@ -823,7 +813,7 @@ public async Task SetPermissions_RoleNotFound_Returns404() JsonContent.Create(new { Permissions = new[] { "users.view" } }))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Roles.RoleNotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Roles.RoleNotFound); } [Fact] diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs index 22bc51a..af9210b 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/AuthControllerTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Contracts; using MyProject.Api.Tests.Fixtures; using MyProject.Application.Cookies.Constants; @@ -30,16 +29,6 @@ private static HttpRequestMessage Post(string url, HttpContent? content = null, return msg; } - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } #region Login @@ -75,7 +64,7 @@ public async Task Login_InvalidCredentials_Returns401WithProblemDetails() Post("/api/auth/login", JsonContent.Create(new { Username = "test@example.com", Password = "wrong" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.LoginInvalidCredentials); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.LoginInvalidCredentials); } [Fact] @@ -136,13 +125,13 @@ public async Task Register_ServiceFailure_Returns400WithProblemDetails() .Returns(true); // @end _factory.AuthenticationService.Register(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Email already registered.")); + .Returns(Result.Failure(ErrorMessages.Auth.RegistrationInvalid)); var response = await _client.SendAsync( Post("/api/auth/register", JsonContent.Create(new { Email = "dup@example.com", Password = "Password1!", CaptchaToken = "valid-token" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, "Email already registered."); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.RegistrationInvalid); } // @feature captcha @@ -156,7 +145,7 @@ public async Task Register_InvalidCaptcha_Returns400() Post("/api/auth/register", JsonContent.Create(new { Email = "new@example.com", Password = "Password1!", CaptchaToken = "invalid-token" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid); } [Fact] @@ -214,7 +203,7 @@ public async Task Refresh_MissingToken_Returns401WithProblemDetails() Post("/api/auth/refresh", JsonContent.Create(new { }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.TokenMissing); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.TokenMissing); } [Fact] @@ -246,7 +235,7 @@ public async Task Refresh_InvalidToken_Returns401WithProblemDetails() Post("/api/auth/refresh", JsonContent.Create(new { RefreshToken = "invalid" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.TokenInvalidated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.TokenInvalidated); } [Fact] @@ -285,7 +274,7 @@ public async Task Logout_Unauthenticated_Returns401() var response = await _client.SendAsync(Post("/api/auth/logout")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } #endregion @@ -322,7 +311,7 @@ public async Task ChangePassword_ServiceFailure_Returns400WithProblemDetails() { _factory.AuthenticationService.ChangePasswordAsync( Arg.Any(), Arg.Any()) - .Returns(Result.Failure("Current password is incorrect.")); + .Returns(Result.Failure(ErrorMessages.Auth.PasswordIncorrect)); var response = await _client.SendAsync( Post("/api/auth/password/change", @@ -330,7 +319,7 @@ public async Task ChangePassword_ServiceFailure_Returns400WithProblemDetails() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, "Current password is incorrect."); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.PasswordIncorrect); } #endregion @@ -383,7 +372,7 @@ public async Task ForgotPassword_InvalidCaptcha_Returns400() Post("/api/auth/password/forgot", JsonContent.Create(new { Email = "test@example.com", CaptchaToken = "invalid-token" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.CaptchaInvalid); } [Fact] @@ -432,7 +421,7 @@ public async Task ResetPassword_InvalidToken_Returns400WithProblemDetails() }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.ResetPasswordTokenInvalid); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.ResetPasswordTokenInvalid); } [Fact] @@ -494,7 +483,7 @@ public async Task VerifyEmail_InvalidToken_Returns400WithProblemDetails() }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailVerificationFailed); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailVerificationFailed); } [Fact] @@ -541,7 +530,7 @@ public async Task ResendVerification_AlreadyVerified_Returns400() Post("/api/auth/email/resend-verification", auth: TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Auth.EmailAlreadyVerified); } #endregion @@ -604,7 +593,7 @@ public async Task TwoFactorLogin_InvalidCode_Returns401() Post("/api/auth/two-factor/login", JsonContent.Create(new { ChallengeToken = "challenge-token", Code = "000000" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.TwoFactor.InvalidCode); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.TwoFactor.InvalidCode); } [Fact] @@ -658,7 +647,7 @@ public async Task TwoFactorRecoveryLogin_InvalidCode_Returns401() Post("/api/auth/two-factor/login/recovery", JsonContent.Create(new { ChallengeToken = "challenge-token", RecoveryCode = "BAD-CODE" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.TwoFactor.RecoveryCodeInvalid); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.TwoFactor.RecoveryCodeInvalid); } [Fact] @@ -698,7 +687,7 @@ public async Task TwoFactorSetup_Unauthenticated_Returns401() Post("/api/auth/two-factor/setup")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } #endregion @@ -736,7 +725,7 @@ public async Task TwoFactorVerifySetup_InvalidCode_Returns400() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.VerificationFailed); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.VerificationFailed); } [Fact] @@ -747,7 +736,7 @@ public async Task TwoFactorVerifySetup_Unauthenticated_Returns401() JsonContent.Create(new { Code = "123456" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } #endregion @@ -776,7 +765,7 @@ public async Task TwoFactorDisable_Unauthenticated_Returns401() JsonContent.Create(new { Password = "Password1!" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } [Fact] @@ -791,7 +780,7 @@ public async Task TwoFactorDisable_ServiceFailure_Returns400() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled); } #endregion @@ -824,7 +813,7 @@ public async Task TwoFactorRegenerateCodes_Unauthenticated_Returns401() JsonContent.Create(new { Password = "Password1!" }))); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } [Fact] @@ -839,7 +828,7 @@ public async Task TwoFactorRegenerateCodes_ServiceFailure_Returns400() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.TwoFactor.NotEnabled); } #endregion diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs index 8bef4f2..0600f10 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/ExternalAuthControllerTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Contracts; using MyProject.Api.Tests.Fixtures; using MyProject.Application.Features.Authentication.Dtos; @@ -36,16 +35,6 @@ private static HttpRequestMessage Get(string url, string? auth = null) return msg; } - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } #region GetProviders @@ -118,7 +107,7 @@ public async Task ExternalChallenge_InvalidProvider_Returns400() JsonContent.Create(new { Provider = "Unknown", RedirectUri = "https://example.com/oauth/callback" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.ProviderNotConfigured); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.ProviderNotConfigured); } [Fact] @@ -223,7 +212,7 @@ public async Task ExternalCallback_InvalidState_Returns400() JsonContent.Create(new { Code = "auth-code", State = "bad-state" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.InvalidState); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.InvalidState); } [Fact] @@ -269,7 +258,7 @@ public async Task ExternalUnlink_LastAuthMethod_Returns400() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.CannotUnlinkLastMethod); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.CannotUnlinkLastMethod); } [Fact] @@ -315,7 +304,7 @@ public async Task SetPassword_AlreadyHasPassword_Returns400() TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.ExternalAuth.PasswordAlreadySet); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.PasswordAlreadySet); } [Fact] diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs index 8022fad..4d860e2 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/OAuthProvidersControllerTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Contracts; using MyProject.Api.Tests.Fixtures; using MyProject.Application.Features.Authentication.Dtos; @@ -44,16 +43,6 @@ private static HttpRequestMessage Post(string url, string auth) return request; } - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } #region ListProviders @@ -140,7 +129,7 @@ public async Task UpdateProvider_UnknownProvider_Returns400() JsonContent.Create(new { IsEnabled = true, ClientId = "id", ClientSecret = "secret" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, "The specified authentication provider is not recognized."); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.UnknownProvider); } [Fact] @@ -217,8 +206,7 @@ public async Task UpdateProvider_EnabledWithoutSecretAndNoExisting_Returns400() JsonContent.Create(new { IsEnabled = true, ClientId = "new-id" }))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, - "A client secret is required when enabling a provider that has no existing secret."); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.ClientSecretRequired); } [Fact] @@ -264,7 +252,7 @@ public async Task TestConnection_InvalidCredentials_Returns400() TestAuth.WithPermissions(AppPermissions.OAuthProviders.Manage))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.TestConnectionInvalidCredentials); } @@ -280,7 +268,7 @@ public async Task TestConnection_NotConfigured_Returns400() TestAuth.WithPermissions(AppPermissions.OAuthProviders.Manage))); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.ExternalAuth.TestConnectionNotConfigured); } diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs index ea97ca2..43afe12 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Controllers/UsersControllerTests.cs @@ -1,7 +1,6 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Contracts; using MyProject.Api.Tests.Fixtures; // @feature audit @@ -71,16 +70,6 @@ private static MultipartFormDataContent CreateAvatarUpload( } // @end - private static async Task AssertProblemDetailsAsync( - HttpResponseMessage response, int expectedStatus, string? expectedDetail = null) - { - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); - if (expectedDetail is not null) - { - Assert.Equal(expectedDetail, json.GetProperty("detail").GetString()); - } - } #region GetMe @@ -109,7 +98,7 @@ public async Task GetMe_Unauthenticated_Returns401() var response = await _client.SendAsync(Get("/api/users/me")); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - await AssertProblemDetailsAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); } [Fact] @@ -121,7 +110,7 @@ public async Task GetMe_ServiceFailure_Returns400WithProblemDetails() var response = await _client.SendAsync(Get("/api/users/me", TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.User.NotFound); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.User.NotFound); } #endregion @@ -198,7 +187,7 @@ public async Task UploadAvatar_ServiceFailure_Returns400() Put("/api/users/me/avatar", CreateAvatarUpload(), TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.Avatar.FileTooLarge); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.Avatar.FileTooLarge); } #endregion @@ -264,7 +253,7 @@ public async Task GetAvatar_NotFound_Returns404() Get($"/api/users/{userId}/avatar", TestAuth.User())); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - await AssertProblemDetailsAsync(response, 404, ErrorMessages.Avatar.NotFound); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Avatar.NotFound); } [Fact] @@ -316,7 +305,7 @@ public async Task DeleteMe_ServiceFailure_Returns400WithProblemDetails() Delete("/api/users/me", JsonContent.Create(new { Password = "WrongPass1!" }), TestAuth.User())); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - await AssertProblemDetailsAsync(response, 400, ErrorMessages.User.DeleteInvalidPassword); + await ProblemDetailsAssert.MatchesAsync(response, 400, ErrorMessages.User.DeleteInvalidPassword); } #endregion diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs b/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs new file mode 100644 index 0000000..acdf736 --- /dev/null +++ b/templates/src/backend/tests/MyProject.Api.Tests/Fixtures/ProblemDetailsAssert.cs @@ -0,0 +1,38 @@ +using System.Net.Http.Json; +using System.Text.Json; +using MyProject.Shared; + +namespace MyProject.Api.Tests.Fixtures; + +/// +/// Shared assertions for ProblemDetails (RFC 9457) responses. +/// +public static class ProblemDetailsAssert +{ + /// + /// Asserts that the response body is a ProblemDetails with the expected status and, when + /// is given, the expected detail and code. + /// + public static async Task MatchesAsync(HttpResponseMessage response, int expectedStatus, Error? expectedError = null) + { + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); + + if (expectedError is not null) + { + Assert.Equal(expectedError.Message, json.GetProperty("detail").GetString()); + Assert.Equal(expectedError.Code, json.GetProperty("code").GetString()); + } + } + + /// + /// Asserts that the response body is a ProblemDetails with the expected status and code. + /// Use for framework-generated bodies (validation, status code pages) that have no ErrorMessages entry. + /// + public static async Task HasCodeAsync(HttpResponseMessage response, int expectedStatus, string expectedCode) + { + var json = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expectedStatus, json.GetProperty("status").GetInt32()); + Assert.Equal(expectedCode, json.GetProperty("code").GetString()); + } +} diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs index a739bfa..d503c49 100644 --- a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs +++ b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/OriginValidationMiddlewareTests.cs @@ -1,6 +1,4 @@ using System.Net; -using System.Net.Http.Json; -using System.Text.Json; using MyProject.Api.Tests.Fixtures; using MyProject.Shared; @@ -127,9 +125,7 @@ public async Task PostRequest_WithDisallowedOrigin_ReturnsProblemDetails() Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); - var json = await response.Content.ReadFromJsonAsync(); - Assert.Equal(403, json.GetProperty("status").GetInt32()); - Assert.Equal(ErrorMessages.Security.CrossOriginRequestBlocked, json.GetProperty("detail").GetString()); + await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Security.CrossOriginRequestBlocked); } // ── Origin case insensitivity ─────────────────────────────────── diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs new file mode 100644 index 0000000..5656e60 --- /dev/null +++ b/templates/src/backend/tests/MyProject.Api.Tests/Middlewares/ProblemDetailsCodeTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Net.Http.Json; +using MyProject.Api.Tests.Fixtures; +using MyProject.Application.Features.Authentication.Dtos; +using MyProject.Shared; +using MyProject.WebApi.Shared; +using NSubstitute.ExceptionExtensions; + +namespace MyProject.Api.Tests.Middlewares; + +/// +/// Verifies that every ProblemDetails response carries a machine-readable code extension, +/// regardless of which part of the pipeline produced it (controller, authorization handler, +/// exception middleware, model validation, status code pages). +/// +public class ProblemDetailsCodeTests : IClassFixture, IDisposable +{ + private readonly CustomWebApplicationFactory _factory; + private readonly HttpClient _client; + + public ProblemDetailsCodeTests(CustomWebApplicationFactory factory) + { + _factory = factory; + _factory.ResetMocks(); + _client = factory.CreateClient(); + } + + public void Dispose() => _client.Dispose(); + + [Fact] + public async Task ControllerFailure_IncludesErrorCode() + { + _factory.UserService.GetCurrentUserAsync(Arg.Any()) + .Returns(Result.Failure(ErrorMessages.User.NotFound, ErrorType.NotFound)); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me"); + request.Headers.Add("Authorization", TestAuth.User()); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.User.NotFound); + } + + [Fact] + public async Task Unauthenticated_IncludesErrorCode() + { + var response = await _client.GetAsync("/api/users/me"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + await ProblemDetailsAssert.MatchesAsync(response, 401, ErrorMessages.Auth.NotAuthenticated); + } + + // @feature admin + [Fact] + public async Task Forbidden_IncludesErrorCode() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/admin/users?pageNumber=1&pageSize=10"); + request.Headers.Add("Authorization", TestAuth.User()); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + await ProblemDetailsAssert.MatchesAsync(response, 403, ErrorMessages.Auth.InsufficientPermissions); + } + + // @end + [Fact] + public async Task ModelValidationFailure_IncludesValidationFailedCode() + { + var response = await _client.PostAsync( + "/api/auth/login", + JsonContent.Create(new { Username = "", Password = "" })); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + await ProblemDetailsAssert.HasCodeAsync(response, 400, ProblemFactory.ValidationFailedCode); + } + + [Fact] + public async Task UnmatchedRoute_IncludesFallbackCode() + { + var response = await _client.GetAsync("/api/does-not-exist"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await ProblemDetailsAssert.HasCodeAsync(response, 404, "not_found"); + } + + [Fact] + public async Task UnhandledException_IncludesErrorCode() + { + _factory.UserService.GetCurrentUserAsync(Arg.Any()) + .ThrowsAsync(new InvalidOperationException("boom")); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me"); + request.Headers.Add("Authorization", TestAuth.User()); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + await ProblemDetailsAssert.MatchesAsync(response, 500, ErrorMessages.Server.InternalError); + } + + [Fact] + public async Task KeyNotFoundException_IncludesEntityNotFoundCode() + { + _factory.UserService.GetCurrentUserAsync(Arg.Any()) + .ThrowsAsync(new KeyNotFoundException("missing")); + + var request = new HttpRequestMessage(HttpMethod.Get, "/api/users/me"); + request.Headers.Add("Authorization", TestAuth.User()); + + var response = await _client.SendAsync(request); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + await ProblemDetailsAssert.MatchesAsync(response, 404, ErrorMessages.Entity.NotFound); + } +} diff --git a/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs b/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs new file mode 100644 index 0000000..162479e --- /dev/null +++ b/templates/src/backend/tests/MyProject.Api.Tests/Shared/ProblemFactoryTests.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using MyProject.Shared; +using MyProject.WebApi.Shared; + +namespace MyProject.Api.Tests.Shared; + +public class ProblemFactoryTests +{ + private static readonly Error TestError = new("test_error", "Something went wrong."); + + [Theory] + [InlineData(ErrorType.Validation, 400)] + [InlineData(ErrorType.Unauthorized, 401)] + [InlineData(ErrorType.Forbidden, 403)] + [InlineData(ErrorType.NotFound, 404)] + [InlineData(null, 400)] + public void Create_MapsErrorTypeToStatusAndCarriesDetailAndCode(ErrorType? errorType, int expectedStatus) + { + var result = ProblemFactory.Create(TestError, errorType); + + var problem = Assert.IsType(result.Value); + Assert.Equal(expectedStatus, result.StatusCode); + Assert.Equal(expectedStatus, problem.Status); + Assert.Equal(TestError.Message, problem.Detail); + Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]); + } + + [Fact] + public void Create_WithNullError_OmitsDetailAndCode() + { + var result = ProblemFactory.Create(null, ErrorType.NotFound); + + var problem = Assert.IsType(result.Value); + Assert.Equal(404, problem.Status); + Assert.Null(problem.Detail); + Assert.False(problem.Extensions.ContainsKey(ProblemFactory.CodeExtensionKey)); + } + + [Fact] + public void CreateProblemDetails_CarriesStatusDetailAndCode() + { + var problem = ProblemFactory.CreateProblemDetails(TestError, StatusCodes.Status429TooManyRequests); + + Assert.Equal(429, problem.Status); + Assert.Equal(TestError.Message, problem.Detail); + Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]); + } + + [Fact] + public void EnsureCode_PreservesExistingCode() + { + var problem = ProblemFactory.CreateProblemDetails(TestError, StatusCodes.Status400BadRequest); + + ProblemFactory.EnsureCode(problem); + + Assert.Equal(TestError.Code, problem.Extensions[ProblemFactory.CodeExtensionKey]); + } + + [Fact] + public void EnsureCode_ValidationProblem_UsesValidationFailedCode() + { + var problem = new HttpValidationProblemDetails { Status = StatusCodes.Status400BadRequest }; + + ProblemFactory.EnsureCode(problem); + + Assert.Equal(ProblemFactory.ValidationFailedCode, problem.Extensions[ProblemFactory.CodeExtensionKey]); + } + + [Theory] + [InlineData(400, "bad_request")] + [InlineData(404, "not_found")] + [InlineData(405, "method_not_allowed")] + [InlineData(415, "unsupported_media_type")] + [InlineData(500, "internal_server_error")] + public void EnsureCode_WithoutCode_FallsBackToSnakeCaseReasonPhrase(int status, string expectedCode) + { + var problem = new ProblemDetails { Status = status }; + + ProblemFactory.EnsureCode(problem); + + Assert.Equal(expectedCode, problem.Extensions[ProblemFactory.CodeExtensionKey]); + } + + [Fact] + public void EnsureCode_WithoutStatus_TreatsAsInternalServerError() + { + var problem = new ProblemDetails(); + + ProblemFactory.EnsureCode(problem); + + Assert.Equal("internal_server_error", problem.Extensions[ProblemFactory.CodeExtensionKey]); + } +} diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs new file mode 100644 index 0000000..75dbbbb --- /dev/null +++ b/templates/src/backend/tests/MyProject.Component.Tests/Extensions/EmailServiceRegistrationTests.cs @@ -0,0 +1,86 @@ +// @feature jobs +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using MyProject.Application.Features.Email; +using MyProject.Infrastructure.Features.Email.Extensions; +using MyProject.Infrastructure.Features.Email.Jobs; +using MyProject.Infrastructure.Features.Email.Services; + +namespace MyProject.Component.Tests.Extensions; + +public class EmailServiceRegistrationTests +{ + private static ServiceCollection Register(bool emailEnabled, bool? jobSchedulingEnabled) + { + var settings = new Dictionary + { + ["Email:Enabled"] = emailEnabled.ToString(), + ["Email:FrontendBaseUrl"] = "https://test.example.com", + ["Email:Smtp:Host"] = "localhost" + }; + + if (jobSchedulingEnabled is not null) + { + settings["JobScheduling:Enabled"] = jobSchedulingEnabled.Value.ToString(); + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + var services = new ServiceCollection(); + services.AddEmailServices(configuration); + return services; + } + + private static Type? ImplementationOf(ServiceCollection services) => + services.Single(d => d.ServiceType == typeof(TService)).ImplementationType; + + [Fact] + public void AddEmailServices_EmailAndJobsEnabled_RegistersBackgroundEmailService() + { + var services = Register(emailEnabled: true, jobSchedulingEnabled: true); + + Assert.Equal(typeof(BackgroundEmailService), ImplementationOf(services)); + Assert.Contains(services, d => d.ServiceType == typeof(EmailDeliveryJob)); + Assert.Contains(services, d => d.ServiceType == typeof(SmtpEmailService)); + } + + [Fact] + public void AddEmailServices_EmailEnabledJobsDefault_RegistersBackgroundEmailService() + { + // JobScheduling:Enabled defaults to true when the section is absent. + var services = Register(emailEnabled: true, jobSchedulingEnabled: null); + + Assert.Equal(typeof(BackgroundEmailService), ImplementationOf(services)); + } + + [Fact] + public void AddEmailServices_EmailEnabledJobsDisabled_RegistersSmtpEmailService() + { + var services = Register(emailEnabled: true, jobSchedulingEnabled: false); + + Assert.Equal(typeof(SmtpEmailService), ImplementationOf(services)); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(EmailDeliveryJob)); + } + + [Fact] + public void AddEmailServices_EmailDisabled_RegistersNoOpEmailService() + { + var services = Register(emailEnabled: false, jobSchedulingEnabled: true); + + Assert.Equal(typeof(NoOpEmailService), ImplementationOf(services)); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(EmailDeliveryJob)); + Assert.DoesNotContain(services, d => d.ServiceType == typeof(SmtpEmailService)); + } + + [Fact] + public void AddEmailServices_AlwaysRegistersTemplatePipeline() + { + var services = Register(emailEnabled: false, jobSchedulingEnabled: false); + + Assert.Equal(typeof(FluidEmailTemplateRenderer), ImplementationOf(services)); + Assert.Equal(typeof(TemplatedEmailSender), ImplementationOf(services)); + } +} +// @end diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs index 95c2c2f..4ff88a4 100644 --- a/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs +++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/AuthenticationServiceTests.cs @@ -399,7 +399,8 @@ public async Task Register_DuplicateEmail_ReturnsFailure() var result = await _sut.Register(input); Assert.True(result.IsFailure); - Assert.Contains("Duplicate email", result.Error); + Assert.Equal(ErrorMessages.Auth.RegistrationInvalid.Code, result.Error?.Code); + Assert.Contains("Duplicate email", result.Error?.Message); } [Fact] @@ -888,7 +889,8 @@ public async Task ChangePassword_IdentityFails_ReturnsFailure() var result = await _sut.ChangePasswordAsync(new ChangePasswordInput("current", "newPass1!")); Assert.True(result.IsFailure); - Assert.Contains("Password too common", result.Error); + Assert.Equal(ErrorMessages.Auth.PasswordPolicyViolation.Code, result.Error?.Code); + Assert.Contains("Password too common", result.Error?.Message); } #endregion @@ -1177,7 +1179,8 @@ public async Task ResetPassword_PasswordPolicyFailure_ReturnsDescriptiveError() new ResetPasswordInput(rawToken, "weak")); Assert.True(result.IsFailure); - Assert.Contains("Password too short", result.Error); + Assert.Equal(ErrorMessages.Auth.PasswordPolicyViolation.Code, result.Error?.Code); + Assert.Contains("Password too short", result.Error?.Message); } #endregion diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs new file mode 100644 index 0000000..0b1802f --- /dev/null +++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/BackgroundEmailServiceTests.cs @@ -0,0 +1,79 @@ +// @feature jobs +using Hangfire; +using Hangfire.Common; +using Hangfire.States; +using Hangfire.Storage; +using Microsoft.Extensions.Logging; +using MyProject.Application.Features.Email; +using MyProject.Infrastructure.Features.Email.Jobs; +using MyProject.Infrastructure.Features.Email.Services; + +namespace MyProject.Component.Tests.Services; + +public class BackgroundEmailServiceTests +{ + private readonly IBackgroundJobClient _backgroundJobClient = Substitute.For(); + private readonly BackgroundEmailService _sut; + + public BackgroundEmailServiceTests() + { + _sut = new BackgroundEmailService(_backgroundJobClient, Substitute.For>()); + } + + private static EmailMessage CreateMessage() => + new("user@test.com", "Subject", "body", "plain text"); + + [Fact] + public async Task SendEmailAsync_EnqueuesEmailDeliveryJobWithMessage() + { + var message = CreateMessage(); + + await _sut.SendEmailAsync(message, CancellationToken.None); + + _backgroundJobClient.Received(1).Create( + Arg.Is(job => + job.Type == typeof(EmailDeliveryJob) && + job.Method.Name == nameof(EmailDeliveryJob.ExecuteAsync) && + ReferenceEquals(job.Args[0], message)), + Arg.Any()); + } + + [Fact] + public async Task SendEmailAsync_DoesNotSendInline() + { + await _sut.SendEmailAsync(CreateMessage(), CancellationToken.None); + + // Only the job creation call may hit the client - nothing else is invoked synchronously. + Assert.Single(_backgroundJobClient.ReceivedCalls()); + } + + [Fact] + public async Task SendEmailAsync_CancellationRequested_ThrowsAndDoesNotEnqueue() + { + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + _sut.SendEmailAsync(CreateMessage(), cts.Token)); + + _backgroundJobClient.DidNotReceiveWithAnyArgs().Create(default!, default!); + } + + [Fact] + public async Task SendEmailAsync_EnqueuedJob_RoundTripsThroughHangfireSerialization() + { + var message = CreateMessage(); + Job? capturedJob = null; + _backgroundJobClient.Create(Arg.Do(job => capturedJob = job), Arg.Any()); + + await _sut.SendEmailAsync(message, CancellationToken.None); + + Assert.NotNull(capturedJob); + var deserialized = InvocationData.SerializeJob(capturedJob).DeserializeJob(); + + Assert.Equal(typeof(EmailDeliveryJob), deserialized.Type); + Assert.Equal(message, Assert.IsType(deserialized.Args[0])); + Assert.Equal(CancellationToken.None, deserialized.Args[1]); + } +} +// @end diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs new file mode 100644 index 0000000..ce45e0a --- /dev/null +++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/EmailDeliveryJobTests.cs @@ -0,0 +1,82 @@ +// @feature jobs +using System.Reflection; +using Hangfire; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MyProject.Application.Features.Email; +using MyProject.Infrastructure.Features.Email.Jobs; +using MyProject.Infrastructure.Features.Email.Options; +using MyProject.Infrastructure.Features.Email.Services; + +namespace MyProject.Component.Tests.Services; + +public class EmailDeliveryJobTests +{ + private static readonly MethodInfo ExecuteMethod = + typeof(EmailDeliveryJob).GetMethod(nameof(EmailDeliveryJob.ExecuteAsync))!; + + private static EmailDeliveryJob CreateJob() + { + var options = Options.Create(new EmailOptions + { + Enabled = true, + Smtp = new EmailOptions.SmtpOptions + { + Host = "invalid.host.test", + Port = 1025, + UseSsl = false + } + }); + + var smtpEmailService = new SmtpEmailService(options, Substitute.For>()); + return new EmailDeliveryJob(smtpEmailService); + } + + private static EmailMessage CreateMessage() => + new("user@test.com", "Subject", "body"); + + [Fact] + public void ExecuteAsync_HasAutomaticRetryWithBackoff() + { + var retry = ExecuteMethod.GetCustomAttribute(); + + Assert.NotNull(retry); + Assert.Equal(EmailDeliveryJob.RetryAttempts, retry.Attempts); + Assert.True(retry.Attempts > 0); + Assert.Equal(retry.Attempts, retry.DelaysInSeconds.Length); + Assert.Equal(AttemptsExceededAction.Fail, retry.OnAttemptsExceeded); + } + + [Fact] + public void ExecuteAsync_RetryDelays_AreIncreasing() + { + var delays = ExecuteMethod.GetCustomAttribute()!.DelaysInSeconds; + + Assert.All(delays, delay => Assert.True(delay > 0)); + Assert.Equal(delays.OrderBy(d => d), delays); + } + + [Fact] + public async Task ExecuteAsync_SmtpFailure_PropagatesExceptionForRetry() + { + var job = CreateJob(); + + var exception = await Record.ExceptionAsync(() => + job.ExecuteAsync(CreateMessage(), CancellationToken.None)); + + Assert.NotNull(exception); + Assert.IsNotType(exception); + } + + [Fact] + public async Task ExecuteAsync_CancelledToken_ThrowsOperationCancelled() + { + var job = CreateJob(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + job.ExecuteAsync(CreateMessage(), cts.Token)); + } +} +// @end diff --git a/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs b/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs index 14aa4bd..9e50ad1 100644 --- a/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs +++ b/templates/src/backend/tests/MyProject.Component.Tests/Services/UserServiceTests.cs @@ -419,7 +419,7 @@ public async Task UploadAvatar_ProcessingFails_ReturnsFailure() var result = await _sut.UploadAvatarAsync([0xFF], "photo.exe", CancellationToken.None); Assert.True(result.IsFailure); - Assert.Contains("Unsupported", result.Error); + Assert.Equal(ErrorMessages.Avatar.UnsupportedFormat, result.Error); await _fileStorageService.DidNotReceive() .UploadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } @@ -435,7 +435,7 @@ public async Task UploadAvatar_StorageFails_ReturnsFailure() _imageProcessingService.ProcessAvatar(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Result.Success(processed)); _fileStorageService.UploadAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(Result.Failure("S3 error")); + .Returns(Result.Failure(ErrorMessages.FileStorage.UploadFailed)); var result = await _sut.UploadAvatarAsync([0xFF], "photo.jpg", CancellationToken.None); @@ -542,7 +542,7 @@ public async Task RemoveAvatar_StorageDeleteFails_StillClearsFlag() _userManager.UpdateAsync(user).Returns(IdentityResult.Success); _userManager.GetRolesAsync(user).Returns(new List { "User" }); _fileStorageService.DeleteAsync(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("S3 error")); + .Returns(Result.Failure(ErrorMessages.FileStorage.DeleteFailed)); var result = await _sut.RemoveAvatarAsync(CancellationToken.None); @@ -615,7 +615,7 @@ public async Task GetAvatar_StorageFailure_ReturnsFailure() var user = new ApplicationUser { Id = _userId, UserName = "test@example.com", HasAvatar = true }; _userManager.FindByIdAsync(_userId.ToString()).Returns(user); _fileStorageService.DownloadAsync($"avatars/{_userId}.webp", Arg.Any()) - .Returns(Result.Failure("Storage error")); + .Returns(Result.Failure(ErrorMessages.FileStorage.DownloadFailed)); var result = await _sut.GetAvatarAsync(_userId, CancellationToken.None); @@ -657,7 +657,7 @@ public async Task DeleteAccount_AvatarCleanupFails_StillDeletesAccount() _userManager.GetRolesAsync(user).Returns(new List { "User" }); _userManager.DeleteAsync(user).Returns(IdentityResult.Success); _fileStorageService.DeleteAsync(Arg.Any(), Arg.Any()) - .Returns(Result.Failure("S3 error")); + .Returns(Result.Failure(ErrorMessages.FileStorage.DeleteFailed)); var result = await _sut.DeleteAccountAsync(new DeleteAccountInput("correct")); diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs index 2b525e0..0b8ab5e 100644 --- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs +++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ErrorMessagesTests.cs @@ -1,9 +1,10 @@ using System.Reflection; +using System.Text.RegularExpressions; using MyProject.Shared; namespace MyProject.Unit.Tests.Shared; -public class ErrorMessagesTests +public partial class ErrorMessagesTests { private static readonly string[] ExpectedNestedClasses = [ @@ -21,12 +22,43 @@ public class ErrorMessagesTests // @feature avatars "Avatar", // @end + // @feature file-storage + "FileStorage", + // @end // @feature oauth "ExternalAuth", // @end "Entity" ]; + [GeneratedRegex("^[a-z][a-z0-9]*(_[a-z0-9]+)*$")] + private static partial Regex SnakeCaseRegex(); + + [GeneratedRegex("(? AllErrors() + { + var nestedTypes = typeof(ErrorMessages) + .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); + + foreach (var type in nestedTypes) + { + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsInitOnly && f.FieldType == typeof(Error)); + + foreach (var field in fields) + { + var error = (Error?)field.GetValue(null); + Assert.NotNull(error); + yield return (type, field, error); + } + } + } + + private static string ToSnakeCase(string pascalCase) => + PascalBoundaryRegex().Replace(pascalCase, "_").ToLowerInvariant(); + [Fact] public void AllNestedClasses_ShouldExist() { @@ -42,67 +74,84 @@ public void AllNestedClasses_ShouldExist() } [Fact] - public void AllConstStringFields_ShouldBeNonNullAndNonEmpty() + public void AllErrors_ShouldHaveNonEmptyMessage() { - var nestedTypes = typeof(ErrorMessages) - .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); - - foreach (var type in nestedTypes) + foreach (var (type, field, error) in AllErrors()) { - var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) - .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); - - foreach (var field in fields) - { - var value = (string?)field.GetRawConstantValue(); - Assert.False( - string.IsNullOrEmpty(value), - $"ErrorMessages.{type.Name}.{field.Name} must not be null or empty."); - } + Assert.False( + string.IsNullOrWhiteSpace(error.Message), + $"ErrorMessages.{type.Name}.{field.Name} must have a non-empty message."); } } [Fact] - public void EachNestedClass_ShouldHaveAtLeastOneConstant() + public void EachNestedClass_ShouldHaveAtLeastOneError() { var nestedTypes = typeof(ErrorMessages) .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); foreach (var type in nestedTypes) { - var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) - .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)) + var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsInitOnly && f.FieldType == typeof(Error)) .ToList(); Assert.True( fields.Count > 0, - $"ErrorMessages.{type.Name} should have at least one const string field."); + $"ErrorMessages.{type.Name} should have at least one Error field."); } } [Fact] public void ErrorMessages_WithinEachClass_ShouldBeUnique() { - var nestedTypes = typeof(ErrorMessages) - .GetNestedTypes(BindingFlags.Public | BindingFlags.Static); + var seen = new Dictionary<(Type Type, string Message), string>(); - foreach (var type in nestedTypes) + foreach (var (type, field, error) in AllErrors()) { - var seen = new Dictionary(); - var fields = type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) - .Where(f => f.IsLiteral && !f.IsInitOnly && f.FieldType == typeof(string)); + var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}"; + Assert.False( + seen.ContainsKey((type, error.Message)), + $"Duplicate error message \"{error.Message}\" found in {qualifiedName} and {seen.GetValueOrDefault((type, error.Message))}."); + seen[(type, error.Message)] = qualifiedName; + } + } - foreach (var field in fields) - { - var value = (string?)field.GetRawConstantValue(); - if (value is null) continue; - - var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}"; - Assert.False( - seen.ContainsKey(value), - $"Duplicate error message value \"{value}\" found in {qualifiedName} and {seen.GetValueOrDefault(value)}."); - seen[value] = qualifiedName; - } + [Fact] + public void ErrorCodes_ShouldBeSnakeCase() + { + foreach (var (type, field, error) in AllErrors()) + { + Assert.True( + SnakeCaseRegex().IsMatch(error.Code), + $"ErrorMessages.{type.Name}.{field.Name} code \"{error.Code}\" must be snake_case."); + } + } + + [Fact] + public void ErrorCodes_ShouldBeDerivedFromDeclaringClassAndFieldName() + { + foreach (var (type, field, error) in AllErrors()) + { + var expectedCode = $"{ToSnakeCase(type.Name)}_{ToSnakeCase(field.Name)}"; + Assert.True( + expectedCode == error.Code, + $"ErrorMessages.{type.Name}.{field.Name} code must be \"{expectedCode}\" but was \"{error.Code}\"."); + } + } + + [Fact] + public void ErrorCodes_ShouldBeGloballyUnique() + { + var seen = new Dictionary(); + + foreach (var (type, field, error) in AllErrors()) + { + var qualifiedName = $"ErrorMessages.{type.Name}.{field.Name}"; + Assert.False( + seen.ContainsKey(error.Code), + $"Duplicate error code \"{error.Code}\" found in {qualifiedName} and {seen.GetValueOrDefault(error.Code)}."); + seen[error.Code] = qualifiedName; } } } diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs index 8cffbaa..403efff 100644 --- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs +++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultGenericTests.cs @@ -4,6 +4,8 @@ namespace MyProject.Unit.Tests.Shared; public class ResultGenericTests { + private static readonly Error TestError = new("test_error", "error"); + [Fact] public void Success_ShouldSetIsSuccessTrue() { @@ -33,24 +35,29 @@ public void Success_ShouldHaveNullError() [Fact] public void Failure_ShouldSetIsSuccessFalse() { - var result = Result.Failure("error"); + var result = Result.Failure(TestError); Assert.False(result.IsSuccess); Assert.True(result.IsFailure); } [Fact] - public void Failure_ShouldPreserveErrorMessage() + public void Failure_ShouldPreserveError() { - var result = Result.Failure("something broke"); + var error = new Error("something_broke", "something broke"); + + var result = Result.Failure(error); - Assert.Equal("something broke", result.Error); + Assert.NotNull(result.Error); + Assert.Same(error, result.Error); + Assert.Equal("something_broke", result.Error.Code); + Assert.Equal("something broke", result.Error.Message); } [Fact] - public void Failure_WithMessage_ShouldDefaultToValidationErrorType() + public void Failure_WithError_ShouldDefaultToValidationErrorType() { - var result = Result.Failure("error"); + var result = Result.Failure(TestError); Assert.Equal(ErrorType.Validation, result.ErrorType); } @@ -59,9 +66,9 @@ public void Failure_WithMessage_ShouldDefaultToValidationErrorType() [InlineData(ErrorType.Validation)] [InlineData(ErrorType.Unauthorized)] [InlineData(ErrorType.NotFound)] - public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType errorType) + public void Failure_WithErrorAndErrorType_ShouldPreserveErrorType(ErrorType errorType) { - var result = Result.Failure("error", errorType); + var result = Result.Failure(TestError, errorType); Assert.Equal(errorType, result.ErrorType); } @@ -69,7 +76,7 @@ public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType er [Fact] public void Value_OnFailure_ShouldThrowInvalidOperationException() { - var result = Result.Failure("error"); + var result = Result.Failure(TestError); var exception = Assert.Throws(() => result.Value); Assert.Equal("Cannot access Value on a failed result.", exception.Message); @@ -96,10 +103,10 @@ public void ResultGeneric_InheritsFromResult() [Fact] public void ResultGeneric_Failure_InheritsFromResult() { - Result result = Result.Failure("error", ErrorType.NotFound); + Result result = Result.Failure(TestError, ErrorType.NotFound); Assert.True(result.IsFailure); - Assert.Equal("error", result.Error); + Assert.Equal(TestError, result.Error); Assert.Equal(ErrorType.NotFound, result.ErrorType); } } diff --git a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs index cc1ab29..244b8f6 100644 --- a/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs +++ b/templates/src/backend/tests/MyProject.Unit.Tests/Shared/ResultTests.cs @@ -4,6 +4,8 @@ namespace MyProject.Unit.Tests.Shared; public class ResultTests { + private static readonly Error TestError = new("test_error", "something went wrong"); + [Fact] public void Success_ShouldSetIsSuccessTrue() { @@ -30,26 +32,29 @@ public void Success_ShouldHaveNullErrorType() } [Fact] - public void Failure_WithMessage_ShouldSetIsSuccessFalse() + public void Failure_WithError_ShouldSetIsSuccessFalse() { - var result = Result.Failure("something went wrong"); + var result = Result.Failure(TestError); Assert.False(result.IsSuccess); Assert.True(result.IsFailure); } [Fact] - public void Failure_WithMessage_ShouldPreserveError() + public void Failure_WithError_ShouldPreserveError() { - var result = Result.Failure("something went wrong"); + var result = Result.Failure(TestError); - Assert.Equal("something went wrong", result.Error); + Assert.NotNull(result.Error); + Assert.Same(TestError, result.Error); + Assert.Equal("test_error", result.Error.Code); + Assert.Equal("something went wrong", result.Error.Message); } [Fact] - public void Failure_WithMessage_ShouldDefaultToValidationErrorType() + public void Failure_WithError_ShouldDefaultToValidationErrorType() { - var result = Result.Failure("something went wrong"); + var result = Result.Failure(TestError); Assert.Equal(ErrorType.Validation, result.ErrorType); } @@ -58,19 +63,21 @@ public void Failure_WithMessage_ShouldDefaultToValidationErrorType() [InlineData(ErrorType.Validation)] [InlineData(ErrorType.Unauthorized)] [InlineData(ErrorType.NotFound)] - public void Failure_WithMessageAndErrorType_ShouldPreserveErrorType(ErrorType errorType) + public void Failure_WithErrorAndErrorType_ShouldPreserveErrorType(ErrorType errorType) { - var result = Result.Failure("error", errorType); + var result = Result.Failure(TestError, errorType); Assert.Equal(errorType, result.ErrorType); } [Fact] - public void Failure_WithMessageAndErrorType_ShouldPreserveError() + public void Failure_WithErrorAndErrorType_ShouldPreserveError() { - var result = Result.Failure("not found", ErrorType.NotFound); + var error = new Error("not_found", "not found"); + + var result = Result.Failure(error, ErrorType.NotFound); - Assert.Equal("not found", result.Error); + Assert.Same(error, result.Error); Assert.False(result.IsSuccess); } } diff --git a/templates/src/frontend/src/lib/api/error-handling.test.ts b/templates/src/frontend/src/lib/api/error-handling.test.ts index a37441f..d594d1d 100644 --- a/templates/src/frontend/src/lib/api/error-handling.test.ts +++ b/templates/src/frontend/src/lib/api/error-handling.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getErrorCode, getErrorMessage, getRetryAfterSeconds, isFetchErrorWithCode, @@ -185,8 +186,68 @@ describe('mapFieldErrors', () => { }); }); +// ── getErrorCode ──────────────────────────────────────────────────── + +describe('getErrorCode', () => { + it('ProblemDetails with code - returns code', () => { + const error = { + status: 409, + detail: 'Already linked.', + code: 'external_auth_already_linked_to_other_user' + }; + expect(getErrorCode(error)).toBe('external_auth_already_linked_to_other_user'); + }); + + it('ProblemDetails without code - returns null', () => { + expect(getErrorCode({ status: 404, detail: 'Not found.' })).toBeNull(); + }); + + it('code is empty string - returns null', () => { + expect(getErrorCode({ code: '' })).toBeNull(); + }); + + it('code is non-string - returns null', () => { + expect(getErrorCode({ code: 42 })).toBeNull(); + }); + + it('null error - returns null', () => { + expect(getErrorCode(null)).toBeNull(); + }); + + it('string error - returns null', () => { + expect(getErrorCode('oops')).toBeNull(); + }); +}); + // ── getErrorMessage ───────────────────────────────────────────────── +describe('getErrorMessage with messagesByCode', () => { + const messages = { + auth_login_account_locked: () => 'translated locked', + auth_login_invalid_credentials: () => 'translated invalid' + }; + + it('code matches a message - returns translated message over detail', () => { + const error = { detail: 'Account is temporarily locked.', code: 'auth_login_account_locked' }; + expect(getErrorMessage(error, 'fallback', messages)).toBe('translated locked'); + }); + + it('code without a matching message - falls back to detail', () => { + const error = { detail: 'Some other error.', code: 'unknown_code' }; + expect(getErrorMessage(error, 'fallback', messages)).toBe('Some other error.'); + }); + + it('no code - falls back to detail', () => { + const error = { detail: 'Detail only.' }; + expect(getErrorMessage(error, 'fallback', messages)).toBe('Detail only.'); + }); + + it('code without a matching message and no detail - returns fallback', () => { + const error = { code: 'unknown_code' }; + expect(getErrorMessage(error, 'fallback', messages)).toBe('fallback'); + }); +}); + describe('getErrorMessage', () => { it('error with detail - returns detail', () => { const error = { detail: 'Invalid credentials', title: 'Unauthorized' }; diff --git a/templates/src/frontend/src/lib/api/error-handling.ts b/templates/src/frontend/src/lib/api/error-handling.ts index 6f31743..dea6954 100644 --- a/templates/src/frontend/src/lib/api/error-handling.ts +++ b/templates/src/frontend/src/lib/api/error-handling.ts @@ -9,20 +9,43 @@ */ /** - * Extended ProblemDetails with validation errors. - * ASP.NET Core returns field-level errors in an `errors` object. + * ProblemDetails (RFC 9457) as returned by the backend. + * + * Every error response carries a stable, machine-readable `code` (snake_case) + * alongside the human-readable `detail`. Branch on `code` - never on `detail` text. * * @see https://tools.ietf.org/html/rfc9457 */ -export interface ValidationProblemDetails { +export interface ProblemDetails { type?: string | null; title?: string | null; status?: number | null; detail?: string | null; instance?: string | null; + code?: string | null; +} + +/** + * Extended ProblemDetails with validation errors. + * ASP.NET Core returns field-level errors in an `errors` object. + */ +export interface ValidationProblemDetails extends ProblemDetails { errors?: Record; } +/** + * Translated messages keyed by backend error `code`. + * Values are Paraglide message functions so translation happens lazily in the current locale. + * + * @example + * ```ts + * const messages: ErrorMessagesByCode = { + * auth_login_account_locked: m.auth_login_accountLocked + * }; + * ``` + */ +export type ErrorMessagesByCode = Record string>; + /** * Type guard to check if an error response is a ValidationProblemDetails. */ @@ -84,21 +107,52 @@ export function mapFieldErrors( return mapped; } +/** + * Extracts the machine-readable error `code` from a ProblemDetails API error response. + * + * @param error - The error object from the API response + * @returns The snake_case error code, or `null` when the response carries none + */ +export function getErrorCode(error: unknown): string | null { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof error.code === 'string' && + error.code.length > 0 + ) { + return error.code; + } + return null; +} + /** * Extracts a user-friendly error message from a ProblemDetails API error response. * * Resolution order: - * 1. `detail` field → ProblemDetails detail (the specific error message) - * 2. `title` field → ProblemDetails title (generic status description) - * 3. Fallback string + * 1. `code` field with a matching entry in `messagesByCode` -> translated message + * 2. `detail` field -> ProblemDetails detail (the specific English message) + * 3. `title` field -> ProblemDetails title (generic status description) + * 4. Fallback string * - * The backend always returns specific, descriptive English messages in `detail`. + * Prefer passing `messagesByCode` for errors the UI wants to translate - the + * backend `code` is a stable contract, while `detail` text may change. * * @param error - The error object from the API response * @param fallback - Fallback message if no error message can be extracted + * @param messagesByCode - Optional translated messages keyed by backend error code * @returns A user-friendly error message */ -export function getErrorMessage(error: unknown, fallback: string): string { +export function getErrorMessage( + error: unknown, + fallback: string, + messagesByCode?: ErrorMessagesByCode +): string { + const code = getErrorCode(error); + const translated = code === null ? undefined : messagesByCode?.[code]; + if (translated) { + return translated(); + } if (typeof error === 'object' && error !== null) { if ('detail' in error && typeof error.detail === 'string') { return error.detail; diff --git a/templates/src/frontend/src/lib/api/v1.d.ts b/templates/src/frontend/src/lib/api/v1.d.ts index 9fbea59..5b096a7 100644 --- a/templates/src/frontend/src/lib/api/v1.d.ts +++ b/templates/src/frontend/src/lib/api/v1.d.ts @@ -4009,6 +4009,8 @@ export interface components { status?: null | number; detail?: null | string; instance?: null | string; + /** @description Stable, machine-readable error code (snake_case). Use it to branch on the error or as a translation key instead of matching the human-readable detail. */ + code?: string; }; /** @description Detailed recurring job response including execution history. */ RecurringJobDetailResponse: { diff --git a/templates/src/frontend/src/lib/components/auth/LoginForm.svelte b/templates/src/frontend/src/lib/components/auth/LoginForm.svelte index ae8ecb5..7b0871e 100644 --- a/templates/src/frontend/src/lib/components/auth/LoginForm.svelte +++ b/templates/src/frontend/src/lib/components/auth/LoginForm.svelte @@ -84,14 +84,10 @@ fallback: m.auth_login_error(), onRateLimited: () => shake.trigger(), onError() { - const detail = getErrorMessage(apiError, ''); - const isLocked = detail.includes('temporarily locked'); - const errorMessage = - response.status === 401 - ? isLocked - ? m.auth_login_accountLocked() - : getErrorMessage(apiError, m.auth_login_invalidCredentials()) - : getErrorMessage(apiError, m.auth_login_error()); + const errorMessage = getErrorMessage(apiError, m.auth_login_error(), { + auth_login_invalid_credentials: m.auth_login_invalidCredentials, + auth_login_account_locked: m.auth_login_accountLocked + }); toast.error(m.auth_login_failed(), { description: errorMessage }); shake.trigger(); } diff --git a/templates/src/frontend/src/messages/cs/oauth.json b/templates/src/frontend/src/messages/cs/oauth.json index 2818259..b5c2311 100644 --- a/templates/src/frontend/src/messages/cs/oauth.json +++ b/templates/src/frontend/src/messages/cs/oauth.json @@ -17,6 +17,8 @@ "oauth_callback_alreadyLinked": "Tento externí účet je již propojen s jiným uživatelem.", "oauth_callback_emailNotVerified": "Před propojením externího účtu je nutné ověřit e-mailovou adresu. Nejprve prosím ověřte svůj e-mail.", "oauth_callback_stateExpired": "Přihlašovací relace vypršela. Zkuste to prosím znovu.", + "oauth_callback_invalidState": "Přihlašovací požadavek je neplatný nebo již byl použit. Zkuste to prosím znovu.", + "oauth_callback_providerError": "Poskytovatel přihlášení vrátil chybu. Zkuste to prosím později.", "oauth_callback_accountLocked": "Váš účet je dočasně uzamčen. Zkuste to prosím později.", "settings_oauth_title": "Propojené účty", diff --git a/templates/src/frontend/src/messages/en/oauth.json b/templates/src/frontend/src/messages/en/oauth.json index 82b92eb..cbcb84f 100644 --- a/templates/src/frontend/src/messages/en/oauth.json +++ b/templates/src/frontend/src/messages/en/oauth.json @@ -17,6 +17,8 @@ "oauth_callback_alreadyLinked": "This external account is already linked to another user.", "oauth_callback_emailNotVerified": "Your email must be verified before linking an external account. Please verify your email first.", "oauth_callback_stateExpired": "The sign-in session has expired. Please try again.", + "oauth_callback_invalidState": "The sign-in request is invalid or was already used. Please try again.", + "oauth_callback_providerError": "The sign-in provider returned an error. Please try again later.", "oauth_callback_accountLocked": "Your account is temporarily locked. Please try again later.", "settings_oauth_title": "Connected Accounts", diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts index 74ca7ac..c617c55 100644 --- a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts +++ b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.server.ts @@ -1,5 +1,6 @@ // @feature oauth import { isRedirect, redirect } from '@sveltejs/kit'; +import { getErrorCode } from '$lib/api'; import { routes } from '$lib/config'; import type { PageServerLoad } from './$types'; @@ -25,9 +26,8 @@ export const load: PageServerLoad = async ({ url, fetch }) => { }); if (!response.ok) { - const body = await response.json().catch(() => null); - const detail = body?.detail ?? 'Unknown error'; - return { error: detail }; + const body: unknown = await response.json().catch(() => null); + return { error: getErrorCode(body) ?? 'unknown_error' }; } const data = await response.json(); diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte index ec98e7f..108c23d 100644 --- a/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte +++ b/templates/src/frontend/src/routes/(public)/oauth/callback/+page.svelte @@ -7,30 +7,28 @@ import { Loader2, CircleAlert } from '@lucide/svelte'; import { IconCircle } from '$lib/components/common'; import { AuthShell } from '$lib/components/auth'; + import type { ErrorMessagesByCode } from '$lib/api'; let { data } = $props(); /** - * Maps backend ProblemDetails `detail` strings to translated messages. - * Keys are the exact English strings from ErrorMessages.cs. - * Unmapped errors fall back to the generic description. - * - * TODO: Remove this map once the backend returns error codes instead of - * English strings, and use those codes as i18n keys directly. + * Translated messages keyed by error code. Backend codes come from the `code` + * extension of the ProblemDetails response (see ErrorMessages.cs); the rest are + * produced locally by the page load. Unmapped codes fall back to the generic description. */ - const ERROR_MAP: Record string> = { - provider_denied: () => m.oauth_callback_providerDenied(), - 'This external account is already linked to another user.': () => - m.oauth_callback_alreadyLinked(), - 'Your email address must be verified before linking an external account. Please verify your email first.': - () => m.oauth_callback_emailNotVerified(), - 'OAuth state token has expired. Please try again.': () => m.oauth_callback_stateExpired(), - 'Account is temporarily locked. Please try again later or contact an administrator.': () => - m.oauth_callback_accountLocked() + const ERROR_MESSAGES: ErrorMessagesByCode = { + provider_denied: m.oauth_callback_providerDenied, + external_auth_already_linked_to_other_user: m.oauth_callback_alreadyLinked, + external_auth_email_not_verified: m.oauth_callback_emailNotVerified, + external_auth_state_expired: m.oauth_callback_stateExpired, + external_auth_invalid_state: m.oauth_callback_invalidState, + external_auth_code_exchange_failed: m.oauth_callback_providerError, + external_auth_provider_error: m.oauth_callback_providerError, + auth_login_account_locked: m.oauth_callback_accountLocked }; const errorMessage = $derived( - (data.error && ERROR_MAP[data.error]?.()) ?? m.oauth_callback_errorDescription() + (data.error && ERROR_MESSAGES[data.error]?.()) ?? m.oauth_callback_errorDescription() ); diff --git a/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts b/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts index 3b0f873..56bd655 100644 --- a/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts +++ b/templates/src/frontend/src/routes/(public)/oauth/callback/page.server.test.ts @@ -112,27 +112,30 @@ describe('OAuth callback page server load', () => { // ── API error responses ──────────────────────────────────── - it('API returns error with detail - returns detail message', async () => { + it('API returns ProblemDetails with code - returns the code', async () => { const result = await load( mockLoadEvent({ searchParams: { code: 'auth-code', state: 'state-token' }, fetchResponse: { ok: false, - json: { detail: 'Invalid or missing OAuth state token.' } + json: { + detail: 'Invalid or missing OAuth state token.', + code: 'external_auth_invalid_state' + } } }) ); - expect(result).toEqual({ error: 'Invalid or missing OAuth state token.' }); + expect(result).toEqual({ error: 'external_auth_invalid_state' }); }); - it('API returns error without detail - returns Unknown error', async () => { + it('API returns error without code - returns unknown_error', async () => { const result = await load( mockLoadEvent({ searchParams: { code: 'auth-code', state: 'state-token' }, - fetchResponse: { ok: false, json: {} } + fetchResponse: { ok: false, json: { detail: 'Some message.' } } }) ); - expect(result).toEqual({ error: 'Unknown error' }); + expect(result).toEqual({ error: 'unknown_error' }); }); // ── Network error ────────────────────────────────────────── diff --git a/templates/src/frontend/src/routes/api/[...path]/+server.ts b/templates/src/frontend/src/routes/api/[...path]/+server.ts index 36cc3d3..77a2a29 100644 --- a/templates/src/frontend/src/routes/api/[...path]/+server.ts +++ b/templates/src/frontend/src/routes/api/[...path]/+server.ts @@ -163,12 +163,17 @@ export const fallback: RequestHandler = async ({ const queryString = targetParams.toString(); const targetUrl = `${SERVER_CONFIG.API_URL}/api/${params.path}${queryString ? `?${queryString}` : ''}`; + // Buffer the body instead of forwarding the incoming ReadableStream. undici + // (Node 24.14 - 24.15) turns any 401 backend response into a network error + // when the request body is a stream without a byte source ("expected + // non-null body source"), which the proxy would surface as a 502 instead of + // the real 401 (nodejs/undici#5018). + const body = request.body ? await request.arrayBuffer() : null; + const newRequest = new Request(targetUrl, { method: request.method, headers: filterRequestHeaders(request.headers, getClientAddress()), - body: request.body, - // @ts-expect-error - duplex is needed for streaming bodies in some node versions/fetch implementations - duplex: 'half' + body }); try { diff --git a/templates/src/frontend/src/routes/api/proxy.test.ts b/templates/src/frontend/src/routes/api/proxy.test.ts index cc48ef8..0fe1372 100644 --- a/templates/src/frontend/src/routes/api/proxy.test.ts +++ b/templates/src/frontend/src/routes/api/proxy.test.ts @@ -67,9 +67,10 @@ function mockProxyEvent( requestHeaders.set('origin', origin); } - const requestInit: RequestInit = { method, headers: requestHeaders }; + const requestInit: RequestInit & { duplex?: 'half' } = { method, headers: requestHeaders }; if (body && method !== 'GET' && method !== 'HEAD') { requestInit.body = body; + requestInit.duplex = 'half'; } const request = new Request(url.toString(), requestInit); @@ -555,6 +556,45 @@ describe('API proxy - URL construction and cookie auth paths', () => { }); }); +describe('API proxy - request body forwarding', () => { + /** Wraps a payload in a ReadableStream, matching what SvelteKit hands the proxy. */ + function streamBody(payload: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + } + }); + } + + it('buffers a streamed request body before proxying', async () => { + // Regression for nodejs/undici#5018 (see the proxy handler for details). + const payload = '{"username":"user@example.com","password":"wrong"}'; + const event = mockProxyEvent({ + method: 'POST', + path: 'auth/login', + origin: 'http://localhost:5173', + headers: { 'content-type': 'application/json' }, + body: streamBody(payload), + fetchResponse: new Response('{"title":"Unauthorized"}', { status: 401 }) + }); + + const response = await fallback(event); + + expect(response.status).toBe(401); + expect(event.request.bodyUsed).toBe(true); + expect(await getProxiedRequest(event).text()).toBe(payload); + }); + + it('does not attach a body to bodyless requests', async () => { + const event = mockProxyEvent({ method: 'GET' }); + + await fallback(event); + + expect(getProxiedRequest(event).body).toBeNull(); + }); +}); + describe('API proxy - error handling', () => { beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {});