simple-module-users 0.0.32__tar.gz → 0.0.33__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.32 → simple_module_users-0.0.33}/PKG-INFO +6 -6
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/pyproject.toml +6 -6
- simple_module_users-0.0.33/tests/_users_app_builders.py +163 -0
- simple_module_users-0.0.33/tests/conftest.py +165 -0
- simple_module_users-0.0.33/tests/test_admin_resend_invite.py +224 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_api_auth.py +1 -1
- simple_module_users-0.0.33/tests/test_auth_screens_props.py +259 -0
- simple_module_users-0.0.33/tests/test_bearer_token_age.py +76 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_bootstrap.py +10 -10
- simple_module_users-0.0.33/tests/test_bulk_invite_message.py +121 -0
- simple_module_users-0.0.33/tests/test_edit_recent_activity.py +163 -0
- simple_module_users-0.0.33/tests/test_profile_password.py +173 -0
- simple_module_users-0.0.33/tests/test_remember_me.py +184 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_service_admin.py +21 -21
- simple_module_users-0.0.33/tests/test_session_expiry.py +183 -0
- simple_module_users-0.0.33/tests/test_session_version_cache.py +153 -0
- simple_module_users-0.0.33/tests/test_sessions_revoke.py +171 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_settings.py +27 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_user_manager.py +30 -6
- simple_module_users-0.0.33/tests/test_users_list_invites.py +124 -0
- simple_module_users-0.0.33/tests/test_users_login_deep_link.py +117 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_views.py +28 -8
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_views_admin.py +14 -14
- simple_module_users-0.0.33/tests-js/EmailChipInput.test.tsx +108 -0
- simple_module_users-0.0.33/tests-js/invite-emails.test.ts +22 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tsconfig.json +1 -1
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/api.py +4 -2
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/bulk_invite.py +19 -15
- simple_module_users-0.0.33/users/admin/components/IndexFilters.tsx +102 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/components/RolesTab.tsx +15 -9
- simple_module_users-0.0.33/users/admin/components/StatusPill.tsx +30 -0
- simple_module_users-0.0.33/users/admin/components/UserCards.tsx +76 -0
- simple_module_users-0.0.33/users/admin/components/UserRow.tsx +132 -0
- simple_module_users-0.0.33/users/admin/components/UserRowMenu.tsx +63 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/components/UsersEmpty.tsx +26 -28
- simple_module_users-0.0.33/users/admin/components/UsersTable.tsx +144 -0
- simple_module_users-0.0.33/users/admin/components/useUserRowActions.ts +105 -0
- simple_module_users-0.0.33/users/admin/components/user-list-item.ts +46 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/queries.py +50 -17
- simple_module_users-0.0.33/users/admin/recent_activity.py +165 -0
- simple_module_users-0.0.33/users/admin/resend_invite.py +92 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/service.py +50 -4
- simple_module_users-0.0.33/users/admin/user_state.py +59 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/views.py +34 -14
- simple_module_users-0.0.33/users/audit.py +68 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/auth_local/api.py +92 -26
- simple_module_users-0.0.33/users/auth_local/components/AuthField.tsx +36 -0
- simple_module_users-0.0.33/users/auth_local/components/AuthIntro.tsx +67 -0
- simple_module_users-0.0.33/users/auth_local/components/AuthStateCard.tsx +57 -0
- simple_module_users-0.0.33/users/auth_local/components/InviteSummary.tsx +52 -0
- simple_module_users-0.0.33/users/auth_local/components/LoginForm.tsx +156 -0
- simple_module_users-0.0.33/users/auth_local/components/PasswordCard.tsx +152 -0
- simple_module_users-0.0.33/users/auth_local/components/PasswordFields.tsx +84 -0
- simple_module_users-0.0.33/users/auth_local/components/PreferencesCard.tsx +138 -0
- simple_module_users-0.0.33/users/auth_local/components/ProfileDetailsCard.tsx +107 -0
- simple_module_users-0.0.33/users/auth_local/components/ResendVerification.tsx +78 -0
- simple_module_users-0.0.33/users/auth_local/components/ResetLinkSent.tsx +95 -0
- simple_module_users-0.0.33/users/auth_local/components/SessionsCard.tsx +99 -0
- simple_module_users-0.0.33/users/auth_local/components/WaitingOnYou.tsx +58 -0
- simple_module_users-0.0.33/users/auth_local/components/profile-user.ts +11 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/auth_local/invite_preview.py +33 -2
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/auth_local/rate_limit.py +26 -0
- simple_module_users-0.0.33/users/auth_local/self_account.py +133 -0
- simple_module_users-0.0.33/users/auth_local/token_preview.py +134 -0
- simple_module_users-0.0.33/users/auth_local/views.py +222 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/backend.py +18 -1
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/constants.py +9 -1
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/contracts/schemas.py +57 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/exceptions.py +19 -0
- simple_module_users-0.0.33/users/locales/en.json +385 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/__init__.py +24 -1
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/console.py +10 -1
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/smtp.py +15 -2
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/templates/invite.txt +4 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/manager.py +41 -8
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/user.py +19 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/module.py +30 -17
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/oauth/api.py +12 -2
- simple_module_users-0.0.33/users/pages/AcceptInvite.tsx +169 -0
- simple_module_users-0.0.33/users/pages/ForgotPassword.tsx +103 -0
- simple_module_users-0.0.33/users/pages/Login.tsx +164 -0
- simple_module_users-0.0.33/users/pages/Profile.tsx +54 -0
- simple_module_users-0.0.33/users/pages/Register.tsx +157 -0
- simple_module_users-0.0.33/users/pages/ResetPassword.tsx +172 -0
- simple_module_users-0.0.33/users/pages/Users/AddPeople.tsx +291 -0
- simple_module_users-0.0.33/users/pages/Users/Edit.tsx +126 -0
- simple_module_users-0.0.33/users/pages/Users/Index.tsx +187 -0
- simple_module_users-0.0.33/users/pages/Users/components/AccountCard.tsx +176 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/pages/Users/components/CreateUserFields.tsx +9 -10
- simple_module_users-0.0.33/users/pages/Users/components/DangerZone.tsx +112 -0
- simple_module_users-0.0.33/users/pages/Users/components/DetailsCard.tsx +94 -0
- simple_module_users-0.0.33/users/pages/Users/components/EditHeaderActions.tsx +43 -0
- simple_module_users-0.0.33/users/pages/Users/components/EmailChipInput.tsx +139 -0
- simple_module_users-0.0.33/users/pages/Users/components/InviteFields.tsx +71 -0
- simple_module_users-0.0.33/users/pages/Users/components/InviteResults.tsx +125 -0
- simple_module_users-0.0.33/users/pages/Users/components/RecentActivityCard.tsx +81 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/pages/Users/components/RolePicker.tsx +18 -3
- simple_module_users-0.0.33/users/pages/Users/components/UserStats.tsx +33 -0
- simple_module_users-0.0.33/users/pages/Users/components/useEditUserForm.ts +159 -0
- simple_module_users-0.0.33/users/pages/Users/components/useUserActions.ts +90 -0
- simple_module_users-0.0.33/users/pages/Users/invite-emails.ts +17 -0
- simple_module_users-0.0.33/users/pages/VerifyEmail.tsx +127 -0
- simple_module_users-0.0.33/users/provider.py +255 -0
- simple_module_users-0.0.33/users/session_version_cache.py +84 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/settings.py +28 -4
- simple_module_users-0.0.33/users/setup.py +51 -0
- simple_module_users-0.0.32/tests/conftest.py +0 -283
- simple_module_users-0.0.32/users/admin/components/IndexFilters.tsx +0 -72
- simple_module_users-0.0.32/users/admin/components/UserRow.tsx +0 -88
- simple_module_users-0.0.32/users/auth_local/views.py +0 -118
- simple_module_users-0.0.32/users/locales/en.json +0 -11
- simple_module_users-0.0.32/users/pages/AcceptInvite.tsx +0 -147
- simple_module_users-0.0.32/users/pages/ForgotPassword.tsx +0 -89
- simple_module_users-0.0.32/users/pages/Login.tsx +0 -208
- simple_module_users-0.0.32/users/pages/Profile.tsx +0 -154
- simple_module_users-0.0.32/users/pages/Register.tsx +0 -161
- simple_module_users-0.0.32/users/pages/ResetPassword.tsx +0 -109
- simple_module_users-0.0.32/users/pages/Users/AddPeople.tsx +0 -212
- simple_module_users-0.0.32/users/pages/Users/Edit.tsx +0 -295
- simple_module_users-0.0.32/users/pages/Users/Index.tsx +0 -290
- simple_module_users-0.0.32/users/pages/Users/components/AccountStatusCard.tsx +0 -117
- simple_module_users-0.0.32/users/pages/Users/components/DangerZone.tsx +0 -108
- simple_module_users-0.0.32/users/pages/Users/components/DetailsCard.tsx +0 -53
- simple_module_users-0.0.32/users/pages/Users/components/InviteFields.tsx +0 -46
- simple_module_users-0.0.32/users/pages/Users/components/InviteResults.tsx +0 -107
- simple_module_users-0.0.32/users/pages/Users/components/MetadataCard.tsx +0 -85
- simple_module_users-0.0.32/users/pages/Users/components/RolesCard.tsx +0 -37
- simple_module_users-0.0.32/users/pages/VerifyEmail.tsx +0 -122
- simple_module_users-0.0.32/users/provider.py +0 -140
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/.gitignore +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/LICENSE +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/README.md +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/package.json +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/cli.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/deps.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/health.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/middleware.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/_base.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/role.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/py.typed +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/users/shared_props.py +0 -0
- {simple_module_users-0.0.32 → simple_module_users-0.0.33}/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.33
|
|
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.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
|
|
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.33"
|
|
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.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",
|
|
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,224 @@
|
|
|
1
|
+
"""Resending an invite from the users table.
|
|
2
|
+
|
|
3
|
+
An invite that never arrived is the most common reason a row sits in the
|
|
4
|
+
"invited" state forever, and until now the only cure was deleting the account
|
|
5
|
+
and inviting again. Resending has to mint a *fresh* token and move
|
|
6
|
+
``invited_at`` forward, or the row would keep advertising the expiry of the
|
|
7
|
+
message nobody received.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
_INVITE = "/api/users/admin/invite"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def _invite(client, email: str) -> str:
|
|
21
|
+
resp = await client.post(_INVITE, json={"email": email, "role_names": []})
|
|
22
|
+
assert resp.status_code == 201, resp.text
|
|
23
|
+
return resp.json()["id"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _set_invited_at(users_db, user_id: str, when: datetime) -> None:
|
|
27
|
+
import uuid
|
|
28
|
+
|
|
29
|
+
from sqlalchemy import update
|
|
30
|
+
from users.models import User
|
|
31
|
+
|
|
32
|
+
await users_db.execute(
|
|
33
|
+
update(User).where(User.id == uuid.UUID(user_id)).values(invited_at=when)
|
|
34
|
+
)
|
|
35
|
+
await users_db.commit()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _SpyMailer:
|
|
39
|
+
"""A mailer that reports itself as delivering, and records the send.
|
|
40
|
+
|
|
41
|
+
Implements the whole protocol: creating and re-inviting go through
|
|
42
|
+
``on_after_register``/verification paths that call the other two methods.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
delivers_email = True
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self.invites: list[tuple[str, str]] = []
|
|
49
|
+
|
|
50
|
+
async def send_verification(self, email: str, token: str) -> None:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
async def send_password_reset(self, email: str, token: str) -> None:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
async def send_invite(
|
|
57
|
+
self, email: str, token: str, invited_by_name: str, message: str | None = None
|
|
58
|
+
) -> None:
|
|
59
|
+
self.invites.append((email, token))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.fixture
|
|
63
|
+
def spy_mailer(users_app):
|
|
64
|
+
original = users_app.state.users.mailer
|
|
65
|
+
spy = _SpyMailer()
|
|
66
|
+
users_app.state.users.mailer = spy
|
|
67
|
+
yield spy
|
|
68
|
+
users_app.state.users.mailer = original
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TestResendInvite:
|
|
72
|
+
@pytest.mark.anyio
|
|
73
|
+
async def test_resend_accepts_and_mails_again(self, admin_client, spy_mailer):
|
|
74
|
+
user_id = await _invite(admin_client, "again@example.com")
|
|
75
|
+
|
|
76
|
+
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
77
|
+
|
|
78
|
+
assert resp.status_code == 202, resp.text
|
|
79
|
+
assert resp.json()["status"] == "sent"
|
|
80
|
+
assert [email for email, _ in spy_mailer.invites] == [
|
|
81
|
+
"again@example.com",
|
|
82
|
+
"again@example.com",
|
|
83
|
+
]
|
|
84
|
+
assert spy_mailer.invites[-1][1]
|
|
85
|
+
|
|
86
|
+
@pytest.mark.anyio
|
|
87
|
+
async def test_resend_refreshes_invited_at(self, admin_client, users_db):
|
|
88
|
+
user_id = await _invite(admin_client, "stale@example.com")
|
|
89
|
+
old = datetime.now(UTC) - timedelta(days=6)
|
|
90
|
+
await _set_invited_at(users_db, user_id, old)
|
|
91
|
+
|
|
92
|
+
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
93
|
+
assert resp.status_code == 202, resp.text
|
|
94
|
+
|
|
95
|
+
rows = await admin_client.get(
|
|
96
|
+
"/admin/users/", headers={"X-Inertia": "true", "Accept": "application/json"}
|
|
97
|
+
)
|
|
98
|
+
row = next(u for u in rows.json()["props"]["users"] if u["email"] == "stale@example.com")
|
|
99
|
+
# SQLite hands back naive datetimes for a tz-aware column, Postgres
|
|
100
|
+
# aware ones — compare on the wall clock either way.
|
|
101
|
+
refreshed = datetime.fromisoformat(row["invited_at"]).replace(tzinfo=None)
|
|
102
|
+
assert refreshed > old.replace(tzinfo=None)
|
|
103
|
+
|
|
104
|
+
@pytest.mark.anyio
|
|
105
|
+
async def test_resend_hands_back_a_link_when_mail_cannot_be_delivered(self, admin_client):
|
|
106
|
+
"""The console mailer delivers nothing, so the admin needs the URL."""
|
|
107
|
+
user_id = await _invite(admin_client, "console@example.com")
|
|
108
|
+
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
109
|
+
assert resp.status_code == 202, resp.text
|
|
110
|
+
body = resp.json()
|
|
111
|
+
assert body["status"] == "link"
|
|
112
|
+
assert "/users/invite/accept?token=" in (body["link"] or "")
|
|
113
|
+
|
|
114
|
+
@pytest.mark.anyio
|
|
115
|
+
async def test_resend_refuses_for_an_already_verified_account(self, admin_client, users_db):
|
|
116
|
+
from test_api_admin import _make_user
|
|
117
|
+
|
|
118
|
+
user = await _make_user(users_db, email="settled@example.com", verified=True)
|
|
119
|
+
resp = await admin_client.post(f"/api/users/admin/{user.id}/resend-invite")
|
|
120
|
+
assert resp.status_code == 409, resp.text
|
|
121
|
+
assert "already accepted" in resp.json()["detail"]
|
|
122
|
+
|
|
123
|
+
@pytest.mark.anyio
|
|
124
|
+
async def test_resend_refuses_for_a_self_signup(self, admin_client, users_db):
|
|
125
|
+
"""Nobody invited them, so there is no invitation to send again."""
|
|
126
|
+
from test_api_admin import _make_user
|
|
127
|
+
|
|
128
|
+
user = await _make_user(users_db, email="walkedin@example.com", verified=False)
|
|
129
|
+
resp = await admin_client.post(f"/api/users/admin/{user.id}/resend-invite")
|
|
130
|
+
assert resp.status_code == 409, resp.text
|
|
131
|
+
assert "signed itself up" in resp.json()["detail"]
|
|
132
|
+
|
|
133
|
+
@pytest.mark.anyio
|
|
134
|
+
async def test_a_refused_resend_leaves_the_state_alone(self, admin_client, users_db):
|
|
135
|
+
"""Refusing must not stamp ``invited_at`` and turn a self-signup into an invite."""
|
|
136
|
+
from test_api_admin import _make_user
|
|
137
|
+
|
|
138
|
+
user = await _make_user(users_db, email="untouched@example.com", verified=False)
|
|
139
|
+
await admin_client.post(f"/api/users/admin/{user.id}/resend-invite")
|
|
140
|
+
|
|
141
|
+
rows = await admin_client.get(
|
|
142
|
+
"/admin/users/", headers={"X-Inertia": "true", "Accept": "application/json"}
|
|
143
|
+
)
|
|
144
|
+
row = next(
|
|
145
|
+
u for u in rows.json()["props"]["users"] if u["email"] == "untouched@example.com"
|
|
146
|
+
)
|
|
147
|
+
assert row["state"] == "unverified"
|
|
148
|
+
assert row["invited_at"] is None
|
|
149
|
+
|
|
150
|
+
@pytest.mark.anyio
|
|
151
|
+
async def test_resend_refuses_for_a_disabled_account(self, admin_client):
|
|
152
|
+
"""A live invite token would let a disabled account back in."""
|
|
153
|
+
user_id = await _invite(admin_client, "shutout@example.com")
|
|
154
|
+
assert (await admin_client.patch(f"/api/users/admin/{user_id}/disable")).status_code == 200
|
|
155
|
+
|
|
156
|
+
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
157
|
+
assert resp.status_code == 409, resp.text
|
|
158
|
+
assert "disabled" in resp.json()["detail"]
|
|
159
|
+
|
|
160
|
+
@pytest.mark.anyio
|
|
161
|
+
async def test_resend_404s_for_an_unknown_user(self, admin_client):
|
|
162
|
+
resp = await admin_client.post(
|
|
163
|
+
"/api/users/admin/00000000-0000-0000-0000-0000000000ff/resend-invite"
|
|
164
|
+
)
|
|
165
|
+
assert resp.status_code == 404
|
|
166
|
+
|
|
167
|
+
@pytest.mark.anyio
|
|
168
|
+
async def test_resend_requires_the_manage_permission(self, user_client):
|
|
169
|
+
resp = await user_client.post(
|
|
170
|
+
"/api/users/admin/00000000-0000-0000-0000-0000000000ff/resend-invite"
|
|
171
|
+
)
|
|
172
|
+
assert resp.status_code == 403
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestInviteTokenClaims:
|
|
176
|
+
@pytest.mark.anyio
|
|
177
|
+
async def test_invite_token_names_the_inviter(self, admin_client, users_app, caplog):
|
|
178
|
+
"""The accept-invite card says who invited you; the claim carries it."""
|
|
179
|
+
import jwt
|
|
180
|
+
|
|
181
|
+
with caplog.at_level(logging.INFO, logger="users.mailer"):
|
|
182
|
+
await _invite(admin_client, "whosent@example.com")
|
|
183
|
+
link = next(r for r in caplog.records if r.getMessage() == "users.invite.email").link
|
|
184
|
+
token = link.split("token=", 1)[1]
|
|
185
|
+
|
|
186
|
+
payload = jwt.decode(
|
|
187
|
+
token,
|
|
188
|
+
users_app.state.users.settings.verification_token_secret,
|
|
189
|
+
algorithms=["HS256"],
|
|
190
|
+
audience="fastapi-users:verify",
|
|
191
|
+
)
|
|
192
|
+
assert payload["invited_by"] == "Test Admin"
|
|
193
|
+
assert "exp" in payload
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestMailerFailureIsNotEchoed:
|
|
197
|
+
"""An SMTP error quotes the host, the port and sometimes the credential."""
|
|
198
|
+
|
|
199
|
+
@pytest.mark.anyio
|
|
200
|
+
async def test_the_admin_gets_the_link_and_a_generic_reason(
|
|
201
|
+
self, admin_client, users_app, caplog
|
|
202
|
+
):
|
|
203
|
+
from users.admin.bulk_invite import MAILER_FAILURE_DETAIL
|
|
204
|
+
|
|
205
|
+
class _BrokenMailer(_SpyMailer):
|
|
206
|
+
async def send_invite(self, email, token, invited_by_name, message=None):
|
|
207
|
+
raise RuntimeError("smtp://relay:s3cret@mail.internal:587 refused AUTH")
|
|
208
|
+
|
|
209
|
+
user_id = await _invite(admin_client, "broken@example.com")
|
|
210
|
+
original = users_app.state.users.mailer
|
|
211
|
+
users_app.state.users.mailer = _BrokenMailer()
|
|
212
|
+
try:
|
|
213
|
+
with caplog.at_level(logging.ERROR, logger="users.admin.resend_invite"):
|
|
214
|
+
resp = await admin_client.post(f"/api/users/admin/{user_id}/resend-invite")
|
|
215
|
+
finally:
|
|
216
|
+
users_app.state.users.mailer = original
|
|
217
|
+
|
|
218
|
+
assert resp.status_code == 202, resp.text
|
|
219
|
+
body = resp.json()
|
|
220
|
+
assert body["status"] == "link"
|
|
221
|
+
assert body["detail"] == MAILER_FAILURE_DETAIL
|
|
222
|
+
assert "s3cret" not in resp.text
|
|
223
|
+
# The real reason is not lost — it is one log line away.
|
|
224
|
+
assert "s3cret" in caplog.text
|
|
@@ -148,7 +148,7 @@ class TestAcceptInvite:
|
|
|
148
148
|
manager = UserManager(user_db, fake_mailer, settings)
|
|
149
149
|
# Reload the user with the manager to get a proper User object
|
|
150
150
|
fetched = await user_db.get_by_email("invited@example.com")
|
|
151
|
-
token =
|
|
151
|
+
token = manager.mint_invite_token(fetched)
|
|
152
152
|
|
|
153
153
|
resp = await anon_client.post(
|
|
154
154
|
"/api/users/auth/accept-invite",
|