simple-module-users 0.0.15__tar.gz → 0.0.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/PKG-INFO +6 -6
  2. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/pyproject.toml +6 -6
  3. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/_middleware_support.py +18 -13
  4. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_admin.py +8 -9
  5. simple_module_users-0.0.17/tests/test_token_api.py +191 -0
  6. simple_module_users-0.0.17/tests/test_users_middleware_resolvers.py +190 -0
  7. simple_module_users-0.0.17/tests/test_users_provider.py +46 -0
  8. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_views.py +34 -0
  9. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/api.py +12 -2
  10. simple_module_users-0.0.17/users/auth_local/token_api.py +159 -0
  11. simple_module_users-0.0.17/users/middleware.py +10 -0
  12. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/__init__.py +2 -0
  13. simple_module_users-0.0.17/users/models/refresh_token.py +22 -0
  14. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/module.py +20 -19
  15. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/AcceptInvite.tsx +2 -1
  16. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/ForgotPassword.tsx +2 -0
  17. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Login.tsx +2 -2
  18. simple_module_users-0.0.17/users/pages/Profile.tsx +154 -0
  19. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Register.tsx +2 -0
  20. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/ResetPassword.tsx +2 -1
  21. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/VerifyEmail.tsx +2 -1
  22. simple_module_users-0.0.17/users/provider.py +130 -0
  23. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/settings.py +4 -0
  24. simple_module_users-0.0.15/users/middleware.py +0 -143
  25. simple_module_users-0.0.15/users/pages/Profile.tsx +0 -148
  26. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/.gitignore +0 -0
  27. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/LICENSE +0 -0
  28. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/README.md +0 -0
  29. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/package.json +0 -0
  30. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/.gitkeep +0 -0
  31. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/conftest.py +0 -0
  32. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_access_token_model.py +0 -0
  33. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_admin_filters.py +0 -0
  34. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_api_auth.py +0 -0
  35. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_backend.py +0 -0
  36. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_bootstrap.py +0 -0
  37. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_bootstrap_resolution.py +0 -0
  38. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_cli.py +0 -0
  39. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_constants.py +0 -0
  40. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_db_adapter.py +0 -0
  41. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_invite_flow.py +0 -0
  42. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_invite_reuse.py +0 -0
  43. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_mailer.py +0 -0
  44. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_negative_authz.py +0 -0
  45. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_oauth.py +0 -0
  46. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_rate_limit.py +0 -0
  47. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_role_model.py +0 -0
  48. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_service_admin.py +0 -0
  49. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_settings.py +0 -0
  50. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_manager.py +0 -0
  51. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_model.py +0 -0
  52. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_role_model.py +0 -0
  53. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_user_service.py +0 -0
  54. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_deps.py +0 -0
  55. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_middleware.py +0 -0
  56. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_users_middleware_public_paths.py +0 -0
  57. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tests/test_views_admin.py +0 -0
  58. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/tsconfig.json +0 -0
  59. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/__init__.py +0 -0
  60. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/__init__.py +0 -0
  61. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/api.py +0 -0
  62. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/IndexFilters.tsx +0 -0
  63. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/RolesTab.tsx +0 -0
  64. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/components/UserRow.tsx +0 -0
  65. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/service.py +0 -0
  66. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/admin/views.py +0 -0
  67. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/__init__.py +0 -0
  68. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/rate_limit.py +0 -0
  69. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/auth_local/views.py +0 -0
  70. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/backend.py +0 -0
  71. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/bootstrap.py +0 -0
  72. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/cli.py +0 -0
  73. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/constants.py +0 -0
  74. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/__init__.py +0 -0
  75. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/events.py +0 -0
  76. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/contracts/schemas.py +0 -0
  77. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/db_adapter.py +0 -0
  78. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/deps.py +0 -0
  79. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/exceptions.py +0 -0
  80. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/__init__.py +0 -0
  81. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/console.py +0 -0
  82. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/smtp.py +0 -0
  83. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/.gitkeep +0 -0
  84. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/invite.txt +0 -0
  85. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/reset_password.txt +0 -0
  86. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/mailer/templates/verify_email.txt +0 -0
  87. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/manager.py +0 -0
  88. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/_base.py +0 -0
  89. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/access_token.py +0 -0
  90. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/oauth_account.py +0 -0
  91. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/role.py +0 -0
  92. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/user.py +0 -0
  93. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/models/user_role.py +0 -0
  94. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/__init__.py +0 -0
  95. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/api.py +0 -0
  96. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/oauth/providers.py +0 -0
  97. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/.gitkeep +0 -0
  98. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Edit.tsx +0 -0
  99. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Index.tsx +0 -0
  100. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/Invite.tsx +0 -0
  101. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/pages/Users/components/AccountStatusCard.tsx +0 -0
  102. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/py.typed +0 -0
  103. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/users/roles_cache.py +0 -0
  104. {simple_module_users-0.0.15 → simple_module_users-0.0.17}/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.15
