Skip to content

Commit bf15d68

Browse files
authored
fix: restore main's gates — file-size cap, and a fixture that ignored its own contract (#315)
Two failures on main, neither of which any single PR's CI could have caught. **`provider.py` at 305 lines.** #309 and #314 were each green against the main they branched from; squash-merging both put the file over the 300-line cap. Split on the seam already there: `_resolve_bearer` moves to `token_strategy`, which is where `ExpiringDatabaseStrategy` lives. Those two are the only readers of `users_access_token` and they have to apply the same deadline and `session_version` rules — keeping them in one file is what stops them drifting. `provider.py` keeps a one-line delegate so the method stays on the provider's surface. 305 → 265, and `token_strategy` → 164. **`setup_pending_app` boots *with* an administrator.** `UsersModule.on_startup` seeds one from `SM_USERS_BOOTSTRAP_*`, read from the environment *and* from a `.env` on disk. A developer who followed `.env.example` has those set, so the fixture whose entire contract is "an app with no administrator" hands back an app that has two — the setup gate releases, the wizard routes 404, and eleven tests in `framework/hosting/tests` fail. CI has no `.env`, so it never saw this: the failure was local-only, which is the worst shape for a fixture to be wrong in. It also looked like test-ordering noise, because whether it reproduced depended on what else had booted an app first. The fixture now scrubs the bootstrap vars and stubs the dotenv reader for the app it builds, the same way `modules/users/tests/conftest.py` does for its own. Adding that pushed `fixtures.py` over the cap too, so the schema machinery (model imports, alembic heads, table creation) moves to `_schema.py` — that module declares fixtures, this one is what they stand on.
1 parent 82bcbd8 commit bf15d68

4 files changed

Lines changed: 176 additions & 115 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Schema construction for the plugin's app fixtures.
2+
3+
Split out of :mod:`simple_module_test.fixtures` so that module declares
4+
fixtures and this one holds the machinery underneath them: importing every
5+
installed module's models, resolving the migration heads, creating the tables
6+
and stamping ``alembic_version`` so the boot-time migration check passes.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import contextlib
12+
import importlib
13+
from functools import lru_cache
14+
15+
from simple_module_core.discovery import discover_modules
16+
from simple_module_db.base import all_module_bases
17+
18+
19+
@lru_cache(maxsize=1)
20+
def _ensure_models_imported() -> list:
21+
"""Import all module models so all_module_bases is populated (cached)."""
22+
for mod in discover_modules():
23+
pkg = type(mod).__module__.split(".")[0]
24+
with contextlib.suppress(ModuleNotFoundError):
25+
importlib.import_module(f"{pkg}.models")
26+
return list(all_module_bases)
27+
28+
29+
@lru_cache(maxsize=1)
30+
def _alembic_heads() -> tuple[str, ...]:
31+
"""Cached head revisions — cannot change within a pytest run.
32+
33+
Plural: each module's first migration sets its own ``branch_labels``, so
34+
the history has one head per module and a real upgraded database carries
35+
an ``alembic_version`` row for each. Stamping only one leaves the rest
36+
looking un-applied, which now reads as a behind-head schema and puts every
37+
test app behind the setup gate.
38+
"""
39+
from simple_module_hosting.migrations import resolve_head_revisions
40+
41+
return resolve_head_revisions()
42+
43+
44+
async def _create_all_tables(engine) -> None:
45+
"""Create all module tables in a single connection.
46+
47+
Also stamps the alembic_version table at heads so the app's startup
48+
migration check (``check_migrations``) treats the test DB as current.
49+
Without the stamp the check would raise because ``create_all`` doesn't
50+
touch alembic_version.
51+
"""
52+
from sqlalchemy import text
53+
54+
bases = _ensure_models_imported()
55+
heads = _alembic_heads()
56+
57+
async with engine.begin() as conn:
58+
59+
def _sync_create_all(sync_conn):
60+
for base in bases:
61+
base.metadata.create_all(sync_conn)
62+
63+
await conn.run_sync(_sync_create_all)
64+
65+
if heads:
66+
await conn.execute(
67+
text(
68+
"CREATE TABLE IF NOT EXISTS alembic_version "
69+
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
70+
)
71+
)
72+
await conn.execute(text("DELETE FROM alembic_version"))
73+
for head in heads:
74+
await conn.execute(
75+
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
76+
{"v": head},
77+
)

‎framework/testing/simple_module_test/fixtures.py‎

Lines changed: 29 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,17 @@
2020

2121
from __future__ import annotations
2222

23-
import contextlib
24-
import importlib
2523
import os
2624
from collections.abc import AsyncGenerator, Iterator
27-
from functools import lru_cache
2825

2926
import httpx
3027
import pytest
31-
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER, discover_modules
32-
from simple_module_db.base import all_module_bases
28+
from simple_module_core.discovery import DEFAULT_AUTH_PROVIDER
3329
from simple_module_db.session import DatabaseState, init_db
3430
from simple_module_hosting.settings import Settings
3531
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
3632

33+
from simple_module_test._schema import _create_all_tables
3734
from simple_module_test.session_cookie import forge_session_cookie
3835

3936
_AUTH_PROVIDER_ENV = "SM_AUTH_PROVIDER"
@@ -107,67 +104,6 @@ async def engine(db_state: DatabaseState) -> AsyncEngine:
107104
return db_state.engine
108105

109106

110-
@lru_cache(maxsize=1)
111-
def _ensure_models_imported() -> list:
112-
"""Import all module models so all_module_bases is populated (cached)."""
113-
for mod in discover_modules():
114-
pkg = type(mod).__module__.split(".")[0]
115-
with contextlib.suppress(ModuleNotFoundError):
116-
importlib.import_module(f"{pkg}.models")
117-
return list(all_module_bases)
118-
119-
120-
@lru_cache(maxsize=1)
121-
def _alembic_heads() -> tuple[str, ...]:
122-
"""Cached head revisions — cannot change within a pytest run.
123-
124-
Plural: each module's first migration sets its own ``branch_labels``, so
125-
the history has one head per module and a real upgraded database carries
126-
an ``alembic_version`` row for each. Stamping only one leaves the rest
127-
looking un-applied, which now reads as a behind-head schema and puts every
128-
test app behind the setup gate.
129-
"""
130-
from simple_module_hosting.migrations import resolve_head_revisions
131-
132-
return resolve_head_revisions()
133-
134-
135-
async def _create_all_tables(engine) -> None:
136-
"""Create all module tables in a single connection.
137-
138-
Also stamps the alembic_version table at heads so the app's startup
139-
migration check (``check_migrations``) treats the test DB as current.
140-
Without the stamp the check would raise because ``create_all`` doesn't
141-
touch alembic_version.
142-
"""
143-
from sqlalchemy import text
144-
145-
bases = _ensure_models_imported()
146-
heads = _alembic_heads()
147-
148-
async with engine.begin() as conn:
149-
150-
def _sync_create_all(sync_conn):
151-
for base in bases:
152-
base.metadata.create_all(sync_conn)
153-
154-
await conn.run_sync(_sync_create_all)
155-
156-
if heads:
157-
await conn.execute(
158-
text(
159-
"CREATE TABLE IF NOT EXISTS alembic_version "
160-
"(version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
161-
)
162-
)
163-
await conn.execute(text("DELETE FROM alembic_version"))
164-
for head in heads:
165-
await conn.execute(
166-
text("INSERT INTO alembic_version (version_num) VALUES (:v)"),
167-
{"v": head},
168-
)
169-
170-
171107
@pytest.fixture
172108
async def db_session(db_state: DatabaseState) -> AsyncGenerator[AsyncSession, None]:
173109
"""Yield an async session backed by in-memory SQLite."""
@@ -236,12 +172,38 @@ async def app(settings: Settings):
236172
yield application
237173

238174

