Skip to content

Commit c74edff

Browse files
authored
fix(users,host): align .env loading + dev-quick-login resolution (#158, #159) (#160)
* fix(users,host): align .env loading + dev-quick-login resolution (#158, #159) - host/main.py + scaffold template: merge .env into os.environ before importing Settings, so framework code reading os.environ directly sees the same values pydantic-settings does. Scaffold template also chdirs to the workspace root so cwd-relative paths in .env resolve under `cd host && uvicorn main:app`. - users.bootstrap: extract resolve_bootstrap_credentials(settings) — the three-tier (settings → os.environ → .env) resolver shared between the boot-time admin seeder and the login-page dev_accounts builder. - auth_local.views.login_page: use the shared resolver so the "Dev quick-login" buttons appear iff an admin would actually be seeded (previously checked only settings + os.environ, missing the .env path). - Tests: unit coverage for the resolver's three tiers + a login-page regression that exercises the .env fallback end-to-end. Scaffold regression test asserts the dotenv load precedes the Settings import. * chore: add verification screenshot
1 parent c42d615 commit c74edff

10 files changed

Lines changed: 215 additions & 53 deletions

File tree

‎.verify/screenshot.png‎

92.1 KB
Loading

‎framework/cli/simple_module_cli/templates/host/main.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44
via entry_points; add them to this host's pyproject.toml to install them.
55
"""
66

7+
import os
8+
from pathlib import Path
9+
10+
from simple_module_core.dotenv import load_dotenv_into_environ
11+
12+
# Resolve the workspace root from this file's location so the web process
13+
# behaves the same regardless of where uvicorn was launched (the scaffolded
14+
# Makefile uses ``cd host && uvicorn main:app``, but ``uv run --project host``
15+
# or a wheel deployment may run from elsewhere). chdir up front so cwd-relative
16+
# paths in ``.env`` (e.g. ``sqlite+aiosqlite:///./host/app.db``) resolve
17+
# consistently; load ``.env`` into ``os.environ`` so framework code reading
18+
# ``os.environ.get("SM_…")`` directly sees the same values pydantic does.
19+
_REPO_ROOT = Path(__file__).resolve().parent.parent
20+
os.chdir(_REPO_ROOT)
21+
load_dotenv_into_environ(_REPO_ROOT / ".env")
22+
723
from simple_module_hosting import Settings, create_app
824
from simple_module_hosting.logging import setup_logging
925

‎framework/cli/tests/test_scaffolding_host.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,28 @@ async def test_env_py_uses_shared_helper(self, tmp_path):
155155
assert "make_include_object" in env_py
156156
assert "for mod in modules:" not in env_py
157157

158+
async def test_main_py_loads_dotenv_before_settings(self, tmp_path):
159+
"""Regression for #158: scaffolded main.py must populate ``os.environ``
160+
from ``.env`` *before* ``Settings`` is imported, otherwise framework
161+
code reading ``os.environ`` directly (e.g. users.bootstrap's dotenv
162+
fallback under uvicorn launched from ``host/``) silently misses values
163+
that pydantic-settings would have picked up.
164+
"""
165+
from simple_module_cli.scaffolding import create_host
166+
167+
dest = tmp_path / "demo"
168+
create_host(dest, name="demo", modules=[])
169+
main_py = (dest / "main.py").read_text(encoding="utf-8")
170+
171+
assert "load_dotenv_into_environ" in main_py
172+
assert "os.chdir" in main_py
173+
# The load_dotenv call must precede the first ``Settings`` import so
174+
# ``BootstrapSettings``' ``env_file=".env"`` lookup and any direct
175+
# ``os.environ.get(...)`` reads see the same view of the environment.
176+
dotenv_idx = main_py.index("load_dotenv_into_environ(")
177+
settings_import_idx = main_py.index("from simple_module_hosting import")
178+
assert dotenv_idx < settings_import_idx
179+
158180
async def test_cli_create_host_runs_end_to_end(self, tmp_path):
159181
"""The Click `smpy create-host` command produces a working scaffold."""
160182
from simple_module_cli.cli import app

‎host/main.py‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
import os
44
from pathlib import Path
55

6-
# Publish the project root so the hosting layer can find host/static and
7-
# host/templates whether we're running from the uv workspace or a wheel.
8-
os.environ.setdefault(
9-
"SM_PROJECT_ROOT",
10-
str(Path(__file__).resolve().parent.parent),
11-
)
6+
from simple_module_core.dotenv import load_dotenv_into_environ
7+
8+
# Publish ``SM_PROJECT_ROOT`` and merge ``.env`` into ``os.environ`` *before*
9+
# any settings import so framework code reading ``os.environ.get("SM_…")``
10+
# directly (not via pydantic-settings) sees the same values pydantic does —
11+
# keeping precedence (real env wins) consistent across the web process, the
12+
# worker, and one-shot scripts.
13+
os.environ.setdefault("SM_PROJECT_ROOT", str(Path(__file__).resolve().parent.parent))
14+
load_dotenv_into_environ(Path(os.environ["SM_PROJECT_ROOT"]) / ".env")
1215

13-
from simple_module_hosting import Settings, create_app
14-
from simple_module_hosting.logging import setup_logging
16+
from simple_module_hosting import Settings, create_app # noqa: E402
17+
from simple_module_hosting.logging import setup_logging # noqa: E402
1518

16-
from host.routes import router as host_router
17-
from host.routes_i18n import router as i18n_router
19+
from host.routes import router as host_router # noqa: E402
20+
from host.routes_i18n import router as i18n_router # noqa: E402
1821

1922
settings = Settings()
2023

‎modules/users/tests/conftest.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,16 @@ def _isolate_users_env(monkeypatch):
2929
After the env→DB migration ``UsersSettings()`` no longer reads these
3030
values, so this is belt-and-braces: it keeps old shell exports from
3131
muddying any ``SM_ENVIRONMENT`` checks or from being misread during
32-
developer spelunking.
32+
developer spelunking. Also stubs out
33+
``users.bootstrap._read_dotenv_bootstrap_vars`` so the developer's local
34+
``.env`` can't seed bootstrap creds into tests that exercise the resolver.
3335
"""
36+
from users import bootstrap as bootstrap_module
37+
3438
for key in list(os.environ):
3539
if key.startswith("SM_USERS_"):
3640
monkeypatch.delenv(key, raising=False)
41+
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
3742

3843

3944
# ---------------------------------------------------------------------------

‎modules/users/tests/test_bootstrap.py‎

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
from fastapi_users.password import PasswordHelper
99
from sqlalchemy import select
1010
from sqlalchemy.ext.asyncio import AsyncSession
11-
from users import bootstrap as bootstrap_module
1211
from users.bootstrap import (
13-
BOOTSTRAP_ENV_KEYS,
1412
CreateAdminResult,
1513
bootstrap_admin_from_env,
1614
create_admin,
@@ -19,15 +17,6 @@
1917
from users.models import Role, User, UserRole
2018
from users.settings import UsersSettings
2119

22-
23-
@pytest.fixture(autouse=True)
24-
def _isolate_from_repo_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
25-
"""Prevent the developer's ``.env`` from leaking bootstrap vars into tests."""
26-
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
27-
for key in BOOTSTRAP_ENV_KEYS.values():
28-
monkeypatch.delenv(key, raising=False)
29-
30-
3120
# ---------------------------------------------------------------------------
3221
# Helpers
3322
# ---------------------------------------------------------------------------
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Tests for ``resolve_bootstrap_credentials`` — the three-tier resolver shared
2+
between the boot-time admin seeder and the login-page dev-quick-fill UI.
3+
4+
Regression coverage for issue #159: previously the login page only consulted
5+
``UsersSettings`` + ``os.environ``, so an admin seeded via the ``.env``
6+
fallback was created but the dev-quick-login button never showed.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
from users import bootstrap as bootstrap_module
13+
from users.bootstrap import BOOTSTRAP_ENV_KEYS, resolve_bootstrap_credentials
14+
from users.settings import UsersSettings
15+
16+
17+
def _bare_users_settings(**overrides: str) -> UsersSettings:
18+
"""UsersSettings with all required-field defaults filled in."""
19+
defaults = {
20+
"reset_password_token_secret": "test-secret",
21+
"verification_token_secret": "test-secret",
22+
}
23+
defaults.update(overrides)
24+
return UsersSettings(**defaults)
25+
26+
27+
def test_prefers_settings_over_environ(monkeypatch: pytest.MonkeyPatch) -> None:
28+
"""A non-empty UsersSettings field beats the same key on ``os.environ``."""
29+
monkeypatch.setenv("SM_USERS_BOOTSTRAP_EMAIL", "env@example.com")
30+
settings = _bare_users_settings(bootstrap_email="settings@example.com")
31+
32+
resolved = resolve_bootstrap_credentials(settings)
33+
34+
assert resolved["bootstrap_email"] == "settings@example.com"
35+
36+
37+
def test_falls_back_to_environ(monkeypatch: pytest.MonkeyPatch) -> None:
38+
"""An empty UsersSettings field falls through to ``os.environ``."""
39+
monkeypatch.setenv("SM_USERS_BOOTSTRAP_EMAIL", "env@example.com")
40+
settings = _bare_users_settings()
41+
42+
resolved = resolve_bootstrap_credentials(settings)
43+
44+
assert resolved["bootstrap_email"] == "env@example.com"
45+
46+
47+
def test_falls_back_to_dotenv(monkeypatch: pytest.MonkeyPatch) -> None:
48+
"""When settings + ``os.environ`` are empty, ``.env`` is consulted last."""
49+
monkeypatch.setattr(
50+
bootstrap_module,
51+
"_read_dotenv_bootstrap_vars",
52+
lambda: {
53+
"SM_USERS_BOOTSTRAP_EMAIL": "dotenv@example.com",
54+
"SM_USERS_BOOTSTRAP_PASSWORD": "DotenvPass1!",
55+
},
56+
)
57+
settings = _bare_users_settings()
58+
59+
resolved = resolve_bootstrap_credentials(settings)
60+
61+
assert resolved["bootstrap_email"] == "dotenv@example.com"
62+
assert resolved["bootstrap_password"] == "DotenvPass1!"
63+
64+
65+
def test_returns_empty_strings_when_nothing_set() -> None:
66+
"""All four keys are present in the result even when unresolved."""
67+
settings = _bare_users_settings()
68+
69+
resolved = resolve_bootstrap_credentials(settings)
70+
71+
assert set(resolved) == set(BOOTSTRAP_ENV_KEYS)
72+
assert all(v == "" for v in resolved.values())

‎modules/users/tests/test_views.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,51 @@ async def test_login_response_contains_inertia_component(self, anon_client):
6767
data = resp.json()
6868
assert data["component"] == "Users/Login"
6969

70+
@pytest.mark.anyio
71+
async def test_dev_accounts_empty_outside_development(self, anon_client):
72+
"""``dev_accounts`` MUST NOT surface bootstrap creds in non-dev envs."""
73+
resp = await anon_client.get(
74+
"/users/login",
75+
headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
76+
)
77+
assert resp.json()["props"]["dev_accounts"] == []
78+
79+
@pytest.mark.anyio
80+
async def test_dev_accounts_resolved_via_dotenv_fallback(
81+
self, anon_client, users_app, monkeypatch
82+
):
83+
"""Regression for #159: login_page surfaces creds seeded only via .env.
84+
85+
With ``environment=development`` and bootstrap vars present only in
86+
``.env`` (not on settings, not on ``os.environ``), the buttons must
87+
still appear — otherwise the admin gets seeded by the boot-time hook
88+
but the dev-quick-login UX silently breaks.
89+
"""
90+
from users import bootstrap as bootstrap_module
91+
92+
monkeypatch.setattr(users_app.state.sm.settings, "environment", "development")
93+
monkeypatch.setattr(
94+
bootstrap_module,
95+
"_read_dotenv_bootstrap_vars",
96+
lambda: {
97+
"SM_USERS_BOOTSTRAP_EMAIL": "admin@example.com",
98+
"SM_USERS_BOOTSTRAP_PASSWORD": "AdminPass1!",
99+
"SM_USERS_BOOTSTRAP_USER_EMAIL": "user@example.com",
100+
"SM_USERS_BOOTSTRAP_USER_PASSWORD": "UserPass1!",
101+
},
102+
)
103+
104+
resp = await anon_client.get(
105+
"/users/login",
106+
headers={"X-Inertia": "true", "X-Inertia-Version": "1.0"},
107+
)
108+
109+
dev_accounts = resp.json()["props"]["dev_accounts"]
110+
assert dev_accounts == [
111+
{"label": "Admin", "email": "admin@example.com", "password": "AdminPass1!"},
112+
{"label": "User", "email": "user@example.com", "password": "UserPass1!"},
113+
]
114+
70115

71116
class TestRegisterPage:
72117
@pytest.mark.anyio

‎modules/users/users/auth_local/views.py‎

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,22 @@
22

33
from __future__ import annotations
44

5-
import os
6-
75
from fastapi import APIRouter, HTTPException, Request
86
from inertia import InertiaResponse
97
from simple_module_hosting.inertia_deps import InertiaDep
108
from starlette.responses import RedirectResponse
119

10+
from users.bootstrap import resolve_bootstrap_credentials
11+
1212
router = APIRouter()
1313

14+
# (label, email-key, password-key) for the dev-quick-login buttons, in the
15+
# order they should appear on the page.
16+
_DEV_ACCOUNT_SPECS: tuple[tuple[str, str, str], ...] = (
17+
("Admin", "bootstrap_email", "bootstrap_password"),
18+
("User", "bootstrap_user_email", "bootstrap_user_password"),
19+
)
20+
1421
_PAGE_LOGIN = "Users/Login"
1522
_PAGE_REGISTER = "Users/Register"
1623
_PAGE_FORGOT_PASSWORD = "Users/ForgotPassword"
@@ -26,27 +33,21 @@ async def login_page(request: Request, inertia: InertiaDep) -> InertiaResponse:
2633
users_settings = users_state.settings
2734
# In development only, surface the bootstrap credentials as click-to-fill
2835
# buttons so manual QA doesn't need to retype them. Never exposed in
29-
# production, regardless of whether the vars are set.
36+
# production, regardless of whether the vars are set. Uses the same
37+
# resolution as the boot-time seeder so the buttons appear iff a seed
38+
# admin would actually be created.
3039
dev_accounts: list[dict[str, str]] = []
3140
if request.app.state.sm.settings.is_development:
32-
admin_email = users_settings.bootstrap_email or os.environ.get(
33-
"SM_USERS_BOOTSTRAP_EMAIL", ""
34-
)
35-
admin_password = users_settings.bootstrap_password or os.environ.get(
36-
"SM_USERS_BOOTSTRAP_PASSWORD", ""
37-
)
38-
if admin_email and admin_password:
39-
dev_accounts.append(
40-
{"label": "Admin", "email": admin_email, "password": admin_password}
41-
)
42-
user_email = users_settings.bootstrap_user_email or os.environ.get(
43-
"SM_USERS_BOOTSTRAP_USER_EMAIL", ""
44-
)
45-
user_password = users_settings.bootstrap_user_password or os.environ.get(
46-
"SM_USERS_BOOTSTRAP_USER_PASSWORD", ""
47-
)
48-
if user_email and user_password:
49-
dev_accounts.append({"label": "User", "email": user_email, "password": user_password})
41+
resolved = resolve_bootstrap_credentials(users_settings)
42+
for label, email_key, password_key in _DEV_ACCOUNT_SPECS:
43+
if resolved[email_key] and resolved[password_key]:
44+
dev_accounts.append(
45+
{
46+
"label": label,
47+
"email": resolved[email_key],
48+
"password": resolved[password_key],
49+
}
50+
)
5051
return await inertia.render(
5152
_PAGE_LOGIN,
5253
{

‎modules/users/users/bootstrap.py‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -200,25 +200,34 @@ def _read_dotenv_bootstrap_vars() -> dict[str, str]:
200200
return {k: v for k, v in parse_dotenv().items() if k in wanted}
201201

202202

203+
def resolve_bootstrap_credentials(settings: UsersSettings) -> dict[str, str]:
204+
"""Resolve the four bootstrap fields with the same precedence everywhere.
205+
206+
Order: ``UsersSettings`` (test overrides) → ``os.environ`` (docker/systemd)
207+
→ ``.env`` file (documented dev path). Used by both the boot-time admin
208+
seeder and the dev-only login-page quick-fill so the two stay in lockstep.
209+
"""
210+
dotenv_vars = _read_dotenv_bootstrap_vars()
211+
return {
212+
attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "")
213+
for attr, env_key in BOOTSTRAP_ENV_KEYS.items()
214+
}
215+
216+
203217
async def bootstrap_admin_from_env(app: FastAPI) -> None:
204218
"""On-startup hook: create admin from env vars iff users table is empty.
205219
206-
Resolves each of the four bootstrap fields in order: ``UsersSettings``
207-
(tests), then ``os.environ`` (docker/systemd), then ``.env`` (documented
208-
dev path). If the admin email or password is still blank, returns
209-
silently — same if the users table already has rows (so restarts don't
210-
try to re-bootstrap).
220+
Resolves each of the four bootstrap fields via
221+
:func:`resolve_bootstrap_credentials`. If the admin email or password is
222+
still blank, returns silently — same if the users table already has rows
223+
(so restarts don't try to re-bootstrap).
211224
212225
Optionally also creates a non-admin user from
213226
``SM_USERS_BOOTSTRAP_USER_EMAIL`` + ``SM_USERS_BOOTSTRAP_USER_PASSWORD`` —
214227
useful in dev for testing non-admin flows alongside the admin account.
215228
"""
216229
settings: UsersSettings = app.state.users.settings
217-
dotenv_vars = _read_dotenv_bootstrap_vars()
218-
resolved = {
219-
attr: getattr(settings, attr) or os.environ.get(env_key) or dotenv_vars.get(env_key, "")
220-
for attr, env_key in BOOTSTRAP_ENV_KEYS.items()
221-
}
230+
resolved = resolve_bootstrap_credentials(settings)
222231
email = resolved["bootstrap_email"]
223232
password = resolved["bootstrap_password"]
224233
user_email = resolved["bootstrap_user_email"]

0 commit comments

Comments
 (0)