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.
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/.gitignore +1 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/PKG-INFO +6 -6
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/pyproject.toml +6 -6
- simple_module_users-0.0.21/tests/test_api_admin_crud.py +163 -0
- simple_module_users-0.0.21/tests/test_external_users.py +228 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_mailer.py +50 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_negative_authz.py +7 -0
- simple_module_users-0.0.21/tests/test_service_admin_crud.py +172 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_views_admin.py +23 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/api.py +95 -3
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/UserRow.tsx +9 -1
- simple_module_users-0.0.19/users/admin/service.py → simple_module_users-0.0.21/users/admin/queries.py +40 -104
- simple_module_users-0.0.21/users/admin/service.py +223 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/views.py +18 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/token_api.py +11 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/backend.py +1 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/constants.py +6 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/events.py +12 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/schemas.py +23 -2
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/db_adapter.py +18 -5
- simple_module_users-0.0.21/users/exceptions.py +38 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/__init__.py +19 -2
- simple_module_users-0.0.21/users/mailer/console.py +43 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/smtp.py +17 -6
- simple_module_users-0.0.21/users/mailer/templates/invite.txt +7 -0
- simple_module_users-0.0.21/users/mailer/templates/reset_password.txt +7 -0
- simple_module_users-0.0.21/users/mailer/templates/verify_email.txt +7 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/manager.py +53 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/user.py +19 -4
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/module.py +10 -2
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/api.py +5 -0
- simple_module_users-0.0.21/users/pages/Users/Create.tsx +175 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Edit.tsx +32 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Index.tsx +14 -6
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/components/AccountStatusCard.tsx +36 -21
- simple_module_users-0.0.21/users/pages/Users/components/DangerZone.tsx +85 -0
- simple_module_users-0.0.21/users/pages/Users/components/DetailsCard.tsx +81 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/provider.py +14 -4
- simple_module_users-0.0.19/users/exceptions.py +0 -18
- simple_module_users-0.0.19/users/mailer/console.py +0 -27
- simple_module_users-0.0.19/users/mailer/templates/invite.txt +0 -1
- simple_module_users-0.0.19/users/mailer/templates/reset_password.txt +0 -1
- simple_module_users-0.0.19/users/mailer/templates/verify_email.txt +0 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/LICENSE +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/README.md +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/package.json +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/conftest.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tests/test_views.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/tsconfig.json +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/cli.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/deps.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/middleware.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/_base.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/role.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/py.typed +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/settings.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.21}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
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.
|
|
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.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.
|
|
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.
|
|
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.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())
|