3
+ Version: 0.0.17
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.15
28
- Requires-Dist: simple-module-core==0.0.15
29
- Requires-Dist: simple-module-db==0.0.15
30
- Requires-Dist: simple-module-hosting==0.0.15
31
- Requires-Dist: simple-module-settings==0.0.15
27
+ Requires-Dist: simple-module-auth==0.0.17
28
+ Requires-Dist: simple-module-core==0.0.17
29
+ Requires-Dist: simple-module-db==0.0.17
30
+ Requires-Dist: simple-module-hosting==0.0.17
31
+ Requires-Dist: simple-module-settings==0.0.17
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.15"
3
+ version = "0.0.17"
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.15",
25
- "simple_module_db==0.0.15",
26
- "simple_module_hosting==0.0.15",
27
- "simple_module_settings==0.0.15",
28
- "simple_module_auth==0.0.15",
24
+ "simple_module_core==0.0.17",
25
+ "simple_module_db==0.0.17",
26
+ "simple_module_hosting==0.0.17",
27
+ "simple_module_settings==0.0.17",
28
+ "simple_module_auth==0.0.17",
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.
@@ -9,35 +9,34 @@ parameters.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- import json
13
12
  import uuid
14
- from base64 import b64encode
15
13
  from types import SimpleNamespace
16
14
  from typing import Any
17
15
 
18
16
  import pytest
17
+ from auth.middleware import AuthMiddleware
19
18
  from fastapi import FastAPI, Request
20
- from itsdangerous import TimestampSigner
19
+ from simple_module_test import forge_session_cookie
21
20
  from starlette.middleware.sessions import SessionMiddleware
22
21
  from starlette.responses import JSONResponse
23
22
  from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
24
- from users.middleware import AuthMiddleware
25
23
 
26
24
  SECRET_KEY = "test-secret-key-for-session-middleware"
27
25
 
28
26
 
29
- def _sign_session(data: dict[str, Any], secret: str = SECRET_KEY) -> str:
30
- """Encode and sign a session dict exactly as Starlette's SessionMiddleware does."""
31
- raw = b64encode(json.dumps(data).encode()).decode()
32
- return TimestampSigner(secret).sign(raw).decode("utf-8")
33
-
34
-
35
27
  def _session_cookie(data: dict[str, Any]) -> dict[str, str]:
36
- return {"session": _sign_session(data)}
28
+ return {"session": forge_session_cookie(SECRET_KEY, data)}
29
+
37
30
 
31
+ async def _build_app(db_state, inner_handler=None, *, principal_resolvers=None):
32
+ """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware.
38
33
 
39
- async def _build_app(db_state, inner_handler=None):
40
- """Build a minimal ASGI app with AuthMiddleware + SessionMiddleware."""
34
+ ``principal_resolvers`` (optional) is a list of resolvers seeded onto
35
+ ``app.state.auth.principal_resolvers`` before the middleware runs.
36
+ Defaults to an empty registry — matches a production app where no
37
+ downstream module has registered anything.
38
+ """
39
+ from auth.state import AuthState
41
40
 
42
41
  async def _default_handler(request: Request):
43
42
  user = getattr(request.state, "user", None)
@@ -62,6 +61,12 @@ async def _build_app(db_state, inner_handler=None):
62
61
 
63
62
  app = FastAPI()
64
63
  app.state.sm = SimpleNamespace(db=db_state)
64
+ from users.provider import UsersAuthProvider
65
+
66
+ app.state.auth = AuthState(
67
+ auth_provider=UsersAuthProvider(),
68
+ principal_resolvers=list(principal_resolvers or []),
69
+ )
65
70
 
66
71
  @app.get("/{path:path}")
67
72
  async def _catch_all(request: Request, path: str = ""):
@@ -46,11 +46,10 @@ class TestAdminList:
46
46
  @pytest.mark.anyio
47
47
  async def test_list_without_auth_is_rejected(self, anon_client):
48
48
  resp = await anon_client.get("/api/users/admin", follow_redirects=False)
49
- # AuthMiddleware redirects unauthenticated non-public API paths to
50
- # /users/login. Preserving the 302 here so a regression to 401 or
51
- # pass-through is caught.
52
- assert resp.status_code == 302
53
- assert resp.headers["location"].endswith("/users/login")
49
+ # AuthMiddleware returns 401 JSON for unauthenticated /api/* paths
50
+ # (view routes still get a 302 redirect to /users/login).
51
+ assert resp.status_code == 401
52
+ assert resp.json() == {"detail": "Not authenticated"}
54
53
 
55
54
  @pytest.mark.anyio
56
55
  async def test_list_as_admin_returns_200(self, admin_client, users_db):
@@ -128,8 +127,8 @@ class TestAdminInvite:
128
127
  json={"email": "hacker@example.com"},
129
128
  follow_redirects=False,
130
129
  )
131
- assert resp.status_code == 302
132
- assert resp.headers["location"].endswith("/users/login")
130
+ assert resp.status_code == 401
131
+ assert resp.json() == {"detail": "Not authenticated"}
133
132
 
134
133
 
135
134
  # ---------------------------------------------------------------------------
@@ -205,8 +204,8 @@ class TestAdminSetRoles:
205
204
  json={"role_names": ["admin"]},
206
205
  follow_redirects=False,
207
206
  )
208
- assert resp.status_code == 302
209
- assert resp.headers["location"].endswith("/users/login")
207
+ assert resp.status_code == 401
208
+ assert resp.json() == {"detail": "Not authenticated"}
210
209
 
211
210
  @pytest.mark.anyio
212
211
  async def test_set_roles_nonexistent_returns_404(self, admin_client):
@@ -0,0 +1,191 @@
1
+ """Tests for bearer token endpoints: /api/users/auth/token*.
2
+
3
+ Covers: login via email+password, refresh rotation, revoke, and error paths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import uuid
9
+
10
+ import pytest
11
+ from fastapi_users.password import PasswordHelper
12
+ from users.models import User
13
+
14
+ _pw = PasswordHelper()
15
+
16
+
17
+ def _hash(plain: str) -> str:
18
+ return _pw.hash(plain)
19
+
20
+
21
+ async def _seed_user(session, email="api@example.com", password="SecurePass1!"):
22
+ """Create a verified, active user for token tests."""
23
+ user = User(
24
+ id=uuid.uuid4(),
25
+ email=email,
26
+ hashed_password=_hash(password),
27
+ is_active=True,
28
+ is_superuser=False,
29
+ is_verified=True,
30
+ )
31
+ session.add(user)
32
+ await session.commit()
33
+ await session.refresh(user)
34
+ return user
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # POST /api/users/auth/token — login
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TestTokenLogin:
43
+ @pytest.mark.anyio
44
+ async def test_invalid_email_returns_401(self, anon_client):
45
+ resp = await anon_client.post(
46
+ "/api/users/auth/token",
47
+ json={"email": "nobody@example.com", "password": "whatever"},
48
+ )
49
+ assert resp.status_code == 401
50
+ assert resp.json()["detail"] == "Invalid credentials"
51
+
52
+ @pytest.mark.anyio
53
+ async def test_wrong_password_returns_401(self, anon_client, users_db):
54
+ await _seed_user(users_db)
55
+ resp = await anon_client.post(
56
+ "/api/users/auth/token",
57
+ json={"email": "api@example.com", "password": "WRONG"},
58
+ )
59
+ assert resp.status_code == 401
60
+ assert resp.json()["detail"] == "Invalid credentials"
61
+
62
+ @pytest.mark.anyio
63
+ async def test_inactive_user_returns_401(self, anon_client, users_db):
64
+ user = await _seed_user(users_db, email="inactive@example.com")
65
+ user.is_active = False
66
+ await users_db.commit()
67
+
68
+ resp = await anon_client.post(
69
+ "/api/users/auth/token",
70
+ json={"email": "inactive@example.com", "password": "SecurePass1!"},
71
+ )
72
+ assert resp.status_code == 401
73
+
74
+ @pytest.mark.anyio
75
+ async def test_valid_credentials_returns_token_pair(self, anon_client, users_db):
76
+ await _seed_user(users_db)
77
+ resp = await anon_client.post(
78
+ "/api/users/auth/token",
79
+ json={"email": "api@example.com", "password": "SecurePass1!"},
80
+ )
81
+ assert resp.status_code == 200
82
+ body = resp.json()
83
+ assert body["token_type"] == "bearer"
84
+ assert body["access_token"]
85
+ assert body["refresh_token"]
86
+ assert body["expires_in"] > 0
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # POST /api/users/auth/token/refresh
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ class TestTokenRefresh:
95
+ @pytest.mark.anyio
96
+ async def test_invalid_uuid_returns_401(self, anon_client):
97
+ resp = await anon_client.post(
98
+ "/api/users/auth/token/refresh",
99
+ json={"refresh_token": "not-a-uuid"},
100
+ )
101
+ assert resp.status_code == 401
102
+ assert resp.json()["detail"] == "Invalid refresh token"
103
+
104
+ @pytest.mark.anyio
105
+ async def test_nonexistent_token_returns_401(self, anon_client):
106
+ resp = await anon_client.post(
107
+ "/api/users/auth/token/refresh",
108
+ json={"refresh_token": str(uuid.uuid4())},
109
+ )
110
+ assert resp.status_code == 401
111
+ assert resp.json()["detail"] == "Invalid or expired refresh token"
112
+
113
+ @pytest.mark.anyio
114
+ async def test_valid_refresh_rotates_tokens(self, anon_client, users_db):
115
+ """Login, then refresh — old refresh revoked, new pair returned."""
116
+ await _seed_user(users_db)
117
+ login = await anon_client.post(
118
+ "/api/users/auth/token",
119
+ json={"email": "api@example.com", "password": "SecurePass1!"},
120
+ )
121
+ assert login.status_code == 200
122
+ old_refresh = login.json()["refresh_token"]
123
+
124
+ refresh_resp = await anon_client.post(
125
+ "/api/users/auth/token/refresh",
126
+ json={"refresh_token": old_refresh},
127
+ )
128
+ assert refresh_resp.status_code == 200
129
+ new_body = refresh_resp.json()
130
+ assert new_body["access_token"]
131
+ assert new_body["refresh_token"] != old_refresh
132
+
133
+ # Old refresh token should now be revoked
134
+ reuse = await anon_client.post(
135
+ "/api/users/auth/token/refresh",
136
+ json={"refresh_token": old_refresh},
137
+ )
138
+ assert reuse.status_code == 401
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # DELETE /api/users/auth/token — revoke
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ class TestTokenRevoke:
147
+ @pytest.mark.anyio
148
+ async def test_invalid_format_returns_400(self, anon_client):
149
+ resp = await anon_client.request(
150
+ "DELETE",
151
+ "/api/users/auth/token",
152
+ json={"refresh_token": "garbage"},
153
+ )
154
+ assert resp.status_code == 400
155
+ assert resp.json()["detail"] == "Invalid token format"
156
+
157
+ @pytest.mark.anyio
158
+ async def test_nonexistent_token_returns_ok(self, anon_client):
159
+ """Revoking a non-existent token is idempotent — still returns ok."""
160
+ resp = await anon_client.request(
161
+ "DELETE",
162
+ "/api/users/auth/token",
163
+ json={"refresh_token": str(uuid.uuid4())},
164
+ )
165
+ assert resp.status_code == 200
166
+ assert resp.json()["status"] == "ok"
167
+
168
+ @pytest.mark.anyio
169
+ async def test_revoke_makes_refresh_unusable(self, anon_client, users_db):
170
+ """After revoking, the refresh token can no longer be used."""
171
+ await _seed_user(users_db)
172
+ login = await anon_client.post(
173
+ "/api/users/auth/token",
174
+ json={"email": "api@example.com", "password": "SecurePass1!"},
175
+ )
176
+ rt = login.json()["refresh_token"]
177
+
178
+ # Revoke
179
+ revoke_resp = await anon_client.request(
180
+ "DELETE",
181
+ "/api/users/auth/token",
182
+ json={"refresh_token": rt},
183
+ )
184
+ assert revoke_resp.status_code == 200
185
+
186
+ # Attempt refresh — should fail
187
+ refresh_resp = await anon_client.post(
188
+ "/api/users/auth/token/refresh",
189
+ json={"refresh_token": rt},
190
+ )
191
+ assert refresh_resp.status_code == 401
@@ -0,0 +1,190 @@
1
+ """AuthMiddleware tests for the principal-resolver chain.
2
+
3
+ Covers ``app.state.auth.principal_resolvers`` consultation order, error
4
+ isolation, the session-short-circuit precedence, and the /api/* vs view-path
5
+ unauthenticated split (401 JSON vs 302 redirect).
6
+
7
+ Helpers + fixtures live in ``_middleware_support`` alongside the broader
8
+ middleware tests in ``test_users_middleware`` and the PUBLIC_PATHS suite in
9
+ ``test_users_middleware_public_paths``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import httpx
15
+ import pytest
16
+ from _middleware_support import _build_app, _session_cookie
17
+ from fastapi import Request
18
+ from starlette.responses import JSONResponse
19
+
20
+
21
+ def _ctx(uid: str = "11111111-1111-1111-1111-111111111111", **overrides):
22
+ """Build a UserContext for resolver tests."""
23
+ from auth.contracts.schemas import UserContext
24
+
25
+ fields = {
26
+ "id": uid,
27
+ "email": "pat@example.com",
28
+ "name": "PAT User",
29
+ "roles": ["user"],
30
+ "tenant_id": None,
31
+ }
32
+ fields.update(overrides)
33
+ return UserContext(**fields)
34
+
35
+
36
+ @pytest.mark.anyio
37
+ async def test_resolver_returning_context_authenticates_request(db_state):
38
+ """A registered resolver that returns a UserContext authenticates the request."""
39
+
40
+ async def stub_resolver(request):
41
+ return _ctx()
42
+
43
+ app = await _build_app(db_state, principal_resolvers=[stub_resolver])
44
+ transport = httpx.ASGITransport(app=app)
45
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
46
+ resp = await client.get("/dashboard")
47
+
48
+ assert resp.status_code == 200
49
+ data = resp.json()
50
+ assert data["user"]["email"] == "pat@example.com"
51
+
52
+
53
+ @pytest.mark.anyio
54
+ async def test_resolver_first_non_none_wins(db_state):
55
+ """The first resolver returning a context wins; later resolvers are not consulted."""
56
+ second_called = False
57
+
58
+ async def first_none(request):
59
+ return None
60
+
61
+ async def second_returns(request):
62
+ nonlocal second_called
63
+ second_called = True
64
+ return _ctx(email="second@example.com")
65
+
66
+ async def third_should_not_run(request):
67
+ raise AssertionError("third resolver should not run after a match")
68
+
69
+ app = await _build_app(
70
+ db_state,
71
+ principal_resolvers=[first_none, second_returns, third_should_not_run],
72
+ )
73
+ transport = httpx.ASGITransport(app=app)
74
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
75
+ resp = await client.get("/dashboard")
76
+
77
+ assert resp.status_code == 200
78
+ assert resp.json()["user"]["email"] == "second@example.com"
79
+ assert second_called
80
+
81
+
82
+ @pytest.mark.anyio
83
+ async def test_all_resolvers_return_none_falls_through_to_redirect(db_state):
84
+ """When every resolver returns None for a view route → 302 to /users/login."""
85
+
86
+ async def none_resolver(request):
87
+ return None
88
+
89
+ app = await _build_app(db_state, principal_resolvers=[none_resolver])
90
+ transport = httpx.ASGITransport(app=app)
91
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
92
+ resp = await client.get("/dashboard", follow_redirects=False)
93
+
94
+ assert resp.status_code == 302
95
+ assert resp.headers["location"] == "/users/login"
96
+
97
+
98
+ @pytest.mark.anyio
99
+ async def test_resolver_raising_does_not_crash_middleware(db_state, caplog):
100
+ """A resolver that raises is logged and the chain continues to the next."""
101
+ import logging
102
+
103
+ async def boom(request):
104
+ raise RuntimeError("resolver kaboom")
105
+
106
+ async def fallback(request):
107
+ return _ctx(email="fallback@example.com")
108
+
109
+ app = await _build_app(db_state, principal_resolvers=[boom, fallback])
110
+ transport = httpx.ASGITransport(app=app)
111
+ with caplog.at_level(logging.ERROR, logger="users.middleware"):
112
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
113
+ resp = await client.get("/dashboard")
114
+
115
+ assert resp.status_code == 200
116
+ assert resp.json()["user"]["email"] == "fallback@example.com"
117
+ assert any("resolver" in rec.message.lower() for rec in caplog.records)
118
+
119
+
120
+ @pytest.mark.anyio
121
+ async def test_api_path_unauthenticated_returns_401_json(db_state):
122
+ """Unauthenticated /api/private should return 401 JSON, not a 302 redirect."""
123
+ app = await _build_app(db_state)
124
+ transport = httpx.ASGITransport(app=app)
125
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
126
+ resp = await client.get("/api/private-thing", follow_redirects=False)
127
+
128
+ assert resp.status_code == 401
129
+ assert resp.headers["content-type"].startswith("application/json")
130
+ assert resp.json() == {"detail": "Not authenticated"}
131
+
132
+
133
+ @pytest.mark.anyio
134
+ async def test_view_path_unauthenticated_still_redirects(db_state):
135
+ """View routes (non-/api/*) keep the existing 302-to-login behavior."""
136
+ app = await _build_app(db_state)
137
+ transport = httpx.ASGITransport(app=app)
138
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
139
+ resp = await client.get("/dashboard", follow_redirects=False)
140
+
141
+ assert resp.status_code == 302
142
+ assert resp.headers["location"] == "/users/login"
143
+
144
+
145
+ @pytest.mark.anyio
146
+ async def test_session_wins_over_resolver(db_state, mw_active_user):
147
+ """A valid session cookie short-circuits — the resolver chain is not consulted."""
148
+ resolver_called = False
149
+
150
+ async def should_not_run(request):
151
+ nonlocal resolver_called
152
+ resolver_called = True
153
+ return _ctx(email="should-not-win@example.com")
154
+
155
+ app = await _build_app(db_state, principal_resolvers=[should_not_run])
156
+ transport = httpx.ASGITransport(app=app)
157
+ cookies = _session_cookie({"user_id": str(mw_active_user.id)})
158
+ async with httpx.AsyncClient(
159
+ transport=transport, base_url="http://testserver", cookies=cookies
160
+ ) as client:
161
+ resp = await client.get("/dashboard")
162
+
163
+ assert resp.status_code == 200
164
+ assert resp.json()["user"]["email"] == "middleware-test@example.com"
165
+ assert resolver_called is False
166
+
167
+
168
+ @pytest.mark.anyio
169
+ async def test_resolver_does_not_write_session(db_state):
170
+ """Resolver-authenticated requests must not persist anything to the session."""
171
+ captured = {}
172
+
173
+ async def capture(request: Request):
174
+ captured["session"] = dict(request.session)
175
+ user = getattr(request.state, "user", None)
176
+ return JSONResponse({"authenticated": user is not None})
177
+
178
+ async def stub_resolver(request):
179
+ return _ctx()
180
+
181
+ app = await _build_app(db_state, capture, principal_resolvers=[stub_resolver])
182
+ transport = httpx.ASGITransport(app=app)
183
+ async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
184
+ resp = await client.get("/dashboard")
185
+
186
+ assert resp.status_code == 200
187
+ assert resp.json() == {"authenticated": True}
188
+ # Session must not contain a user_id, user_ctx, or anything resolver-added.
189
+ assert "user_id" not in captured["session"]
190
+ assert "user_ctx" not in captured["session"]
@@ -0,0 +1,46 @@
1
+ """Tests for UsersAuthProvider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from auth.contracts.provider import AuthProvider
6
+ from users.provider import UsersAuthProvider
7
+
8
+
9
+ def test_users_provider_satisfies_protocol():
10
+ provider = UsersAuthProvider()
11
+ assert isinstance(provider, AuthProvider)
12
+
13
+
14
+ def test_login_url():
15
+ provider = UsersAuthProvider()
16
+ assert provider.get_login_url(None) == "/users/login"
17
+
18
+
19
+ def test_logout_url():
20
+ provider = UsersAuthProvider()
21
+ assert provider.get_logout_url(None) == "/users/logout"
22
+
23
+
24
+ def test_public_paths():
25
+ provider = UsersAuthProvider()
26
+ prefixes, _exact = provider.get_public_paths()
27
+ assert "/users/login" in prefixes
28
+ assert "/api/users/auth/" in prefixes
29
+
30
+
31
+ def test_is_bearer_request_true():
32
+ from unittest.mock import MagicMock
33
+
34
+ request = MagicMock()
35
+ request.headers = {"authorization": "Bearer abc123"}
36
+ provider = UsersAuthProvider()
37
+ assert provider.is_bearer_request(request) is True
38
+
39
+
40
+ def test_is_bearer_request_false():
41
+ from unittest.mock import MagicMock
42
+
43
+ request = MagicMock()
44
+ request.headers = {}
45
+ provider = UsersAuthProvider()
46
+ assert provider.is_bearer_request(request) is False
@@ -112,6 +112,15 @@ class TestLoginPage:
112
112
  {"label": "User", "email": "user@example.com", "password": "UserPass1!"},
113
113
  ]
114
114
 
115
+ @pytest.mark.anyio
116
+ async def test_login_redirect_url_is_dashboard_when_installed(self, anon_client):
117
+ """Regression for #173: with Dashboard installed, prop stays /dashboard/."""
118
+ resp = await anon_client.get(
119
+ "/users/login",
120
+ headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
121
+ )
122
+ assert resp.json()["props"]["login_redirect_url"] == "/dashboard/"
123
+
115
124
 
116
125
  class TestRegisterPage:
117
126
  @pytest.mark.anyio
@@ -241,3 +250,28 @@ async def test_roles_payload_returns_id_name_dicts(users_app):
241
250
  names = [item["name"] for item in payload]
242
251
  assert "admin" in names
243
252
  assert "user" in names
253
+
254
+
255
+ @pytest.mark.anyio
256
+ async def test_login_redirect_fallback_without_dashboard(users_app):
257
+ """Regression for #173: without Dashboard, redirect falls back to
258
+ the first sibling module view_prefix, not ``/`` (which may 404)."""
259
+ sm = users_app.state.sm
260
+ original = sm.modules
261
+ no_dash = tuple(m for m in original if m.meta.name != "Dashboard")
262
+ assert len(no_dash) < len(original), "test setup: Dashboard should exist"
263
+
264
+ settings = users_app.state.users.settings
265
+ settings.login_redirect_url = "/dashboard/"
266
+ object.__setattr__(sm, "modules", no_dash)
267
+ try:
268
+ from users.module import UsersModule
269
+
270
+ mod = UsersModule()
271
+ await mod.on_startup(users_app)
272
+ url = settings.login_redirect_url
273
+ assert url != "/", "must not fall back to / (may 404)"
274
+ assert url.startswith("/"), "must be an absolute path"
275
+ assert url.endswith("/"), "must have trailing slash"
276
+ finally:
277
+ object.__setattr__(sm, "modules", original)
@@ -117,12 +117,22 @@ async def login(
117
117
  # ── Mount fastapi-users stock routers ────────────────────────────────────────
118
118
 
119
119
  # The stock auth router (login + logout) is mounted at /auth-inner so its
120
- # logout and other endpoints remain accessible. Our wrapper at /auth/login
121
- # shadows the stock login endpoint. Logout is exposed via /auth-inner/logout.
120
+ # endpoints remain accessible. Our wrappers at /auth/login and /auth/logout
121
+ # shadow the stock endpoints to also manage the session cookie.
122
122
  auth_inner = fastapi_users.get_auth_router(auth_backend, requires_verification=True)
123
123
  router.include_router(auth_inner, prefix="/auth-inner")
124
124
 
125
125
 
126
+ @router.post("/auth/logout", status_code=204)
127
+ async def api_logout(request: Request):
128
+ """API logout — clears both the access-token cookie and the session."""
129
+ request.session.clear()
130
+ cookie_name = request.app.state.users.settings.cookie_name
131
+ response = Response(status_code=204)
132
+ response.delete_cookie(cookie_name, path="/")
133
+ return response
134
+
135
+
126
136
  # ── Accept-invite (verify + set password + login, one shot) ─────────────────
127
137
 
128
138