Files
Aegis/backend/tests/conftest.py
T
kitos 504dfc52f5
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
feat(auth): make email the unique login identifier
Login is now by email, not username. username still exists internally
(JWT sub claim, audit logs, Jira actor attribution, SSO provisioning all
still key off it) but is now always kept equal to email everywhere a user
is created or their email changes — never a separately-chosen value.

- User.email is now unique + NOT NULL (migration b067 backfills any
  missing/blank email from username first, so existing rows — notably
  the seeded admin, which historically had none — never violate it).
- /auth/login and the (unused but updated for consistency)
  authenticate_user() now query by email.
- create_user (legacy, unreferenced but kept) and
  create_user_without_password both derive username from email.
- update_user keeps username in sync when email changes, and rejects
  duplicate emails.
- seed.py reads ADMIN_EMAIL (new env var, wired through install.sh and
  docker-compose.prod.yml) for the initial admin; falls back to an
  email-shaped ADMIN_USERNAME or a placeholder that's flagged for the
  operator to fix.
- admin_config.py's import bundle now matches/creates users by email,
  skipping (not crashing on) entries with no email.
- sso_service.py always sets username = email for SSO-provisioned users.
- LoginPage/auth.ts updated to email input/copy (wire field name stays
  'username' — that's the OAuth2PasswordRequestForm spec, not the value).
2026-07-23 14:39:36 +02:00

353 lines
10 KiB
Python

"""Pytest fixtures and configuration for backend tests.
The conftest intentionally avoids importing ``app.main`` at module level
because that triggers heavy side-effect imports (boto3, APScheduler, etc.)
which are NOT needed for unit tests. The ``client`` fixture lazily imports
the FastAPI app only when actually requested.
"""
import os
# Set DATABASE_URL to SQLite *before* any app module is imported so that
# the lazy engine in app.database never tries to connect to PostgreSQL.
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import pytest
from sqlalchemy import JSON, String, Text, create_engine, event
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
# ── Patch PostgreSQL-specific column types so SQLite can handle them ─────
# Must run BEFORE importing models, because column type objects are
# instantiated at class-definition time.
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSONB as PG_JSONB
# Tell SQLAlchemy: when compiling for SQLite, render JSONB as plain JSON
# and PostgreSQL UUID as CHAR(32).
from sqlalchemy.dialects.sqlite.base import SQLiteTypeCompiler
if not hasattr(SQLiteTypeCompiler, "visit_JSONB"):
SQLiteTypeCompiler.visit_JSONB = lambda self, type_, **kw: "JSON"
if not hasattr(SQLiteTypeCompiler, "visit_UUID"):
SQLiteTypeCompiler.visit_UUID = lambda self, type_, **kw: "CHAR(32)"
from app.auth import hash_password
from app.models.user import User
# ── Import all models so Base.metadata knows about every table ──────────
import app.models # noqa: F401 — triggers model registration via __init__
# Use in-memory SQLite for tests
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# SQLite needs PRAGMA foreign_keys to enforce FK constraints
@event.listens_for(engine, "connect")
def _set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
"""Override the database dependency for testing."""
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(scope="function")
def db():
"""Create a fresh database for each test."""
Base.metadata.create_all(bind=engine)
db = TestingSessionLocal()
yield db
db.close()
Base.metadata.drop_all(bind=engine)
@pytest.fixture(scope="function")
def client(db, monkeypatch):
"""Create a test client with database override.
Imports ``app.main`` lazily to avoid pulling in boto3 / APScheduler
when only the ``db`` fixture is needed.
MinIO and the background scheduler are no-ops here so tests do not
require Docker services for application startup.
JWT blacklist uses an in-memory FakeRedis so tests do not require a
real Redis instance.
"""
import fakeredis
_fake_redis = fakeredis.FakeRedis(decode_responses=True)
def _blacklist_conn():
return _fake_redis
monkeypatch.setattr(
"app.infrastructure.redis_client.get_redis_blacklist",
_blacklist_conn,
)
monkeypatch.setattr("app.main.ensure_bucket_exists", lambda: None)
monkeypatch.setattr("app.main.start_scheduler", lambda: None)
monkeypatch.setattr(
"app.main.scheduler.shutdown",
lambda wait=False: None,
)
from app.main import app
from app.database import get_db
import app.database as _db_mod
_db_mod._engine = engine
_db_mod._SessionLocal = TestingSessionLocal
app.dependency_overrides[get_db] = override_get_db
Base.metadata.create_all(bind=engine)
from app.limiter import limiter
limiter.enabled = False
from fastapi.testclient import TestClient
with TestClient(app) as test_client:
yield test_client
Base.metadata.drop_all(bind=engine)
app.dependency_overrides.clear()
@pytest.fixture(scope="function")
def admin_user(db):
"""Create an admin user for testing."""
user = User(
username="admin",
email="admin@test.com",
hashed_password=hash_password("admin123"),
role="admin",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def red_tech_user(db):
"""Create a red_tech user for testing."""
user = User(
username="redtech",
email="redtech@test.com",
hashed_password=hash_password("redtech123"),
role="red_tech",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def blue_tech_user(db):
"""Create a blue_tech user for testing."""
user = User(
username="bluetech",
email="bluetech@test.com",
hashed_password=hash_password("bluetech123"),
role="blue_tech",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def red_lead_user(db):
"""Create a red_lead user for testing."""
user = User(
username="redlead",
email="redlead@test.com",
hashed_password=hash_password("redlead123"),
role="red_lead",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def blue_lead_user(db):
"""Create a blue_lead user for testing."""
user = User(
username="bluelead",
email="bluelead@test.com",
hashed_password=hash_password("bluelead123"),
role="blue_lead",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def manager_user(db):
"""Create a manager user for testing."""
user = User(
username="manager",
email="manager@test.com",
hashed_password=hash_password("manager123"),
role="manager",
is_active=True,
must_change_password=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture(scope="function")
def admin_token(client, admin_user):
"""Get an auth token for the admin user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "admin@test.com", "password": "admin123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def red_tech_token(client, red_tech_user):
"""Get an auth token for the red_tech user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "redtech@test.com", "password": "redtech123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def auth_headers(admin_token):
"""Return authorization headers for admin user."""
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture(scope="function")
def red_tech_headers(red_tech_token):
"""Return authorization headers for red_tech user."""
return {"Authorization": f"Bearer {red_tech_token}"}
@pytest.fixture(scope="function")
def blue_tech_token(client, blue_tech_user):
"""Get an auth token for the blue_tech user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "bluetech@test.com", "password": "bluetech123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def blue_tech_headers(blue_tech_token):
"""Return authorization headers for blue_tech user."""
return {"Authorization": f"Bearer {blue_tech_token}"}
@pytest.fixture(scope="function")
def red_lead_token(client, red_lead_user):
"""Get an auth token for the red_lead user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "redlead@test.com", "password": "redlead123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def red_lead_headers(red_lead_token):
"""Return authorization headers for red_lead user."""
return {"Authorization": f"Bearer {red_lead_token}"}
@pytest.fixture(scope="function")
def blue_lead_token(client, blue_lead_user):
"""Get an auth token for the blue_lead user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "bluelead@test.com", "password": "bluelead123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def blue_lead_headers(blue_lead_token):
"""Return authorization headers for blue_lead user."""
return {"Authorization": f"Bearer {blue_lead_token}"}
@pytest.fixture(scope="function")
def manager_token(client, manager_user):
"""Get an auth token for the manager user."""
response = client.post(
"/api/v1/auth/login",
data={"username": "manager@test.com", "password": "manager123"},
)
return response.json()["access_token"]
@pytest.fixture(scope="function")
def manager_headers(manager_token):
"""Return authorization headers for manager user."""
return {"Authorization": f"Bearer {manager_token}"}
@pytest.fixture(scope="function")
def api(client):
"""Issue an authenticated request while avoiding stale-cookie role bleed.
``client``'s cookie jar persists across requests within a test, and
``get_current_user`` prefers the ``aegis_token`` cookie over the
``Authorization`` header. A test that uses more than one role's
``*_headers`` fixture (e.g. submit as red_lead, then approve as
manager) would otherwise have its *first* request silently
authenticate as whichever role's fixture happens to log in last,
since pytest resolves all fixtures before the test body runs. Use
this instead of ``client.post``/``client.get`` directly whenever a
test mixes more than one role.
Usage: ``api("post", url, headers, json=payload)``.
"""
def _request(method: str, url: str, headers: dict, **kwargs):
client.cookies.clear()
return getattr(client, method)(url, headers=headers, **kwargs)
return _request