simple-module-users 0.0.19__tar.gz → 0.0.20__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.20}/.gitignore +1 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/PKG-INFO +6 -6
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/pyproject.toml +6 -6
- simple_module_users-0.0.20/tests/test_api_admin_crud.py +163 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_negative_authz.py +7 -0
- simple_module_users-0.0.20/tests/test_service_admin_crud.py +172 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_views_admin.py +23 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/api.py +86 -3
- simple_module_users-0.0.19/users/admin/service.py → simple_module_users-0.0.20/users/admin/queries.py +38 -104
- simple_module_users-0.0.20/users/admin/service.py +212 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/views.py +18 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/events.py +12 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/schemas.py +21 -2
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/db_adapter.py +18 -5
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/exceptions.py +8 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/user.py +7 -2
- simple_module_users-0.0.20/users/pages/Users/Create.tsx +175 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Edit.tsx +12 -1
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Index.tsx +14 -6
- simple_module_users-0.0.20/users/pages/Users/components/DangerZone.tsx +85 -0
- simple_module_users-0.0.20/users/pages/Users/components/DetailsCard.tsx +81 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/provider.py +14 -4
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/LICENSE +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/README.md +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/package.json +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/conftest.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_admin.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tests/test_views.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/tsconfig.json +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/auth_local/views.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/backend.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/cli.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/constants.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/deps.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/console.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/smtp.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/manager.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/middleware.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/_base.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/role.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/module.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/AcceptInvite.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/ForgotPassword.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Login.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Register.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/ResetPassword.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/Invite.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/pages/VerifyEmail.tsx +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/py.typed +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/users/settings.py +0 -0
- {simple_module_users-0.0.19 → simple_module_users-0.0.20}/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.20
|
|
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.20
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.20
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.20
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.20
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.20
|
|
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.20"
|
|
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.20",
|
|
25
|
+
"simple_module_db==0.0.20",
|
|
26
|
+
"simple_module_hosting==0.0.20",
|
|
27
|
+
"simple_module_settings==0.0.20",
|
|
28
|
+
"simple_module_auth==0.0.20",
|
|
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
|
|
@@ -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())
|
|
@@ -126,3 +126,26 @@ class TestHasPermissionsModuleFlag:
|
|
|
126
126
|
assert resp.status_code == 200
|
|
127
127
|
props = resp.json()["props"]
|
|
128
128
|
assert props["has_permissions_module"] is False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Admin create page
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestAdminCreatePage:
|
|
137
|
+
@pytest.mark.anyio
|
|
138
|
+
async def test_create_page_renders_with_roles(self, admin_client):
|
|
139
|
+
resp = await admin_client.get(
|
|
140
|
+
"/users/admin/create",
|
|
141
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
142
|
+
)
|
|
143
|
+
assert resp.status_code == 200
|
|
144
|
+
data = resp.json()
|
|
145
|
+
assert data["component"] == "Users/Users/Create"
|
|
146
|
+
assert "roles" in data["props"]
|
|
147
|
+
|
|
148
|
+
@pytest.mark.anyio
|
|
149
|
+
async def test_create_page_requires_auth(self, anon_client):
|
|
150
|
+
resp = await anon_client.get("/users/admin/create", follow_redirects=False)
|
|
151
|
+
assert resp.status_code == 302
|
|
@@ -4,22 +4,31 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import uuid
|
|
6
6
|
|
|
7
|
-
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
7
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
|
8
8
|
from fastapi import status as http_status
|
|
9
|
+
from fastapi_users import exceptions as fa_exceptions
|
|
9
10
|
from simple_module_core.events import EventBus
|
|
10
11
|
from simple_module_hosting.permissions import RequiresPermission
|
|
11
12
|
|
|
12
13
|
from users.admin.service import UserService
|
|
13
14
|
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
|
|
14
|
-
from users.contracts.events import
|
|
15
|
+
from users.contracts.events import (
|
|
16
|
+
RoleAssigned,
|
|
17
|
+
UserCreated,
|
|
18
|
+
UserDeleted,
|
|
19
|
+
UserDisabled,
|
|
20
|
+
UserInvited,
|
|
21
|
+
)
|
|
15
22
|
from users.contracts.schemas import (
|
|
16
23
|
PasswordResetLink,
|
|
17
24
|
RoleAssignment,
|
|
25
|
+
UserAdminCreate,
|
|
26
|
+
UserDetailsUpdate,
|
|
18
27
|
UserInvite,
|
|
19
28
|
UserListItem,
|
|
20
29
|
)
|
|
21
30
|
from users.deps import get_event_bus, get_mailer, get_user_service
|
|
22
|
-
from users.exceptions import UserNotFoundError
|
|
31
|
+
from users.exceptions import EmailAlreadyExistsError, UserNotFoundError
|
|
23
32
|
|
|
24
33
|
admin_router = APIRouter(
|
|
25
34
|
prefix="/admin",
|
|
@@ -84,6 +93,80 @@ async def admin_invite_user(
|
|
|
84
93
|
return service.to_list_item(user)
|
|
85
94
|
|
|
86
95
|
|
|
96
|
+
@admin_router.post(
|
|
97
|
+
"",
|
|
98
|
+
response_model=UserListItem,
|
|
99
|
+
status_code=http_status.HTTP_201_CREATED,
|
|
100
|
+
)
|
|
101
|
+
async def admin_create_user(
|
|
102
|
+
data: UserAdminCreate,
|
|
103
|
+
request: Request,
|
|
104
|
+
bus: EventBus = Depends(get_event_bus),
|
|
105
|
+
service: UserService = Depends(get_user_service),
|
|
106
|
+
):
|
|
107
|
+
"""Create an active+verified user with an admin-set password."""
|
|
108
|
+
creator = getattr(request.state, "user", None)
|
|
109
|
+
created_by = str(creator.id) if creator else None
|
|
110
|
+
try:
|
|
111
|
+
user = await service.create_user(
|
|
112
|
+
data.email,
|
|
113
|
+
data.password,
|
|
114
|
+
data.full_name,
|
|
115
|
+
data.role_names,
|
|
116
|
+
created_by=created_by,
|
|
117
|
+
)
|
|
118
|
+
except fa_exceptions.UserAlreadyExists:
|
|
119
|
+
raise HTTPException(
|
|
120
|
+
status_code=409,
|
|
121
|
+
detail="A user with this email already exists.",
|
|
122
|
+
) from None
|
|
123
|
+
except fa_exceptions.InvalidPasswordException as exc:
|
|
124
|
+
raise HTTPException(status_code=400, detail=exc.reason) from None
|
|
125
|
+
await bus.publish(UserCreated(user_id=user.id, email=user.email, created_by=created_by))
|
|
126
|
+
return service.to_list_item(user)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@admin_router.patch("/{user_id}", response_model=UserListItem)
|
|
130
|
+
async def admin_update_user(
|
|
131
|
+
user_id: uuid.UUID,
|
|
132
|
+
data: UserDetailsUpdate,
|
|
133
|
+
service: UserService = Depends(get_user_service),
|
|
134
|
+
):
|
|
135
|
+
"""Update a user's email and full name."""
|
|
136
|
+
try:
|
|
137
|
+
user = await service.update_details(user_id, data.email, data.full_name)
|
|
138
|
+
except UserNotFoundError:
|
|
139
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
140
|
+
except EmailAlreadyExistsError:
|
|
141
|
+
raise HTTPException(
|
|
142
|
+
status_code=409,
|
|
143
|
+
detail="A user with this email already exists.",
|
|
144
|
+
) from None
|
|
145
|
+
return service.to_list_item(user)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@admin_router.delete("/{user_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
|
149
|
+
async def admin_delete_user(
|
|
150
|
+
user_id: uuid.UUID,
|
|
151
|
+
request: Request,
|
|
152
|
+
bus: EventBus = Depends(get_event_bus),
|
|
153
|
+
service: UserService = Depends(get_user_service),
|
|
154
|
+
):
|
|
155
|
+
"""Hard-delete a user. An admin cannot delete their own account."""
|
|
156
|
+
actor = getattr(request.state, "user", None)
|
|
157
|
+
if actor is not None and str(user_id) == actor.id:
|
|
158
|
+
raise HTTPException(
|
|
159
|
+
status_code=400,
|
|
160
|
+
detail="You cannot delete your own account.",
|
|
161
|
+
)
|
|
162
|
+
try:
|
|
163
|
+
await service.delete_user(user_id)
|
|
164
|
+
except UserNotFoundError:
|
|
165
|
+
raise HTTPException(status_code=404, detail="User not found") from None
|
|
166
|
+
await bus.publish(UserDeleted(user_id=user_id))
|
|
167
|
+
return Response(status_code=http_status.HTTP_204_NO_CONTENT)
|
|
168
|
+
|
|
169
|
+
|
|
87
170
|
@admin_router.patch("/{user_id}/disable", response_model=UserListItem)
|
|
88
171
|
async def admin_disable_user(
|
|
89
172
|
user_id: uuid.UUID,
|