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.
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/PKG-INFO +6 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/pyproject.toml +6 -6
- simple_module_users-0.0.34/tests/test_activity_summary.py +111 -0
- simple_module_users-0.0.34/tests/test_bearer_token_expiry.py +265 -0
- simple_module_users-0.0.34/tests/test_legacy_session_expiry.py +159 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_remember_me.py +6 -2
- simple_module_users-0.0.34/tests/test_session_version_hardening.py +242 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_manager.py +1 -1
- simple_module_users-0.0.34/tests/test_user_state.py +48 -0
- simple_module_users-0.0.34/tests-js/deriveState.test.ts +43 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/recent_activity.py +73 -25
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/views.py +3 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/audit.py +9 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/api.py +2 -2
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/LoginForm.tsx +2 -9
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/self_account.py +35 -12
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/token_api.py +22 -3
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/backend.py +46 -19
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/locales/en.json +10 -1
- simple_module_users-0.0.34/users/models/access_token.py +69 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/module.py +5 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/AcceptInvite.tsx +1 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/ForgotPassword.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Register.tsx +1 -1
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/ResetPassword.tsx +1 -6
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/provider.py +48 -38
- simple_module_users-0.0.34/users/session_version_cache.py +129 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/settings.py +8 -0
- simple_module_users-0.0.34/users/token_strategy.py +164 -0
- simple_module_users-0.0.33/users/models/access_token.py +0 -33
- simple_module_users-0.0.33/users/session_version_cache.py +0 -84
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/.gitignore +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/LICENSE +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/README.md +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/package.json +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/conftest.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_views.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/tsconfig.json +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/bulk_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/useUserRowActions.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/resend_invite.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/service.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/invite_preview.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/cli.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/constants.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/deps.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/exceptions.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/health.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/manager.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/middleware.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/_base.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/role.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/user.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/DangerZone.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/useEditUserForm.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/components/useUserActions.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/py.typed +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/setup.py +0 -0
- {simple_module_users-0.0.33 → simple_module_users-0.0.34}/users/shared_props.py +0 -0
- {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.
|
|
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.
|
|
28
|
-
Requires-Dist: simple-module-core==0.0.
|
|
29
|
-
Requires-Dist: simple-module-db==0.0.
|
|
30
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
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.
|
|
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.
|
|
25
|
-
"simple_module_db==0.0.
|
|
26
|
-
"simple_module_hosting==0.0.
|
|
27
|
-
"simple_module_settings==0.0.
|
|
28
|
-
"simple_module_auth==0.0.
|
|
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
|
-
|
|
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:
|