simple-module-users 0.0.32__tar.gz → 0.0.34__tar.gz

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 (202) hide show
  1. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/pyproject.toml +6 -6
  3. simple_module_users-0.0.34/tests/_users_app_builders.py +163 -0
  4. simple_module_users-0.0.34/tests/conftest.py +165 -0
  5. simple_module_users-0.0.34/tests/test_activity_summary.py +111 -0
  6. simple_module_users-0.0.34/tests/test_admin_resend_invite.py +224 -0
  7. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_api_auth.py +1 -1
  8. simple_module_users-0.0.34/tests/test_auth_screens_props.py +259 -0
  9. simple_module_users-0.0.34/tests/test_bearer_token_age.py +76 -0
  10. simple_module_users-0.0.34/tests/test_bearer_token_expiry.py +265 -0
  11. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_bootstrap.py +10 -10
  12. simple_module_users-0.0.34/tests/test_bulk_invite_message.py +121 -0
  13. simple_module_users-0.0.34/tests/test_edit_recent_activity.py +163 -0
  14. simple_module_users-0.0.34/tests/test_legacy_session_expiry.py +159 -0
  15. simple_module_users-0.0.34/tests/test_profile_password.py +173 -0
  16. simple_module_users-0.0.34/tests/test_remember_me.py +188 -0
  17. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_service_admin.py +21 -21
  18. simple_module_users-0.0.34/tests/test_session_expiry.py +183 -0
  19. simple_module_users-0.0.34/tests/test_session_version_cache.py +153 -0
  20. simple_module_users-0.0.34/tests/test_session_version_hardening.py +242 -0
  21. simple_module_users-0.0.34/tests/test_sessions_revoke.py +171 -0
  22. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_settings.py +27 -0
  23. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_user_manager.py +31 -7
  24. simple_module_users-0.0.34/tests/test_user_state.py +48 -0
  25. simple_module_users-0.0.34/tests/test_users_list_invites.py +124 -0
  26. simple_module_users-0.0.34/tests/test_users_login_deep_link.py +117 -0
  27. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_views.py +28 -8
  28. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_views_admin.py +14 -14
  29. simple_module_users-0.0.34/tests-js/EmailChipInput.test.tsx +108 -0
  30. simple_module_users-0.0.34/tests-js/deriveState.test.ts +43 -0
  31. simple_module_users-0.0.34/tests-js/invite-emails.test.ts +22 -0
  32. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tsconfig.json +1 -1
  33. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/api.py +4 -2
  34. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/bulk_invite.py +19 -15
  35. simple_module_users-0.0.34/users/admin/components/IndexFilters.tsx +102 -0
  36. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/components/RolesTab.tsx +15 -9
  37. simple_module_users-0.0.34/users/admin/components/StatusPill.tsx +30 -0
  38. simple_module_users-0.0.34/users/admin/components/UserCards.tsx +76 -0
  39. simple_module_users-0.0.34/users/admin/components/UserRow.tsx +132 -0
  40. simple_module_users-0.0.34/users/admin/components/UserRowMenu.tsx +63 -0
  41. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/components/UsersEmpty.tsx +26 -28
  42. simple_module_users-0.0.34/users/admin/components/UsersTable.tsx +144 -0
  43. simple_module_users-0.0.34/users/admin/components/useUserRowActions.ts +105 -0
  44. simple_module_users-0.0.34/users/admin/components/user-list-item.ts +46 -0
  45. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/queries.py +50 -17
  46. simple_module_users-0.0.34/users/admin/recent_activity.py +213 -0
  47. simple_module_users-0.0.34/users/admin/resend_invite.py +92 -0
  48. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/service.py +50 -4
  49. simple_module_users-0.0.34/users/admin/user_state.py +59 -0
  50. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/views.py +36 -14
  51. simple_module_users-0.0.34/users/audit.py +77 -0
  52. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/auth_local/api.py +92 -26
  53. simple_module_users-0.0.34/users/auth_local/components/AuthField.tsx +36 -0
  54. simple_module_users-0.0.34/users/auth_local/components/AuthIntro.tsx +67 -0
  55. simple_module_users-0.0.34/users/auth_local/components/AuthStateCard.tsx +57 -0
  56. simple_module_users-0.0.34/users/auth_local/components/InviteSummary.tsx +52 -0
  57. simple_module_users-0.0.34/users/auth_local/components/LoginForm.tsx +149 -0
  58. simple_module_users-0.0.34/users/auth_local/components/PasswordCard.tsx +152 -0
  59. simple_module_users-0.0.34/users/auth_local/components/PasswordFields.tsx +84 -0
  60. simple_module_users-0.0.34/users/auth_local/components/PreferencesCard.tsx +138 -0
  61. simple_module_users-0.0.34/users/auth_local/components/ProfileDetailsCard.tsx +107 -0
  62. simple_module_users-0.0.34/users/auth_local/components/ResendVerification.tsx +78 -0
  63. simple_module_users-0.0.34/users/auth_local/components/ResetLinkSent.tsx +95 -0
  64. simple_module_users-0.0.34/users/auth_local/components/SessionsCard.tsx +99 -0
  65. simple_module_users-0.0.34/users/auth_local/components/WaitingOnYou.tsx +58 -0
  66. simple_module_users-0.0.34/users/auth_local/components/profile-user.ts +11 -0
  67. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/auth_local/invite_preview.py +33 -2
  68. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/auth_local/rate_limit.py +26 -0
  69. simple_module_users-0.0.34/users/auth_local/self_account.py +156 -0
  70. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/auth_local/token_api.py +22 -3
  71. simple_module_users-0.0.34/users/auth_local/token_preview.py +134 -0
  72. simple_module_users-0.0.34/users/auth_local/views.py +222 -0
  73. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/backend.py +53 -9
  74. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/constants.py +9 -1
  75. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/contracts/schemas.py +57 -0
  76. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/exceptions.py +19 -0
  77. simple_module_users-0.0.34/users/locales/en.json +394 -0
  78. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/__init__.py +24 -1
  79. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/console.py +10 -1
  80. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/smtp.py +15 -2
  81. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/templates/invite.txt +4 -0
  82. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/manager.py +41 -8
  83. simple_module_users-0.0.34/users/models/access_token.py +69 -0
  84. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/user.py +19 -0
  85. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/module.py +35 -17
  86. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/oauth/api.py +12 -2
  87. simple_module_users-0.0.34/users/pages/AcceptInvite.tsx +164 -0
  88. simple_module_users-0.0.34/users/pages/ForgotPassword.tsx +103 -0
  89. simple_module_users-0.0.34/users/pages/Login.tsx +164 -0
  90. simple_module_users-0.0.34/users/pages/Profile.tsx +54 -0
  91. simple_module_users-0.0.34/users/pages/Register.tsx +157 -0
  92. simple_module_users-0.0.34/users/pages/ResetPassword.tsx +167 -0
  93. simple_module_users-0.0.34/users/pages/Users/AddPeople.tsx +291 -0
  94. simple_module_users-0.0.34/users/pages/Users/Edit.tsx +126 -0
  95. simple_module_users-0.0.34/users/pages/Users/Index.tsx +187 -0
  96. simple_module_users-0.0.34/users/pages/Users/components/AccountCard.tsx +176 -0
  97. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/pages/Users/components/CreateUserFields.tsx +9 -10
  98. simple_module_users-0.0.34/users/pages/Users/components/DangerZone.tsx +112 -0
  99. simple_module_users-0.0.34/users/pages/Users/components/DetailsCard.tsx +94 -0
  100. simple_module_users-0.0.34/users/pages/Users/components/EditHeaderActions.tsx +43 -0
  101. simple_module_users-0.0.34/users/pages/Users/components/EmailChipInput.tsx +139 -0
  102. simple_module_users-0.0.34/users/pages/Users/components/InviteFields.tsx +71 -0
  103. simple_module_users-0.0.34/users/pages/Users/components/InviteResults.tsx +125 -0
  104. simple_module_users-0.0.34/users/pages/Users/components/RecentActivityCard.tsx +81 -0
  105. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/pages/Users/components/RolePicker.tsx +18 -3
  106. simple_module_users-0.0.34/users/pages/Users/components/UserStats.tsx +33 -0
  107. simple_module_users-0.0.34/users/pages/Users/components/useEditUserForm.ts +159 -0
  108. simple_module_users-0.0.34/users/pages/Users/components/useUserActions.ts +90 -0
  109. simple_module_users-0.0.34/users/pages/Users/invite-emails.ts +17 -0
  110. simple_module_users-0.0.34/users/pages/VerifyEmail.tsx +127 -0
  111. simple_module_users-0.0.34/users/provider.py +265 -0
  112. simple_module_users-0.0.34/users/session_version_cache.py +129 -0
  113. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/settings.py +36 -4
  114. simple_module_users-0.0.34/users/setup.py +51 -0
  115. simple_module_users-0.0.34/users/token_strategy.py +164 -0
  116. simple_module_users-0.0.32/tests/conftest.py +0 -283
  117. simple_module_users-0.0.32/users/admin/components/IndexFilters.tsx +0 -72
  118. simple_module_users-0.0.32/users/admin/components/UserRow.tsx +0 -88
  119. simple_module_users-0.0.32/users/auth_local/views.py +0 -118
  120. simple_module_users-0.0.32/users/locales/en.json +0 -11
  121. simple_module_users-0.0.32/users/models/access_token.py +0 -33
  122. simple_module_users-0.0.32/users/pages/AcceptInvite.tsx +0 -147
  123. simple_module_users-0.0.32/users/pages/ForgotPassword.tsx +0 -89
  124. simple_module_users-0.0.32/users/pages/Login.tsx +0 -208
  125. simple_module_users-0.0.32/users/pages/Profile.tsx +0 -154
  126. simple_module_users-0.0.32/users/pages/Register.tsx +0 -161
  127. simple_module_users-0.0.32/users/pages/ResetPassword.tsx +0 -109
  128. simple_module_users-0.0.32/users/pages/Users/AddPeople.tsx +0 -212
  129. simple_module_users-0.0.32/users/pages/Users/Edit.tsx +0 -295
  130. simple_module_users-0.0.32/users/pages/Users/Index.tsx +0 -290
  131. simple_module_users-0.0.32/users/pages/Users/components/AccountStatusCard.tsx +0 -117
  132. simple_module_users-0.0.32/users/pages/Users/components/DangerZone.tsx +0 -108
  133. simple_module_users-0.0.32/users/pages/Users/components/DetailsCard.tsx +0 -53
  134. simple_module_users-0.0.32/users/pages/Users/components/InviteFields.tsx +0 -46
  135. simple_module_users-0.0.32/users/pages/Users/components/InviteResults.tsx +0 -107
  136. simple_module_users-0.0.32/users/pages/Users/components/MetadataCard.tsx +0 -85
  137. simple_module_users-0.0.32/users/pages/Users/components/RolesCard.tsx +0 -37
  138. simple_module_users-0.0.32/users/pages/VerifyEmail.tsx +0 -122
  139. simple_module_users-0.0.32/users/provider.py +0 -140
  140. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/.gitignore +0 -0
  141. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/LICENSE +0 -0
  142. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/README.md +0 -0
  143. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/package.json +0 -0
  144. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/.gitkeep +0 -0
  145. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/_middleware_support.py +0 -0
  146. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_access_token_model.py +0 -0
  147. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_api_admin.py +0 -0
  148. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_api_admin_crud.py +0 -0
  149. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_api_admin_filters.py +0 -0
  150. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_backend.py +0 -0
  151. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_bootstrap_resolution.py +0 -0
  152. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_cli.py +0 -0
  153. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_constants.py +0 -0
  154. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_db_adapter.py +0 -0
  155. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_external_users.py +0 -0
  156. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_invite_flow.py +0 -0
  157. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_invite_reuse.py +0 -0
  158. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_mailer.py +0 -0
  159. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_negative_authz.py +0 -0
  160. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_oauth.py +0 -0
  161. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_oauth_routes.py +0 -0
  162. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_rate_limit.py +0 -0
  163. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_role_model.py +0 -0
  164. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_service_admin_crud.py +0 -0
  165. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_token_api.py +0 -0
  166. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_user_model.py +0 -0
  167. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_user_role_model.py +0 -0
  168. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_user_service.py +0 -0
  169. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_bulk_invite.py +0 -0
  170. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_deps.py +0 -0
  171. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_middleware.py +0 -0
  172. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_middleware_public_paths.py +0 -0
  173. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_middleware_resolvers.py +0 -0
  174. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_provider.py +0 -0
  175. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/tests/test_users_shared_props.py +0 -0
  176. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/__init__.py +0 -0
  177. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/admin/__init__.py +0 -0
  178. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/auth_local/__init__.py +0 -0
  179. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/bootstrap.py +0 -0
  180. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/cli.py +0 -0
  181. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/contracts/__init__.py +0 -0
  182. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/contracts/events.py +0 -0
  183. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/db_adapter.py +0 -0
  184. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/deps.py +0 -0
  185. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/health.py +0 -0
  186. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/templates/.gitkeep +0 -0
  187. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/templates/reset_password.txt +0 -0
  188. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/mailer/templates/verify_email.txt +0 -0
  189. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/middleware.py +0 -0
  190. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/__init__.py +0 -0
  191. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/_base.py +0 -0
  192. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/oauth_account.py +0 -0
  193. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/refresh_token.py +0 -0
  194. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/role.py +0 -0
  195. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/models/user_role.py +0 -0
  196. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/oauth/__init__.py +0 -0
  197. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/oauth/providers.py +0 -0
  198. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/pages/.gitkeep +0 -0
  199. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/py.typed +0 -0
  200. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/roles_cache.py +0 -0
  201. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/shared_props.py +0 -0
  202. {simple_module_users-0.0.32 → simple_module_users-0.0.34}/users/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_users
