fix(auth): auth deps use their own DB session; smoke suites made dataset-independent

RUNTIME BUG (caught by live smoke tests): the global auth dependency
queried the request-scoped session, auto-beginning its transaction — any
endpoint opening 'async with db.begin()' afterwards crashed with
'transaction already begun' (POST /materials 500). get_current_user /
get_actor now resolve in a short-lived session (expire_on_commit=False
keeps the context usable detached); inventory/pm scopes keep passing the
request session explicitly.

Smoke suites rewritten against CURRENT contracts:
- assertions are behavior-based (shapes, envelopes, filters), no magic
  seed counts; dynamic lookups instead of hard-coded names/ids
- rerun-tolerant: tolerate 409-on-create by reusing prior artifacts
- aligned with current rules: revert blocked after inventory receipts
  (v2.6), attachments allowed in every status, termsheets optional at
  quote level
- SMOKE_USER/SMOKE_PASSWORD env for login (default tester/123456)

Verified on a fresh volume: migration + audit_log bootstrap, Casdoor
seed (bcrypt), demo data, and 29+76+61 smoke checks all green.
This commit is contained in:
MMD
2026-09-19 00:49:15 +03:30
parent e03d94207b
commit e1495afee9
6 changed files with 351 additions and 167 deletions
+19 -5
View File
@@ -26,7 +26,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db.engine import get_db
from app.db.engine import AsyncSessionLocal
from app.models.procurement import Project, User
from app.utils.errors import (
ForbiddenError,
@@ -170,11 +170,16 @@ async def pm_project_ids(db: AsyncSession, user: User) -> list:
return list(rows)
async def get_optional_user(request: Request, db: AsyncSession) -> UserContext | None:
async def get_optional_user(request: Request, db: AsyncSession | None = None) -> UserContext | None:
"""Resolve the caller from the Casdoor Bearer token, or None when absent.
Raises UnauthorizedError for a present-but-invalid token; callers decide
their own fallback only for the no-token case (e.g. dev headers).
IMPORTANT: when ``db`` is omitted (pure auth-dependency usage), a
short-lived session of its own is used — querying the REQUEST session
here would auto-begin its transaction and break endpoints that open
explicit ``async with db.begin()`` blocks afterwards.
"""
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
@@ -182,13 +187,22 @@ async def get_optional_user(request: Request, db: AsyncSession) -> UserContext |
token = auth.split(" ", 1)[1].strip()
if not token:
return None
if db is None:
async with AsyncSessionLocal() as own_session:
claims = await _verify_token(token)
return await context_from_claims(claims, own_session)
claims = await _verify_token(token)
return await context_from_claims(claims, db)
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned)."""
ctx = await get_optional_user(request, db)
async def get_current_user(request: Request) -> UserContext:
"""Require a valid Casdoor token (401 missing/invalid; 403 unprovisioned).
Resolves the user in its own session (see get_optional_user) so the
global auth dependency never interferes with an endpoint's explicit
transaction handling.
"""
ctx = await get_optional_user(request)
if ctx is None:
raise UnauthorizedError("احراز هویت لازم است — توکن Casdoor ارسال کنید")
if ctx.role is None: