simple-module-users 0.0.40__tar.gz → 0.0.42__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.40 → simple_module_users-0.0.42}/PKG-INFO +7 -7
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/package.json +4 -4
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/pyproject.toml +7 -7
- simple_module_users-0.0.42/tests/invites/test_users_bulk_invite_error_privacy.py +24 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_bearer_token_expiry.py +28 -5
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_remember_me.py +27 -4
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_revocation_broadcast.py +7 -5
- simple_module_users-0.0.42/tests/test_users_admin_create_commit.py +77 -0
- simple_module_users-0.0.42/tests/test_users_data_compatibility.py +66 -0
- simple_module_users-0.0.42/tests/test_users_mailer_templates.py +148 -0
- simple_module_users-0.0.42/tests/test_users_me_credentials.py +46 -0
- simple_module_users-0.0.42/tests/test_users_oauth_error_responses.py +111 -0
- simple_module_users-0.0.42/tests/test_users_oauth_login_errors.py +51 -0
- simple_module_users-0.0.42/tests/test_users_refresh_family_max_age.py +178 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_refresh_reuse.py +1 -1
- simple_module_users-0.0.42/tests/test_users_reset_credentials.py +118 -0
- simple_module_users-0.0.42/tests/test_users_reset_password_retry.py +40 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_token_pruning.py +3 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_Login.test.tsx +16 -0
- simple_module_users-0.0.42/tests-js/users_ResetPassword.test.tsx +114 -0
- simple_module_users-0.0.42/tests-js/users_admin_error_details.test.tsx +112 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/api.py +21 -15
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/useUserRowActions.ts +10 -4
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/api.py +3 -3
- simple_module_users-0.0.42/users/auth_local/resetPasswordError.ts +13 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/self_account.py +3 -43
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/token_api.py +22 -5
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/views.py +3 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/constants.py +10 -0
- simple_module_users-0.0.42/users/credentials.py +52 -0
- simple_module_users-0.0.42/users/data_compatibility.py +62 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/db_adapter.py +24 -3
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/deps.py +17 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/invites/bulk.py +2 -10
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/locales/de.json +14 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/locales/en.json +14 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/mailer/__init__.py +9 -2
- simple_module_users-0.0.42/users/mailer/templated.py +111 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/manager.py +11 -2
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/module.py +7 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/oauth/api.py +19 -24
- simple_module_users-0.0.42/users/oauth/errors.py +27 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Login.tsx +11 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/ResetPassword.tsx +3 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/DangerZone.tsx +3 -3
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/useEditUserForm.ts +16 -8
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/useUserActions.ts +6 -4
- simple_module_users-0.0.42/users/pages/failureMessage.ts +41 -0
- simple_module_users-0.0.42/users/reset_revocation.py +53 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/settings.py +13 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/startup.py +1 -1
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/token_storage.py +26 -3
- simple_module_users-0.0.40/users/mailer/templated.py +0 -77
- simple_module_users-0.0.40/users/pages/failureMessage.ts +0 -16
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/.gitignore +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/LICENSE +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/README.md +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/_demo_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/_users_app_builders.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/_users_oauth_support.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/conftest.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/demo/test_users_demo_account.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/demo/test_users_demo_credentials.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/demo/test_users_demo_guard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/demo/test_users_demo_login.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_admin_resend_invite.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_bulk_invite.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_bulk_invite_message.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_invite_flow.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_invite_reuse.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/invites/test_users_list_invites.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_admin_unlock.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_checkpoint.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_columns.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_enumeration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_lockout_service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/lockout/test_users_login_lockout.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_activity_summary.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_api_admin_pagination_bounds.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_auth_screens_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_bearer_token_age.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_edit_recent_activity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_legacy_cached_tenant.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_legacy_session_expiry.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_login_redirect_root_mount.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_profile_password.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_expiry.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_version_atomic.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_version_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_version_failopen.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_session_version_hardening.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_sessions_revoke.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_user_state.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_admin_effective_permissions.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_admin_privilege_guards.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_admin_role_grant_rules.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_admin_session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_bearer_scheme.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_default_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_deletion_events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_directory.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_domain_errors.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_login_deep_link.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_login_session_rotation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_mailer_core_sender.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_manager.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_oauth_email_claim.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_oauth_provider_identity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_oauth_signup_gate.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_oauth_unverified_link.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_password_change_keeps_api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_register_role_durable.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_register_single_connection.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_require_verification.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_reset_link_origin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_seed_role_migration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_setup_lock.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_setup_wizard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_shared_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_startup_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_token_hash_migration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_token_storage.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_users_update_schema.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_views.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_views_admin.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/AccountCardLocked.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/EmailChipInput.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/LoginForm.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/deriveState.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/invite-emails.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/registerErrors.test.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_Register.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_VerifyEmail.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_actions.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_fixtures.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests-js/users_profile_cards.test.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/tsconfig.json +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/StatusPill.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/UserCards.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/UserRowMenu.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/UsersEmpty.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/UsersTable.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/components/user-list-item.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/links.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/queries.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/recent_activity.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/safety.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/service.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/user_state.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/admin/views.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/audit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/AuthField.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/AuthIntro.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/AuthStateCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/DemoSignIn.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/InviteSummary.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/LoginForm.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/PasswordCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/PasswordFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/PreferencesCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/ProfileDetailsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/ResendVerification.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/ResetLinkSent.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/SessionsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/WaitingOnYou.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/components/profile-user.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/lockout/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/lockout/attempt.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/registerErrors.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/auth_local/token_preview.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/backend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/cli.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/contracts/roles.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/contracts/schemas.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/demo/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/demo/api.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/demo/guard.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/directory.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/errors.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/exceptions.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/invites/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/invites/preview.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/invites/resend.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/middleware.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/_base.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/user.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/AddPeople.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/Edit.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/Index.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/AccountCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/CreateUserFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/DetailsCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/EditHeaderActions.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/EmailChipInput.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/InviteFields.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/InviteResults.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/MailerBanner.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/RecentActivityCard.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/RolePicker.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/UserStats.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/components/useAddPeople.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/Users/invite-emails.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/api.generated.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/api.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/pages/signIn.ts +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/provider.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/py.typed +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/registration.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/session_revocation.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/session_version_cache.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/setup.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/setup_action.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/shared_props.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/state.py +0 -0
- {simple_module_users-0.0.40 → simple_module_users-0.0.42}/users/token_strategy.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.42
|
|
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
|
|
@@ -23,12 +23,12 @@ Classifier: Typing :: Typed
|
|
|
23
23
|
Requires-Python: >=3.12
|
|
24
24
|
Requires-Dist: cachetools<8,>=5.3
|
|
25
25
|
Requires-Dist: fastapi-users[oauth,sqlalchemy]<16,>=15
|
|
26
|
-
Requires-Dist: simple-module-auth==0.0.
|
|
27
|
-
Requires-Dist: simple-module-core==0.0.
|
|
28
|
-
Requires-Dist: simple-module-db==0.0.
|
|
29
|
-
Requires-Dist: simple-module-hosting==0.0.
|
|
30
|
-
Requires-Dist: simple-module-mail==0.0.
|
|
31
|
-
Requires-Dist: simple-module-settings==0.0.
|
|
26
|
+
Requires-Dist: simple-module-auth==0.0.42
|
|
27
|
+
Requires-Dist: simple-module-core==0.0.42
|
|
28
|
+
Requires-Dist: simple-module-db==0.0.42
|
|
29
|
+
Requires-Dist: simple-module-hosting==0.0.42
|
|
30
|
+
Requires-Dist: simple-module-mail==0.0.42
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.42
|
|
32
32
|
Requires-Dist: typer<0.28,>=0.12
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simple-module-py/users",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.42",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Frontend assets for the Users module",
|
|
6
6
|
"peerDependencies": {
|
|
7
|
+
"@inertiajs/react": "^3.9.1",
|
|
8
|
+
"@simple-module-py/ui": "*",
|
|
7
9
|
"react": "^19.0.0",
|
|
8
|
-
"react-dom": "^19.
|
|
9
|
-
"@inertiajs/react": "^3.7.0",
|
|
10
|
-
"@simple-module-py/ui": "*"
|
|
10
|
+
"react-dom": "^19.3.1"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
13
|
"@simple-module-py/tsconfig": "*"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "simple_module_users"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.42"
|
|
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,14 +21,14 @@ 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.42",
|
|
25
|
+
"simple_module_db==0.0.42",
|
|
26
|
+
"simple_module_hosting==0.0.42",
|
|
27
|
+
"simple_module_settings==0.0.42",
|
|
28
|
+
"simple_module_auth==0.0.42",
|
|
29
29
|
# Sends through the framework mail sender; `mail` owns the SMTP transport
|
|
30
30
|
# (GH #496), so an upgrade keeps delivering mail.
|
|
31
|
-
"simple_module_mail==0.0.
|
|
31
|
+
"simple_module_mail==0.0.42",
|
|
32
32
|
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
33
33
|
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
34
34
|
# Bumping the major version requires re-checking those field names.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Invite responses never expose database statements or exception details (#698)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from fastapi_users.exceptions import UserAlreadyExists
|
|
5
|
+
from sqlalchemy.exc import IntegrityError
|
|
6
|
+
from users.invites.bulk import _failure_detail
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize(
|
|
10
|
+
"error",
|
|
11
|
+
[
|
|
12
|
+
IntegrityError(
|
|
13
|
+
"INSERT INTO users VALUES (:password)", {"password": "secret"}, Exception("db")
|
|
14
|
+
),
|
|
15
|
+
RuntimeError("private backend details"),
|
|
16
|
+
Exception(),
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
def test_users_bulk_invite_unknown_error_is_generic(error):
|
|
20
|
+
assert _failure_detail(error) == "The invitation could not be created. Please try again."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_users_bulk_invite_already_exists_has_safe_message():
|
|
24
|
+
assert _failure_detail(UserAlreadyExists()) == "Already a member of this workspace"
|
|
@@ -32,6 +32,30 @@ _ME = "/api/users/me"
|
|
|
32
32
|
_CREDS = {"username": "admin@example.com", "password": "AdminPass1!"}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
async def _cookie_strategy_user(app, client):
|
|
36
|
+
"""Resolve the ``sm_auth`` cookie through the cookie strategy itself.
|
|
37
|
+
|
|
38
|
+
``/api/users/me`` now resolves the request principal (session or bearer,
|
|
39
|
+
#658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
|
|
40
|
+
the way ``fastapi_users.current_user`` does instead.
|
|
41
|
+
"""
|
|
42
|
+
from users.backend import build_strategy
|
|
43
|
+
from users.db_adapter import PruningAccessTokenDatabase
|
|
44
|
+
from users.models import UserAccessToken
|
|
45
|
+
|
|
46
|
+
async with app.state.sm.db.session_factory() as session:
|
|
47
|
+
|
|
48
|
+
class _Manager:
|
|
49
|
+
def parse_id(self, value):
|
|
50
|
+
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
|
51
|
+
|
|
52
|
+
async def get(self, user_id):
|
|
53
|
+
return await session.get(User, user_id)
|
|
54
|
+
|
|
55
|
+
strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
|
|
56
|
+
return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
|
|
57
|
+
|
|
58
|
+
|
|
35
59
|
async def _tokens(app) -> list[UserAccessToken]:
|
|
36
60
|
async with app.state.sm.db.session_factory() as session:
|
|
37
61
|
return list((await session.execute(select(UserAccessToken))).scalars())
|
|
@@ -129,15 +153,14 @@ class TestAnExpiredRowStopsAuthenticating:
|
|
|
129
153
|
async def test_the_cookie_strategy_path_refuses_it(self, users_app, anon_client):
|
|
130
154
|
"""`fastapi_users.current_user`, via ExpiringDatabaseStrategy.
|
|
131
155
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
thirty-day read ceiling — only the deadline it was issued for has passed.
|
|
156
|
+
The row stays well inside the thirty-day read ceiling — only the
|
|
157
|
+
deadline it was issued for has passed.
|
|
135
158
|
"""
|
|
136
159
|
assert (await anon_client.post(_LOGIN, data=_CREDS)).status_code == 204
|
|
137
|
-
assert
|
|
160
|
+
assert await _cookie_strategy_user(users_app, anon_client) is not None
|
|
138
161
|
|
|
139
162
|
await _expire_all(users_app)
|
|
140
|
-
assert
|
|
163
|
+
assert await _cookie_strategy_user(users_app, anon_client) is None
|
|
141
164
|
|
|
142
165
|
@pytest.mark.anyio
|
|
143
166
|
async def test_the_provider_path_refuses_it(self, users_app, anon_client):
|
|
@@ -77,6 +77,30 @@ async def _age_access_tokens(app, *, days: int) -> None:
|
|
|
77
77
|
await session.commit()
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
async def _cookie_strategy_user(app, client):
|
|
81
|
+
"""Resolve the ``sm_auth`` cookie through the cookie strategy itself.
|
|
82
|
+
|
|
83
|
+
``/api/users/me`` now resolves the request principal (session or bearer,
|
|
84
|
+
#658), so it no longer exercises ``ExpiringDatabaseStrategy``; read the row
|
|
85
|
+
the way ``fastapi_users.current_user`` does instead.
|
|
86
|
+
"""
|
|
87
|
+
from users.backend import build_strategy
|
|
88
|
+
from users.db_adapter import PruningAccessTokenDatabase
|
|
89
|
+
from users.models import UserAccessToken
|
|
90
|
+
|
|
91
|
+
async with app.state.sm.db.session_factory() as session:
|
|
92
|
+
|
|
93
|
+
class _Manager:
|
|
94
|
+
def parse_id(self, value):
|
|
95
|
+
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
|
96
|
+
|
|
97
|
+
async def get(self, user_id):
|
|
98
|
+
return await session.get(User, user_id)
|
|
99
|
+
|
|
100
|
+
strategy = build_strategy(PruningAccessTokenDatabase(session, UserAccessToken), 900)
|
|
101
|
+
return await strategy.read_token(client.cookies.get("sm_auth"), _Manager())
|
|
102
|
+
|
|
103
|
+
|
|
80
104
|
class TestRemembered:
|
|
81
105
|
@pytest.mark.anyio
|
|
82
106
|
async def test_the_auth_cookie_lasts_thirty_days(self, anon_client, users_db):
|
|
@@ -104,14 +128,13 @@ class TestRemembered:
|
|
|
104
128
|
know which sign-ins asked to be remembered — a row minted to last
|
|
105
129
|
thirty days therefore has to be accepted for thirty, or the cookie
|
|
106
130
|
outlives the credential it carries and the checkbox starts lying on
|
|
107
|
-
day fourteen.
|
|
108
|
-
``current_user``, which is the code path that reads the row.
|
|
131
|
+
day fourteen.
|
|
109
132
|
"""
|
|
110
133
|
await _make_user(users_db, "remember-token@example.com")
|
|
111
134
|
await _login(anon_client, "remember-token@example.com", remember=True)
|
|
112
135
|
|
|
113
136
|
await _age_access_tokens(users_app, days=29)
|
|
114
|
-
assert
|
|
137
|
+
assert await _cookie_strategy_user(users_app, anon_client) is not None
|
|
115
138
|
|
|
116
139
|
@pytest.mark.anyio
|
|
117
140
|
async def test_a_token_past_the_window_stops_being_read(self, anon_client, users_db, users_app):
|
|
@@ -121,7 +144,7 @@ class TestRemembered:
|
|
|
121
144
|
await _login(anon_client, "stale-token@example.com", remember=True)
|
|
122
145
|
|
|
123
146
|
await _age_access_tokens(users_app, days=31)
|
|
124
|
-
assert
|
|
147
|
+
assert await _cookie_strategy_user(users_app, anon_client) is None
|
|
125
148
|
|
|
126
149
|
|
|
127
150
|
class TestTheWindowSurvivesLaterRequests:
|
{simple_module_users-0.0.40 → simple_module_users-0.0.42}/tests/test_revocation_broadcast.py
RENAMED
|
@@ -133,18 +133,20 @@ class TestRollbackDoesNotBroadcast:
|
|
|
133
133
|
):
|
|
134
134
|
"""Fail the endpoint *after* it has registered the on-commit callback.
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
transaction did not commit" without needing to break the database.
|
|
136
|
+
Let the shared helper finish, then fail before the response commits.
|
|
137
|
+
This exercises rollback with an invalidation callback already queued.
|
|
139
138
|
"""
|
|
140
139
|
import users.auth_local.self_account as self_account
|
|
141
140
|
|
|
142
141
|
await _sign_in(anon_client)
|
|
143
142
|
|
|
144
|
-
|
|
143
|
+
revoke = self_account.revoke_all_credentials
|
|
144
|
+
|
|
145
|
+
async def _explode(*args, **kwargs):
|
|
146
|
+
await revoke(*args, **kwargs)
|
|
145
147
|
raise RuntimeError("simulated failure after the session_version bump")
|
|
146
148
|
|
|
147
|
-
monkeypatch.setattr(self_account, "
|
|
149
|
+
monkeypatch.setattr(self_account, "revoke_all_credentials", _explode)
|
|
148
150
|
|
|
149
151
|
with pytest.raises(RuntimeError, match="simulated failure"):
|
|
150
152
|
await anon_client.post(_REVOKE)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Single admin creation/invitation side effects follow the commit (#692, #627)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from background_tasks.models import OutboxMessage
|
|
5
|
+
from simple_module_db import transaction
|
|
6
|
+
from sqlalchemy import event
|
|
7
|
+
from sqlmodel import select
|
|
8
|
+
from test_users_deletion_events import PausedUserTransport, record_user_event
|
|
9
|
+
from users.contracts.events import UserCreated, UserInvited
|
|
10
|
+
from users.models import User
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize("operation,event_type", [("invite", UserInvited), ("create", UserCreated)])
|
|
14
|
+
@pytest.mark.parametrize("durable", [False, True])
|
|
15
|
+
@pytest.mark.parametrize("fail_commit", [True, False])
|
|
16
|
+
async def test_admin_creation_side_effects_wait_for_commit(
|
|
17
|
+
admin_client, users_app, captured_mail, monkeypatch, operation, event_type, fail_commit, durable
|
|
18
|
+
):
|
|
19
|
+
observed = []
|
|
20
|
+
before_commit = []
|
|
21
|
+
|
|
22
|
+
async def observe(message):
|
|
23
|
+
observed.append(message)
|
|
24
|
+
|
|
25
|
+
bus = users_app.state.sm.event_bus
|
|
26
|
+
bus.subscribe(event_type, observe)
|
|
27
|
+
if durable:
|
|
28
|
+
bus.durable_transport = PausedUserTransport()
|
|
29
|
+
bus.subscribe_durable(event_type, record_user_event)
|
|
30
|
+
email = "commit-boundary@example.com"
|
|
31
|
+
|
|
32
|
+
def fail(session):
|
|
33
|
+
raise RuntimeError("forced commit failure")
|
|
34
|
+
|
|
35
|
+
finalize = transaction.finalize_session
|
|
36
|
+
|
|
37
|
+
async def check_commit(session):
|
|
38
|
+
before_commit.append((list(captured_mail.messages), list(observed)))
|
|
39
|
+
if fail_commit:
|
|
40
|
+
event.listen(session.sync_session, "before_commit", fail, once=True)
|
|
41
|
+
await finalize(session)
|
|
42
|
+
|
|
43
|
+
monkeypatch.setattr(transaction, "finalize_session", check_commit)
|
|
44
|
+
db = users_app.state.sm.db
|
|
45
|
+
body = {"email": email, "role_names": []}
|
|
46
|
+
if operation == "create":
|
|
47
|
+
body["password"] = "CompliantPass1!"
|
|
48
|
+
response = await admin_client.post(
|
|
49
|
+
"/api/users/admin/invite" if operation == "invite" else "/api/users/admin", json=body
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert response.status_code == (500 if fail_commit else 201), response.text
|
|
53
|
+
assert before_commit == [([], [])]
|
|
54
|
+
async with db.session_factory() as session:
|
|
55
|
+
user = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
56
|
+
outbox = (await session.execute(select(OutboxMessage))).scalars().all()
|
|
57
|
+
assert len(outbox) == (1 if durable and not fail_commit else 0)
|
|
58
|
+
if fail_commit:
|
|
59
|
+
assert captured_mail.messages == []
|
|
60
|
+
assert observed == []
|
|
61
|
+
else:
|
|
62
|
+
assert user is not None
|
|
63
|
+
assert len(observed) == 1
|
|
64
|
+
assert observed[0].user_id == user.id
|
|
65
|
+
assert observed[0].email == email
|
|
66
|
+
if operation == "invite":
|
|
67
|
+
assert any("/users/invite/accept?token=" in m.text for m in captured_mail.to(email))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_admin_invite_existing_email_is_a_conflict(admin_client, captured_mail):
|
|
71
|
+
body = {"email": "dup-invite@example.com", "role_names": []}
|
|
72
|
+
first = await admin_client.post("/api/users/admin/invite", json=body)
|
|
73
|
+
assert first.status_code == 201, first.text
|
|
74
|
+
sent = len(captured_mail.messages)
|
|
75
|
+
again = await admin_client.post("/api/users/admin/invite", json=body)
|
|
76
|
+
assert again.status_code == 409, again.text
|
|
77
|
+
assert len(captured_mail.messages) == sent
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Semantic token checks accept current baselines and equivalent ports (#711)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from sqlalchemy import text
|
|
5
|
+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
6
|
+
from users.module import UsersModule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.parametrize(
|
|
10
|
+
("access_type", "refresh_type", "token", "incompatible"),
|
|
11
|
+
[
|
|
12
|
+
("VARCHAR(64)", "VARCHAR(64)", None, False),
|
|
13
|
+
("VARCHAR(64)", "VARCHAR(64)", "a" * 64, False),
|
|
14
|
+
("VARCHAR(43)", "CHAR(32)", None, True),
|
|
15
|
+
("VARCHAR(64)", "VARCHAR(64)", "plaintext", True),
|
|
16
|
+
("VARCHAR(64)", "VARCHAR(64)", "z" * 64, True),
|
|
17
|
+
],
|
|
18
|
+
)
|
|
19
|
+
async def test_users_semantic_token_compatibility(access_type, refresh_type, token, incompatible):
|
|
20
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
21
|
+
try:
|
|
22
|
+
async with engine.begin() as conn:
|
|
23
|
+
for name, kind in [("access", access_type), ("refresh", refresh_type)]:
|
|
24
|
+
await conn.execute(text(f"CREATE TABLE users_{name}_token (token {kind})"))
|
|
25
|
+
if token:
|
|
26
|
+
await conn.execute(
|
|
27
|
+
text(f"INSERT INTO users_{name}_token VALUES (:token)"), {"token": token}
|
|
28
|
+
)
|
|
29
|
+
async with async_sessionmaker(engine)() as db:
|
|
30
|
+
issues = await UsersModule().check_data_compatibility(db)
|
|
31
|
+
assert bool(issues) is incompatible
|
|
32
|
+
if incompatible:
|
|
33
|
+
assert issues[0].upgrade_id == "users.hashed_bearer_tokens_v1"
|
|
34
|
+
assert issues[0].severity == "error"
|
|
35
|
+
if token:
|
|
36
|
+
assert await db.scalar(text("SELECT token FROM users_access_token")) == token
|
|
37
|
+
finally:
|
|
38
|
+
await engine.dispose()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def test_missing_token_tables_are_not_a_finding():
|
|
42
|
+
# An unmigrated database is the migration check's problem; this must not
|
|
43
|
+
# refuse a production boot on its own.
|
|
44
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
45
|
+
try:
|
|
46
|
+
async with async_sessionmaker(engine)() as db:
|
|
47
|
+
assert await UsersModule().check_data_compatibility(db) == []
|
|
48
|
+
finally:
|
|
49
|
+
await engine.dispose()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def test_plaintext_rows_behind_hashed_ones_are_found():
|
|
53
|
+
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
54
|
+
try:
|
|
55
|
+
async with engine.begin() as conn:
|
|
56
|
+
for name in ("access", "refresh"):
|
|
57
|
+
await conn.execute(text(f"CREATE TABLE users_{name}_token (token VARCHAR(64))"))
|
|
58
|
+
for i in range(150):
|
|
59
|
+
await conn.execute(
|
|
60
|
+
text(f"INSERT INTO users_{name}_token VALUES (:t)"), {"t": f"{i:064x}"}
|
|
61
|
+
)
|
|
62
|
+
await conn.execute(text("INSERT INTO users_access_token VALUES ('plaintext')"))
|
|
63
|
+
async with async_sessionmaker(engine)() as db:
|
|
64
|
+
assert await UsersModule().check_data_compatibility(db)
|
|
65
|
+
finally:
|
|
66
|
+
await engine.dispose()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Host template precedence, optional HTML, and startup configuration (#712)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from jinja2 import TemplateNotFound, TemplateSyntaxError
|
|
10
|
+
from jinja2.exceptions import SecurityError
|
|
11
|
+
from simple_module_core.settings_hydrate import build_settings
|
|
12
|
+
from users.mailer import build_mailer
|
|
13
|
+
from users.mailer.templated import TemplatedMailer
|
|
14
|
+
from users.settings import UsersSettings
|
|
15
|
+
from users.startup import _build_mailer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize(
|
|
19
|
+
("stem", "method", "args", "route"),
|
|
20
|
+
[
|
|
21
|
+
("verify_email", "send_verification", (), "verify"),
|
|
22
|
+
("reset_password", "send_password_reset", (), "reset-password"),
|
|
23
|
+
("invite", "send_invite", ("<Alice>", " <note> "), "invite/accept"),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
26
|
+
async def test_html_only_override_keeps_bundled_text(
|
|
27
|
+
tmp_path, captured_mail, stem, method, args, route
|
|
28
|
+
):
|
|
29
|
+
(tmp_path / f"{stem}.html").write_text(
|
|
30
|
+
"<p>{{ app_name }}|{{ link }}|{{ invited_by_name }}|{{ message }}</p>"
|
|
31
|
+
)
|
|
32
|
+
mailer = TemplatedMailer("https://example.com/", lambda: "<Acme>", template_dirs=[tmp_path])
|
|
33
|
+
await getattr(mailer, method)("a@example.com", "tok&x", *args)
|
|
34
|
+
|
|
35
|
+
(msg,) = captured_mail.messages
|
|
36
|
+
assert f"https://example.com/users/{route}?token=tok&x" in msg.text
|
|
37
|
+
assert "<Acme>" in msg.text
|
|
38
|
+
assert f"https://example.com/users/{route}?token=tok&x" in msg.html
|
|
39
|
+
assert "<Acme>" in msg.html
|
|
40
|
+
if stem == "invite":
|
|
41
|
+
assert "<Alice>|<note>" in msg.html
|
|
42
|
+
assert "<Alice>" in msg.text and "<note>" in msg.text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def test_directory_order_and_independent_file_fallback(tmp_path, captured_mail):
|
|
46
|
+
first, second = tmp_path / "first", tmp_path / "second"
|
|
47
|
+
first.mkdir()
|
|
48
|
+
second.mkdir()
|
|
49
|
+
(first / "verify_email.txt").write_text("first {{ app_name }}")
|
|
50
|
+
(second / "verify_email.txt").write_text("second")
|
|
51
|
+
(second / "verify_email.html").write_text("<b>second {{ app_name }}</b>")
|
|
52
|
+
mailer = build_mailer(UsersSettings(), lambda: "<Acme>", template_dirs=[str(first), second])
|
|
53
|
+
await mailer.send_verification("a@example.com", "tok")
|
|
54
|
+
await mailer.send_password_reset("a@example.com", "reset")
|
|
55
|
+
verify, reset = captured_mail.messages
|
|
56
|
+
assert verify.text == "first <Acme>"
|
|
57
|
+
assert verify.html == "<b>second <Acme></b>"
|
|
58
|
+
assert "/users/reset-password?token=reset" in reset.text
|
|
59
|
+
assert reset.html is None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def test_mailer_environments_are_isolated(tmp_path, captured_mail):
|
|
63
|
+
(tmp_path / "verify_email.txt").write_text("custom")
|
|
64
|
+
custom = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
|
|
65
|
+
default = TemplatedMailer("https://example.com")
|
|
66
|
+
await custom.send_verification("a@example.com", "tok")
|
|
67
|
+
await default.send_verification("a@example.com", "tok")
|
|
68
|
+
await custom.send_verification("a@example.com", "tok")
|
|
69
|
+
assert [m.text for m in captured_mail.messages][::2] == ["custom", "custom"]
|
|
70
|
+
assert "Welcome to SimpleModule!" in captured_mail.messages[1].text
|
|
71
|
+
assert all(m.html is None for m in captured_mail.messages)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize(
|
|
75
|
+
("source", "error"),
|
|
76
|
+
[
|
|
77
|
+
("{% if %}", TemplateSyntaxError),
|
|
78
|
+
('{% include "missing.html" %}', TemplateNotFound),
|
|
79
|
+
('{% extends "missing.html" %}', TemplateNotFound),
|
|
80
|
+
("{{ 1 / 0 }}", ZeroDivisionError),
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
async def test_broken_html_prevents_delivery(tmp_path, captured_mail, source, error):
|
|
84
|
+
(tmp_path / "verify_email.html").write_text(source)
|
|
85
|
+
mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
|
|
86
|
+
with pytest.raises(error):
|
|
87
|
+
await mailer.send_verification("a@example.com", "tok")
|
|
88
|
+
assert not captured_mail.messages
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def test_broken_text_is_not_hidden_by_bundled_fallback(tmp_path, captured_mail):
|
|
92
|
+
(tmp_path / "verify_email.txt").write_text("{% if %}")
|
|
93
|
+
mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
|
|
94
|
+
with pytest.raises(TemplateSyntaxError):
|
|
95
|
+
await mailer.send_verification("a@example.com", "tok")
|
|
96
|
+
assert not captured_mail.messages
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize("kind", ["relative", "missing", "file"])
|
|
100
|
+
def test_invalid_template_directory_is_skipped_with_warning(tmp_path, kind, caplog):
|
|
101
|
+
path = tmp_path / kind
|
|
102
|
+
if kind == "file":
|
|
103
|
+
path.write_text("not a directory")
|
|
104
|
+
if kind == "relative":
|
|
105
|
+
path = Path("relative")
|
|
106
|
+
settings = UsersSettings(mail_template_dirs=[str(path)])
|
|
107
|
+
state = SimpleNamespace()
|
|
108
|
+
_build_mailer(FastAPI(), state, settings)
|
|
109
|
+
assert state.mailer is not None
|
|
110
|
+
assert "Ignoring mail template path" in caplog.text
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def test_startup_forwards_db_setting_and_keeps_branding_live(tmp_path, captured_mail):
|
|
114
|
+
(tmp_path / "verify_email.html").write_text("<p>{{ app_name }}</p>")
|
|
115
|
+
settings = build_settings(
|
|
116
|
+
UsersSettings, {"mail_template_dirs": (json.dumps([str(tmp_path)]), "json")}
|
|
117
|
+
)
|
|
118
|
+
app = FastAPI()
|
|
119
|
+
app.state.branding = SimpleNamespace(settings=SimpleNamespace(app_name="First"))
|
|
120
|
+
state = SimpleNamespace()
|
|
121
|
+
_build_mailer(app, state, settings)
|
|
122
|
+
app.state.branding.settings.app_name = "<Second>"
|
|
123
|
+
await state.mailer.send_verification("a@example.com", "tok")
|
|
124
|
+
(msg,) = captured_mail.messages
|
|
125
|
+
assert msg.html == "<p><Second></p>"
|
|
126
|
+
assert msg.subject == "Verify your email for <Second>"
|
|
127
|
+
assert "<Second>" in msg.text
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_template_setting_is_restart_required_and_defaults_are_independent(monkeypatch):
|
|
131
|
+
monkeypatch.setenv("SM_USERS_MAIL_TEMPLATE_DIRS", '["/ignored"]')
|
|
132
|
+
first, second = UsersSettings(), UsersSettings()
|
|
133
|
+
assert first.mail_template_dirs == []
|
|
134
|
+
first.mail_template_dirs.append("/trusted/templates")
|
|
135
|
+
assert second.mail_template_dirs == []
|
|
136
|
+
assert UsersSettings.model_fields["mail_template_dirs"].json_schema_extra == {
|
|
137
|
+
"requires_restart": True
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_host_templates_render_sandboxed(tmp_path, captured_mail):
|
|
142
|
+
# The directory list is admin-editable, so a template must not reach Python
|
|
143
|
+
# internals (the classic ``__class__.__mro__`` escape).
|
|
144
|
+
(tmp_path / "verify_email.txt").write_text("{{ ''.__class__.__mro__[1].__subclasses__() }}")
|
|
145
|
+
mailer = TemplatedMailer("https://example.com", template_dirs=[tmp_path])
|
|
146
|
+
with pytest.raises(SecurityError):
|
|
147
|
+
await mailer.send_verification("a@example.com", "tok")
|
|
148
|
+
assert captured_mail.messages == []
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Self-profile routes accept local bearer tokens as well as browser cookies."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from _users_app_builders import user_row
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.mark.parametrize("credential", ["bearer", "cookie"])
|
|
8
|
+
async def test_users_me_reads_and_updates_profile(anon_client, alice, users_app, credential):
|
|
9
|
+
if credential == "bearer":
|
|
10
|
+
login = await anon_client.post(
|
|
11
|
+
"/api/users/auth/token",
|
|
12
|
+
json={"email": alice.email, "password": "UserPass1!"},
|
|
13
|
+
)
|
|
14
|
+
assert login.status_code == 200
|
|
15
|
+
anon_client.cookies.clear()
|
|
16
|
+
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
|
17
|
+
else:
|
|
18
|
+
login = await anon_client.post(
|
|
19
|
+
"/api/users/auth/login",
|
|
20
|
+
data={"username": alice.email, "password": "UserPass1!"},
|
|
21
|
+
)
|
|
22
|
+
assert login.status_code == 204
|
|
23
|
+
headers = {}
|
|
24
|
+
|
|
25
|
+
profile = await anon_client.get("/api/users/me", headers=headers)
|
|
26
|
+
assert profile.status_code == 200
|
|
27
|
+
assert profile.json()["id"] == str(alice.id)
|
|
28
|
+
assert profile.json()["email"] == alice.email
|
|
29
|
+
assert "hashed_password" not in profile.json()
|
|
30
|
+
|
|
31
|
+
updated = await anon_client.patch(
|
|
32
|
+
"/api/users/me", headers=headers, json={"full_name": "Updated Profile"}
|
|
33
|
+
)
|
|
34
|
+
assert updated.status_code == 200
|
|
35
|
+
assert updated.json()["id"] == str(alice.id)
|
|
36
|
+
assert updated.json()["full_name"] == "Updated Profile"
|
|
37
|
+
assert (await user_row(users_app, alice.email)).full_name == "Updated Profile"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.parametrize("method", ["GET", "PATCH"])
|
|
41
|
+
@pytest.mark.parametrize("headers", [{}, {"Authorization": "Bearer invalid-token"}])
|
|
42
|
+
async def test_users_me_requires_valid_credentials(anon_client, method, headers):
|
|
43
|
+
response = await anon_client.request(
|
|
44
|
+
method, "/api/users/me", headers=headers, json={"full_name": "Unauthorized"}
|
|
45
|
+
)
|
|
46
|
+
assert response.status_code == 401
|