3
- Version: 0.0.32
3
+ Version: 0.0.34
4
4
  Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -24,11 +24,11 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: aiosmtplib>=3.0
25
25
  Requires-Dist: cachetools>=5.3
26
26
  Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
27
- Requires-Dist: simple-module-auth==0.0.32
28
- Requires-Dist: simple-module-core==0.0.32
29
- Requires-Dist: simple-module-db==0.0.32
30
- Requires-Dist: simple-module-hosting==0.0.32
31
- Requires-Dist: simple-module-settings==0.0.32
27
+ Requires-Dist: simple-module-auth==0.0.34
28
+ Requires-Dist: simple-module-core==0.0.34
29
+ Requires-Dist: simple-module-db==0.0.34
30
+ Requires-Dist: simple-module-hosting==0.0.34
31
+ Requires-Dist: simple-module-settings==0.0.34
32
32
  Requires-Dist: typer>=0.12
33
33
  Description-Content-Type: text/markdown
34
34
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_users"
3
- version = "0.0.32"
3
+ version = "0.0.34"
4
4
  description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,11 +21,11 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.32",
25
- "simple_module_db==0.0.32",
26
- "simple_module_hosting==0.0.32",
27
- "simple_module_settings==0.0.32",
28
- "simple_module_auth==0.0.32",
24
+ "simple_module_core==0.0.34",
25
+ "simple_module_db==0.0.34",
26
+ "simple_module_hosting==0.0.34",
27
+ "simple_module_settings==0.0.34",
28
+ "simple_module_auth==0.0.34",
29
29
  # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
30
30
  # fields after construction (see reconfigure_cookie_transport in backend.py).
31
31
  # Bumping the major version requires re-checking those field names.
@@ -0,0 +1,163 @@
1
+ """App-construction helpers for the users test fixtures.
2
+
3
+ Split out of ``conftest.py`` so that file holds fixtures and this one holds
4
+ the machinery they call: schema creation, seed data, and the ``create_app``
5
+ wiring. Not named ``test_*`` so pytest does not collect it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from simple_module_hosting.settings import Settings
11
+ from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
12
+
13
+ # ---------------------------------------------------------------------------
14
+
15
+
16
+ def _users_overrides(allow_signup: bool = False) -> dict[str, tuple[str, str]]:
17
+ """Values seeded into the ``SettingsStore`` before the app lifespan runs."""
18
+ return {
19
+ "allow_signup": (str(allow_signup).lower(), "bool"),
20
+ "mailer": ("console", "string"),
21
+ "base_url": ("http://testserver", "string"),
22
+ "cookie_secure": ("false", "bool"),
23
+ # 32+ bytes to clear pyjwt's InsecureKeyLengthWarning for HMAC-SHA256.
24
+ "reset_password_token_secret": ("test-reset-secret-32-bytes-xxxxx", "string"),
25
+ "verification_token_secret": ("test-verify-secret-32-bytes-xxxxx", "string"),
26
+ "login_rate_limit_failures": ("5", "int"),
27
+ "login_rate_limit_window_seconds": ("300", "int"),
28
+ "login_rate_limit_cooldown_seconds": ("900", "int"),
29
+ }
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Full-app fixture
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ async def _setup_app_db(application) -> None:
38
+ """Create all tables and stamp alembic version so migration check passes."""
39
+
40
+ from simple_module_db.base import all_module_bases
41
+ from simple_module_hosting.migrations import resolve_head_revisions
42
+ from sqlalchemy import text
43
+
44
+ # Plural: each module's first migration sets its own branch_labels, so a
45
+ # real upgraded database carries one alembic_version row per branch.
46
+ # Stamping only one leaves the others looking un-applied, which reads as a
47
+ # behind-head schema and puts every app built here behind the setup gate.
48
+ heads = resolve_head_revisions()
49
+
50
+ async with application.state.sm.db.engine.begin() as conn:
51
+
52
+ def _create(sync_conn):
53
+ for base in all_module_bases:
54
+ base.metadata.create_all(sync_conn)
55
+
56
+ await conn.run_sync(_create)
57
+
58
+ if heads:
59
+ await conn.execute(
60
+ text(
61
+ "CREATE TABLE IF NOT EXISTS alembic_version "
62
+ "(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
63
+ )
64
+ )
65
+ await conn.execute(text("DELETE FROM alembic_version"))
66
+ for head in heads:
67
+ await conn.execute(
68
+ text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
69
+ {"v": head},
70
+ )
71
+
72
+
73
+ async def _seed_roles(application) -> None:
74
+ """Insert admin/user Role rows with deterministic UUIDs if missing."""
75
+ from sqlalchemy import select
76
+ from users.models import Role
77
+
78
+ async with application.state.sm.db.session_factory() as session:
79
+ existing = set((await session.execute(select(Role.name))).scalars().all())
80
+ if "admin" not in existing:
81
+ session.add(Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"))
82
+ if "user" not in existing:
83
+ session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
84
+ await session.commit()
85
+
86
+
87
+ async def _seed_users_settings(application, *, allow_signup: bool) -> None:
88
+ """Write the test UsersSettings overrides to the DB before hydrate runs.
89
+
90
+ Hydrate fires inside the lifespan ``__aenter__``, so this needs to land
91
+ between table creation and lifespan entry.
92
+ """
93
+ from settings.service import SettingService
94
+ from settings.store import SettingsStore
95
+
96
+ async with application.state.sm.db.session_factory() as session:
97
+ store = SettingsStore(SettingService(session))
98
+ for field, (raw, vtype) in _users_overrides(allow_signup=allow_signup).items():
99
+ await store.set_override("users", field, raw, vtype)
100
+ await session.commit()
101
+
102
+
103
+ async def _make_admin_user(app):
104
+ """Seed an admin User + Role into app's DB and return the User row."""
105
+ from users.bootstrap import create_admin
106
+ from users.models import User
107
+
108
+ async with app.state.sm.db.session_factory() as session:
109
+ result = await create_admin(
110
+ session,
111
+ email="admin@example.com",
112
+ password="AdminPass1!",
113
+ full_name="Test Admin",
114
+ )
115
+ user: User = result.user
116
+ return user
117
+
118
+
119
+ async def _make_standard_user(app, email: str = "user@example.com"):
120
+ """Seed a non-admin User with the standard ``user`` role.
121
+
122
+ Used by the negative-authz tests to confirm endpoints protected by
123
+ ``RequiresPermission(...)`` reject authenticated-but-non-admin callers.
124
+ """
125
+ from users.bootstrap import create_standard_user
126
+ from users.models import User
127
+
128
+ async with app.state.sm.db.session_factory() as session:
129
+ result = await create_standard_user(
130
+ session,
131
+ email=email,
132
+ password="UserPass1!",
133
+ full_name="Regular User",
134
+ )
135
+ user: User = result.user
136
+ return user
137
+
138
+
139
+ async def _build_users_app(monkeypatch, *, allow_signup: bool, seed_admin: bool = True):
140
+ """Build a test FastAPI app with DB created, settings seeded, lifespan started."""
141
+ from simple_module_hosting.app_builder import create_app
142
+
143
+ settings = Settings(
144
+ database_url="sqlite+aiosqlite:///:memory:",
145
+ environment="testing",
146
+ secret_key="test-secret-key",
147
+ multi_tenant=False,
148
+ )
149
+ application = create_app(settings)
150
+ await _setup_app_db(application)
151
+ await _seed_users_settings(application, allow_signup=allow_signup)
152
+
153
+ ctx = application.router.lifespan_context(application)
154
+ await ctx.__aenter__()
155
+ await _seed_roles(application)
156
+ # SetupMiddleware redirects everything to /setup until an administrator
157
+ # exists. These tests assert on ordinary pages, not on first-run setup, so
158
+ # the app has to model a configured install. Reuses the same admin
159
+ # admin_client seeds — create_admin is idempotent, so that fixture's own
160
+ # call still resolves the id without creating a second account.
161
+ if seed_admin:
162
+ await _make_admin_user(application)
163
+ return application, ctx
@@ -0,0 +1,165 @@
1
+ """Shared fixtures for users module API tests.
2
+
3
+ The ``users_app`` fixture builds a full FastAPI app via ``create_app`` but
4
+ with an in-memory SQLite database, seeded roles, and test-friendly settings
5
+ (ConsoleMailer, signup disabled by default, short secrets).
6
+
7
+ The ``anon_client`` gives a plain httpx client.
8
+ The ``admin_client`` gives a client with a signed local-user session cookie
9
+ carrying a real admin User row (written into the in-memory DB).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from collections.abc import AsyncGenerator
16
+
17
+ import httpx
18
+ import pytest
19
+
20
+ # Bare import, not relative: tests/ has no __init__.py, so these are
21
+ # top-level modules on pytest's sys.path — same mechanism as the
22
+ # _middleware_support plugin loaded at the bottom of this file.
23
+ from _users_app_builders import (
24
+ _build_users_app,
25
+ _make_admin_user,
26
+ _make_standard_user,
27
+ )
28
+ from simple_module_test import forge_session_cookie
29
+ from sqlalchemy.ext.asyncio import AsyncSession
30
+
31
+
32
+ @pytest.fixture(autouse=True)
33
+ def _isolate_users_env(monkeypatch):
34
+ """Scrub stale ``SM_USERS_*`` env vars from the shell/.env.
35
+
36
+ After the env→DB migration ``UsersSettings()`` no longer reads these
37
+ values, so this is belt-and-braces: it keeps old shell exports from
38
+ muddying any ``SM_ENVIRONMENT`` checks or from being misread during
39
+ developer spelunking. Also stubs out
40
+ ``users.bootstrap._read_dotenv_bootstrap_vars`` so the developer's local
41
+ ``.env`` can't seed bootstrap creds into tests that exercise the resolver.
42
+ """
43
+ from users import bootstrap as bootstrap_module
44
+
45
+ for key in list(os.environ):
46
+ if key.startswith("SM_USERS_"):
47
+ monkeypatch.delenv(key, raising=False)
48
+ monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Settings helpers
53
+
54
+
55
+ @pytest.fixture
56
+ async def users_app(monkeypatch):
57
+ """Full FastAPI app with in-memory DB, seeded roles, users module active."""
58
+ application, ctx = await _build_users_app(monkeypatch, allow_signup=False)
59
+ yield application
60
+ await ctx.__aexit__(None, None, None)
61
+
62
+
63
+ @pytest.fixture
64
+ async def users_app_empty(monkeypatch):
65
+ """``users_app`` with no seeded administrator, so the users table starts empty.
66
+
67
+ For DB-level tests that assert on absolute user counts or on behaviour
68
+ when no account exists yet — bootstrap-from-env, user listing. They reach
69
+ the app only through ``session_factory`` and never issue HTTP requests, so
70
+ the first-run setup gate never applies to them.
71
+ """
72
+ application, ctx = await _build_users_app(monkeypatch, allow_signup=False, seed_admin=False)
73
+ yield application
74
+ await ctx.__aexit__(None, None, None)
75
+
76
+
77
+ @pytest.fixture
78
+ async def users_app_signup(monkeypatch):
79
+ """Like users_app but with allow_signup=True."""
80
+ application, ctx = await _build_users_app(monkeypatch, allow_signup=True)
81
+ yield application
82
+ await ctx.__aexit__(None, None, None)
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Client fixtures
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ @pytest.fixture
91
+ async def anon_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
92
+ """Unauthenticated client against users_app."""
93
+ transport = httpx.ASGITransport(app=users_app)
94
+ async with httpx.AsyncClient(
95
+ transport=transport,
96
+ base_url="http://testserver",
97
+ ) as c:
98
+ yield c
99
+
100
+
101
+ @pytest.fixture
102
+ async def anon_client_signup(users_app_signup) -> AsyncGenerator[httpx.AsyncClient, None]:
103
+ """Unauthenticated client against users_app_signup (signup enabled)."""
104
+ transport = httpx.ASGITransport(app=users_app_signup)
105
+ async with httpx.AsyncClient(
106
+ transport=transport,
107
+ base_url="http://testserver",
108
+ ) as c:
109
+ yield c
110
+
111
+
112
+ @pytest.fixture
113
+ async def admin_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
114
+ """Client with a signed local-user session cookie (admin role)."""
115
+ user = await _make_admin_user(users_app)
116
+ cookie = forge_session_cookie(
117
+ str(users_app.state.sm.settings.secret_key),
118
+ {"user_id": str(user.id)},
119
+ )
120
+ transport = httpx.ASGITransport(app=users_app)
121
+ async with httpx.AsyncClient(
122
+ transport=transport,
123
+ base_url="http://testserver",
124
+ cookies={"session": cookie},
125
+ ) as c:
126
+ yield c
127
+
128
+
129
+ @pytest.fixture
130
+ async def user_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
131
+ """Client with a signed session cookie for an authenticated non-admin user.
132
+
133
+ Counterpart to ``admin_client`` — every endpoint behind
134
+ ``RequiresPermission(...)`` should answer with 403 for this caller, since
135
+ the default role map only grants the wildcard to ``admin``.
136
+ """
137
+ user = await _make_standard_user(users_app)
138
+ cookie = forge_session_cookie(
139
+ str(users_app.state.sm.settings.secret_key),
140
+ {"user_id": str(user.id)},
141
+ )
142
+ transport = httpx.ASGITransport(app=users_app)
143
+ async with httpx.AsyncClient(
144
+ transport=transport,
145
+ base_url="http://testserver",
146
+ cookies={"session": cookie},
147
+ ) as c:
148
+ yield c
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # DB session fixture scoped to users_app
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ @pytest.fixture
157
+ async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]:
158
+ """Session against the users_app in-memory DB."""
159
+ async with users_app.state.sm.db.session_factory() as session:
160
+ yield session
161
+
162
+
163
+ # Fixtures consumed by the users.middleware unit tests live in
164
+ # _middleware_support.py (imported as a pytest plugin below).
165
+ pytest_plugins = ["_middleware_support"]
@@ -0,0 +1,111 @@
1
+ """Every activity line comes out of a catalog, not out of an f-string.
2
+
3
+ The card used to build its sentences in Python — ``f"{verb} {kind} {label}"``
4
+ from an English verb table — so the "Recent activity" list read as English in
5
+ every locale and no translation could reach it. Neither lint check could see
6
+ it: they only parse ``.tsx``.
7
+
8
+ These tests translate with a stub that echoes ``key(args)`` instead of copy, so
9
+ an assertion here fails the moment a sentence is assembled locally again — an
10
+ f-string would produce prose where the stub produces a key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from simple_module_core import AuditLink, AuditLinkRegistry
18
+ from users.admin.recent_activity import _kind_of, _summarise
19
+
20
+
21
+ class _EchoTranslator:
22
+ """Stands in for ``Translator``, rendering the key and its arguments."""
23
+
24
+ def t(self, key: str, **params: Any) -> str:
25
+ rendered = ", ".join(f"{name}={value}" for name, value in sorted(params.items()))
26
+ return f"{key}({rendered})"
27
+
28
+
29
+ class _KeylessTranslator(_EchoTranslator):
30
+ """A translator with an empty catalog: ``t`` echoes the key back."""
31
+
32
+ def t(self, key: str, **params: Any) -> str:
33
+ return key
34
+
35
+
36
+ T = _EchoTranslator()
37
+
38
+
39
+ class TestSummaryKeys:
40
+ def test_a_create_reads_from_the_created_key(self) -> None:
41
+ assert _summarise(T, "create", "setting", "smtp_host", []) == (
42
+ "users.recent_activity.summary.created(kind=setting, label=smtp_host)"
43
+ )
44
+
45
+ def test_every_spelling_of_one_action_shares_a_key(self) -> None:
46
+ """Writers disagree on "delete"/"deleted"; the reader should not."""
47
+ assert _summarise(T, "delete", "file", "x", []) == _summarise(T, "deleted", "file", "x", [])
48
+
49
+ def test_an_unknown_action_keeps_its_verb_as_an_argument(self) -> None:
50
+ """Inventing a translation for a verb only one writer uses is a guess."""
51
+ assert _summarise(T, "archive", "file", "x", []) == (
52
+ "users.recent_activity.summary.other(action=Archive, kind=file, label=x)"
53
+ )
54
+
55
+ def test_named_fields_are_one_argument_not_a_locally_joined_sentence(self) -> None:
56
+ summary = _summarise(T, "update", "user", "Ada", [{"field": "email"}, {"field": "roles"}])
57
+ assert summary == (
58
+ "users.recent_activity.summary.changed_fields(fields=email, roles, label=Ada)"
59
+ )
60
+
61
+ def test_a_long_field_list_becomes_a_count(self) -> None:
62
+ changes = [{"field": name} for name in ("a", "b", "c", "d")]
63
+ assert _summarise(T, "update", "user", "Ada", changes) == (
64
+ "users.recent_activity.summary.changed_count(count=4, label=Ada)"
65
+ )
66
+
67
+ def test_an_update_with_no_recorded_fields_falls_back_to_the_plain_verb(self) -> None:
68
+ assert _summarise(T, "update", "user", "Ada", None) == (
69
+ "users.recent_activity.summary.updated(kind=user, label=Ada)"
70
+ )
71
+
72
+ def test_a_missing_catalog_entry_leaves_no_trailing_space(self) -> None:
73
+ """The key echoes back verbatim; nothing here appends a stray gap."""
74
+ assert _summarise(_KeylessTranslator(), "create", "setting", "x", []) == (
75
+ "users.recent_activity.summary.created"
76
+ )
77
+
78
+
79
+ class TestKindOf:
80
+ def _registry(self, link: AuditLink) -> AuditLinkRegistry:
81
+ registry = AuditLinkRegistry()
82
+ registry.register(link)
83
+ return registry
84
+
85
+ def test_the_links_label_key_is_translated(self) -> None:
86
+ registry = self._registry(
87
+ AuditLink(
88
+ entity_type="Setting",
89
+ url_template="/s/{id}",
90
+ label="Setting",
91
+ label_key="settings.audit.setting",
92
+ )
93
+ )
94
+ assert _kind_of(T, registry, "Setting") == "settings.audit.setting()"
95
+
96
+ def test_an_unresolved_key_falls_back_to_the_english_label(self) -> None:
97
+ registry = self._registry(
98
+ AuditLink(
99
+ entity_type="Setting",
100
+ url_template="/s/{id}",
101
+ label="Setting",
102
+ label_key="settings.audit.setting",
103
+ )
104
+ )
105
+ assert _kind_of(_KeylessTranslator(), registry, "Setting") == "setting"
106
+
107
+ def test_a_type_no_module_claims_reads_as_its_class_name(self) -> None:
108
+ assert _kind_of(T, AuditLinkRegistry(), "Unclaimed") == "unclaimed"
109
+
110
+ def test_no_registry_at_all_is_survivable(self) -> None:
111
+ assert _kind_of(T, None, "Unclaimed") == "unclaimed"