simple-module-users 0.0.33__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 (180) hide show
  1. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/pyproject.toml +6 -6
  3. simple_module_users-0.0.34/tests/test_activity_summary.py +111 -0
  4. simple_module_users-0.0.34/tests/test_bearer_token_expiry.py +265 -0
  5. simple_module_users-0.0.34/tests/test_legacy_session_expiry.py +159 -0
  6. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_remember_me.py +6 -2
  7. simple_module_users-0.0.34/tests/test_session_version_hardening.py +242 -0
  8. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_manager.py +1 -1
  9. simple_module_users-0.0.34/tests/test_user_state.py +48 -0
  10. simple_module_users-0.0.34/tests-js/deriveState.test.ts +43 -0
  11. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/recent_activity.py +73 -25
  12. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/views.py +3 -1
  13. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/audit.py +9 -0
  14. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/api.py +2 -2
  15. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/LoginForm.tsx +2 -9
  16. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/self_account.py +35 -12
  17. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/token_api.py +22 -3
  18. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/backend.py +46 -19
  19. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/locales/en.json +10 -1
  20. simple_module_users-0.0.34/users/models/access_token.py +69 -0
  21. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/module.py +5 -0
  22. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/AcceptInvite.tsx +1 -6
  23. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/ForgotPassword.tsx +1 -1
  24. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Register.tsx +1 -1
  25. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/ResetPassword.tsx +1 -6
  26. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/provider.py +48 -38
  27. simple_module_users-0.0.34/users/session_version_cache.py +129 -0
  28. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/settings.py +8 -0
  29. simple_module_users-0.0.34/users/token_strategy.py +164 -0
  30. simple_module_users-0.0.33/users/models/access_token.py +0 -33
  31. simple_module_users-0.0.33/users/session_version_cache.py +0 -84
  32. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/.gitignore +0 -0
  33. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/LICENSE +0 -0
  34. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/README.md +0 -0
  35. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/package.json +0 -0
  36. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/.gitkeep +0 -0
  37. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/_middleware_support.py +0 -0
  38. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/_users_app_builders.py +0 -0
  39. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/conftest.py +0 -0
  40. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_access_token_model.py +0 -0
  41. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_admin_resend_invite.py +0 -0
  42. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin.py +0 -0
  43. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin_crud.py +0 -0
  44. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin_filters.py +0 -0
  45. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_auth.py +0 -0
  46. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_auth_screens_props.py +0 -0
  47. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_backend.py +0 -0
  48. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bearer_token_age.py +0 -0
  49. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bootstrap.py +0 -0
  50. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bootstrap_resolution.py +0 -0
  51. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bulk_invite_message.py +0 -0
  52. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_cli.py +0 -0
  53. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_constants.py +0 -0
  54. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_db_adapter.py +0 -0
  55. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_edit_recent_activity.py +0 -0
  56. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_external_users.py +0 -0
  57. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_invite_flow.py +0 -0
  58. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_invite_reuse.py +0 -0
  59. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_mailer.py +0 -0
  60. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_negative_authz.py +0 -0
  61. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_oauth.py +0 -0
  62. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_oauth_routes.py +0 -0
  63. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_profile_password.py +0 -0
  64. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_rate_limit.py +0 -0
  65. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_role_model.py +0 -0
  66. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_service_admin.py +0 -0
  67. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_service_admin_crud.py +0 -0
  68. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_session_expiry.py +0 -0
  69. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_session_version_cache.py +0 -0
  70. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_sessions_revoke.py +0 -0
  71. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_settings.py +0 -0
  72. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_token_api.py +0 -0
  73. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_model.py +0 -0
  74. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_role_model.py +0 -0
  75. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_service.py +0 -0
  76. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_bulk_invite.py +0 -0
  77. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_deps.py +0 -0
  78. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_list_invites.py +0 -0
  79. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_login_deep_link.py +0 -0
  80. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware.py +0 -0
  81. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware_public_paths.py +0 -0
  82. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware_resolvers.py +0 -0
  83. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_provider.py +0 -0
  84. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_shared_props.py +0 -0
  85. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_views.py +0 -0
  86. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_views_admin.py +0 -0
  87. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests-js/EmailChipInput.test.tsx +0 -0
  88. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests-js/invite-emails.test.ts +0 -0
  89. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tsconfig.json +0 -0
  90. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/__init__.py +0 -0
  91. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/__init__.py +0 -0
  92. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/api.py +0 -0
  93. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/bulk_invite.py +0 -0
  94. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/IndexFilters.tsx +0 -0
  95. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/RolesTab.tsx +0 -0
  96. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/StatusPill.tsx +0 -0
  97. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserCards.tsx +0 -0
  98. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserRow.tsx +0 -0
  99. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserRowMenu.tsx +0 -0
  100. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UsersEmpty.tsx +0 -0
  101. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UsersTable.tsx +0 -0
  102. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/useUserRowActions.ts +0 -0
  103. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/user-list-item.ts +0 -0
  104. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/queries.py +0 -0
  105. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/resend_invite.py +0 -0
  106. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/service.py +0 -0
  107. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/user_state.py +0 -0
  108. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/__init__.py +0 -0
  109. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthField.tsx +0 -0
  110. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthIntro.tsx +0 -0
  111. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthStateCard.tsx +0 -0
  112. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/InviteSummary.tsx +0 -0
  113. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PasswordCard.tsx +0 -0
  114. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PasswordFields.tsx +0 -0
  115. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PreferencesCard.tsx +0 -0
  116. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
  117. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ResendVerification.tsx +0 -0
  118. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ResetLinkSent.tsx +0 -0
  119. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/SessionsCard.tsx +0 -0
  120. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/WaitingOnYou.tsx +0 -0
  121. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/profile-user.ts +0 -0
  122. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/invite_preview.py +0 -0
  123. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/rate_limit.py +0 -0
  124. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/token_preview.py +0 -0
  125. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/views.py +0 -0
  126. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/bootstrap.py +0 -0
  127. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/cli.py +0 -0
  128. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/constants.py +0 -0
  129. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/__init__.py +0 -0
  130. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/events.py +0 -0
  131. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/schemas.py +0 -0
  132. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/db_adapter.py +0 -0
  133. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/deps.py +0 -0
  134. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/exceptions.py +0 -0
  135. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/health.py +0 -0
  136. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/__init__.py +0 -0
  137. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/console.py +0 -0
  138. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/smtp.py +0 -0
  139. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/.gitkeep +0 -0
  140. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/invite.txt +0 -0
  141. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/reset_password.txt +0 -0
  142. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/verify_email.txt +0 -0
  143. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/manager.py +0 -0
  144. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/middleware.py +0 -0
  145. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/__init__.py +0 -0
  146. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/_base.py +0 -0
  147. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/oauth_account.py +0 -0
  148. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/refresh_token.py +0 -0
  149. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/role.py +0 -0
  150. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/user.py +0 -0
  151. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/user_role.py +0 -0
  152. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/__init__.py +0 -0
  153. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/api.py +0 -0
  154. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/providers.py +0 -0
  155. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/.gitkeep +0 -0
  156. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Login.tsx +0 -0
  157. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Profile.tsx +0 -0
  158. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/AddPeople.tsx +0 -0
  159. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/Edit.tsx +0 -0
  160. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/Index.tsx +0 -0
  161. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/AccountCard.tsx +0 -0
  162. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/CreateUserFields.tsx +0 -0
  163. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/DangerZone.tsx +0 -0
  164. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/DetailsCard.tsx +0 -0
  165. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
  166. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/EmailChipInput.tsx +0 -0
  167. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/InviteFields.tsx +0 -0
  168. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/InviteResults.tsx +0 -0
  169. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
  170. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/RolePicker.tsx +0 -0
  171. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/UserStats.tsx +0 -0
  172. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/useEditUserForm.ts +0 -0
  173. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/useUserActions.ts +0 -0
  174. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/invite-emails.ts +0 -0
  175. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/VerifyEmail.tsx +0 -0
  176. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/py.typed +0 -0
  177. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/roles_cache.py +0 -0
  178. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/setup.py +0 -0
  179. {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/shared_props.py +0 -0
  180. {simple_module_users-0.0.33 → 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.33
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.33
28
- Requires-Dist: simple-module-core==0.0.33
29
- Requires-Dist: simple-module-db==0.0.33
30
- Requires-Dist: simple-module-hosting==0.0.33
31
- Requires-Dist: simple-module-settings==0.0.33
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.33"
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.33",
25
- "simple_module_db==0.0.33",
26
- "simple_module_hosting==0.0.33",
27
- "simple_module_settings==0.0.33",
28
- "simple_module_auth==0.0.33",
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,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"
@@ -0,0 +1,265 @@
1
+ """A bearer credential lasts as long as it was issued for — not thirty days.
2
+
3
+ ``_TOKEN_LIFETIME_SECONDS`` is one number for the whole process, because
4
+ ``current_user`` resolves its strategy from the shared backend and cannot vary
5
+ it per request. It has to be the widest window any sign-in asks for, so every
6
+ other credential inherited it: an ordinary sign-in wrote an ``sm_auth`` cookie
7
+ whose ``Max-Age`` said fourteen days while the row behind it was accepted for
8
+ thirty, and ``/api/users/auth/token`` returned ``expires_in=900`` for a row good
9
+ for a month. ``Max-Age`` is browser-enforced only — a cookie lifted off disk is
10
+ replayed without one.
11
+
12
+ Each row now carries its own deadline and the ``session_version`` it was minted
13
+ under, and both bearer read paths enforce them: ``ExpiringDatabaseStrategy``
14
+ (which backs ``fastapi_users.current_user``) and ``UsersAuthProvider
15
+ ._resolve_bearer`` (which backs ``AuthMiddleware``).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import uuid
21
+ from datetime import UTC, datetime, timedelta
22
+
23
+ import httpx
24
+ import pytest
25
+ from sqlalchemy import select
26
+ from users.models import User, UserAccessToken
27
+
28
+ _LOGIN = "/api/users/auth/login"
29
+ _TOKEN = "/api/users/auth/token"
30
+ _ME = "/api/users/me"
31
+ _CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
32
+
33
+
34
+ async def _tokens(app) -> list[UserAccessToken]:
35
+ async with app.state.sm.db.session_factory() as session:
36
+ return list((await session.execute(select(UserAccessToken))).scalars())
37
+
38
+
39
+ async def _only_token(app) -> UserAccessToken:
40
+ rows = await _tokens(app)
41
+ assert len(rows) == 1, f"expected exactly one access token, got {len(rows)}"
42
+ return rows[0]
43
+
44
+
45
+ def _seconds_out(row: UserAccessToken) -> float:
46
+ """How far ``expires_at`` sits from ``created_at``, in seconds."""
47
+ created, expires = row.created_at, row.expires_at
48
+ created = created.replace(tzinfo=UTC) if created.tzinfo is None else created
49
+ expires = expires.replace(tzinfo=UTC) if expires.tzinfo is None else expires
50
+ return (expires - created).total_seconds()
51
+
52
+
53
+ def _bearer(app, token: str) -> httpx.AsyncClient:
54
+ """A client presenting the row as an ``Authorization: Bearer`` header.
55
+
56
+ Read by ``UsersAuthProvider._resolve_bearer`` through ``AuthMiddleware``.
57
+ """
58
+ return httpx.AsyncClient(
59
+ transport=httpx.ASGITransport(app=app),
60
+ base_url="http://testserver",
61
+ headers={"Authorization": f"Bearer {token}"},
62
+ )
63
+
64
+
65
+ async def _expire_all(app) -> None:
66
+ """Push every row's deadline into the past, leaving ``created_at`` alone."""
67
+ async with app.state.sm.db.session_factory() as session:
68
+ for row in (await session.execute(select(UserAccessToken))).scalars():
69
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
70
+ await session.commit()
71
+
72
+
73
+ async def _expire(app, token: str) -> None:
74
+ """Push a row's deadline into the past, leaving ``created_at`` alone.
75
+
76
+ The point of the test: the row is well inside the thirty-day read ceiling
77
+ and must still be refused, because the window it was *issued* for has run
78
+ out.
79
+ """
80
+ async with app.state.sm.db.session_factory() as session:
81
+ row = await session.get(UserAccessToken, token)
82
+ row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
83
+ await session.commit()
84
+
85
+
86
+ class TestTheRowLastsWhatWasPromised:
87
+ @pytest.mark.anyio
88
+ async def test_an_ordinary_sign_in_gets_the_cookie_window(self, users_app, anon_client):
89
+ """Not the thirty-day ceiling. This is the whole bug: the `Max-Age` said
90
+ fourteen days and the row behind it was honoured for thirty."""
91
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
92
+
93
+ settings = users_app.state.users.settings
94
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(
95
+ settings.cookie_max_age_seconds, abs=5
96
+ )
97
+
98
+ @pytest.mark.anyio
99
+ async def test_keep_me_signed_in_gets_the_longer_window(self, users_app, anon_client):
100
+ """The checkbox still has to mean what it says."""
101
+ assert (
102
+ await anon_client.post(_LOGIN, data={**_CREDS, "remember": "true"})
103
+ ).status_code == 204
104
+
105
+ settings = users_app.state.users.settings
106
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(
107
+ settings.remember_me_max_age_seconds, abs=5
108
+ )
109
+
110
+ @pytest.mark.anyio
111
+ async def test_the_token_endpoint_honours_its_own_expires_in(self, users_app, anon_client):
112
+ """It advertised 15 minutes and minted a row good for a month, so a
113
+ client that honoured `expires_in` re-authenticated every 15 minutes
114
+ while the token it discarded stayed valid."""
115
+ resp = await anon_client.post(
116
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
117
+ )
118
+ assert resp.status_code == 200, resp.text
119
+ advertised = resp.json()["expires_in"]
120
+
121
+ settings = users_app.state.users.settings
122
+ assert advertised == settings.bearer_token_lifetime_seconds
123
+ assert _seconds_out(await _only_token(users_app)) == pytest.approx(advertised, abs=5)
124
+
125
+
126
+ class TestAnExpiredRowStopsAuthenticating:
127
+ @pytest.mark.anyio
128
+ async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
129
+ """`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
130
+
131
+ ``/api/users/me`` is the probe because it depends on ``current_user``,
132
+ which is what reads the ``sm_auth`` row. The row stays well inside the
133
+ thirty-day read ceiling — only the deadline it was issued for has passed.
134
+ """
135
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
136
+ assert (await anon_client.get(_ME)).status_code == 200
137
+
138
+ await _expire_all(users_app)
139
+ assert (await anon_client.get(_ME)).status_code == 401
140
+
141
+ @pytest.mark.anyio
142
+ async def test_the_provider_path_refuses_it(self, users_app, anon_client):
143
+ """`UsersAuthProvider._resolve_bearer`, via AuthMiddleware."""
144
+ from users.provider import UsersAuthProvider
145
+
146
+ resp = await anon_client.post(
147
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
148
+ )
149
+ token = resp.json()["access_token"]
150
+ scope = {"app": users_app}
151
+
152
+ provider = UsersAuthProvider()
153
+ assert await provider._resolve_bearer(scope, token) is not None
154
+ await _expire(users_app, token)
155
+ assert await provider._resolve_bearer(scope, token) is None
156
+
157
+
158
+ class TestAPasswordChangeStrandsBearerTokens:
159
+ """`change_my_password` bumped `session_version` and stranded every session,
160
+ while every bearer token minted before it kept working — including any an
161
+ attacker who knew the old password had already collected."""
162
+
163
+ @pytest.mark.anyio
164
+ async def test_a_version_bump_strands_a_token_on_both_paths(self, users_app, anon_client):
165
+ from users.provider import UsersAuthProvider, forget_session_version
166
+
167
+ # One row per path: the sign-in writes the `sm_auth` row `current_user`
168
+ # reads, and `/auth/token` mints the one the Bearer header carries.
169
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
170
+ minted = await anon_client.post(
171
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
172
+ )
173
+ token = minted.json()["access_token"]
174
+ provider = UsersAuthProvider()
175
+ scope = {"app": users_app}
176
+
177
+ assert (await anon_client.get(_ME)).status_code == 200
178
+ assert await provider._resolve_bearer(scope, token) is not None
179
+
180
+ async with users_app.state.sm.db.session_factory() as session:
181
+ user = (
182
+ await session.execute(select(User).where(User.email == "admin@example.com"))
183
+ ).scalar_one()
184
+ user.session_version = int(user.session_version or 0) + 1
185
+ await session.commit()
186
+ forget_session_version(user.id)
187
+
188
+ assert (await anon_client.get(_ME)).status_code == 401
189
+ assert await provider._resolve_bearer(scope, token) is None
190
+
191
+ @pytest.mark.anyio
192
+ async def test_changing_the_password_deletes_the_rows(self, users_app, anon_client):
193
+ """The stamped counter already strands them; deleting is what stops a
194
+ stolen token resolving a row at all, and stops the refresh tokens
195
+ minting fresh ones behind it."""
196
+ assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
197
+ assert await _tokens(users_app)
198
+
199
+ resp = await anon_client.post(
200
+ "/api/users/me/password",
201
+ json={"current_password": "AdminPass1!", "new_password": "NewAdminPass1!"},
202
+ )
203
+ assert resp.status_code == 204, resp.text
204
+ assert await _tokens(users_app) == []
205
+
206
+
207
+ class TestTheMintedCounterIsCurrent:
208
+ @pytest.mark.anyio
209
+ async def test_a_refresh_carries_the_accounts_counter(self, users_app, anon_client):
210
+ """A refresh token outlives the access tokens it mints, so the account
211
+ is re-read rather than trusted from the row."""
212
+ resp = await anon_client.post(
213
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
214
+ )
215
+ refresh = resp.json()["refresh_token"]
216
+
217
+ async with users_app.state.sm.db.session_factory() as session:
218
+ user = (
219
+ await session.execute(select(User).where(User.email == "admin@example.com"))
220
+ ).scalar_one()
221
+ user.session_version = 42
222
+ await session.commit()
223
+
224
+ rotated = await anon_client.post(
225
+ "/api/users/auth/token/refresh", json={"refresh_token": refresh}
226
+ )
227
+ assert rotated.status_code == 200, rotated.text
228
+
229
+ async with users_app.state.sm.db.session_factory() as session:
230
+ row = (
231
+ await session.execute(
232
+ select(UserAccessToken).where(
233
+ UserAccessToken.token == rotated.json()["access_token"]
234
+ )
235
+ )
236
+ ).scalar_one()
237
+ assert row.session_version == 42
238
+
239
+ @pytest.mark.anyio
240
+ async def test_a_refresh_for_a_deactivated_account_is_refused(self, users_app, anon_client):
241
+ resp = await anon_client.post(
242
+ _TOKEN, json={"email": "admin@example.com", "password": "AdminPass1!"}
243
+ )
244
+ refresh = resp.json()["refresh_token"]
245
+
246
+ async with users_app.state.sm.db.session_factory() as session:
247
+ user = (
248
+ await session.execute(select(User).where(User.email == "admin@example.com"))
249
+ ).scalar_one()
250
+ user.is_active = False
251
+ await session.commit()
252
+
253
+ rotated = await anon_client.post(
254
+ "/api/users/auth/token/refresh", json={"refresh_token": refresh}
255
+ )
256
+ assert rotated.status_code == 401
257
+
258
+
259
+ class TestTheModelDefault:
260
+ def test_a_row_built_directly_still_gets_a_deadline(self):
261
+ """The mint paths always stamp one; the default keeps a caller that
262
+ builds the model by hand from minting a credential that never expires."""
263
+ row = UserAccessToken(token="t", user_id=uuid.uuid4())
264
+ assert row.expires_at is not None
265
+ assert _seconds_out(row) == pytest.approx(30 * 24 * 60 * 60, abs=5)
@@ -0,0 +1,159 @@
1
+ """A session minted before deadlines existed gets one on first sight.
2
+
3
+ ``SESSION_EXPIRES_AT_KEY`` holds each session's own absolute deadline, which is
4
+ what holds an ordinary sign-in to the fourteen days it asked for while the
5
+ signature window stays at thirty for everyone (that is what "keep me signed in"
6
+ needs). Sessions minted before the key existed carry none, and
7
+ ``session_has_expired`` accepted them — a fail-open with nothing tracking when
8
+ it could safely be closed.
9
+
10
+ Stamping on first sight closes it without a deploy-time mass sign-out. It can
11
+ only ever tighten: the signer independently enforces the signature window from
12
+ the session's original mint time, so a stamp written now cannot outlast the
13
+ ceiling the session already had.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+
20
+ import httpx
21
+ import pytest
22
+ from simple_module_hosting.session import (
23
+ SESSION_EXPIRES_AT_KEY,
24
+ SESSION_REMEMBER_KEY,
25
+ SESSION_SIGNATURE_MAX_AGE,
26
+ ensure_session_expiry,
27
+ session_has_expired,
28
+ stamp_session_expiry,
29
+ )
30
+ from simple_module_test import forge_session_cookie
31
+ from sqlalchemy import select
32
+ from users.models import User
33
+
34
+ # Probed with a page route rather than ``/api/users/me``: that one also depends
35
+ # on ``current_user``, which reads the ``sm_auth`` cookie, and a forged session
36
+ # cookie carries no such row. This route is gated by ``AuthMiddleware`` alone —
37
+ # which is the code path under test.
38
+ _GUARDED = "/admin/users/"
39
+ _DAY = 24 * 60 * 60
40
+
41
+
42
+ class TestSessionHasExpiredFailsClosed:
43
+ """Absence used to mean "accept"; the only caller stamps first, so absence
44
+ now means the session never went through that path."""
45
+
46
+ def test_a_session_with_no_deadline_is_expired(self):
47
+ assert session_has_expired({}) is True
48
+
49
+ def test_a_non_mapping_is_expired(self):
50
+ assert session_has_expired(None) is True
51
+
52
+ def test_an_unreadable_deadline_is_expired(self):
53
+ assert session_has_expired({SESSION_EXPIRES_AT_KEY: "soon"}) is True
54
+ # ``bool`` is an ``int``: True must not read as epoch second 1.
55
+ assert session_has_expired({SESSION_EXPIRES_AT_KEY: True}) is True
56
+
57
+ def test_a_future_deadline_is_live(self):
58
+ assert session_has_expired({SESSION_EXPIRES_AT_KEY: time.time() + 60}) is False
59
+
60
+ def test_a_past_deadline_is_expired(self):
61
+ assert session_has_expired({SESSION_EXPIRES_AT_KEY: time.time() - 1}) is True
62
+
63
+
64
+ class TestEnsureSessionExpiry:
65
+ def test_it_stamps_a_session_that_has_none(self):
66
+ session: dict = {}
67
+ deadline = ensure_session_expiry(session, 14 * _DAY)
68
+
69
+ assert session[SESSION_EXPIRES_AT_KEY] == deadline
70
+ assert deadline == pytest.approx(time.time() + 14 * _DAY, abs=5)
71
+ assert session_has_expired(session) is False
72
+
73
+ def test_it_never_widens_an_existing_deadline(self):
74
+ """Otherwise every request would slide the window forward and the
75
+ session would never expire."""
76
+ session: dict = {}
77
+ first = stamp_session_expiry(session, 60)
78
+ again = ensure_session_expiry(session, SESSION_SIGNATURE_MAX_AGE)
79
+
80
+ assert again == first
81
+ assert session[SESSION_EXPIRES_AT_KEY] == first
82
+
83
+ def test_it_leaves_an_already_expired_deadline_alone(self):
84
+ """Re-stamping here would resurrect a session the caller is about to
85
+ reject."""
86
+ session = {SESSION_EXPIRES_AT_KEY: int(time.time()) - 1}
87
+ ensure_session_expiry(session, 14 * _DAY)
88
+
89
+ assert session_has_expired(session) is True
90
+
91
+
92
+ class TestALegacySessionKeepsWorking:
93
+ """The deploy-time behaviour: nobody is signed out, and from the first
94
+ request on the session carries the same bound as one minted today."""
95
+
96
+ @staticmethod
97
+ def _legacy_client(app, user_id, session_version: int, **extra) -> httpx.AsyncClient:
98
+ """A browser holding a cookie minted before deadlines existed."""
99
+ payload = {"user_id": str(user_id), "session_version": session_version, **extra}
100
+ assert SESSION_EXPIRES_AT_KEY not in payload
101
+ cookie = forge_session_cookie(str(app.state.sm.settings.secret_key), payload)
102
+ return httpx.AsyncClient(
103
+ transport=httpx.ASGITransport(app=app),
104
+ base_url="http://testserver",
105
+ cookies={"session": cookie},
106
+ )
107
+
108
+ @staticmethod
109
+ async def _admin(app):
110
+ async with app.state.sm.db.session_factory() as session:
111
+ return (
112
+ await session.execute(select(User).where(User.email == "admin@example.com"))
113
+ ).scalar_one()
114
+
115
+ @pytest.mark.anyio
116
+ async def test_it_is_not_signed_out(self, users_app):
117
+ admin = await self._admin(users_app)
118
+ async with self._legacy_client(users_app, admin.id, admin.session_version) as client:
119
+ assert (await client.get(_GUARDED)).status_code == 200
120
+
121
+ @pytest.mark.anyio
122
+ async def test_the_ordinary_window_is_stamped_onto_it(self, users_app):
123
+ admin = await self._admin(users_app)
124
+ async with self._legacy_client(users_app, admin.id, admin.session_version) as client:
125
+ resp = await client.get(_GUARDED)
126
+ assert resp.status_code == 200
127
+
128
+ session = _read_session(users_app, resp)
129
+ assert session[SESSION_EXPIRES_AT_KEY] == pytest.approx(
130
+ time.time() + users_app.state.users.settings.cookie_max_age_seconds, abs=30
131
+ )
132
+
133
+ @pytest.mark.anyio
134
+ async def test_a_remembered_legacy_session_is_not_demoted(self, users_app):
135
+ """It recorded the longer window; stamping must read that back rather
136
+ than quietly cutting it to the ordinary one."""
137
+ admin = await self._admin(users_app)
138
+ async with self._legacy_client(
139
+ users_app, admin.id, admin.session_version, **{SESSION_REMEMBER_KEY: 30 * _DAY}
140
+ ) as client:
141
+ resp = await client.get(_GUARDED)
142
+ assert resp.status_code == 200
143
+
144
+ session = _read_session(users_app, resp)
145
+ assert session[SESSION_EXPIRES_AT_KEY] == pytest.approx(time.time() + 30 * _DAY, abs=30)
146
+
147
+
148
+ def _read_session(app, response: httpx.Response) -> dict:
149
+ """Decode the session cookie this response wrote."""
150
+ import itsdangerous
151
+ from starlette.datastructures import Secret
152
+
153
+ raw = response.cookies.get("session")
154
+ assert raw is not None, "expected the stamping request to rewrite the session cookie"
155
+ signer = itsdangerous.TimestampSigner(str(Secret(str(app.state.sm.settings.secret_key))))
156
+ import base64
157
+ import json
158
+
159
+ return json.loads(base64.b64decode(signer.unsign(raw, max_age=SESSION_SIGNATURE_MAX_AGE)))
@@ -155,8 +155,12 @@ class TestTheWindowSurvivesLaterRequests:
155
155
  page = await anon_client.get("/dashboard/", follow_redirects=False)
156
156
  assert page.status_code == 200, page.status_code
157
157
  reissued = _reissued_cookie(page, "session")
158
- if reissued is not None:
159
- assert "Max-Age=1209600" in reissued
158
+ # Asserted, not guarded: `if reissued is not None:` let this pass when
159
+ # the page wrote no session cookie at all, which is precisely the case
160
+ # where it has stopped exercising the regression. Its remembered
161
+ # sibling three tests up already asserts presence first.
162
+ assert reissued is not None, "the page wrote no session cookie to check"
163
+ assert "Max-Age=1209600" in reissued
160
164
 
161
165
 
162
166
  class TestNotRemembered: