simple-module-users 0.0.1__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.1/.gitignore +59 -0
- simple_module_users-0.0.1/LICENSE +21 -0
- simple_module_users-0.0.1/PKG-INFO +88 -0
- simple_module_users-0.0.1/README.md +55 -0
- simple_module_users-0.0.1/package.json +16 -0
- simple_module_users-0.0.1/pyproject.toml +63 -0
- simple_module_users-0.0.1/tests/.gitkeep +0 -0
- simple_module_users-0.0.1/tests/_middleware_support.py +109 -0
- simple_module_users-0.0.1/tests/conftest.py +236 -0
- simple_module_users-0.0.1/tests/test_access_token_model.py +54 -0
- simple_module_users-0.0.1/tests/test_api_admin.py +237 -0
- simple_module_users-0.0.1/tests/test_api_admin_filters.py +80 -0
- simple_module_users-0.0.1/tests/test_api_auth.py +220 -0
- simple_module_users-0.0.1/tests/test_backend.py +60 -0
- simple_module_users-0.0.1/tests/test_bootstrap.py +260 -0
- simple_module_users-0.0.1/tests/test_cli.py +167 -0
- simple_module_users-0.0.1/tests/test_constants.py +30 -0
- simple_module_users-0.0.1/tests/test_db_adapter.py +139 -0
- simple_module_users-0.0.1/tests/test_invite_flow.py +102 -0
- simple_module_users-0.0.1/tests/test_mailer.py +80 -0
- simple_module_users-0.0.1/tests/test_rate_limit.py +132 -0
- simple_module_users-0.0.1/tests/test_role_model.py +33 -0
- simple_module_users-0.0.1/tests/test_service_admin.py +233 -0
- simple_module_users-0.0.1/tests/test_settings.py +167 -0
- simple_module_users-0.0.1/tests/test_user_manager.py +233 -0
- simple_module_users-0.0.1/tests/test_user_model.py +47 -0
- simple_module_users-0.0.1/tests/test_user_role_model.py +136 -0
- simple_module_users-0.0.1/tests/test_user_service.py +105 -0
- simple_module_users-0.0.1/tests/test_users_deps.py +62 -0
- simple_module_users-0.0.1/tests/test_users_middleware.py +219 -0
- simple_module_users-0.0.1/tests/test_users_middleware_public_paths.py +61 -0
- simple_module_users-0.0.1/tests/test_views.py +198 -0
- simple_module_users-0.0.1/tests/test_views_admin.py +128 -0
- simple_module_users-0.0.1/tsconfig.json +11 -0
- simple_module_users-0.0.1/users/__init__.py +0 -0
- simple_module_users-0.0.1/users/backend.py +85 -0
- simple_module_users-0.0.1/users/bootstrap.py +246 -0
- simple_module_users-0.0.1/users/cli.py +75 -0
- simple_module_users-0.0.1/users/components/IndexFilters.tsx +72 -0
- simple_module_users-0.0.1/users/components/RolesTab.tsx +72 -0
- simple_module_users-0.0.1/users/constants.py +42 -0
- simple_module_users-0.0.1/users/contracts/__init__.py +0 -0
- simple_module_users-0.0.1/users/contracts/events.py +32 -0
- simple_module_users-0.0.1/users/contracts/schemas.py +85 -0
- simple_module_users-0.0.1/users/db_adapter.py +48 -0
- simple_module_users-0.0.1/users/deps.py +83 -0
- simple_module_users-0.0.1/users/endpoints/__init__.py +1 -0
- simple_module_users-0.0.1/users/endpoints/api.py +227 -0
- simple_module_users-0.0.1/users/endpoints/api_admin.py +167 -0
- simple_module_users-0.0.1/users/endpoints/views.py +220 -0
- simple_module_users-0.0.1/users/exceptions.py +18 -0
- simple_module_users-0.0.1/users/mailer/__init__.py +33 -0
- simple_module_users-0.0.1/users/mailer/console.py +27 -0
- simple_module_users-0.0.1/users/mailer/smtp.py +77 -0
- simple_module_users-0.0.1/users/mailer/templates/.gitkeep +0 -0
- simple_module_users-0.0.1/users/mailer/templates/invite.txt +1 -0
- simple_module_users-0.0.1/users/mailer/templates/reset_password.txt +1 -0
- simple_module_users-0.0.1/users/mailer/templates/verify_email.txt +1 -0
- simple_module_users-0.0.1/users/manager.py +146 -0
- simple_module_users-0.0.1/users/middleware.py +143 -0
- simple_module_users-0.0.1/users/models/__init__.py +24 -0
- simple_module_users-0.0.1/users/models/_base.py +9 -0
- simple_module_users-0.0.1/users/models/access_token.py +33 -0
- simple_module_users-0.0.1/users/models/role.py +34 -0
- simple_module_users-0.0.1/users/models/user.py +67 -0
- simple_module_users-0.0.1/users/models/user_role.py +39 -0
- simple_module_users-0.0.1/users/module.py +155 -0
- simple_module_users-0.0.1/users/pages/.gitkeep +0 -0
- simple_module_users-0.0.1/users/pages/AcceptInvite.tsx +106 -0
- simple_module_users-0.0.1/users/pages/ForgotPassword.tsx +90 -0
- simple_module_users-0.0.1/users/pages/Login.tsx +181 -0
- simple_module_users-0.0.1/users/pages/Profile.tsx +112 -0
- simple_module_users-0.0.1/users/pages/Register.tsx +152 -0
- simple_module_users-0.0.1/users/pages/ResetPassword.tsx +112 -0
- simple_module_users-0.0.1/users/pages/Users/Edit.tsx +293 -0
- simple_module_users-0.0.1/users/pages/Users/Index.tsx +296 -0
- simple_module_users-0.0.1/users/pages/Users/Invite.tsx +135 -0
- simple_module_users-0.0.1/users/pages/VerifyEmail.tsx +110 -0
- simple_module_users-0.0.1/users/py.typed +0 -0
- simple_module_users-0.0.1/users/rate_limit.py +59 -0
- simple_module_users-0.0.1/users/roles_cache.py +58 -0
- simple_module_users-0.0.1/users/service.py +257 -0
- simple_module_users-0.0.1/users/settings.py +99 -0
- simple_module_users-0.0.1/users/state.py +33 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# UV
|
|
11
|
+
uv.lock
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
|
|
16
|
+
# IDE
|
|
17
|
+
.idea/
|
|
18
|
+
.vscode/
|
|
19
|
+
*.swp
|
|
20
|
+
*.swo
|
|
21
|
+
|
|
22
|
+
# Environment
|
|
23
|
+
.env
|
|
24
|
+
|
|
25
|
+
# Database
|
|
26
|
+
*.db
|
|
27
|
+
*.sqlite3
|
|
28
|
+
|
|
29
|
+
# Module-managed runtime state (e.g. uploaded dataset files,
|
|
30
|
+
# default storage_dir for SM_DATASETS_STORAGE_DIR).
|
|
31
|
+
var/
|
|
32
|
+
|
|
33
|
+
# file_storage filesystem backend default root (override via SM_FILE_STORAGE_FS_ROOT_PATH).
|
|
34
|
+
uploads/
|
|
35
|
+
|
|
36
|
+
# Vite
|
|
37
|
+
host/static/dist/
|
|
38
|
+
|
|
39
|
+
# Auto-generated frontend module manifest (regenerated by the host at boot
|
|
40
|
+
# or via `make gen-pages`).
|
|
41
|
+
host/client_app/modules.manifest.json
|
|
42
|
+
host/client_app/modules.generated.ts
|
|
43
|
+
host/client_app/modules.generated.css
|
|
44
|
+
|
|
45
|
+
# Worktrees
|
|
46
|
+
.worktrees/
|
|
47
|
+
|
|
48
|
+
# Performance profiles
|
|
49
|
+
.memray/
|
|
50
|
+
.benchmarks/
|
|
51
|
+
|
|
52
|
+
# OS
|
|
53
|
+
.DS_Store
|
|
54
|
+
Thumbs.db
|
|
55
|
+
|
|
56
|
+
.playwright-cli/*
|
|
57
|
+
.playwright-mcp/*
|
|
58
|
+
host/client_app/.playwright-cli/*
|
|
59
|
+
.superpowers/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anto Subash
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_users
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps
|
|
5
|
+
Project-URL: Homepage, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
7
|
+
Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: admin,authentication,fastapi-users,simple-module,users
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.12
|
|
24
|
+
Requires-Dist: aiosmtplib>=3.0
|
|
25
|
+
Requires-Dist: cachetools>=5.3
|
|
26
|
+
Requires-Dist: fastapi-users[sqlalchemy]<16,>=15
|
|
27
|
+
Requires-Dist: simple-module-auth==0.0.1
|
|
28
|
+
Requires-Dist: simple-module-core==0.0.1
|
|
29
|
+
Requires-Dist: simple-module-db==0.0.1
|
|
30
|
+
Requires-Dist: simple-module-hosting==0.0.1
|
|
31
|
+
Requires-Dist: typer>=0.12
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# simple_module_users
|
|
35
|
+
|
|
36
|
+
Email+password user management for [simple_module](https://github.com/antosubash/simple_module_python) apps. Replaces Keycloak/Auth0 for the common case: local accounts, admin invites, password reset, optional public signup. Built on `fastapi-users`.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install simple_module_users
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pre-wired into any app scaffolded with `simple-module new`.
|
|
45
|
+
|
|
46
|
+
## What it provides
|
|
47
|
+
|
|
48
|
+
- Email + password registration, login, logout, password reset.
|
|
49
|
+
- Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
|
|
50
|
+
- Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
|
|
51
|
+
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
52
|
+
- `sm-users create-admin` CLI for ad-hoc admin creation.
|
|
53
|
+
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
54
|
+
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
CLI:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run sm-users create-admin --email admin@example.com --password 'change-me'
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Bootstrap-on-boot (`.env`):
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
|
|
68
|
+
SM_USERS_BOOTSTRAP_PASSWORD=change-me
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Program:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from users.deps import CurrentUser # type: ignore[import-not-found]
|
|
75
|
+
|
|
76
|
+
@router.get("/profile")
|
|
77
|
+
async def profile(user: CurrentUser):
|
|
78
|
+
return {"email": user.email}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Depends on
|
|
82
|
+
|
|
83
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
|
|
84
|
+
- `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# simple_module_users
|
|
2
|
+
|
|
3
|
+
Email+password user management for [simple_module](https://github.com/antosubash/simple_module_python) apps. Replaces Keycloak/Auth0 for the common case: local accounts, admin invites, password reset, optional public signup. Built on `fastapi-users`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install simple_module_users
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Pre-wired into any app scaffolded with `simple-module new`.
|
|
12
|
+
|
|
13
|
+
## What it provides
|
|
14
|
+
|
|
15
|
+
- Email + password registration, login, logout, password reset.
|
|
16
|
+
- Admin invite flow — admin enters an email, recipient clicks a link, sets a password, is logged in.
|
|
17
|
+
- Public signup toggle (`SM_USERS_ALLOW_SIGNUP`, default `false`).
|
|
18
|
+
- Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty.
|
|
19
|
+
- `sm-users create-admin` CLI for ad-hoc admin creation.
|
|
20
|
+
- Inertia pages for login/register/invite-accept/admin-invite.
|
|
21
|
+
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
CLI:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv run sm-users create-admin --email admin@example.com --password 'change-me'
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Bootstrap-on-boot (`.env`):
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
SM_USERS_BOOTSTRAP_EMAIL=admin@example.com
|
|
35
|
+
SM_USERS_BOOTSTRAP_PASSWORD=change-me
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Program:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from users.deps import CurrentUser # type: ignore[import-not-found]
|
|
42
|
+
|
|
43
|
+
@router.get("/profile")
|
|
44
|
+
async def profile(user: CurrentUser):
|
|
45
|
+
return {"email": user.email}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Depends on
|
|
49
|
+
|
|
50
|
+
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_auth`
|
|
51
|
+
- `fastapi-users[sqlalchemy]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT — see [LICENSE](https://github.com/antosubash/simple_module_python/blob/main/LICENSE).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/users",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Users module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "simple_module_users"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Email + password user management, admin invites, RBAC-ready — replaces Keycloak for simple_module apps"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
authors = [{ name = "Anto Subash", email = "antosubash@live.com" }]
|
|
10
|
+
keywords = ["simple-module", "users", "authentication", "fastapi-users", "admin"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Framework :: FastAPI",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"simple_module_core==0.0.1",
|
|
25
|
+
"simple_module_db==0.0.1",
|
|
26
|
+
"simple_module_hosting==0.0.1",
|
|
27
|
+
"simple_module_auth==0.0.1", # workspace module — contracts
|
|
28
|
+
# Pinned to a narrow range: `deps.py` relies on mutating CookieTransport
|
|
29
|
+
# fields after construction (see reconfigure_cookie_transport in backend.py).
|
|
30
|
+
# Bumping the major version requires re-checking those field names.
|
|
31
|
+
"fastapi-users[sqlalchemy]>=15,<16",
|
|
32
|
+
"aiosmtplib>=3.0",
|
|
33
|
+
"cachetools>=5.3",
|
|
34
|
+
"typer>=0.12",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.entry-points.simple_module]
|
|
38
|
+
users = "users.module:UsersModule"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
sm-users = "users.cli:app"
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://github.com/antosubash/simple_module_python"
|
|
45
|
+
Repository = "https://github.com/antosubash/simple_module_python"
|
|
46
|
+
Issues = "https://github.com/antosubash/simple_module_python/issues"
|
|
47
|
+
Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["hatchling"]
|
|
51
|
+
build-backend = "hatchling.build"
|
|
52
|
+
|
|
53
|
+
[tool.hatch.build.targets.wheel]
|
|
54
|
+
packages = ["users"]
|
|
55
|
+
|
|
56
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
57
|
+
"package.json" = "users/package.json"
|
|
58
|
+
|
|
59
|
+
[tool.uv.sources]
|
|
60
|
+
simple_module_core = { workspace = true }
|
|
61
|
+
simple_module_db = { workspace = true }
|
|
62
|
+
simple_module_hosting = { workspace = true }
|
|
63
|
+
simple_module_auth = { workspace = true }
|
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Helpers + fixtures for the users.middleware unit tests.
|
|
2
|
+
|
|
3
|
+
Registered as a pytest plugin via ``pytest_plugins = ["_middleware_support"]``
|
|
4
|
+
in conftest.py — that way the fixtures (``_mw_seed_roles``, ``mw_active_user``)
|
|
5
|
+
are auto-discovered by pytest without needing imports in the test files,
|
|
6
|
+
which avoids F811 warnings where fixture names appear as test-function
|
|
7
|
+
parameters.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
from base64 import b64encode
|
|
15
|
+
from types import SimpleNamespace
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from fastapi import FastAPI, Request
|
|
20
|
+
from itsdangerous import TimestampSigner
|
|
21
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
22
|
+
from starlette.responses import JSONResponse
|
|
23
|
+
from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
|
|
24
|
+
from users.middleware import AuthMiddleware
|
|
25
|
+
|
|
26
|
+
SECRET_KEY = "test-secret-key-for-session-middleware"
|
|
27
|
+
|
|
28
|
+
|
|
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
|
+
def _session_cookie(data: dict[str, Any]) -> dict[str, str]:
|
|
36
|
+
return {"session": _sign_session(data)}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _build_app(db_state, inner_handler=None):
|
|
40
|
+
"""Build a minimal ASGI app with AuthMiddleware + SessionMiddleware."""
|
|
41
|
+
|
|
42
|
+
async def _default_handler(request: Request):
|
|
43
|
+
user = getattr(request.state, "user", None)
|
|
44
|
+
return JSONResponse(
|
|
45
|
+
{
|
|
46
|
+
"path": request.url.path,
|
|
47
|
+
"user": (
|
|
48
|
+
{
|
|
49
|
+
"id": user.id,
|
|
50
|
+
"email": user.email,
|
|
51
|
+
"name": user.name,
|
|
52
|
+
"roles": user.roles,
|
|
53
|
+
"tenant_id": user.tenant_id,
|
|
54
|
+
}
|
|
55
|
+
if user is not None
|
|
56
|
+
else None
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
handler = inner_handler or _default_handler
|
|
62
|
+
|
|
63
|
+
app = FastAPI()
|
|
64
|
+
app.state.sm = SimpleNamespace(db=db_state)
|
|
65
|
+
|
|
66
|
+
@app.get("/{path:path}")
|
|
67
|
+
async def _catch_all(request: Request, path: str = ""):
|
|
68
|
+
return await handler(request)
|
|
69
|
+
|
|
70
|
+
# Middleware is applied in reverse order: SessionMiddleware outermost.
|
|
71
|
+
app.add_middleware(AuthMiddleware)
|
|
72
|
+
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
|
73
|
+
return app
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
async def _mw_seed_roles(db_session):
|
|
78
|
+
"""Insert the standard admin/user roles for middleware tests."""
|
|
79
|
+
from users.models import Role
|
|
80
|
+
|
|
81
|
+
db_session.add_all(
|
|
82
|
+
[
|
|
83
|
+
Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"),
|
|
84
|
+
Role(id=USER_ROLE_ID, name="user", description="Standard user"),
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
await db_session.commit()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.fixture
|
|
91
|
+
async def mw_active_user(db_session, _mw_seed_roles):
|
|
92
|
+
"""Active user with the 'admin' role — used by the middleware tests."""
|
|
93
|
+
from users.models import User, UserRole
|
|
94
|
+
|
|
95
|
+
user_id = uuid.uuid4()
|
|
96
|
+
user = User(
|
|
97
|
+
id=user_id,
|
|
98
|
+
email="middleware-test@example.com",
|
|
99
|
+
hashed_password="hashed",
|
|
100
|
+
is_active=True,
|
|
101
|
+
is_superuser=False,
|
|
102
|
+
is_verified=True,
|
|
103
|
+
full_name="Middleware Tester",
|
|
104
|
+
tenant_id="acme",
|
|
105
|
+
)
|
|
106
|
+
link = UserRole(user_id=user_id, role_id=ADMIN_ROLE_ID)
|
|
107
|
+
db_session.add_all([user, link])
|
|
108
|
+
await db_session.commit()
|
|
109
|
+
return user
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Shared fixtures for users module API tests.
|
|
2
|
+
|
|
3
|
+
The ``users_app`` fixture builds a full FastAPI app via ``create_app`` but
|
|
4
|
+
with an in-memory SQLite database, seeded roles, and test-friendly settings
|
|
5
|
+
(ConsoleMailer, signup disabled by default, short secrets).
|
|
6
|
+
|
|
7
|
+
The ``anon_client`` gives a plain httpx client.
|
|
8
|
+
The ``admin_client`` gives a client with a signed local-user session cookie
|
|
9
|
+
carrying a real admin User row (written into the in-memory DB).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import AsyncGenerator
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import pytest
|
|
19
|
+
from simple_module_hosting.settings import Settings
|
|
20
|
+
from simple_module_testing import forge_session_cookie
|
|
21
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
22
|
+
from users.constants import ADMIN_ROLE_ID, USER_ROLE_ID
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def _isolate_users_env(monkeypatch):
|
|
27
|
+
"""Scrub stale ``SM_USERS_*`` env vars from the shell/.env.
|
|
28
|
+
|
|
29
|
+
After the env→DB migration ``UsersSettings()`` no longer reads these
|
|
30
|
+
values, so this is belt-and-braces: it keeps old shell exports from
|
|
31
|
+
muddying any ``SM_ENVIRONMENT`` checks or from being misread during
|
|
32
|
+
developer spelunking.
|
|
33
|
+
"""
|
|
34
|
+
for key in list(os.environ):
|
|
35
|
+
if key.startswith("SM_USERS_"):
|
|
36
|
+
monkeypatch.delenv(key, raising=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Settings helpers
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _users_overrides(allow_signup: bool = False) -> dict[str, tuple[str, str]]:
|
|
45
|
+
"""Values seeded into the ``SettingsStore`` before the app lifespan runs."""
|
|
46
|
+
return {
|
|
47
|
+
"allow_signup": (str(allow_signup).lower(), "bool"),
|
|
48
|
+
"mailer": ("console", "string"),
|
|
49
|
+
"base_url": ("http://testserver", "string"),
|
|
50
|
+
"cookie_secure": ("false", "bool"),
|
|
51
|
+
# 32+ bytes to clear pyjwt's InsecureKeyLengthWarning for HMAC-SHA256.
|
|
52
|
+
"reset_password_token_secret": ("test-reset-secret-32-bytes-xxxxx", "string"),
|
|
53
|
+
"verification_token_secret": ("test-verify-secret-32-bytes-xxxxx", "string"),
|
|
54
|
+
"login_rate_limit_failures": ("5", "int"),
|
|
55
|
+
"login_rate_limit_window_seconds": ("300", "int"),
|
|
56
|
+
"login_rate_limit_cooldown_seconds": ("900", "int"),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Full-app fixture
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _setup_app_db(application) -> None:
|
|
66
|
+
"""Create all tables and stamp alembic version so migration check passes."""
|
|
67
|
+
|
|
68
|
+
from simple_module_db.base import all_module_bases
|
|
69
|
+
from simple_module_hosting.migrations import resolve_head_revision
|
|
70
|
+
from sqlalchemy import text
|
|
71
|
+
|
|
72
|
+
head = resolve_head_revision()
|
|
73
|
+
|
|
74
|
+
async with application.state.sm.db.engine.begin() as conn:
|
|
75
|
+
|
|
76
|
+
def _create(sync_conn):
|
|
77
|
+
for base in all_module_bases:
|
|
78
|
+
base.metadata.create_all(sync_conn)
|
|
79
|
+
|
|
80
|
+
await conn.run_sync(_create)
|
|
81
|
+
|
|
82
|
+
if head:
|
|
83
|
+
await conn.execute(
|
|
84
|
+
text(
|
|
85
|
+
"CREATE TABLE IF NOT EXISTS alembic_version "
|
|
86
|
+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
await conn.execute(text("DELETE FROM alembic_version"))
|
|
90
|
+
await conn.execute(
|
|
91
|
+
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
|
|
92
|
+
{"v": head},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _seed_roles(application) -> None:
|
|
97
|
+
"""Insert admin/user Role rows with deterministic UUIDs if missing."""
|
|
98
|
+
from sqlalchemy import select
|
|
99
|
+
from users.models import Role
|
|
100
|
+
|
|
101
|
+
async with application.state.sm.db.session_factory() as session:
|
|
102
|
+
existing = set((await session.execute(select(Role.name))).scalars().all())
|
|
103
|
+
if "admin" not in existing:
|
|
104
|
+
session.add(Role(id=ADMIN_ROLE_ID, name="admin", description="Administrator"))
|
|
105
|
+
if "user" not in existing:
|
|
106
|
+
session.add(Role(id=USER_ROLE_ID, name="user", description="Standard user"))
|
|
107
|
+
await session.commit()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def _seed_users_settings(application, *, allow_signup: bool) -> None:
|
|
111
|
+
"""Write the test UsersSettings overrides to the DB before hydrate runs.
|
|
112
|
+
|
|
113
|
+
Hydrate fires inside the lifespan ``__aenter__``, so this needs to land
|
|
114
|
+
between table creation and lifespan entry.
|
|
115
|
+
"""
|
|
116
|
+
from settings.service import SettingService
|
|
117
|
+
from settings.store import SettingsStore
|
|
118
|
+
|
|
119
|
+
async with application.state.sm.db.session_factory() as session:
|
|
120
|
+
store = SettingsStore(SettingService(session))
|
|
121
|
+
for field, (raw, vtype) in _users_overrides(allow_signup=allow_signup).items():
|
|
122
|
+
await store.set_override("users", field, raw, vtype)
|
|
123
|
+
await session.commit()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def _build_users_app(monkeypatch, *, allow_signup: bool):
|
|
127
|
+
"""Build a test FastAPI app with DB created, settings seeded, lifespan started."""
|
|
128
|
+
from simple_module_hosting.app_builder import create_app
|
|
129
|
+
|
|
130
|
+
settings = Settings(
|
|
131
|
+
database_url="sqlite+aiosqlite:///:memory:",
|
|
132
|
+
environment="testing",
|
|
133
|
+
secret_key="test-secret-key",
|
|
134
|
+
multi_tenant=False,
|
|
135
|
+
)
|
|
136
|
+
application = create_app(settings)
|
|
137
|
+
await _setup_app_db(application)
|
|
138
|
+
await _seed_users_settings(application, allow_signup=allow_signup)
|
|
139
|
+
|
|
140
|
+
ctx = application.router.lifespan_context(application)
|
|
141
|
+
await ctx.__aenter__()
|
|
142
|
+
await _seed_roles(application)
|
|
143
|
+
return application, ctx
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.fixture
|
|
147
|
+
async def users_app(monkeypatch):
|
|
148
|
+
"""Full FastAPI app with in-memory DB, seeded roles, users module active."""
|
|
149
|
+
application, ctx = await _build_users_app(monkeypatch, allow_signup=False)
|
|
150
|
+
yield application
|
|
151
|
+
await ctx.__aexit__(None, None, None)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.fixture
|
|
155
|
+
async def users_app_signup(monkeypatch):
|
|
156
|
+
"""Like users_app but with allow_signup=True."""
|
|
157
|
+
application, ctx = await _build_users_app(monkeypatch, allow_signup=True)
|
|
158
|
+
yield application
|
|
159
|
+
await ctx.__aexit__(None, None, None)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Client fixtures
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.fixture
|
|
168
|
+
async def anon_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
169
|
+
"""Unauthenticated client against users_app."""
|
|
170
|
+
transport = httpx.ASGITransport(app=users_app)
|
|
171
|
+
async with httpx.AsyncClient(
|
|
172
|
+
transport=transport,
|
|
173
|
+
base_url="http://testserver",
|
|
174
|
+
) as c:
|
|
175
|
+
yield c
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.fixture
|
|
179
|
+
async def anon_client_signup(users_app_signup) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
180
|
+
"""Unauthenticated client against users_app_signup (signup enabled)."""
|
|
181
|
+
transport = httpx.ASGITransport(app=users_app_signup)
|
|
182
|
+
async with httpx.AsyncClient(
|
|
183
|
+
transport=transport,
|
|
184
|
+
base_url="http://testserver",
|
|
185
|
+
) as c:
|
|
186
|
+
yield c
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def _make_admin_user(app):
|
|
190
|
+
"""Seed an admin User + Role into app's DB and return the User row."""
|
|
191
|
+
from users.bootstrap import create_admin
|
|
192
|
+
from users.models import User
|
|
193
|
+
|
|
194
|
+
async with app.state.sm.db.session_factory() as session:
|
|
195
|
+
result = await create_admin(
|
|
196
|
+
session,
|
|
197
|
+
email="admin@example.com",
|
|
198
|
+
password="AdminPass1!",
|
|
199
|
+
full_name="Test Admin",
|
|
200
|
+
)
|
|
201
|
+
user: User = result.user
|
|
202
|
+
return user
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.fixture
|
|
206
|
+
async def admin_client(users_app) -> AsyncGenerator[httpx.AsyncClient, None]:
|
|
207
|
+
"""Client with a signed local-user session cookie (admin role)."""
|
|
208
|
+
user = await _make_admin_user(users_app)
|
|
209
|
+
cookie = forge_session_cookie(
|
|
210
|
+
str(users_app.state.sm.settings.secret_key),
|
|
211
|
+
{"user_id": str(user.id)},
|
|
212
|
+
)
|
|
213
|
+
transport = httpx.ASGITransport(app=users_app)
|
|
214
|
+
async with httpx.AsyncClient(
|
|
215
|
+
transport=transport,
|
|
216
|
+
base_url="http://testserver",
|
|
217
|
+
cookies={"session": cookie},
|
|
218
|
+
) as c:
|
|
219
|
+
yield c
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# DB session fixture scoped to users_app
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.fixture
|
|
228
|
+
async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]:
|
|
229
|
+
"""Session against the users_app in-memory DB."""
|
|
230
|
+
async with users_app.state.sm.db.session_factory() as session:
|
|
231
|
+
yield session
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Fixtures consumed by the users.middleware unit tests live in
|
|
235
|
+
# _middleware_support.py (imported as a pytest plugin below).
|
|
236
|
+
pytest_plugins = ["_middleware_support"]
|