175+
def _disable_env_bootstrap(monkeypatch: pytest.MonkeyPatch) -> None:
176+
"""Stop ``bootstrap_admin_from_env`` seeding the app being built.
177+
178+
``UsersModule.on_startup`` creates an administrator from
179+
``SM_USERS_BOOTSTRAP_*``, read from the environment *and* from a ``.env`` on
180+
disk. A developer who followed ``.env.example`` has those set, so every app
181+
this plugin builds gets an admin whether the fixture asked for one or not.
182+
For ``setup_pending_app`` that is not a nuisance but the opposite of its
183+
contract: the setup gate releases the moment an administrator exists, so the
184+
wizard routes 404 and every test using it fails.
185+
186+
They fail *locally only* — CI has no ``.env`` — which is the worst shape for
187+
a fixture to be wrong in, and left the failure looking like test-ordering
188+
noise rather than a fixture that does not do what it says.
189+
"""
190+
for key in list(os.environ):
191+
if key.startswith("SM_USERS_BOOTSTRAP_"):
192+
monkeypatch.delenv(key, raising=False)
193+
try:
194+
from users import bootstrap as bootstrap_module
195+
except ImportError:
196+
return # No local-accounts provider installed; nothing bootstraps.
197+
monkeypatch.setattr(bootstrap_module, "_read_dotenv_bootstrap_vars", dict)
198+
199+
239200
@pytest.fixture
240-
async def setup_pending_app(settings: Settings):
201+
async def setup_pending_app(settings: Settings, monkeypatch: pytest.MonkeyPatch):
241202
"""An app with no administrator, so the first-run setup gate is engaged.
242203
243204
The counterpart to ``app``: use this to assert on setup-mode behaviour.
244205
"""
206+
_disable_env_bootstrap(monkeypatch)
245207
async for application in _build_app(settings, seed_admin=False):
246208
yield application
247209

‎modules/users/users/provider.py‎

Lines changed: 8 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import logging
1111
import uuid as uuid_mod
1212
from collections.abc import Mapping
13-
from datetime import UTC, datetime, timedelta
1413
from typing import Any
1514

1615
from auth.contracts.schemas import UserContext
@@ -221,55 +220,16 @@ def is_bearer_request(self, request: Request | None) -> bool:
221220
return request.headers.get("authorization", "").startswith("Bearer ")
222221

223222
async def _resolve_bearer(self, scope, token: str) -> UserContext | None:
224-
"""Look up an access token in users_access_token and return the user."""
225-
try:
226-
from sqlalchemy import select
227-
from sqlalchemy.orm import noload, selectinload
223+
"""Resolve an ``Authorization: Bearer`` token to its user.
228224
229-
from users.backend import _TOKEN_LIFETIME_SECONDS
230-
from users.models import User, UserAccessToken
231-
from users.token_strategy import token_is_live
225+
Delegates to :func:`users.token_strategy.resolve_bearer`, which is where
226+
``ExpiringDatabaseStrategy`` also lives — the two readers of
227+
``users_access_token`` apply the same deadline and ``session_version``
228+
rules, and keeping them in one file is what stops them drifting apart.
229+
"""
230+
from users.token_strategy import resolve_bearer
232231

233-
session_factory = scope["app"].state.sm.db.session_factory
234-
async with session_factory() as db_session:
235-
# Neither clause is optional: this path bypasses fastapi-users'
236-
# DatabaseStrategy, which is where a lifetime is normally
237-
# applied, so without them a row authenticated forever. The
238-
# ceiling is the same constant the strategy reads with, and
239-
# ``expires_at`` is the row's own deadline — an ordinary
240-
# sign-in's fourteen days, or ``/auth/token``'s fifteen
241-
# minutes, rather than the thirty-day ceiling for all of them.
242-
now = datetime.now(UTC)
243-
cutoff = now - timedelta(seconds=_TOKEN_LIFETIME_SECONDS)
244-
stmt = select(UserAccessToken).where(
245-
UserAccessToken.token == token,
246-
UserAccessToken.created_at > cutoff,
247-
UserAccessToken.expires_at > now,
248-
)
249-
access = (await db_session.execute(stmt)).scalar_one_or_none()
250-
if access is None:
251-
return None
252-
# noload oauth_accounts: lazy="selectin" on the model would
253-
# otherwise fire an extra query the UserContext never reads.
254-
stmt = (
255-
select(User)
256-
.where(User.id == access.user_id)
257-
.options(selectinload(User.roles), noload(User.oauth_accounts))
258-
)
259-
user = (await db_session.execute(stmt)).scalar_one_or_none()
260-
if user is None or not user.is_active or user.disabled_at is not None:
261-
return None
262-
# The revocation check the session path has had all along. A
263-
# password change bumps ``session_version`` and strands every
264-
# session; without this the bearer tokens minted before it kept
265-
# working, including any an attacker who knew the old password
266-
# had already collected. Free here — the row is already loaded.
267-
if not token_is_live(access, user, now):
268-
return None
269-
return UserContext.from_user(user)
270-
except Exception:
271-
logger.exception("Bearer token resolution failed")
272-
return None
232+
return await resolve_bearer(scope, token)
273233

