authnz_eleven 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +384 -0
  5. data/lib/authnz_eleven/identity.rb +210 -0
  6. data/lib/authnz_eleven/principal.rb +201 -0
  7. data/lib/authnz_eleven/principals.rb +111 -0
  8. data/lib/authnz_eleven/version.rb +5 -0
  9. data/lib/authnz_eleven/wizard.rb +570 -0
  10. data/lib/authnz_eleven.rb +15 -0
  11. data/lib/generators/authnz_eleven/USAGE +26 -0
  12. data/lib/generators/authnz_eleven/authnz_eleven_generator.rb +3040 -0
  13. data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.LICENSE +22 -0
  14. data/lib/generators/authnz_eleven/templates/config/forbidden_usernames.txt +558 -0
  15. data/lib/generators/authnz_eleven/templates/config/initializers/authentication.rb.tt +275 -0
  16. data/lib/generators/authnz_eleven/templates/config/initializers/omniauth.rb.tt +9 -0
  17. data/lib/generators/authnz_eleven/templates/config/initializers/team_slug.rb.tt +24 -0
  18. data/lib/generators/authnz_eleven/templates/config/initializers/webauthn.rb.tt +14 -0
  19. data/lib/generators/authnz_eleven/templates/config/locales/authentication.en.yml.tt +11 -0
  20. data/lib/generators/authnz_eleven/templates/controllers/active_teams_controller.rb.tt +6 -0
  21. data/lib/generators/authnz_eleven/templates/controllers/admin/dashboard_controller.rb.tt +4 -0
  22. data/lib/generators/authnz_eleven/templates/controllers/admin/sessions_controller.rb.tt +17 -0
  23. data/lib/generators/authnz_eleven/templates/controllers/admin/users_controller.rb.tt +91 -0
  24. data/lib/generators/authnz_eleven/templates/controllers/admin_controller.rb.tt +6 -0
  25. data/lib/generators/authnz_eleven/templates/controllers/base_controller.rb.tt +11 -0
  26. data/lib/generators/authnz_eleven/templates/controllers/concerns/api_token_authentication.rb.tt +21 -0
  27. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/easy_dev_login.rb.tt +47 -0
  28. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/registering.rb.tt +25 -0
  29. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/sudoable.rb.tt +117 -0
  30. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication/webauthn_ceremony.rb.tt +51 -0
  31. data/lib/generators/authnz_eleven/templates/controllers/concerns/authentication.rb.tt +421 -0
  32. data/lib/generators/authnz_eleven/templates/controllers/concerns/authorization.rb.tt +48 -0
  33. data/lib/generators/authnz_eleven/templates/controllers/concerns/captchable.rb.tt +11 -0
  34. data/lib/generators/authnz_eleven/templates/controllers/impersonations_controller.rb.tt +8 -0
  35. data/lib/generators/authnz_eleven/templates/controllers/invitations_controller.rb.tt +65 -0
  36. data/lib/generators/authnz_eleven/templates/controllers/settings/api_tokens_controller.rb.tt +54 -0
  37. data/lib/generators/authnz_eleven/templates/controllers/settings/authentications/events_controller.rb.tt +5 -0
  38. data/lib/generators/authnz_eleven/templates/controllers/settings/dashboard_controller.rb.tt +4 -0
  39. data/lib/generators/authnz_eleven/templates/controllers/settings/email_verifications_controller.rb.tt +15 -0
  40. data/lib/generators/authnz_eleven/templates/controllers/settings/emails_controller.rb.tt +93 -0
  41. data/lib/generators/authnz_eleven/templates/controllers/settings/invitations_controller.rb.tt +61 -0
  42. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/authenticators_controller.rb.tt +87 -0
  43. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/recovery_codes_controller.rb.tt +27 -0
  44. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/security_keys_controller.rb.tt +86 -0
  45. data/lib/generators/authnz_eleven/templates/controllers/settings/multi_factor_authentication/sms_controller.rb.tt +84 -0
  46. data/lib/generators/authnz_eleven/templates/controllers/settings/omniauth_identities_controller.rb.tt +27 -0
  47. data/lib/generators/authnz_eleven/templates/controllers/settings/passkeys_controller.rb.tt +70 -0
  48. data/lib/generators/authnz_eleven/templates/controllers/settings/passwords_controller.rb.tt +74 -0
  49. data/lib/generators/authnz_eleven/templates/controllers/settings/phone_verifications_controller.rb.tt +39 -0
  50. data/lib/generators/authnz_eleven/templates/controllers/settings/phones_controller.rb.tt +88 -0
  51. data/lib/generators/authnz_eleven/templates/controllers/settings/sessions_controller.rb.tt +29 -0
  52. data/lib/generators/authnz_eleven/templates/controllers/settings/users_controller.rb.tt +42 -0
  53. data/lib/generators/authnz_eleven/templates/controllers/settings_controller.rb.tt +5 -0
  54. data/lib/generators/authnz_eleven/templates/controllers/teams_controller.rb.tt +38 -0
  55. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passkeys_controller.rb.tt +36 -0
  56. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments/passwords_controller.rb.tt +28 -0
  57. data/lib/generators/authnz_eleven/templates/controllers/users/credential_enrollments_controller.rb.tt +14 -0
  58. data/lib/generators/authnz_eleven/templates/controllers/users/easy_dev_logins_controller.rb.tt +19 -0
  59. data/lib/generators/authnz_eleven/templates/controllers/users/email_verifications_controller.rb.tt +36 -0
  60. data/lib/generators/authnz_eleven/templates/controllers/users/magic_link_controller.rb.tt +53 -0
  61. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/recovery_codes_controller.rb.tt +30 -0
  62. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/security_keys_controller.rb.tt +34 -0
  63. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/sms_controller.rb.tt +45 -0
  64. data/lib/generators/authnz_eleven/templates/controllers/users/multi_factor_authentication/challenge/totps_controller.rb.tt +42 -0
  65. data/lib/generators/authnz_eleven/templates/controllers/users/omniauth/registrations_controller.rb.tt +26 -0
  66. data/lib/generators/authnz_eleven/templates/controllers/users/omniauth_controller.rb.tt +139 -0
  67. data/lib/generators/authnz_eleven/templates/controllers/users/passkeys/sessions_controller.rb.tt +18 -0
  68. data/lib/generators/authnz_eleven/templates/controllers/users/password_resets_controller.rb.tt +64 -0
  69. data/lib/generators/authnz_eleven/templates/controllers/users/registrations/email_verifications_controller.rb.tt +53 -0
  70. data/lib/generators/authnz_eleven/templates/controllers/users/registrations/phone_verifications_controller.rb.tt +37 -0
  71. data/lib/generators/authnz_eleven/templates/controllers/users/registrations_controller.rb.tt +112 -0
  72. data/lib/generators/authnz_eleven/templates/controllers/users/sessions_controller.rb.tt +52 -0
  73. data/lib/generators/authnz_eleven/templates/controllers/users/sms_sessions_controller.rb.tt +53 -0
  74. data/lib/generators/authnz_eleven/templates/controllers/users/sudos_controller.rb.tt +42 -0
  75. data/lib/generators/authnz_eleven/templates/erb/admin/dashboard/index.html.erb.tt +9 -0
  76. data/lib/generators/authnz_eleven/templates/erb/admin/sessions/index.html.erb.tt +27 -0
  77. data/lib/generators/authnz_eleven/templates/erb/admin/users/index.html.erb.tt +32 -0
  78. data/lib/generators/authnz_eleven/templates/erb/admin/users/show.html.erb.tt +70 -0
  79. data/lib/generators/authnz_eleven/templates/erb/invitations/show.html.erb.tt +9 -0
  80. data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/create.html.erb.tt +7 -0
  81. data/lib/generators/authnz_eleven/templates/erb/settings/api_tokens/index.html.erb.tt +29 -0
  82. data/lib/generators/authnz_eleven/templates/erb/settings/authentications/events/index.html.erb.tt +18 -0
  83. data/lib/generators/authnz_eleven/templates/erb/settings/dashboard/index.html.erb.tt +75 -0
  84. data/lib/generators/authnz_eleven/templates/erb/settings/emails/edit.html.erb.tt +68 -0
  85. data/lib/generators/authnz_eleven/templates/erb/settings/invitations/new.html.erb.tt +31 -0
  86. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/authenticators/new.html.erb.tt +57 -0
  87. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/create.html.erb.tt +14 -0
  88. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/recovery_codes/index.html.erb.tt +13 -0
  89. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/edit.html.erb.tt +14 -0
  90. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/index.html.erb.tt +20 -0
  91. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/security_keys/new.html.erb.tt +17 -0
  92. data/lib/generators/authnz_eleven/templates/erb/settings/multi_factor_authentication/sms/new.html.erb.tt +35 -0
  93. data/lib/generators/authnz_eleven/templates/erb/settings/omniauth_identities/index.html.erb.tt +28 -0
  94. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/edit.html.erb.tt +14 -0
  95. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/index.html.erb.tt +21 -0
  96. data/lib/generators/authnz_eleven/templates/erb/settings/passkeys/new.html.erb.tt +13 -0
  97. data/lib/generators/authnz_eleven/templates/erb/settings/passwords/edit.html.erb.tt +68 -0
  98. data/lib/generators/authnz_eleven/templates/erb/settings/phone_verifications/new.html.erb.tt +22 -0
  99. data/lib/generators/authnz_eleven/templates/erb/settings/phones/edit.html.erb.tt +58 -0
  100. data/lib/generators/authnz_eleven/templates/erb/settings/sessions/index.html.erb.tt +29 -0
  101. data/lib/generators/authnz_eleven/templates/erb/settings/users/show.html.erb.tt +28 -0
  102. data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_nav.html.erb.tt +24 -0
  103. data/lib/generators/authnz_eleven/templates/erb/shared/_authnz_eleven_sudo_challenge.html.erb.tt +43 -0
  104. data/lib/generators/authnz_eleven/templates/erb/shared/_captcha.html.erb.tt +8 -0
  105. data/lib/generators/authnz_eleven/templates/erb/teams/index.html.erb.tt +34 -0
  106. data/lib/generators/authnz_eleven/templates/erb/teams/new.html.erb.tt +22 -0
  107. data/lib/generators/authnz_eleven/templates/erb/teams/show.html.erb.tt +5 -0
  108. data/lib/generators/authnz_eleven/templates/erb/user_mailer/email_change_verification.html.erb.tt +7 -0
  109. data/lib/generators/authnz_eleven/templates/erb/user_mailer/invitation.html.erb.tt +11 -0
  110. data/lib/generators/authnz_eleven/templates/erb/user_mailer/magic_link.html.erb.tt +5 -0
  111. data/lib/generators/authnz_eleven/templates/erb/user_mailer/password_reset.html.erb.tt +6 -0
  112. data/lib/generators/authnz_eleven/templates/erb/user_mailer/registration_email_verification.html.erb.tt +7 -0
  113. data/lib/generators/authnz_eleven/templates/erb/user_mailer/security_notification.html.erb.tt +15 -0
  114. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passkeys/new.html.erb.tt +24 -0
  115. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/passwords/new.html.erb.tt +21 -0
  116. data/lib/generators/authnz_eleven/templates/erb/users/credential_enrollments/show.html.erb.tt +21 -0
  117. data/lib/generators/authnz_eleven/templates/erb/users/email_verifications/show.html.erb.tt +9 -0
  118. data/lib/generators/authnz_eleven/templates/erb/users/magic_link/edit.html.erb.tt +9 -0
  119. data/lib/generators/authnz_eleven/templates/erb/users/magic_link/new.html.erb.tt +22 -0
  120. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/recovery_codes/new.html.erb.tt +20 -0
  121. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/security_keys/new.html.erb.tt +17 -0
  122. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/sms/new.html.erb.tt +25 -0
  123. data/lib/generators/authnz_eleven/templates/erb/users/multi_factor_authentication/challenge/totps/new.html.erb.tt +21 -0
  124. data/lib/generators/authnz_eleven/templates/erb/users/omniauth/registrations/new.html.erb.tt +28 -0
  125. data/lib/generators/authnz_eleven/templates/erb/users/password_resets/edit.html.erb.tt +26 -0
  126. data/lib/generators/authnz_eleven/templates/erb/users/password_resets/new.html.erb.tt +20 -0
  127. data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/new.html.erb.tt +11 -0
  128. data/lib/generators/authnz_eleven/templates/erb/users/registrations/email_verifications/show.html.erb.tt +34 -0
  129. data/lib/generators/authnz_eleven/templates/erb/users/registrations/new.html.erb.tt +87 -0
  130. data/lib/generators/authnz_eleven/templates/erb/users/registrations/phone_verifications/new.html.erb.tt +46 -0
  131. data/lib/generators/authnz_eleven/templates/erb/users/sessions/new.html.erb.tt +88 -0
  132. data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/edit.html.erb.tt +30 -0
  133. data/lib/generators/authnz_eleven/templates/erb/users/sms_sessions/new.html.erb.tt +22 -0
  134. data/lib/generators/authnz_eleven/templates/erb/users/sudos/new.html.erb.tt +9 -0
  135. data/lib/generators/authnz_eleven/templates/javascript/controllers/webauthn_controller.js.tt +142 -0
  136. data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_deferred_job.rb.tt +9 -0
  137. data/lib/generators/authnz_eleven/templates/jobs/authnz_eleven_mail_delivery_job.rb.tt +3 -0
  138. data/lib/generators/authnz_eleven/templates/jobs/sms_delivery_job.rb.tt +11 -0
  139. data/lib/generators/authnz_eleven/templates/lib/captcha/null.rb.tt +9 -0
  140. data/lib/generators/authnz_eleven/templates/lib/captcha/turnstile.rb.tt +39 -0
  141. data/lib/generators/authnz_eleven/templates/lib/captcha.rb.tt +15 -0
  142. data/lib/generators/authnz_eleven/templates/lib/sms/log.rb.tt +8 -0
  143. data/lib/generators/authnz_eleven/templates/lib/sms/twilio.rb.tt +70 -0
  144. data/lib/generators/authnz_eleven/templates/lib/sms.rb.tt +15 -0
  145. data/lib/generators/authnz_eleven/templates/mailers/user_mailer.rb.tt +98 -0
  146. data/lib/generators/authnz_eleven/templates/migrations/add_guest_to_users_migration.rb.tt +6 -0
  147. data/lib/generators/authnz_eleven/templates/migrations/create_api_tokens_migration.rb.tt +14 -0
  148. data/lib/generators/authnz_eleven/templates/migrations/create_events_migration.rb.tt +26 -0
  149. data/lib/generators/authnz_eleven/templates/migrations/create_invitations_migration.rb.tt +20 -0
  150. data/lib/generators/authnz_eleven/templates/migrations/create_memberships_migration.rb.tt +12 -0
  151. data/lib/generators/authnz_eleven/templates/migrations/create_omniauth_identities_migration.rb.tt +16 -0
  152. data/lib/generators/authnz_eleven/templates/migrations/create_password_histories_migration.rb.tt +11 -0
  153. data/lib/generators/authnz_eleven/templates/migrations/create_pending_registrations_migration.rb.tt +31 -0
  154. data/lib/generators/authnz_eleven/templates/migrations/create_recovery_codes_migration.rb.tt +11 -0
  155. data/lib/generators/authnz_eleven/templates/migrations/create_sessions_migration.rb.tt +19 -0
  156. data/lib/generators/authnz_eleven/templates/migrations/create_teams_migration.rb.tt +9 -0
  157. data/lib/generators/authnz_eleven/templates/migrations/create_users_migration.rb.tt +70 -0
  158. data/lib/generators/authnz_eleven/templates/migrations/create_webauthn_credentials_migration.rb.tt +16 -0
  159. data/lib/generators/authnz_eleven/templates/models/api_token.rb.tt +24 -0
  160. data/lib/generators/authnz_eleven/templates/models/concerns/adminable.rb.tt +15 -0
  161. data/lib/generators/authnz_eleven/templates/models/concerns/authenticatable.rb.tt +151 -0
  162. data/lib/generators/authnz_eleven/templates/models/concerns/bannable.rb.tt +24 -0
  163. data/lib/generators/authnz_eleven/templates/models/concerns/deadboltable.rb.tt +39 -0
  164. data/lib/generators/authnz_eleven/templates/models/concerns/email_verifiable.rb.tt +52 -0
  165. data/lib/generators/authnz_eleven/templates/models/concerns/eventable.rb.tt +22 -0
  166. data/lib/generators/authnz_eleven/templates/models/concerns/guestable.rb.tt +30 -0
  167. data/lib/generators/authnz_eleven/templates/models/concerns/invitable.rb.tt +12 -0
  168. data/lib/generators/authnz_eleven/templates/models/concerns/magic_linkable.rb.tt +34 -0
  169. data/lib/generators/authnz_eleven/templates/models/concerns/password_historical.rb.tt +28 -0
  170. data/lib/generators/authnz_eleven/templates/models/concerns/password_policy.rb.tt +38 -0
  171. data/lib/generators/authnz_eleven/templates/models/concerns/password_rotatable.rb.tt +15 -0
  172. data/lib/generators/authnz_eleven/templates/models/concerns/phone_verifiable.rb.tt +50 -0
  173. data/lib/generators/authnz_eleven/templates/models/concerns/principals.rb.tt +67 -0
  174. data/lib/generators/authnz_eleven/templates/models/concerns/recoverable.rb.tt +19 -0
  175. data/lib/generators/authnz_eleven/templates/models/concerns/second_factor.rb.tt +52 -0
  176. data/lib/generators/authnz_eleven/templates/models/concerns/sms_challengeable.rb.tt +38 -0
  177. data/lib/generators/authnz_eleven/templates/models/concerns/team_member.rb.tt +20 -0
  178. data/lib/generators/authnz_eleven/templates/models/current.rb.tt +28 -0
  179. data/lib/generators/authnz_eleven/templates/models/event.rb.tt +32 -0
  180. data/lib/generators/authnz_eleven/templates/models/invitation.rb.tt +122 -0
  181. data/lib/generators/authnz_eleven/templates/models/membership.rb.tt +16 -0
  182. data/lib/generators/authnz_eleven/templates/models/omniauth_identity.rb.tt +12 -0
  183. data/lib/generators/authnz_eleven/templates/models/password_history.rb.tt +3 -0
  184. data/lib/generators/authnz_eleven/templates/models/pending_registration.rb.tt +216 -0
  185. data/lib/generators/authnz_eleven/templates/models/recovery_code.rb.tt +41 -0
  186. data/lib/generators/authnz_eleven/templates/models/session.rb.tt +46 -0
  187. data/lib/generators/authnz_eleven/templates/models/team.rb.tt +12 -0
  188. data/lib/generators/authnz_eleven/templates/models/user.rb.tt +59 -0
  189. data/lib/generators/authnz_eleven/templates/models/webauthn_credential.rb.tt +12 -0
  190. data/lib/generators/authnz_eleven/templates/tasks/authnz_eleven.rake.tt +41 -0
  191. data/lib/generators/authnz_eleven/templates/test_unit/controllers/account_existence_parity_test.rb.tt +158 -0
  192. data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/access_test.rb.tt +139 -0
  193. data/lib/generators/authnz_eleven/templates/test_unit/controllers/admin/multi_factor_authentication_test.rb.tt +65 -0
  194. data/lib/generators/authnz_eleven/templates/test_unit/controllers/api_token_authentication_test.rb.tt +45 -0
  195. data/lib/generators/authnz_eleven/templates/test_unit/controllers/credential_enrollment_test.rb.tt +84 -0
  196. data/lib/generators/authnz_eleven/templates/test_unit/controllers/guest_access_test.rb.tt +113 -0
  197. data/lib/generators/authnz_eleven/templates/test_unit/controllers/invitations_controller_test.rb.tt +230 -0
  198. data/lib/generators/authnz_eleven/templates/test_unit/controllers/passkey_ceremony_test.rb.tt +126 -0
  199. data/lib/generators/authnz_eleven/templates/test_unit/controllers/password_rotation_test.rb.tt +67 -0
  200. data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_key_ceremony_test.rb.tt +218 -0
  201. data/lib/generators/authnz_eleven/templates/test_unit/controllers/security_notifications_test.rb.tt +39 -0
  202. data/lib/generators/authnz_eleven/templates/test_unit/controllers/session_lifecycle_test.rb.tt +102 -0
  203. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/api_tokens_controller_test.rb.tt +61 -0
  204. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/authentications/events_controller_test.rb.tt +17 -0
  205. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/email_verifications_controller_test.rb.tt +39 -0
  206. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/invitations_controller_test.rb.tt +157 -0
  207. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/management_test.rb.tt +256 -0
  208. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/multi_factor_authentication/management_test.rb.tt +222 -0
  209. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/omniauth_identities_controller_test.rb.tt +57 -0
  210. data/lib/generators/authnz_eleven/templates/test_unit/controllers/settings/phone_verifications_controller_test.rb.tt +146 -0
  211. data/lib/generators/authnz_eleven/templates/test_unit/controllers/teams_controller_test.rb.tt +83 -0
  212. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/email_verifications_controller_test.rb.tt +74 -0
  213. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/magic_link_controller_test.rb.tt +90 -0
  214. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/multi_factor_authentication/challenge_test.rb.tt +143 -0
  215. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth/registrations_controller_test.rb.tt +138 -0
  216. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/omniauth_controller_test.rb.tt +97 -0
  217. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/password_resets_controller_test.rb.tt +58 -0
  218. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/email_verifications_controller_test.rb.tt +102 -0
  219. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations/phone_verifications_controller_test.rb.tt +69 -0
  220. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/registrations_controller_test.rb.tt +180 -0
  221. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sessions_controller_test.rb.tt +124 -0
  222. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sms_sessions_controller_test.rb.tt +111 -0
  223. data/lib/generators/authnz_eleven/templates/test_unit/controllers/users/sudos_controller_test.rb.tt +110 -0
  224. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/memberships.yml.tt +13 -0
  225. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/teams.yml.tt +2 -0
  226. data/lib/generators/authnz_eleven/templates/test_unit/fixtures/users.yml.tt +68 -0
  227. data/lib/generators/authnz_eleven/templates/test_unit/mailers/user_mailer_test.rb.tt +88 -0
  228. data/lib/generators/authnz_eleven/templates/test_unit/models/api_token_test.rb.tt +38 -0
  229. data/lib/generators/authnz_eleven/templates/test_unit/models/password_history_test.rb.tt +51 -0
  230. data/lib/generators/authnz_eleven/templates/test_unit/models/session_test.rb.tt +16 -0
  231. data/lib/generators/authnz_eleven/templates/test_unit/models/sms_challengeable_test.rb.tt +108 -0
  232. data/lib/generators/authnz_eleven/templates/test_unit/models/user/guestable_test.rb.tt +78 -0
  233. data/lib/generators/authnz_eleven/templates/test_unit/models/user/recovery_codes_test.rb.tt +32 -0
  234. data/lib/generators/authnz_eleven/templates/test_unit/models/user/sign_in_methods_test.rb.tt +56 -0
  235. data/lib/generators/authnz_eleven/templates/test_unit/models/user_test.rb.tt +145 -0
  236. data/lib/generators/authnz_eleven/wizard_generator.rb +52 -0
  237. metadata +309 -0
