simple-module-users 0.0.30__tar.gz → 0.0.32__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.30 → simple_module_users-0.0.32}/.gitignore +1 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/PKG-INFO +7 -7
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/pyproject.toml +6 -6
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin.py +11 -0
- simple_module_users-0.0.32/tests/test_users_bulk_invite.py +229 -0
- simple_module_users-0.0.32/tests/test_users_shared_props.py +52 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_views_admin.py +91 -7
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/api.py +5 -0
- simple_module_users-0.0.32/users/admin/bulk_invite.py +219 -0
- simple_module_users-0.0.32/users/admin/components/UsersEmpty.tsx +83 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/queries.py +8 -3
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/views.py +56 -25
- simple_module_users-0.0.32/users/auth_local/invite_preview.py +64 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/views.py +17 -3
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/schemas.py +47 -1
- simple_module_users-0.0.32/users/health.py +47 -0
- simple_module_users-0.0.32/users/locales/en.json +11 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/console.py +9 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/smtp.py +22 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/module.py +48 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/AcceptInvite.tsx +37 -3
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/ForgotPassword.tsx +3 -2
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Login.tsx +2 -4
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Register.tsx +3 -2
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/ResetPassword.tsx +2 -1
- simple_module_users-0.0.32/users/pages/Users/AddPeople.tsx +212 -0
- simple_module_users-0.0.32/users/pages/Users/Edit.tsx +295 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/Index.tsx +38 -23
- simple_module_users-0.0.32/users/pages/Users/components/CreateUserFields.tsx +80 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/components/DangerZone.tsx +28 -5
- simple_module_users-0.0.32/users/pages/Users/components/DetailsCard.tsx +53 -0
- simple_module_users-0.0.32/users/pages/Users/components/InviteFields.tsx +46 -0
- simple_module_users-0.0.32/users/pages/Users/components/InviteResults.tsx +107 -0
- simple_module_users-0.0.32/users/pages/Users/components/MetadataCard.tsx +85 -0
- simple_module_users-0.0.32/users/pages/Users/components/RolePicker.tsx +42 -0
- simple_module_users-0.0.32/users/pages/Users/components/RolesCard.tsx +37 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/VerifyEmail.tsx +4 -3
- simple_module_users-0.0.32/users/shared_props.py +29 -0
- simple_module_users-0.0.30/users/pages/Users/Create.tsx +0 -175
- simple_module_users-0.0.30/users/pages/Users/Edit.tsx +0 -266
- simple_module_users-0.0.30/users/pages/Users/Invite.tsx +0 -147
- simple_module_users-0.0.30/users/pages/Users/components/DetailsCard.tsx +0 -81
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/LICENSE +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/README.md +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/package.json +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/_middleware_support.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/conftest.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_access_token_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin_crud.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_admin_filters.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_api_auth.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_backend.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_bootstrap.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_bootstrap_resolution.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_cli.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_constants.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_db_adapter.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_external_users.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_invite_flow.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_invite_reuse.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_mailer.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_negative_authz.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_oauth.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_oauth_routes.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_rate_limit.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_role_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_service_admin.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_service_admin_crud.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_settings.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_token_api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_manager.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_role_model.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_user_service.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_deps.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware_public_paths.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_middleware_resolvers.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_users_provider.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tests/test_views.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/tsconfig.json +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/IndexFilters.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/RolesTab.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/components/UserRow.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/admin/service.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/rate_limit.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/auth_local/token_api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/backend.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/bootstrap.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/cli.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/constants.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/contracts/events.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/db_adapter.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/deps.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/exceptions.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/invite.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/reset_password.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/mailer/templates/verify_email.txt +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/manager.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/middleware.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/_base.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/access_token.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/oauth_account.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/refresh_token.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/role.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/user.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/models/user_role.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/__init__.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/api.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/oauth/providers.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/.gitkeep +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Profile.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/provider.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/py.typed +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/roles_cache.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/settings.py +0 -0
- {simple_module_users-0.0.30 → simple_module_users-0.0.32}/users/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: simple_module_users
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.32
|
|
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.32
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.32
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.32
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.32
|
|
31
|
+
Requires-Dist: simple-module-settings==0.0.32
|
|
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.32"
|
|
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.32",
|
|
25
|
+
"simple_module_db==0.0.32",
|
|
26
|
+
"simple_module_hosting==0.0.32",
|
|
27
|
+
"simple_module_settings==0.0.32",
|
|
28
|
+
"simple_module_auth==0.0.32",
|
|
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.
|
|
@@ -71,6 +71,17 @@ class TestAdminList:
|
|
|
71
71
|
assert "alpha@example.com" in emails
|
|
72
72
|
assert "beta@example.com" not in emails
|
|
73
73
|
|
|
74
|
+
@pytest.mark.anyio
|
|
75
|
+
async def test_list_search_matches_like_metacharacters_literally(self, admin_client, users_db):
|
|
76
|
+
"""A literal "_" or "%" in the search must match as text, not wildcard."""
|
|
77
|
+
await _make_user(users_db, email="dana_x@example.com")
|
|
78
|
+
await _make_user(users_db, email="danaYx@example.com")
|
|
79
|
+
|
|
80
|
+
resp = await admin_client.get("/api/users/admin?q=dana_x")
|
|
81
|
+
assert resp.status_code == 200
|
|
82
|
+
emails = [u["email"] for u in resp.json()]
|
|
83
|
+
assert emails == ["dana_x@example.com"]
|
|
84
|
+
|
|
74
85
|
@pytest.mark.anyio
|
|
75
86
|
async def test_list_pagination(self, admin_client, users_db):
|
|
76
87
|
for i in range(5):
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Bulk invite — many addresses per submit, with per-address outcomes.
|
|
2
|
+
|
|
3
|
+
The invite form took one address at a time, so onboarding a team meant
|
|
4
|
+
repeating the form once per person. Partial success is the normal case here:
|
|
5
|
+
one already-registered address must not discard the rest.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
_URL = "/api/users/admin/invite/bulk"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestBulkInvite:
|
|
17
|
+
async def test_invites_every_address(self, authenticated_client: httpx.AsyncClient):
|
|
18
|
+
resp = await authenticated_client.post(
|
|
19
|
+
_URL,
|
|
20
|
+
json={"emails": ["a@example.com", "b@example.com"], "role_names": []},
|
|
21
|
+
)
|
|
22
|
+
assert resp.status_code == 200, resp.text
|
|
23
|
+
results = resp.json()["results"]
|
|
24
|
+
assert [r["email"] for r in results] == ["a@example.com", "b@example.com"]
|
|
25
|
+
|
|
26
|
+
async def test_duplicate_addresses_are_invited_once(
|
|
27
|
+
self, authenticated_client: httpx.AsyncClient
|
|
28
|
+
):
|
|
29
|
+
"""Pasting a list with a repeat should not mint two invites for it."""
|
|
30
|
+
resp = await authenticated_client.post(
|
|
31
|
+
_URL,
|
|
32
|
+
json={"emails": ["dup@example.com", "DUP@example.com"], "role_names": []},
|
|
33
|
+
)
|
|
34
|
+
assert len(resp.json()["results"]) == 1
|
|
35
|
+
|
|
36
|
+
async def test_addresses_are_normalised_to_lowercase(
|
|
37
|
+
self, authenticated_client: httpx.AsyncClient
|
|
38
|
+
):
|
|
39
|
+
resp = await authenticated_client.post(
|
|
40
|
+
_URL, json={"emails": ["Mixed@Example.com"], "role_names": []}
|
|
41
|
+
)
|
|
42
|
+
assert resp.json()["results"][0]["email"] == "mixed@example.com"
|
|
43
|
+
|
|
44
|
+
async def test_one_failure_does_not_discard_the_others(
|
|
45
|
+
self, authenticated_client: httpx.AsyncClient
|
|
46
|
+
):
|
|
47
|
+
"""A duplicate in a pasted list of twenty must not lose the other 19."""
|
|
48
|
+
await authenticated_client.post(
|
|
49
|
+
_URL, json={"emails": ["taken@example.com"], "role_names": []}
|
|
50
|
+
)
|
|
51
|
+
resp = await authenticated_client.post(
|
|
52
|
+
_URL,
|
|
53
|
+
json={"emails": ["taken@example.com", "fresh@example.com"], "role_names": []},
|
|
54
|
+
)
|
|
55
|
+
assert resp.status_code == 200, resp.text
|
|
56
|
+
by_email = {r["email"]: r for r in resp.json()["results"]}
|
|
57
|
+
assert by_email["taken@example.com"]["status"] == "failed"
|
|
58
|
+
assert by_email["fresh@example.com"]["status"] in ("sent", "link")
|
|
59
|
+
|
|
60
|
+
async def test_a_failure_always_carries_a_reason(self, authenticated_client: httpx.AsyncClient):
|
|
61
|
+
"""Every failed row must say why.
|
|
62
|
+
|
|
63
|
+
``str(UserAlreadyExists())`` is empty — the reason lives in the type, not
|
|
64
|
+
the message — so passing it through rendered an address in red with a
|
|
65
|
+
blank reason beside it. Asserting the status alone is what let that ship.
|
|
66
|
+
"""
|
|
67
|
+
await authenticated_client.post(
|
|
68
|
+
_URL, json={"emails": ["why@example.com"], "role_names": []}
|
|
69
|
+
)
|
|
70
|
+
resp = await authenticated_client.post(
|
|
71
|
+
_URL, json={"emails": ["why@example.com"], "role_names": []}
|
|
72
|
+
)
|
|
73
|
+
result = resp.json()["results"][0]
|
|
74
|
+
assert result["status"] == "failed"
|
|
75
|
+
assert result["detail"], "a failed address must explain itself"
|
|
76
|
+
assert "already" in result["detail"].lower()
|
|
77
|
+
|
|
78
|
+
async def test_console_mailer_hands_back_a_copyable_link(
|
|
79
|
+
self, authenticated_client: httpx.AsyncClient
|
|
80
|
+
):
|
|
81
|
+
"""The test app uses the console mailer, which delivers nothing — the
|
|
82
|
+
admin needs the link or the invite is undeliverable."""
|
|
83
|
+
resp = await authenticated_client.post(
|
|
84
|
+
_URL, json={"emails": ["linkme@example.com"], "role_names": []}
|
|
85
|
+
)
|
|
86
|
+
result = resp.json()["results"][0]
|
|
87
|
+
assert result["status"] == "link"
|
|
88
|
+
assert "/users/invite/accept?token=" in result["link"]
|
|
89
|
+
|
|
90
|
+
async def test_roles_apply_to_every_address(self, authenticated_client: httpx.AsyncClient):
|
|
91
|
+
resp = await authenticated_client.post(
|
|
92
|
+
_URL,
|
|
93
|
+
json={"emails": ["r1@example.com", "r2@example.com"], "role_names": ["user"]},
|
|
94
|
+
)
|
|
95
|
+
assert resp.status_code == 200, resp.text
|
|
96
|
+
assert all(r["status"] in ("sent", "link") for r in resp.json()["results"])
|
|
97
|
+
|
|
98
|
+
async def test_roles_survive_a_later_failure(self, authenticated_client: httpx.AsyncClient):
|
|
99
|
+
"""A failure mid-list must not roll back an earlier invite's roles.
|
|
100
|
+
|
|
101
|
+
``invite`` only flushes its role rows; the rollback that clears failed
|
|
102
|
+
transaction state used to take them with it, so the person invited just
|
|
103
|
+
before a duplicate address ended up with none of the chosen roles.
|
|
104
|
+
"""
|
|
105
|
+
await authenticated_client.post(
|
|
106
|
+
_URL, json={"emails": ["dupe@example.com"], "role_names": []}
|
|
107
|
+
)
|
|
108
|
+
resp = await authenticated_client.post(
|
|
109
|
+
_URL,
|
|
110
|
+
json={"emails": ["kept@example.com", "dupe@example.com"], "role_names": ["admin"]},
|
|
111
|
+
)
|
|
112
|
+
assert resp.status_code == 200, resp.text
|
|
113
|
+
|
|
114
|
+
listing = await authenticated_client.get("/api/users/admin")
|
|
115
|
+
users = {u["email"]: u for u in listing.json()}
|
|
116
|
+
assert users["kept@example.com"]["roles"] == ["admin"]
|
|
117
|
+
|
|
118
|
+
async def test_a_malformed_address_does_not_reject_the_submit(
|
|
119
|
+
self, authenticated_client: httpx.AsyncClient
|
|
120
|
+
):
|
|
121
|
+
"""A typo in one line of a pasted column used to 422 the whole body,
|
|
122
|
+
leaving the admin with a generic error and no idea which line."""
|
|
123
|
+
resp = await authenticated_client.post(
|
|
124
|
+
_URL,
|
|
125
|
+
json={"emails": ["good@example.com", "not-an-address"], "role_names": []},
|
|
126
|
+
)
|
|
127
|
+
assert resp.status_code == 200, resp.text
|
|
128
|
+
by_email = {r["email"]: r for r in resp.json()["results"]}
|
|
129
|
+
assert by_email["good@example.com"]["status"] in ("sent", "link")
|
|
130
|
+
assert by_email["not-an-address"]["status"] == "failed"
|
|
131
|
+
assert "valid email" in by_email["not-an-address"]["detail"]
|
|
132
|
+
|
|
133
|
+
async def test_empty_list_is_accepted_and_does_nothing(
|
|
134
|
+
self, authenticated_client: httpx.AsyncClient
|
|
135
|
+
):
|
|
136
|
+
resp = await authenticated_client.post(_URL, json={"emails": [], "role_names": []})
|
|
137
|
+
assert resp.status_code == 200
|
|
138
|
+
assert resp.json()["results"] == []
|
|
139
|
+
|
|
140
|
+
async def test_address_count_is_capped(self, authenticated_client: httpx.AsyncClient):
|
|
141
|
+
"""One submit must not be able to mint unbounded live invite tokens."""
|
|
142
|
+
from users.admin.bulk_invite import MAX_ADDRESSES
|
|
143
|
+
|
|
144
|
+
emails = [f"bulk{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
145
|
+
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
146
|
+
invited = [r for r in resp.json()["results"] if r["status"] != "failed"]
|
|
147
|
+
assert len(invited) == MAX_ADDRESSES
|
|
148
|
+
|
|
149
|
+
async def test_addresses_over_the_cap_are_reported_not_dropped(
|
|
150
|
+
self, authenticated_client: httpx.AsyncClient
|
|
151
|
+
):
|
|
152
|
+
"""Silent truncation reports "100 invites sent" while 5 people are
|
|
153
|
+
never contacted, with nothing on screen saying so."""
|
|
154
|
+
from users.admin.bulk_invite import MAX_ADDRESSES
|
|
155
|
+
|
|
156
|
+
emails = [f"over{i}@example.com" for i in range(MAX_ADDRESSES + 5)]
|
|
157
|
+
resp = await authenticated_client.post(_URL, json={"emails": emails, "role_names": []})
|
|
158
|
+
results = resp.json()["results"]
|
|
159
|
+
|
|
160
|
+
assert len(results) == MAX_ADDRESSES + 5, "every submitted address needs an outcome"
|
|
161
|
+
overflow = {r["email"]: r for r in results[-5:]}
|
|
162
|
+
assert set(overflow) == {
|
|
163
|
+
f"over{i}@example.com" for i in range(MAX_ADDRESSES, MAX_ADDRESSES + 5)
|
|
164
|
+
}
|
|
165
|
+
for result in overflow.values():
|
|
166
|
+
assert result["status"] == "failed"
|
|
167
|
+
assert "limit" in result["detail"]
|
|
168
|
+
|
|
169
|
+
async def test_requires_authentication(self, client: httpx.AsyncClient):
|
|
170
|
+
resp = await client.post(
|
|
171
|
+
_URL, json={"emails": ["x@example.com"], "role_names": []}, follow_redirects=False
|
|
172
|
+
)
|
|
173
|
+
assert resp.status_code in (302, 401, 403)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestInvitePreview:
|
|
177
|
+
async def test_accept_page_names_the_invitee(
|
|
178
|
+
self, authenticated_client: httpx.AsyncClient, client: httpx.AsyncClient
|
|
179
|
+
):
|
|
180
|
+
"""The card asked for a password while identifying nobody."""
|
|
181
|
+
created = await authenticated_client.post(
|
|
182
|
+
_URL, json={"emails": ["preview@example.com"], "role_names": []}
|
|
183
|
+
)
|
|
184
|
+
link = created.json()["results"][0]["link"]
|
|
185
|
+
token = link.split("token=")[1]
|
|
186
|
+
|
|
187
|
+
resp = await client.get(
|
|
188
|
+
f"/users/invite/accept?token={token}",
|
|
189
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
190
|
+
)
|
|
191
|
+
assert resp.status_code == 200, resp.text
|
|
192
|
+
invite = resp.json()["props"]["invite"]
|
|
193
|
+
assert invite["email"] == "preview@example.com"
|
|
194
|
+
assert invite["already_accepted"] is False
|
|
195
|
+
|
|
196
|
+
@pytest.mark.parametrize("token", ["", "not-a-jwt", "a.b.c"])
|
|
197
|
+
async def test_unreadable_tokens_yield_no_preview(self, client: httpx.AsyncClient, token: str):
|
|
198
|
+
"""Expired, tampered and absent all look the same here on purpose —
|
|
199
|
+
the reason belongs to the accept attempt, which validates properly."""
|
|
200
|
+
resp = await client.get(
|
|
201
|
+
f"/users/invite/accept?token={token}",
|
|
202
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
203
|
+
)
|
|
204
|
+
assert resp.status_code == 200
|
|
205
|
+
assert resp.json()["props"]["invite"] is None
|
|
206
|
+
|
|
207
|
+
async def test_preview_does_not_consume_the_invite(
|
|
208
|
+
self, authenticated_client: httpx.AsyncClient, client: httpx.AsyncClient
|
|
209
|
+
):
|
|
210
|
+
"""Viewing the page must leave the token usable — UserManager.verify
|
|
211
|
+
marks the account verified as a side effect, so the preview cannot
|
|
212
|
+
route through it."""
|
|
213
|
+
created = await authenticated_client.post(
|
|
214
|
+
_URL, json={"emails": ["unspent@example.com"], "role_names": []}
|
|
215
|
+
)
|
|
216
|
+
token = created.json()["results"][0]["link"].split("token=")[1]
|
|
217
|
+
|
|
218
|
+
for _ in range(2):
|
|
219
|
+
resp = await client.get(
|
|
220
|
+
f"/users/invite/accept?token={token}",
|
|
221
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
222
|
+
)
|
|
223
|
+
assert resp.json()["props"]["invite"]["already_accepted"] is False
|
|
224
|
+
|
|
225
|
+
accepted = await client.post(
|
|
226
|
+
"/api/users/auth/accept-invite",
|
|
227
|
+
json={"token": token, "password": "a-good-password-123"},
|
|
228
|
+
)
|
|
229
|
+
assert accepted.status_code in (200, 204), accepted.text
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The ``signup`` shared prop drives whether a "Sign up" link is rendered.
|
|
2
|
+
|
|
3
|
+
``/users/register`` raises 404 when ``allow_signup`` is off, so the public shell
|
|
4
|
+
has to know the answer before it draws the link. Getting the default wrong in
|
|
5
|
+
either direction is a visible bug: too eager and every visitor hits a 404, too
|
|
6
|
+
shy and a genuinely open instance hides its own signup.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from users.shared_props import users_shared_props
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _request(users_state: object) -> SimpleNamespace:
|
|
18
|
+
"""A stand-in carrying only what the provider is allowed to touch."""
|
|
19
|
+
return SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(users=users_state)))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestSignupSharedProp:
|
|
23
|
+
@pytest.mark.parametrize("allow", [True, False])
|
|
24
|
+
def test_reflects_the_setting(self, allow: bool) -> None:
|
|
25
|
+
request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=allow)))
|
|
26
|
+
assert users_shared_props(request) == {"signup": {"allowed": allow}}
|
|
27
|
+
|
|
28
|
+
def test_coerces_to_a_real_bool(self) -> None:
|
|
29
|
+
"""The value is serialised straight to JSON, so it must not leak a
|
|
30
|
+
truthy non-bool into the page props."""
|
|
31
|
+
request = _request(SimpleNamespace(settings=SimpleNamespace(allow_signup=1)))
|
|
32
|
+
assert users_shared_props(request)["signup"]["allowed"] is True
|
|
33
|
+
|
|
34
|
+
def test_defaults_closed_when_settings_are_missing(self) -> None:
|
|
35
|
+
request = _request(SimpleNamespace())
|
|
36
|
+
assert users_shared_props(request) == {"signup": {"allowed": False}}
|
|
37
|
+
|
|
38
|
+
def test_defaults_closed_when_module_state_is_absent(self) -> None:
|
|
39
|
+
"""Never raises: the provider runs on every request and a failure here
|
|
40
|
+
would cost the whole page, not just the link."""
|
|
41
|
+
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()))
|
|
42
|
+
assert users_shared_props(request) == {"signup": {"allowed": False}}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestProviderIsRegistered:
|
|
46
|
+
def test_the_built_app_carries_it(self, app) -> None:
|
|
47
|
+
"""Registered during ``register_settings``, so a fully booted app has it
|
|
48
|
+
in place before the first request is served. Asserted against the real
|
|
49
|
+
app rather than a bare FastAPI: the registration sits downstream of the
|
|
50
|
+
settings module, and a stub would pass while the wiring was broken."""
|
|
51
|
+
providers = getattr(app.state, "inertia_shared_providers", [])
|
|
52
|
+
assert users_shared_props in providers
|
|
@@ -59,6 +59,45 @@ class TestAdminIndexFilters:
|
|
|
59
59
|
assert props["filters"]["sort"] == "email"
|
|
60
60
|
assert props["filters"]["order"] == "asc"
|
|
61
61
|
|
|
62
|
+
@pytest.mark.anyio
|
|
63
|
+
async def test_page_past_the_end_clamps_to_last_page(self, admin_client, users_db):
|
|
64
|
+
"""A stale/hand-edited ?page= beyond the last page must clamp and
|
|
65
|
+
return the last page's rows, not an empty list with a nonzero total —
|
|
66
|
+
the same bug class background_tasks and file_storage fix for their
|
|
67
|
+
own admin listings."""
|
|
68
|
+
from test_api_admin import _make_user
|
|
69
|
+
|
|
70
|
+
await _make_user(users_db, email="clamp-a@x.com")
|
|
71
|
+
await _make_user(users_db, email="clamp-b@x.com")
|
|
72
|
+
|
|
73
|
+
resp = await admin_client.get(
|
|
74
|
+
"/users/admin?page=999&per_page=1",
|
|
75
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
76
|
+
)
|
|
77
|
+
assert resp.status_code == 200
|
|
78
|
+
props = resp.json()["props"]
|
|
79
|
+
assert props["users"], "expected the clamped last page to carry rows"
|
|
80
|
+
assert props["pagination"]["page"] < 999
|
|
81
|
+
assert props["pagination"]["total"] >= 2
|
|
82
|
+
|
|
83
|
+
@pytest.mark.anyio
|
|
84
|
+
async def test_pagination_prop_echoes_clamped_values(self, admin_client, users_db):
|
|
85
|
+
"""The pagination prop must carry the values the query actually ran
|
|
86
|
+
with — ?page=0 renders page-1 rows and must not be labelled page 0,
|
|
87
|
+
and an oversized per_page is bounded to what the service fetched."""
|
|
88
|
+
from test_api_admin import _make_user
|
|
89
|
+
|
|
90
|
+
await _make_user(users_db, email="clamp-c@x.com")
|
|
91
|
+
|
|
92
|
+
resp = await admin_client.get(
|
|
93
|
+
"/users/admin?page=0&per_page=1000",
|
|
94
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
95
|
+
)
|
|
96
|
+
assert resp.status_code == 200
|
|
97
|
+
pagination = resp.json()["props"]["pagination"]
|
|
98
|
+
assert pagination["page"] == 1
|
|
99
|
+
assert pagination["per_page"] == 200
|
|
100
|
+
|
|
62
101
|
|
|
63
102
|
# ---------------------------------------------------------------------------
|
|
64
103
|
# has_permissions_module flag on admin edit page
|
|
@@ -129,23 +168,68 @@ class TestHasPermissionsModuleFlag:
|
|
|
129
168
|
|
|
130
169
|
|
|
131
170
|
# ---------------------------------------------------------------------------
|
|
132
|
-
# Admin create
|
|
171
|
+
# Admin add-people page (create + invite merged behind a mode switch)
|
|
133
172
|
# ---------------------------------------------------------------------------
|
|
134
173
|
|
|
135
174
|
|
|
136
|
-
class
|
|
175
|
+
class TestAdminAddPeoplePage:
|
|
137
176
|
@pytest.mark.anyio
|
|
138
|
-
async def
|
|
177
|
+
async def test_add_page_renders_with_roles(self, admin_client):
|
|
139
178
|
resp = await admin_client.get(
|
|
140
|
-
"/users/admin/
|
|
179
|
+
"/users/admin/add",
|
|
141
180
|
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
142
181
|
)
|
|
143
182
|
assert resp.status_code == 200
|
|
144
183
|
data = resp.json()
|
|
145
|
-
assert data["component"] == "Users/Users/
|
|
184
|
+
assert data["component"] == "Users/Users/AddPeople"
|
|
146
185
|
assert "roles" in data["props"]
|
|
147
186
|
|
|
148
187
|
@pytest.mark.anyio
|
|
149
|
-
async def
|
|
150
|
-
|
|
188
|
+
async def test_add_page_reports_whether_mail_can_be_delivered(self, admin_client):
|
|
189
|
+
"""Drives the copy-link panel — the page has to know before submitting."""
|
|
190
|
+
resp = await admin_client.get(
|
|
191
|
+
"/users/admin/add",
|
|
192
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
193
|
+
)
|
|
194
|
+
assert "mailer_delivers" in resp.json()["props"]
|
|
195
|
+
|
|
196
|
+
@pytest.mark.anyio
|
|
197
|
+
async def test_no_mailer_does_not_promise_delivery(self, admin_client, app):
|
|
198
|
+
"""With nothing able to send, the page must offer the copy-link panel —
|
|
199
|
+
claiming delivery is the one answer that is certainly wrong."""
|
|
200
|
+
original = app.state.users.mailer
|
|
201
|
+
app.state.users.mailer = None
|
|
202
|
+
try:
|
|
203
|
+
resp = await admin_client.get(
|
|
204
|
+
"/users/admin/add",
|
|
205
|
+
headers={"X-Inertia": "true", "Accept": "application/json"},
|
|
206
|
+
)
|
|
207
|
+
assert resp.json()["props"]["mailer_delivers"] is False
|
|
208
|
+
finally:
|
|
209
|
+
app.state.users.mailer = original
|
|
210
|
+
|
|
211
|
+
@pytest.mark.anyio
|
|
212
|
+
async def test_add_page_requires_auth(self, anon_client):
|
|
213
|
+
resp = await anon_client.get("/users/admin/add", follow_redirects=False)
|
|
214
|
+
assert resp.status_code == 302
|
|
215
|
+
|
|
216
|
+
@pytest.mark.anyio
|
|
217
|
+
@pytest.mark.parametrize(
|
|
218
|
+
("old_path", "mode"),
|
|
219
|
+
[("/users/admin/create", "create"), ("/users/admin/invite", "invite")],
|
|
220
|
+
)
|
|
221
|
+
async def test_old_urls_redirect_into_the_right_mode(self, admin_client, old_path, mode):
|
|
222
|
+
"""Existing links must land on the merged form with their mode preselected."""
|
|
223
|
+
resp = await admin_client.get(old_path, follow_redirects=False)
|
|
224
|
+
assert resp.status_code == 307
|
|
225
|
+
assert resp.headers["location"] == f"/users/admin/add?mode={mode}"
|
|
226
|
+
|
|
227
|
+
@pytest.mark.anyio
|
|
228
|
+
@pytest.mark.parametrize("old_path", ["/users/admin/create", "/users/admin/invite"])
|
|
229
|
+
async def test_old_urls_require_auth(self, anon_client, old_path):
|
|
230
|
+
"""The legacy aliases must stay gated behind the same permission as
|
|
231
|
+
the page they redirect to — an anonymous caller must not reach the
|
|
232
|
+
redirect (and learn the target route exists) before being sent to
|
|
233
|
+
log in."""
|
|
234
|
+
resp = await anon_client.get(old_path, follow_redirects=False)
|
|
151
235
|
assert resp.status_code == 302
|
|
@@ -10,6 +10,7 @@ from fastapi_users import exceptions as fa_exceptions
|
|
|
10
10
|
from simple_module_core.events import EventBus
|
|
11
11
|
from simple_module_hosting.permissions import RequiresPermission
|
|
12
12
|
|
|
13
|
+
from users.admin.bulk_invite import bulk_router
|
|
13
14
|
from users.admin.service import UserService
|
|
14
15
|
from users.constants import PERM_USERS_MANAGE, sanitize_list_filters
|
|
15
16
|
from users.contracts.events import (
|
|
@@ -40,6 +41,10 @@ admin_router = APIRouter(
|
|
|
40
41
|
tags=["users-admin"],
|
|
41
42
|
)
|
|
42
43
|
|
|
44
|
+
# Bulk invite lives in its own module (this file is near the 300-line cap) but
|
|
45
|
+
# mounts here so it inherits the users.manage guard above.
|
|
46
|
+
admin_router.include_router(bulk_router)
|
|
47
|
+
|
|
43
48
|
|
|
44
49
|
@admin_router.get("", response_model=list[UserListItem])
|
|
45
50
|
async def admin_list_users(
|