simple-module-users 0.0.19__tar.gz → 0.0.21__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/.gitignore +1 -0
  2. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/PKG-INFO +6 -6
  3. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/pyproject.toml +6 -6
  4. simple_module_users-0.0.21/tests/test_api_admin_crud.py +163 -0
  5. simple_module_users-0.0.21/tests/test_external_users.py +228 -0
  6. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_mailer.py +50 -0
  7. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_negative_authz.py +7 -0
  8. simple_module_users-0.0.21/tests/test_service_admin_crud.py +172 -0
  9. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_views_admin.py +23 -0
  10. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/api.py +95 -3
  11. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/UserRow.tsx +9 -1
  12. simple_module_users-0.0.19/users/admin/service.py → simple_module_users-0.0.21/users/admin/queries.py +40 -104
  13. simple_module_users-0.0.21/users/admin/service.py +223 -0
  14. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/views.py +18 -0
  15. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/token_api.py +11 -1
  16. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/backend.py +1 -1
  17. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/constants.py +6 -0
  18. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/events.py +12 -0
  19. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/schemas.py +23 -2
  20. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/db_adapter.py +18 -5
  21. simple_module_users-0.0.21/users/exceptions.py +38 -0
  22. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/__init__.py +19 -2
  23. simple_module_users-0.0.21/users/mailer/console.py +43 -0
  24. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/smtp.py +17 -6
  25. simple_module_users-0.0.21/users/mailer/templates/invite.txt +7 -0
  26. simple_module_users-0.0.21/users/mailer/templates/reset_password.txt +7 -0
  27. simple_module_users-0.0.21/users/mailer/templates/verify_email.txt +7 -0
  28. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/manager.py +53 -1
  29. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/user.py +19 -4
  30. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/module.py +10 -2
  31. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/api.py +5 -0
  32. simple_module_users-0.0.21/users/pages/Users/Create.tsx +175 -0
  33. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Edit.tsx +32 -1
  34. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Index.tsx +14 -6
  35. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/components/AccountStatusCard.tsx +36 -21
  36. simple_module_users-0.0.21/users/pages/Users/components/DangerZone.tsx +85 -0
  37. simple_module_users-0.0.21/users/pages/Users/components/DetailsCard.tsx +81 -0
  38. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/provider.py +14 -4
  39. simple_module_users-0.0.19/users/exceptions.py +0 -18
  40. simple_module_users-0.0.19/users/mailer/console.py +0 -27
  41. simple_module_users-0.0.19/users/mailer/templates/invite.txt +0 -1
  42. simple_module_users-0.0.19/users/mailer/templates/reset_password.txt +0 -1
  43. simple_module_users-0.0.19/users/mailer/templates/verify_email.txt +0 -1
  44. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/LICENSE +0 -0
  45. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/README.md +0 -0
  46. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/package.json +0 -0
  47. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/.gitkeep +0 -0
  48. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/_middleware_support.py +0 -0
  49. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/conftest.py +0 -0
  50. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_access_token_model.py +0 -0
  51. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_admin.py +0 -0
  52. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_admin_filters.py +0 -0
  53. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_auth.py +0 -0
  54. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_backend.py +0 -0
  55. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_bootstrap.py +0 -0
  56. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_bootstrap_resolution.py +0 -0
  57. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_cli.py +0 -0
  58. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_constants.py +0 -0
  59. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_db_adapter.py +0 -0
  60. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_invite_flow.py +0 -0
  61. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_invite_reuse.py +0 -0
  62. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_oauth.py +0 -0
  63. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_oauth_routes.py +0 -0
  64. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_rate_limit.py +0 -0
  65. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_role_model.py +0 -0
  66. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_service_admin.py +0 -0
  67. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_settings.py +0 -0
  68. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_token_api.py +0 -0
  69. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_manager.py +0 -0
  70. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_model.py +0 -0
  71. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_role_model.py +0 -0
  72. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_service.py +0 -0
  73. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_deps.py +0 -0
  74. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware.py +0 -0
  75. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware_public_paths.py +0 -0
  76. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware_resolvers.py +0 -0
  77. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_provider.py +0 -0
  78. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_views.py +0 -0
  79. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tsconfig.json +0 -0
  80. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/__init__.py +0 -0
  81. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/__init__.py +0 -0
  82. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/IndexFilters.tsx +0 -0
  83. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/RolesTab.tsx +0 -0
  84. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/__init__.py +0 -0
  85. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/api.py +0 -0
  86. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/rate_limit.py +0 -0
  87. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/views.py +0 -0
  88. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/bootstrap.py +0 -0
  89. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/cli.py +0 -0
  90. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/__init__.py +0 -0
  91. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/deps.py +0 -0
  92. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/templates/.gitkeep +0 -0
  93. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/middleware.py +0 -0
  94. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/__init__.py +0 -0
  95. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/_base.py +0 -0
  96. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/access_token.py +0 -0
  97. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/oauth_account.py +0 -0
  98. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/refresh_token.py +0 -0
  99. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/role.py +0 -0
  100. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/user_role.py +0 -0
  101. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/__init__.py +0 -0
  102. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/providers.py +0 -0
  103. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/.gitkeep +0 -0
  104. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/AcceptInvite.tsx +0 -0
  105. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/ForgotPassword.tsx +0 -0
  106. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Login.tsx +0 -0
  107. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Profile.tsx +0 -0
  108. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Register.tsx +0 -0
  109. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/ResetPassword.tsx +0 -0
  110. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Invite.tsx +0 -0
  111. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/VerifyEmail.tsx +0 -0
  112. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/py.typed +0 -0
  113. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/roles_cache.py +0 -0
  114. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/settings.py +0 -0
  115. {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/state.py +0 -0
@@ -61,3 +61,4 @@ Thumbs.db
61
61
  .playwright-mcp/*
62
62
  host/client_app/.playwright-cli/*
63
63
  .superpowers/
64
+ .qa/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_users
3
- Version: 0.0.19
3
+ Version: 0.0.21
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.19
28
- Requires-Dist: simple-module-core==0.0.19
29
- Requires-Dist: simple-module-db==0.0.19
30
- Requires-Dist: simple-module-hosting==0.0.19
31
- Requires-Dist: simple-module-settings==0.0.19
27
+ Requires-Dist: simple-module-auth==0.0.21
28
+ Requires-Dist: simple-module-core==0.0.21
29
+ Requires-Dist: simple-module-db==0.0.21
30
+ Requires-Dist: simple-module-hosting==0.0.21
31
+ Requires-Dist: simple-module-settings==0.0.21
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.19"
3
+ version = "0.0.21"
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.19",
25
- "simple_module_db==0.0.19",
26
- "simple_module_hosting==0.0.19",
27
- "simple_module_settings==0.0.19",
28
- "simple_module_auth==0.0.19",
24
+ "simple_module_core==0.0.21",
25
+ "simple_module_db==0.0.21",
26
+ "simple_module_hosting==0.0.21",
27
+ "simple_module_settings==0.0.21",
28
+ "simple_module_auth==0.0.21",
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
+ """Admin REST CRUD tests: create / update / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ import pytest
8
+ from sqlalchemy import select
9
+ from test_api_admin import _make_user
10
+ from users.models import User, UserRole
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Admin create
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ class TestAdminCreate:
18
+ @pytest.mark.anyio
19
+ async def test_create_returns_201(self, admin_client):
20
+ resp = await admin_client.post(
21
+ "/api/users/admin",
22
+ json={
23
+ "email": "newuser@example.com",
24
+ "password": "SecurePass1!",
25
+ "full_name": "New User",
26
+ "role_names": ["user"],
27
+ },
28
+ )
29
+ assert resp.status_code == 201
30
+ body = resp.json()
31
+ assert body["email"] == "newuser@example.com"
32
+ assert body["is_active"] is True
33
+ assert body["is_verified"] is True
34
+ assert body["roles"] == ["user"]
35
+
36
+ @pytest.mark.anyio
37
+ async def test_create_duplicate_returns_409(self, admin_client, users_db):
38
+ await _make_user(users_db, email="taken@example.com")
39
+ resp = await admin_client.post(
40
+ "/api/users/admin",
41
+ json={"email": "taken@example.com", "password": "SecurePass1!"},
42
+ )
43
+ assert resp.status_code == 409
44
+ assert "already exists" in resp.json()["detail"]
45
+
46
+ @pytest.mark.anyio
47
+ async def test_create_weak_password_returns_400(self, admin_client):
48
+ resp = await admin_client.post(
49
+ "/api/users/admin",
50
+ json={"email": "weakpw@example.com", "password": "short"},
51
+ )
52
+ assert resp.status_code == 400
53
+ assert "8 characters" in resp.json()["detail"]
54
+
55
+ @pytest.mark.anyio
56
+ async def test_create_without_auth_is_rejected(self, anon_client):
57
+ resp = await anon_client.post(
58
+ "/api/users/admin",
59
+ json={"email": "hacker@example.com", "password": "SecurePass1!"},
60
+ follow_redirects=False,
61
+ )
62
+ assert resp.status_code == 401
63
+ assert resp.json() == {"detail": "Not authenticated"}
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Admin update details
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ class TestAdminUpdate:
72
+ @pytest.mark.anyio
73
+ async def test_update_changes_email_and_name(self, admin_client, users_db):
74
+ user = await _make_user(users_db, email="before@example.com")
75
+ resp = await admin_client.patch(
76
+ f"/api/users/admin/{user.id}",
77
+ json={"email": "after@example.com", "full_name": "After Name"},
78
+ )
79
+ assert resp.status_code == 200
80
+ body = resp.json()
81
+ assert body["email"] == "after@example.com"
82
+ assert body["full_name"] == "After Name"
83
+
84
+ @pytest.mark.anyio
85
+ async def test_update_duplicate_email_returns_409(self, admin_client, users_db):
86
+ await _make_user(users_db, email="exists@example.com")
87
+ target = await _make_user(users_db, email="target@example.com")
88
+ resp = await admin_client.patch(
89
+ f"/api/users/admin/{target.id}",
90
+ json={"email": "exists@example.com"},
91
+ )
92
+ assert resp.status_code == 409
93
+
94
+ @pytest.mark.anyio
95
+ async def test_update_nonexistent_returns_404(self, admin_client):
96
+ resp = await admin_client.patch(
97
+ f"/api/users/admin/{uuid.uuid4()}",
98
+ json={"email": "ghost@example.com"},
99
+ )
100
+ assert resp.status_code == 404
101
+
102
+ @pytest.mark.anyio
103
+ async def test_update_without_auth_is_rejected(self, anon_client):
104
+ resp = await anon_client.patch(
105
+ f"/api/users/admin/{uuid.uuid4()}",
106
+ json={"email": "x@example.com"},
107
+ follow_redirects=False,
108
+ )
109
+ assert resp.status_code == 401
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Admin delete
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ class TestAdminDelete:
118
+ @pytest.mark.anyio
119
+ async def test_delete_returns_204(self, admin_client, users_db):
120
+ user = await _make_user(users_db, email="deleteme@example.com")
121
+ resp = await admin_client.delete(f"/api/users/admin/{user.id}")
122
+ assert resp.status_code == 204
123
+
124
+ @pytest.mark.anyio
125
+ async def test_delete_user_with_role_returns_204(self, admin_client, users_db):
126
+ """Regression: deleting a user that HAS a role must not 500.
127
+
128
+ The delete runs in the request session, which eager-loads the user's
129
+ ``roles`` relationship. Bulk-deleting the ``users_user_role`` rows the
130
+ ORM is tracking raised ``StaleDataError`` on flush. The roleless
131
+ ``test_delete_returns_204`` never exercised this path."""
132
+ user = await _make_user(users_db, email="hasrole@example.com", role_names=["admin"])
133
+ resp = await admin_client.delete(f"/api/users/admin/{user.id}")
134
+ assert resp.status_code == 204
135
+ # The role association row is gone too (no orphan).
136
+ rows = (
137
+ (await users_db.execute(select(UserRole).where(UserRole.user_id == user.id)))
138
+ .scalars()
139
+ .all()
140
+ )
141
+ assert rows == []
142
+
143
+ @pytest.mark.anyio
144
+ async def test_delete_nonexistent_returns_404(self, admin_client):
145
+ resp = await admin_client.delete(f"/api/users/admin/{uuid.uuid4()}")
146
+ assert resp.status_code == 404
147
+
148
+ @pytest.mark.anyio
149
+ async def test_delete_self_returns_400(self, admin_client, users_app):
150
+ async with users_app.state.sm.db.session_factory() as session:
151
+ admin = (
152
+ await session.execute(select(User).where(User.email == "admin@example.com"))
153
+ ).scalar_one()
154
+ resp = await admin_client.delete(f"/api/users/admin/{admin.id}")
155
+ assert resp.status_code == 400
156
+
157
+ @pytest.mark.anyio
158
+ async def test_delete_without_auth_is_rejected(self, anon_client):
159
+ resp = await anon_client.delete(
160
+ f"/api/users/admin/{uuid.uuid4()}",
161
+ follow_redirects=False,
162
+ )
163
+ assert resp.status_code == 401
@@ -0,0 +1,228 @@
1
+ """External (SSO) user behaviour: null password, marking, and guards.
2
+
3
+ Covers the feature added on top of the OAuth/OIDC login flow:
4
+ - a *new* OAuth login provisions an external user (null password, marked),
5
+ - an OAuth login that *links* to an existing password account leaves it local,
6
+ - external users cannot password-login or be sent a reset link,
7
+ - admins can still assign roles to external users like any other user,
8
+ - the admin list surfaces ``is_external``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ import pytest
17
+ from fastapi_users.password import PasswordHelper
18
+ from sqlalchemy import select
19
+ from users.models import Role, User
20
+
21
+ _pw = PasswordHelper()
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Fake OAuth provider (no network) to drive the /login + /callback flow
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ class _FakeOAuthClient:
30
+ def __init__(self, account_id: str, account_email: str) -> None:
31
+ self._account_id = account_id
32
+ self._account_email = account_email
33
+
34
+ async def get_authorization_url(self, redirect_uri: str, state: str) -> str:
35
+ return f"https://idp.example/authorize?state={state}"
36
+
37
+ async def get_access_token(self, code: str, redirect_uri: str) -> dict:
38
+ return {"access_token": "fake-token", "expires_at": None, "refresh_token": None}
39
+
40
+ async def get_id_email(self, access_token: str) -> tuple[str, str]:
41
+ return self._account_id, self._account_email
42
+
43
+
44
+ def _install_fake_provider(app, account_id: str, account_email: str) -> None:
45
+ from users.oauth import OAuthProvider
46
+
47
+ app.state.users.oauth_clients["microsoft"] = OAuthProvider(
48
+ "microsoft", "Microsoft", _FakeOAuthClient(account_id, account_email)
49
+ )
50
+
51
+
52
+ async def _run_oauth_login(client) -> int:
53
+ """Drive /login then /callback; return the callback status code."""
54
+ login = await client.get("/api/users/auth/microsoft/login", follow_redirects=False)
55
+ assert login.status_code == 302
56
+ state = parse_qs(urlparse(login.headers["location"]).query)["state"][0]
57
+ cb = await client.get(
58
+ f"/api/users/auth/microsoft/callback?code=abc&state={state}",
59
+ follow_redirects=False,
60
+ )
61
+ return cb.status_code
62
+
63
+
64
+ async def _get_user_by_email(db, email: str) -> User:
65
+ return (await db.execute(select(User).where(User.email == email))).scalar_one()
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Provisioning
70
+ # ---------------------------------------------------------------------------
71
+
72
+
73
+ @pytest.mark.anyio
74
+ async def test_new_oauth_login_creates_external_user(users_app, anon_client, users_db):
75
+ _install_fake_provider(users_app, "ms-oid-1", "sso-new@example.com")
76
+
77
+ assert await _run_oauth_login(anon_client) == 303
78
+
79
+ user = await _get_user_by_email(users_db, "sso-new@example.com")
80
+ assert user.is_external is True
81
+ assert user.hashed_password is None
82
+ assert user.is_verified is True
83
+
84
+
85
+ @pytest.mark.anyio
86
+ async def test_oauth_login_links_existing_password_user_stays_local(
87
+ users_app, anon_client, users_db
88
+ ):
89
+ existing = User(
90
+ id=uuid.uuid4(),
91
+ email="local@example.com",
92
+ hashed_password=_pw.hash("SecurePass1!"),
93
+ is_active=True,
94
+ is_verified=True,
95
+ )
96
+ users_db.add(existing)
97
+ await users_db.commit()
98
+
99
+ _install_fake_provider(users_app, "ms-oid-2", "local@example.com")
100
+ assert await _run_oauth_login(anon_client) == 303
101
+
102
+ user = await _get_user_by_email(users_db, "local@example.com")
103
+ assert user.is_external is False
104
+ assert user.hashed_password is not None # password retained
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Credential guards
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ async def _seed_external_user(db, email: str = "ext@example.com") -> User:
113
+ user = User(
114
+ id=uuid.uuid4(),
115
+ email=email,
116
+ hashed_password=None,
117
+ is_active=True,
118
+ is_verified=True,
119
+ is_external=True,
120
+ )
121
+ db.add(user)
122
+ await db.commit()
123
+ return user
124
+
125
+
126
+ @pytest.mark.anyio
127
+ async def test_external_user_cannot_password_login(users_app, anon_client, users_db):
128
+ await _seed_external_user(users_db, "ext-login@example.com")
129
+
130
+ resp = await anon_client.post(
131
+ "/api/users/auth/login",
132
+ data={"username": "ext-login@example.com", "password": "anything-at-all"},
133
+ )
134
+ assert resp.status_code == 400
135
+ assert resp.json()["detail"] == "LOGIN_BAD_CREDENTIALS"
136
+
137
+
138
+ @pytest.mark.anyio
139
+ async def test_external_user_cannot_bearer_token_login(users_app, anon_client, users_db):
140
+ """The bearer-token login path must reject external users cleanly (401),
141
+ not 500 on a None-hash verify, and without a timing/error enumeration leak."""
142
+ await _seed_external_user(users_db, "ext-token@example.com")
143
+
144
+ resp = await anon_client.post(
145
+ "/api/users/auth/token",
146
+ json={"email": "ext-token@example.com", "password": "anything-at-all"},
147
+ )
148
+ assert resp.status_code == 401
149
+ assert resp.json()["detail"] == "Invalid credentials"
150
+
151
+
152
+ @pytest.mark.anyio
153
+ async def test_admin_reset_link_rejected_for_external_user(admin_client, users_db):
154
+ user = await _seed_external_user(users_db, "ext-reset@example.com")
155
+
156
+ resp = await admin_client.post(f"/api/users/admin/{user.id}/reset-password-link")
157
+ assert resp.status_code == 409
158
+
159
+
160
+ @pytest.mark.anyio
161
+ async def test_forgot_password_is_noop_for_external_user(users_app):
162
+ from users.db_adapter import UserDatabaseWithRoles
163
+ from users.manager import UserManager
164
+ from users.models import OAuthAccount
165
+
166
+ async with users_app.state.sm.db.session_factory() as session:
167
+ user_db = UserDatabaseWithRoles(session, User, OAuthAccount)
168
+ manager = UserManager(user_db, users_app.state.users.mailer, users_app.state.users.settings)
169
+
170
+ sent: list[str] = []
171
+
172
+ async def _spy(user, token, request=None):
173
+ sent.append(user.email)
174
+
175
+ manager.on_after_forgot_password = _spy
176
+
177
+ external = User(
178
+ id=uuid.uuid4(),
179
+ email="ext-forgot@example.com",
180
+ hashed_password=None,
181
+ is_active=True,
182
+ is_verified=True,
183
+ is_external=True,
184
+ )
185
+ local = User(
186
+ id=uuid.uuid4(),
187
+ email="local-forgot@example.com",
188
+ hashed_password=_pw.hash("SecurePass1!"),
189
+ is_active=True,
190
+ is_verified=True,
191
+ )
192
+
193
+ await manager.forgot_password(external)
194
+ assert sent == [] # SSO account: no reset email
195
+
196
+ await manager.forgot_password(local)
197
+ assert sent == ["local-forgot@example.com"] # control: local account still works
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Roles + admin visibility (external users are managed like normal users)
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ @pytest.mark.anyio
206
+ async def test_admin_can_assign_role_to_external_user(admin_client, users_db):
207
+ user = await _seed_external_user(users_db, "ext-role@example.com")
208
+ users_db.add(Role(id=uuid.uuid4(), name="member", description="Member"))
209
+ await users_db.commit()
210
+
211
+ resp = await admin_client.put(
212
+ f"/api/users/admin/{user.id}/roles",
213
+ json={"role_names": ["member"]},
214
+ )
215
+ assert resp.status_code == 200
216
+ assert "member" in resp.json()["roles"]
217
+
218
+
219
+ @pytest.mark.anyio
220
+ async def test_admin_list_exposes_is_external(admin_client, users_db):
221
+ await _seed_external_user(users_db, "ext-list@example.com")
222
+
223
+ resp = await admin_client.get("/api/users/admin?per_page=100")
224
+ assert resp.status_code == 200
225
+ rows = {r["email"]: r for r in resp.json()}
226
+ assert rows["ext-list@example.com"]["is_external"] is True
227
+ # The seeded admin is a local account.
228
+ assert rows["admin@example.com"]["is_external"] is False
@@ -78,3 +78,53 @@ async def test_base_url_trailing_slash_stripped(caplog):
78
78
  link = caplog.records[0].link # type: ignore[attr-defined]
79
79
  assert not link.startswith("http://localhost:8000//")
80
80
  assert link == "http://localhost:8000/users/verify?token=tok"
81
+
82
+
83
+ @pytest.mark.anyio
84
+ async def test_console_logs_configured_app_name(caplog):
85
+ """A supplied provider brands the console log; otherwise the default."""
86
+ from users.mailer.console import ConsoleMailer
87
+
88
+ branded = ConsoleMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme")
89
+ default = ConsoleMailer(base_url="http://localhost:8000")
90
+
91
+ with caplog.at_level(logging.INFO, logger="users.mailer"):
92
+ await branded.send_invite("a@b.com", "tok", "Alice")
93
+ await default.send_verification("a@b.com", "tok")
94
+
95
+ assert caplog.records[0].app_name == "Acme" # type: ignore[attr-defined]
96
+ assert caplog.records[1].app_name == "SimpleModule" # type: ignore[attr-defined]
97
+
98
+
99
+ @pytest.mark.anyio
100
+ async def test_smtp_emails_carry_the_app_name(monkeypatch):
101
+ """SMTP subjects + bodies include the live app name (and keep the link)."""
102
+ from users.mailer.smtp import SmtpMailer
103
+
104
+ captured: list[dict[str, str]] = []
105
+
106
+ async def fake_send(self, to: str, subject: str, body: str) -> None:
107
+ captured.append({"to": to, "subject": subject, "body": body})
108
+
109
+ monkeypatch.setattr(SmtpMailer, "_send", fake_send)
110
+ mailer = SmtpMailer(
111
+ host="smtp.test",
112
+ port=25,
113
+ username="",
114
+ password="",
115
+ from_address="from@test",
116
+ use_tls=False,
117
+ base_url="http://localhost:8000",
118
+ app_name_provider=lambda: "Acme",
119
+ )
120
+
121
+ await mailer.send_invite("new@x.com", "tok", "Alice")
122
+ await mailer.send_verification("v@x.com", "vtok")
123
+ await mailer.send_password_reset("r@x.com", "rtok")
124
+
125
+ invite, verify, reset = captured
126
+ assert "Acme" in invite["subject"] and "Alice" in invite["subject"]
127
+ assert "Acme" in invite["body"]
128
+ assert "http://localhost:8000/users/invite/accept?token=tok" in invite["body"]
129
+ assert "Acme" in verify["subject"] and "Acme" in verify["body"]
130
+ assert "Acme" in reset["subject"] and "Acme" in reset["body"]
@@ -27,6 +27,13 @@ _PROTECTED_ENDPOINTS: tuple[tuple[str, str, dict | None], ...] = (
27
27
  ("PUT", f"/api/users/admin/{_FAKE_ID}/roles", {"role_names": []}),
28
28
  ("PATCH", f"/api/users/admin/{_FAKE_ID}/verify", None),
29
29
  ("POST", f"/api/users/admin/{_FAKE_ID}/reset-password-link", None),
30
+ (
31
+ "POST",
32
+ "/api/users/admin",
33
+ {"email": "x@y.test", "password": "SecurePass1!", "role_names": []},
34
+ ),
35
+ ("PATCH", f"/api/users/admin/{_FAKE_ID}", {"email": "x@y.test"}),
36
+ ("DELETE", f"/api/users/admin/{_FAKE_ID}", None),
30
37
  # permissions — root GET lists registered groups (PERM_VIEW)
31
38
  ("GET", "/api/permissions/", None),
32
39
  ("GET", f"/api/permissions/roles/{_FAKE_ID}", None),
@@ -0,0 +1,172 @@
1
+ """UserService write-op tests: create / update / delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ import pytest
8
+ from test_service_admin import _build_service
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # create_user
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ @pytest.mark.anyio
16
+ async def test_create_user_active_verified_with_roles(users_app):
17
+ """create_user makes an active+verified user and assigns roles."""
18
+ async with users_app.state.sm.db.session_factory() as session:
19
+ svc = _build_service(session, users_app)
20
+ user = await svc.create_user(
21
+ email="created@example.com",
22
+ password="SecurePass1!",
23
+ full_name="Created User",
24
+ role_names=["user"],
25
+ created_by="admin-id",
26
+ )
27
+ assert user.is_active is True
28
+ assert user.is_verified is True
29
+ assert user.email == "created@example.com"
30
+ assert user.full_name == "Created User"
31
+ assert [r.name for r in user.roles] == ["user"]
32
+
33
+
34
+ @pytest.mark.anyio
35
+ async def test_create_user_weak_password_rejected(users_app):
36
+ from fastapi_users import exceptions as fa_exc
37
+
38
+ async with users_app.state.sm.db.session_factory() as session:
39
+ svc = _build_service(session, users_app)
40
+ with pytest.raises(fa_exc.InvalidPasswordException):
41
+ await svc.create_user(
42
+ email="weak@example.com",
43
+ password="short",
44
+ full_name=None,
45
+ role_names=[],
46
+ created_by=None,
47
+ )
48
+
49
+
50
+ @pytest.mark.anyio
51
+ async def test_create_user_duplicate_email_rejected(users_app):
52
+ from fastapi_users import exceptions as fa_exc
53
+
54
+ async with users_app.state.sm.db.session_factory() as session:
55
+ svc = _build_service(session, users_app)
56
+ await svc.create_user(
57
+ email="dup@example.com",
58
+ password="SecurePass1!",
59
+ full_name=None,
60
+ role_names=[],
61
+ created_by=None,
62
+ )
63
+ await session.flush()
64
+ with pytest.raises(fa_exc.UserAlreadyExists):
65
+ await svc.create_user(
66
+ email="dup@example.com",
67
+ password="SecurePass1!",
68
+ full_name=None,
69
+ role_names=[],
70
+ created_by=None,
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # update_details
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ @pytest.mark.anyio
80
+ async def test_update_details_changes_email_and_name(users_app):
81
+ from test_api_admin import _make_user
82
+
83
+ async with users_app.state.sm.db.session_factory() as session:
84
+ user = await _make_user(session, email="old@example.com")
85
+ svc = _build_service(session, users_app)
86
+ updated = await svc.update_details(user.id, email="new@example.com", full_name="New Name")
87
+ assert updated.email == "new@example.com"
88
+ assert updated.full_name == "New Name"
89
+
90
+
91
+ @pytest.mark.anyio
92
+ async def test_update_details_duplicate_email_rejected(users_app):
93
+ from test_api_admin import _make_user
94
+ from users.exceptions import EmailAlreadyExistsError
95
+
96
+ async with users_app.state.sm.db.session_factory() as session:
97
+ await _make_user(session, email="a@example.com")
98
+ target = await _make_user(session, email="b@example.com")
99
+ svc = _build_service(session, users_app)
100
+ with pytest.raises(EmailAlreadyExistsError):
101
+ await svc.update_details(target.id, email="a@example.com", full_name=None)
102
+
103
+
104
+ @pytest.mark.anyio
105
+ async def test_update_details_same_email_is_allowed(users_app):
106
+ from test_api_admin import _make_user
107
+
108
+ async with users_app.state.sm.db.session_factory() as session:
109
+ user = await _make_user(session, email="keep@example.com")
110
+ svc = _build_service(session, users_app)
111
+ updated = await svc.update_details(user.id, email="keep@example.com", full_name="Renamed")
112
+ assert updated.email == "keep@example.com"
113
+ assert updated.full_name == "Renamed"
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # delete_user
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ @pytest.mark.anyio
122
+ async def test_delete_user_removes_user_and_all_child_rows(users_app):
123
+ from datetime import UTC, datetime, timedelta
124
+
125
+ from sqlalchemy import select
126
+ from test_api_admin import _make_user
127
+ from users.models import (
128
+ OAuthAccount,
129
+ RefreshToken,
130
+ User,
131
+ UserAccessToken,
132
+ UserRole,
133
+ )
134
+
135
+ async with users_app.state.sm.db.session_factory() as session:
136
+ user = await _make_user(session, email="todelete@example.com", role_names=["admin"])
137
+ session.add(UserAccessToken(token=f"tok-{user.id.hex}", user_id=user.id))
138
+ session.add(
139
+ OAuthAccount(
140
+ user_id=user.id,
141
+ oauth_name="google",
142
+ access_token="x",
143
+ account_id="acct-1",
144
+ account_email="todelete@example.com",
145
+ )
146
+ )
147
+ session.add(RefreshToken(user_id=user.id, expires_at=datetime.now(UTC) + timedelta(days=1)))
148
+ await session.flush()
149
+
150
+ svc = _build_service(session, users_app)
151
+ await svc.delete_user(user.id)
152
+
153
+ assert (
154
+ await session.execute(select(User).where(User.id == user.id))
155
+ ).scalar_one_or_none() is None
156
+ for model in (UserRole, UserAccessToken, OAuthAccount, RefreshToken):
157
+ rows = (
158
+ (await session.execute(select(model).where(model.user_id == user.id)))
159
+ .scalars()
160
+ .all()
161
+ )
162
+ assert rows == [], f"{model.__name__} rows not cleaned up"
163
+
164
+
165
+ @pytest.mark.anyio
166
+ async def test_delete_user_nonexistent_raises(users_app):
167
+ from users.exceptions import UserNotFoundError
168
+
169
+ async with users_app.state.sm.db.session_factory() as session:
170
+ svc = _build_service(session, users_app)
171
+ with pytest.raises(UserNotFoundError):
172
+ await svc.delete_user(uuid.uuid4())