274234
async def _load_user(self, scope, user_id: uuid_mod.UUID, session=None) -> UserContext | None:
275235
try:

‎modules/users/users/token_strategy.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@
1818

1919
from __future__ import annotations
2020

21+
import logging
2122
from datetime import UTC, datetime, timedelta
2223
from typing import Any
2324

25+
from auth.contracts.schemas import UserContext
2426
from fastapi_users import exceptions, models
2527
from fastapi_users.authentication.strategy.db import AccessTokenDatabase, DatabaseStrategy
2628
from fastapi_users.manager import BaseUserManager
2729

2830
from users.models import UserAccessToken
2931

32+
logger = logging.getLogger(__name__)
33+
3034

3135
def token_is_live(access_token: UserAccessToken, user: Any, now: datetime | None = None) -> bool:
3236
"""Whether this row still authenticates ``user``.
@@ -100,3 +104,61 @@ async def read_token(
100104
if not token_is_live(access_token, user):
101105
return None
102106
return user
107+
108+
109+
async def resolve_bearer(scope, token: str) -> UserContext | None:
110+
"""Look up an access token in ``users_access_token`` and return the user.
111+
112+
The provider's half of the pair. ``AuthMiddleware`` reaches this through
113+
``UsersAuthProvider.resolve_user``; ``fastapi_users.current_user`` reaches
114+
:class:`ExpiringDatabaseStrategy` instead. Same two bounds either way,
115+
written once as SQL and once in Python because one path can push them into
116+
the query and the other cannot.
117+
"""
118+
try:
119+
from sqlalchemy import select
120+
from sqlalchemy.orm import noload, selectinload
121+
122+
from users.backend import _TOKEN_LIFETIME_SECONDS
123+
from users.models import User
124+
125+
session_factory = scope["app"].state.sm.db.session_factory
126+
async with session_factory() as db_session:
127+
# Neither clause is optional: this path bypasses fastapi-users'
128+
# DatabaseStrategy, which is where a lifetime is normally applied,
129+
# so without them a row authenticated forever. The ceiling is the
130+
# same constant the strategy reads with, and ``expires_at`` is the
131+
# row's own deadline — an ordinary sign-in's fourteen days, or
132+
# ``/auth/token``'s fifteen minutes, rather than the thirty-day
133+
# ceiling for all of them.
134+
now = datetime.now(UTC)
135+
cutoff = now - timedelta(seconds=_TOKEN_LIFETIME_SECONDS)
136+
stmt = select(UserAccessToken).where(
137+
UserAccessToken.token == token,
138+
UserAccessToken.created_at > cutoff,
139+
UserAccessToken.expires_at > now,
140+
)
141+
access = (await db_session.execute(stmt)).scalar_one_or_none()
142+
if access is None:
143+
return None
144+
# noload oauth_accounts: lazy="selectin" on the model would
145+
# otherwise fire an extra query the UserContext never reads.
146+
stmt = (
147+
select(User)
148+
.where(User.id == access.user_id)
149+
.options(selectinload(User.roles), noload(User.oauth_accounts))
150+
)
151+
user = (await db_session.execute(stmt)).scalar_one_or_none()
152+
if user is None or not user.is_active or user.disabled_at is not None:
153+
return None
154+
# The revocation check the session path has had all along. A
155+
# password change bumps ``session_version`` and strands every
156+
# session; without this the bearer tokens minted before it kept
157+
# working, including any an attacker who knew the old password had
158+
# already collected. Free here — the row is already loaded.
159+
if not token_is_live(access, user, now):
160+
return None
161+
return UserContext.from_user(user)
162+
except Exception:
163+
logger.exception("Bearer token resolution failed")
164+
return None

0 commit comments

Comments
 (0)