@@ -0,0 +1,230 @@
1
+ require "test_helper"
2
+
3
+ class <%= identity.namespaced? ? "#{identity.module_name}::InvitationsController" : "InvitationsController" %>Test < ActionDispatch::IntegrationTest
4
+ include ActiveJob::TestHelper
5
+
6
+ setup { @inviter = <%= identity.plural %>(:alice) }
7
+ <%- if teams? -%>
8
+
9
+ def invite(address, team: <%= identity.team_class %>.create!(name: "Acme"))
10
+ <%= identity.invitation_class %>.create!(sent_to: address, inviter: @inviter, team:)
11
+ end
12
+ <%- else -%>
13
+
14
+ def invite(address) = <%= identity.invitation_class %>.create!(sent_to: address, inviter: @inviter)
15
+ <%- end -%>
16
+
17
+ <%- if teams? -%>
18
+ test "opening the link while signed in spends nothing until it is confirmed" do
19
+ invitee = <%= identity.plural %>(:bob)
20
+ invitation = invite(invitee.<%= email_invitable? ? "email" : "phone" %>)
21
+ sign_in_as invitee
22
+
23
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
24
+
25
+ assert_response :success
26
+ assert_not invitation.reload.accepted?
27
+ end
28
+
29
+ test "an existing account signs in, comes back to the invitation, and accepts it" do
30
+ invitee = <%= identity.plural %>(:bob)
31
+ invitation = invite(invitee.<%= email_invitable? ? "email" : "phone" %>)
32
+ token = invitation.offer
33
+
34
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token)
35
+ assert_redirected_to <%= identity.route_scope %>_sign_in_path
36
+ assert_equal <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token), session[:<%= interrupted_destination_key %>]
37
+
38
+ sign_in_as invitee
39
+ assert_not invitation.reload.accepted?, "signing in accepts nothing"
40
+
41
+ patch <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token)
42
+ assert_predicate invitation.reload, :accepted?
43
+ assert invitee.member_of?(invitation.team)
44
+ assert_equal "You've joined Acme.", flash[:notice]
45
+ end
46
+
47
+ test "a second invitation to a team already joined is accepted without a second membership" do
48
+ invitee = <%= identity.plural %>(:bob)
49
+ team = <%= identity.team_class %>.create!(name: "Acme")
50
+ first = invite(invitee.<%= email_invitable? ? "email" : "phone" %>, team:)
51
+ second = invite(invitee.<%= email_invitable? ? "email" : "phone" %>, team:)
52
+
53
+ assert first.accept(invitee)
54
+ assert second.accept(invitee)
55
+ assert_equal 1, team.memberships.where(<%= identity.singular %>: invitee).count
56
+ end
57
+
58
+ test "confirming spends the invitation its own link names" do
59
+ invitee = <%= identity.plural %>(:bob)
60
+ mine = invite(invitee.<%= email_invitable? ? "email" : "phone" %>)
61
+ token = mine.offer
62
+ stale = invite(invitee.<%= email_invitable? ? "email" : "phone" %>)
63
+ sign_in_as invitee
64
+
65
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: stale.offer)
66
+ patch <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token)
67
+
68
+ assert mine.reload.accepted?, "the confirmed link is the one that gets spent"
69
+ assert_not stale.reload.accepted?, "the session's last link is not spent by another link's button"
70
+ end
71
+
72
+ test "accepting while signed in lands on the invited team" do
73
+ invitee = <%= identity.plural %>(:bob)
74
+ invitee.memberships.create!(team: <%= identity.team_class %>.create!(name: "Original"))
75
+ invitation = invite(invitee.<%= email_invitable? ? "email" : "phone" %>)
76
+ sign_in_as invitee
77
+
78
+ patch <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
79
+
80
+ <%- if scope_teams? -%>
81
+ assert_redirected_to <%= team_root_helper %>(team_id: invitation.team)
82
+ <%- elsif middleware_teams? -%>
83
+ assert_redirected_to root_url(script_name: "/#{invitation.team.id}")
84
+ <%- else -%>
85
+ assert_equal invitation.team, invitee.sessions.sole.team
86
+ <%- end -%>
87
+ assert_equal "You've joined Acme.", flash[:notice]
88
+ end
89
+
90
+ test "an invitation with no team whose address signed up first is accepted and the account still holds for a team" do
91
+ invitee = <%= identity.plural %>(:bob)
92
+ invitee.memberships.destroy_all
93
+ invitation = invite(<%= email_invitable? ? '"stranger-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>, team: nil)
94
+ invitation.update_column(:sent_to, invitee.<%= email_invitable? ? "email" : "phone" %>)
95
+ sign_in_as invitee
96
+
97
+ patch <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
98
+
99
+ assert invitation.reload.accepted?
100
+ <%- if team_gated_page -%>
101
+ get <%= team_gated_page %>
102
+ assert_redirected_to <%= identity.nested_helper("teams_path") %>
103
+ <%- end -%>
104
+ end
105
+ <%- else -%>
106
+ test "an existing account has nothing to accept" do
107
+ invitee = <%= identity.plural %>(:bob)
108
+ invitation = invite(<%= email_invitable? ? '"stranger-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>)
109
+ sign_in_as invitee
110
+
111
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
112
+
113
+ assert_redirected_to <%= identity.namespaced? ? "#{identity.route_scope}_root_path" : "root_path" %>
114
+ assert_not invitation.reload.accepted?
115
+ end
116
+ <%- end -%>
117
+
118
+ test "a tampered invitation link is refused" do
119
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: "not-a-real-token")
120
+ assert_redirected_to <%= identity.namespaced? ? "#{identity.route_scope}_root_path" : "root_path" %>
121
+ end
122
+
123
+ test "a stranger accepting an invitation is routed to create an account" do
124
+ destination = <%= email_invitable? ? '"invitee-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>
125
+ invitation = invite(destination)
126
+ token = invitation.offer
127
+
128
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token)
129
+ assert_redirected_to <%= registration_entry_path %>
130
+ assert_equal token, session[:<%= session_key(:pending_invitation_token) %>],
131
+ "the session is the only thing carrying the invitation from here on"
132
+ end
133
+ <%- if test_drivable_door? -%>
134
+
135
+ test "signing in drops the invitation from the session" do
136
+ invitation = invite(<%= email_invitable? ? '"invitee-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>)
137
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
138
+
139
+ sign_in_as <%= identity.plural %>(:bob)
140
+
141
+ assert_nil session[:<%= session_key(:pending_invitation_token) %>]
142
+ end
143
+ <%- end -%>
144
+
145
+ test "changing the destination invalidates an outstanding link" do
146
+ invitation = invite(<%= email_invitable? ? '"first-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>)
147
+ token = invitation.offer
148
+ invitation.update!(sent_to: <%= email_invitable? ? '"second-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>)
149
+
150
+ assert_nil <%= identity.invitation_class %>.find_by_token_for(:invitation, token)
151
+ end
152
+
153
+ test "accepting spends the link" do
154
+ destination = <%= email_invitable? ? '"invitee-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>
155
+ invitation = invite(destination)
156
+ token = invitation.offer
157
+ invitee = <%= identity.class_name %>.create!(<%= account_fixture_literal(indent: 4) %>.merge(<%= email_invitable? ? "email" : "phone" %>: destination))
158
+ invitation.accept(invitee)
159
+
160
+ assert_nil <%= identity.invitation_class %>.find_by_token_for(:invitation, token)
161
+ end
162
+ <%- if typed_registration? -%>
163
+
164
+ test "an invited stranger signs up and the invitation is applied" do
165
+ destination = <%= email_invitable? ? '"invitee-#{SecureRandom.hex(4)}@example.com"' : '"+1415555#{rand(1000..9999)}"' %>
166
+ invitation = invite(destination)
167
+
168
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: invitation.offer)
169
+ assert_redirected_to <%= identity.route_scope %>_sign_up_path
170
+
171
+ post <%= identity.route_scope %>_sign_up_path, params: sign_up_params(<%= email_invitable? ? "email" : "phone" %>: destination)
172
+
173
+ <%- unless email_invitable? ? email_invitation_completes_registration? : phone_invitation_completes_registration? -%>
174
+ assert_nil <%= identity.class_name %>.find_by(<%= email_invitable? ? "email" : "phone" %>: destination)
175
+ assert <%= identity.pending_registration_class %>.exists?
176
+ assert_not_predicate invitation.reload, :accepted?
177
+ <%- else -%>
178
+ <%= identity.singular %> = <%= identity.class_name %>.find_by(<%= email_invitable? ? "email" : "phone" %>: destination)
179
+ assert <%= identity.singular %>, "clicking the invitation verifies the address, so the account is minted here"
180
+ assert_predicate invitation.reload, :accepted?
181
+ assert_equal <%= identity.singular %>, invitation.invitee
182
+ <%- if teams? -%>
183
+ assert <%= identity.singular %>.member_of?(invitation.team)
184
+ <%- end -%>
185
+ <%- end -%>
186
+ end
187
+ <%- if phone_invitable? -%>
188
+
189
+ test "a phone invitation signs up without sending a verification code" do
190
+ number = "+1415555#{rand(1000..9999)}"
191
+ invitation = invite(number)
192
+ token = invitation.offer
193
+ clear_enqueued_jobs
194
+
195
+ get <%= identity.namespaced? ? "#{identity.route_scope}_accept_invitation_path" : "accept_invitation_path" %>(token: token)
196
+ assert_redirected_to <%= identity.route_scope %>_sign_up_path
197
+
198
+ assert_no_enqueued_jobs only: SmsDeliveryJob do
199
+ post <%= identity.route_scope %>_sign_up_path, params: sign_up_params.except(:phone)
200
+ end
201
+
202
+ <%- unless phone_invitation_completes_registration? -%>
203
+ assert_nil <%= identity.class_name %>.find_by(phone: number)
204
+ assert <%= identity.pending_registration_class %>.exists?
205
+ assert_not_predicate invitation.reload, :accepted?
206
+ <%- else -%>
207
+ <%= identity.singular %> = <%= identity.class_name %>.find_by(phone: number)
208
+ assert <%= identity.singular %>
209
+ assert_predicate invitation.reload, :accepted?
210
+ assert_equal <%= identity.singular %>, invitation.invitee
211
+ <%- if teams? -%>
212
+ assert <%= identity.singular %>.member_of?(invitation.team)
213
+ <%- end -%>
214
+ <%- end -%>
215
+ end
216
+ <%- end -%>
217
+ <%- end -%>
218
+
219
+ private
220
+ <%- if typed_registration? -%>
221
+
222
+ def sign_up_params(**overrides)
223
+ <%= sign_up_fixture_literal(indent: 4) %>.merge(overrides)
224
+ end
225
+ <%- end -%>
226
+
227
+ def sign_in_as(user)
228
+ <%= test_sign_in_body %>
229
+ end
230
+ end
@@ -0,0 +1,126 @@
1
+ require "test_helper"
2
+ require "webauthn/fake_client"
3
+
4
+ class <%= identity.module_name %>::PasskeyCeremonyTest < ActionDispatch::IntegrationTest
5
+ setup do
6
+ @user = <%= identity.plural %>(:bob)
7
+ @authenticator = WebAuthn::FakeAuthenticator.new
8
+ @client = WebAuthn::FakeClient.new(
9
+ WebAuthn.configuration.allowed_origins.first,
10
+ authenticator: @authenticator
11
+ )
12
+ end
13
+
14
+ test "passkey enrollment requires user verification" do
15
+ sign_in @user
16
+
17
+ assert_no_difference -> { @user.webauthn_credentials.count } do
18
+ enroll_passkey(user_verified: false)
19
+ end
20
+ assert_response :unprocessable_entity
21
+ end
22
+
23
+ test "a verified passkey enrolls and signs in" do
24
+ sign_in @user
25
+ assert_difference -> { @user.webauthn_credentials.count }, 1 do
26
+ enroll_passkey(user_verified: true)
27
+ end
28
+ assert_response :created
29
+
30
+ sign_out
31
+ assert_difference -> { @user.sessions.count }, 1 do
32
+ sign_in_with_passkey(user_verified: true)
33
+ end
34
+ assert_response :created
35
+ assert cookies[:<%= identity.cookie_name %>].present?
36
+ end
37
+
38
+ test "a replayed passkey assertion is refused, not an error" do
39
+ sign_in @user
40
+ enroll_passkey(user_verified: true)
41
+ sign_out
42
+
43
+ credential = sign_in_with_passkey(user_verified: true)
44
+ assert_response :created
45
+ sign_out
46
+
47
+ assert_no_difference -> { @user.sessions.count } do
48
+ post <%= identity.route_scope %>_passkeys_session_path, params: { credential: credential }, as: :json
49
+ end
50
+ assert_response :unprocessable_entity
51
+ end
52
+
53
+ test "passkey sign-in requires user verification" do
54
+ sign_in @user
55
+ enroll_passkey(user_verified: true)
56
+ sign_out
57
+
58
+ assert_no_difference -> { @user.sessions.count } do
59
+ sign_in_with_passkey(user_verified: false)
60
+ end
61
+ assert_response :unprocessable_entity
62
+ assert cookies[:<%= identity.cookie_name %>].blank?
63
+ end
64
+
65
+ <%- if sudoable? -%>
66
+
67
+ test "a passkey satisfies the sudo challenge on a guarded action" do
68
+ sign_in @user
69
+ enroll_passkey(user_verified: true)
70
+
71
+ assert_no_difference -> { <%= identity.class_name %>.count }, "no assertion, no deletion" do
72
+ delete <%= identity.settings_helper_prefix %>_user_path, params: { confirmation: "DELETE ACCOUNT" }
73
+ end
74
+
75
+ assert_difference -> { <%= identity.class_name %>.count }, -1 do
76
+ delete <%= identity.settings_helper_prefix %>_user_path,
77
+ params: { confirmation: "DELETE ACCOUNT", sudo_credential: <%= identity.route_scope %>_sudo_assertion }
78
+ end
79
+ end
80
+ <%- end -%>
81
+
82
+ private
83
+
84
+ def clear_sudo_bar
85
+ <%= test_clear_sudo_body %>
86
+ end
87
+
88
+ def enroll_passkey(user_verified:)
89
+ clear_sudo_bar
90
+ get new_<%= identity.settings_helper_prefix %>_passkey_path, as: :json
91
+ credential = @client.create(
92
+ challenge: response.parsed_body.fetch("challenge"),
93
+ rp_id: WebAuthn.configuration.rp_id,
94
+ user_verified: user_verified
95
+ )
96
+
97
+ post <%= identity.settings_helper_prefix %>_passkeys_path,
98
+ params: { credential: credential }, as: :json
99
+ end
100
+
101
+ def sign_in_with_passkey(user_verified:)
102
+ get new_<%= identity.route_scope %>_passkeys_session_path, as: :json
103
+ credential = @client.get(
104
+ challenge: response.parsed_body.fetch("challenge"),
105
+ rp_id: WebAuthn.configuration.rp_id,
106
+ user_handle: @user.webauthn_handle,
107
+ user_verified: user_verified
108
+ )
109
+
110
+ post <%= identity.route_scope %>_passkeys_session_path,
111
+ params: { credential: credential }, as: :json
112
+ credential
113
+ end
114
+
115
+ def sign_in(user)
116
+ record = user.sessions.create!
117
+ env = Rails.application.env_config.merge("HTTP_HOST" => "www.example.com")
118
+ jar = ActionDispatch::Request.new(env).cookie_jar
119
+ jar.signed[:<%= identity.cookie_name %>] = record.id
120
+ cookies[:<%= identity.cookie_name %>] = jar[:<%= identity.cookie_name %>]
121
+ end
122
+
123
+ def sign_out
124
+ delete <%= identity.route_scope %>_sign_out_path
125
+ end
126
+ end
@@ -0,0 +1,67 @@
1
+ require "test_helper"
2
+
3
+ class <%= identity.module_name %>::PasswordRotationTest < ActionDispatch::IntegrationTest
4
+ setup { @user = <%= identity.plural %>(:bob) }
5
+
6
+ test "a <%= identity.singular %> with an expired password is held at the change-password page" do
7
+ expire_password
8
+ assert_not @user.reload.password_fresh?
9
+ sign_in_as @user
10
+
11
+ get <%= identity.settings_helper_prefix %>_path
12
+ assert_redirected_to edit_<%= identity.settings_helper_prefix %>_password_path
13
+ end
14
+
15
+ test "the change-password page itself stays reachable while held" do
16
+ expire_password
17
+ sign_in_as @user
18
+
19
+ get edit_<%= identity.settings_helper_prefix %>_password_path
20
+ assert_response :success
21
+ end
22
+
23
+ test "signing out is allowed even while held (never trapped in purgatory)" do
24
+ expire_password
25
+ sign_in_as @user
26
+
27
+ assert_difference -> { @user.sessions.count }, -1 do
28
+ delete <%= identity.route_scope %>_sign_out_path
29
+ end
30
+ assert_response :redirect
31
+ end
32
+
33
+ test "a <%= identity.singular %> with a current password passes the gate" do
34
+ assert @user.password_fresh?, "fixture password should start fresh"
35
+ sign_in_as @user
36
+
37
+ get <%= identity.settings_helper_prefix %>_path
38
+ assert_response :success
39
+ end
40
+
41
+ test "changing the password clears the gate" do
42
+ expire_password
43
+ sign_in_as @user
44
+
45
+ patch <%= identity.settings_helper_prefix %>_password_path, params: {
46
+ password_challenge: "quilted-lantern-moss-97",
47
+ password: "amber-thicket-90-vale",
48
+ password_confirmation: "amber-thicket-90-vale"
49
+ }
50
+ assert @user.reload.password_fresh?, "a fresh password stamps password_changed_at"
51
+
52
+ get <%= identity.settings_helper_prefix %>_path
53
+ assert_response :success
54
+ end
55
+
56
+ private
57
+
58
+ def expire_password
59
+ # Bypass the password-change callback so it does not replace the backdated timestamp.
60
+ @user.update_column(:password_changed_at, (<%= identity.config_reference %>.password.maximum_age + 1.day).ago)
61
+ end
62
+
63
+ def sign_in_as(user)
64
+ post <%= identity.route_scope %>_sign_in_path,
65
+ params: { <%= sign_in_field %>: user.<%= login_field %>, password: "quilted-lantern-moss-97" }
66
+ end
67
+ end
@@ -0,0 +1,218 @@
1
+ require "test_helper"
2
+ require "webauthn/fake_client"
3
+
4
+ class <%= identity.module_name %>::SecurityKeyCeremonyTest < ActionDispatch::IntegrationTest
5
+ setup do
6
+ @user = <%= identity.plural %>(:bob)
7
+ @authenticator = WebAuthn::FakeAuthenticator.new
8
+ @client = WebAuthn::FakeClient.new(
9
+ WebAuthn.configuration.allowed_origins.first,
10
+ authenticator: @authenticator
11
+ )
12
+ end
13
+
14
+ test "a security key enrolls as a possession-only second factor" do
15
+ sign_in @user
16
+
17
+ assert_difference -> { @user.webauthn_credentials.count }, 1 do
18
+ enroll_security_key
19
+ end
20
+ assert_response :success
21
+ assert_equal "text/html", response.media_type
22
+
23
+ codes = response.body.scan(%r{<code>([a-z0-9]+)</code>}).flatten
24
+ assert_equal <%= identity.config_reference %>.second_factor.recovery_code_count, codes.length
25
+ assert_equal codes.length, @user.recovery_codes.count
26
+ assert <%= identity.recovery_code_class %>.redeem(user: @user, code: codes.first)
27
+
28
+ get <%= identity.settings_helper_prefix %>_mfa_security_keys_path
29
+ assert_response :success
30
+ codes.each { |code| assert_not_includes response.body, code }
31
+ end
32
+
33
+ test "the enrollment form opts out of Turbo" do
34
+ sign_in @user
35
+ clear_sudo_bar
36
+ get new_<%= identity.settings_helper_prefix %>_mfa_security_key_path
37
+
38
+ assert_select 'form[action=?][method="post"][data-turbo="false"]', <%= identity.settings_helper_prefix %>_mfa_security_keys_path
39
+ end
40
+
41
+ test "another key preserves the existing recovery codes" do
42
+ sign_in @user
43
+ enroll_security_key
44
+ codes = @user.recovery_codes.pluck(:code_digest).sort
45
+ assert_not_empty codes
46
+
47
+ assert_difference -> { @user.webauthn_credentials.count }, 1 do
48
+ enroll_security_key
49
+ end
50
+
51
+ assert_response :see_other
52
+ assert_redirected_to edit_<%= identity.settings_helper_prefix %>_mfa_security_key_path(@user.webauthn_credentials.order(:id).last)
53
+ assert_equal codes, @user.recovery_codes.reload.pluck(:code_digest).sort
54
+ end
55
+
56
+ test "a recovery-code failure rolls back enrollment" do
57
+ sign_in @user
58
+ credential = security_key_credential
59
+
60
+ assert_no_difference -> { @user.webauthn_credentials.count } do
61
+ failing_recovery_codes do
62
+ assert_raises(RuntimeError) do
63
+ post <%= identity.settings_helper_prefix %>_mfa_security_keys_path, params: { credential: credential.to_json }
64
+ end
65
+ end
66
+ end
67
+ assert_empty @user.recovery_codes.reload
68
+ end
69
+
70
+ test "malformed enrollment renders a retry form" do
71
+ sign_in @user
72
+ security_key_credential
73
+
74
+ [ nil, "not json" ].each do |malformed|
75
+ assert_no_difference -> { @user.webauthn_credentials.count } do
76
+ post <%= identity.settings_helper_prefix %>_mfa_security_keys_path, params: { credential: malformed }
77
+ end
78
+ assert_response :unprocessable_entity
79
+ assert_select '[data-webauthn-target="field"]'
80
+ assert_select "p", text: "We couldn't add that security key. Please try again."
81
+ end
82
+
83
+ enroll_security_key
84
+ assert_response :success
85
+ end
86
+ <%- if admin_mfa_gate? && password_rotatable? -%>
87
+
88
+ test "MFA enrollment displays recovery codes before the next requirement takes over" do
89
+ @user = <%= identity.plural %>(:alice)
90
+ @user.update_column(:password_changed_at, (<%= identity.config_reference %>.password.maximum_age + 1.day).ago)
91
+ sign_in @user
92
+
93
+ get <%= identity.settings_helper_prefix %>_path
94
+ assert_redirected_to <%= admin_mfa_hold_path %>
95
+
96
+ enroll_security_key
97
+ assert_response :success
98
+ assert @user.reload.second_factor_enrolled?
99
+ assert_not @user.password_fresh?
100
+ assert_select "h1", "Save your recovery codes"
101
+ assert_select "li code", count: <%= identity.config_reference %>.second_factor.recovery_code_count
102
+ assert_nil flash[:alert]
103
+
104
+ post complete_<%= identity.settings_helper_prefix %>_mfa_recovery_codes_path
105
+ assert_redirected_to edit_<%= identity.settings_helper_prefix %>_password_path
106
+ end
107
+ <%- end -%>
108
+
109
+ test "a replayed enrollment is refused, not an error" do
110
+ sign_in @user
111
+ credential = enroll_security_key
112
+ assert_response :success
113
+
114
+ assert_no_difference -> { @user.webauthn_credentials.count } do
115
+ post <%= identity.settings_helper_prefix %>_mfa_security_keys_path,
116
+ params: { credential: credential.to_json }
117
+ end
118
+ assert_response :unprocessable_entity
119
+ end
120
+ <%- if password? -%>
121
+
122
+ test "a security key clears the second-factor challenge" do
123
+ sign_in @user
124
+ enroll_security_key
125
+ sign_out
126
+
127
+ start_first_factor(@user)
128
+
129
+ get new_<%= identity.route_scope %>_mfa_challenge_security_keys_path, as: :json
130
+ credential = @client.get(
131
+ challenge: response.parsed_body.fetch("challenge"),
132
+ rp_id: WebAuthn.configuration.rp_id,
133
+ user_verified: false
134
+ )
135
+
136
+ assert_difference -> { @user.sessions.count }, 1 do
137
+ post <%= identity.route_scope %>_mfa_challenge_security_keys_path,
138
+ params: { credential: credential }, as: :json
139
+ end
140
+ assert_response :created
141
+ assert cookies[:<%= identity.cookie_name %>].present?
142
+ end
143
+ <%- end -%>
144
+
145
+ test "a security key can be removed" do
146
+ sign_in @user
147
+ enroll_security_key
148
+
149
+ assert_difference -> { @user.webauthn_credentials.count }, -1 do
150
+ delete <%= identity.settings_helper_prefix %>_mfa_security_key_path(@user.webauthn_credentials.sole)
151
+ end
152
+ end
153
+ <%- if sudoable? -%>
154
+
155
+ test "a security key satisfies the sudo challenge on a guarded action" do
156
+ sign_in @user
157
+ enroll_security_key
158
+
159
+ assert_no_difference -> { <%= identity.class_name %>.count }, "no assertion, no deletion" do
160
+ delete <%= identity.settings_helper_prefix %>_user_path, params: { confirmation: "DELETE ACCOUNT" }
161
+ end
162
+
163
+ assert_difference -> { <%= identity.class_name %>.count }, -1 do
164
+ delete <%= identity.settings_helper_prefix %>_user_path,
165
+ params: { confirmation: "DELETE ACCOUNT", sudo_credential: <%= identity.route_scope %>_sudo_assertion }
166
+ end
167
+ end
168
+ <%- end -%>
169
+
170
+ private
171
+
172
+ def failing_recovery_codes
173
+ original = <%= identity.recovery_code_class %>.method(:regenerate_for)
174
+ <%= identity.recovery_code_class %>.define_singleton_method(:regenerate_for) { |*| raise "Recovery codes unavailable" }
175
+ yield
176
+ ensure
177
+ <%= identity.recovery_code_class %>.define_singleton_method(:regenerate_for, original)
178
+ end
179
+
180
+ def clear_sudo_bar
181
+ <%= test_clear_sudo_body %>
182
+ end
183
+
184
+ def enroll_security_key
185
+ credential = security_key_credential
186
+ post <%= identity.settings_helper_prefix %>_mfa_security_keys_path,
187
+ params: { credential: credential.to_json }
188
+ credential
189
+ end
190
+
191
+ def security_key_credential
192
+ clear_sudo_bar
193
+ get new_<%= identity.settings_helper_prefix %>_mfa_security_key_path, as: :json
194
+ @client.create(
195
+ challenge: response.parsed_body.fetch("challenge"),
196
+ rp_id: WebAuthn.configuration.rp_id,
197
+ user_verified: false # User verification is optional here because the password is the first factor.
198
+ )
199
+ end
200
+
201
+ def sign_in(user)
202
+ record = user.sessions.create!
203
+ env = Rails.application.env_config.merge("HTTP_HOST" => "www.example.com")
204
+ jar = ActionDispatch::Request.new(env).cookie_jar
205
+ jar.signed[:<%= identity.cookie_name %>] = record.id
206
+ cookies[:<%= identity.cookie_name %>] = jar[:<%= identity.cookie_name %>]
207
+ end
208
+
209
+ def sign_out
210
+ delete <%= identity.route_scope %>_sign_out_path
211
+ end
212
+ <%- if password? -%>
213
+
214
+ def start_first_factor(user)
215
+ <%= test_sign_in_body %>
216
+ end
217
+ <%- end -%>
218
+ end
@@ -0,0 +1,39 @@
1
+ require "test_helper"
2
+
3
+ class <%= identity.module_name %>::SecurityNotificationsTest < ActionDispatch::IntegrationTest
4
+ include ActionMailer::TestHelper
5
+
6
+ setup { @user = <%= identity.plural %>(:bob) }
7
+
8
+ test "revoking a session emails the account" do
9
+ sign_in_as @user
10
+ other_session = @user.sessions.create!
11
+
12
+ assert_security_notification(:session_revoked) do
13
+ delete <%= identity.settings_helper_prefix %>_session_path(other_session)
14
+ end
15
+ end
16
+ <%- if password? -%>
17
+
18
+ test "changing the password emails the account" do
19
+ sign_in_as @user
20
+
21
+ assert_security_notification(:password_changed) do
22
+ patch <%= identity.settings_helper_prefix %>_password_path,
23
+ params: { password_challenge: "quilted-lantern-moss-97", password: "heron-basalt-drift-41",
24
+ password_confirmation: "heron-basalt-drift-41" }
25
+ end
26
+ end
27
+ <%- end -%>
28
+
29
+ private
30
+
31
+ def assert_security_notification(event, &block)
32
+ expected = ->(params) { params[:to] == @user.email && params[:event] == event }
33
+ assert_enqueued_email_with <%= identity.mailer_class %>, :security_notification, params: expected, &block
34
+ end
35
+
36
+ def sign_in_as(user)
37
+ <%= test_sign_in_body %>
38
+ end
39
+ end