Files

201 lines
6.7 KiB
Python
Raw Permalink Normal View History

"""Tests for M4-T01: LoginThrottle model and auth_login_throttle migration."""
from __future__ import annotations
from datetime import datetime, timezone
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.auth_throttle import LoginThrottle
def _make_app_alembic_config(database_url: str) -> Config:
config = Config("alembic_app.ini")
config.set_main_option("sqlalchemy.url", database_url)
return config
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def throttle_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to the current Alembic head."""
db_path = tmp_path / "throttle_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
yield engine
engine.dispose()
# ---------------------------------------------------------------------------
# Migration shape tests
# ---------------------------------------------------------------------------
def test_table_exists_after_upgrade(throttle_db):
"""auth_login_throttle table must be present after upgrade to head."""
inspector = inspect(throttle_db)
assert "auth_login_throttle" in inspector.get_table_names()
def test_table_has_expected_columns(throttle_db):
"""All required columns must be present with correct nullability."""
inspector = inspect(throttle_db)
columns = {col["name"]: col for col in inspector.get_columns("auth_login_throttle")}
expected_non_nullable = {"id", "key", "scope", "failures", "first_failed_at", "last_failed_at"}
expected_nullable = {"next_allowed_at"}
for col_name in expected_non_nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert not columns[col_name]["nullable"], f"Column {col_name} should be NOT NULL"
for col_name in expected_nullable:
assert col_name in columns, f"Missing column: {col_name}"
assert columns[col_name]["nullable"], f"Column {col_name} should be nullable"
def test_unique_constraint_scope_key(throttle_db):
"""(scope, key) pair must be unique — inserting a duplicate must raise."""
now = datetime.now(tz=timezone.utc)
with Session(throttle_db) as session:
session.add(
LoginThrottle(
key="192.168.1.1",
scope="ip",
failures=1,
first_failed_at=now,
last_failed_at=now,
)
)
session.commit()
with Session(throttle_db) as session:
session.add(
LoginThrottle(
key="192.168.1.1",
scope="ip",
failures=2,
first_failed_at=now,
last_failed_at=now,
)
)
with pytest.raises(IntegrityError):
session.commit()
def test_same_key_different_scope_allowed(throttle_db):
"""Same key string but different scopes are independent rows — both should insert."""
now = datetime.now(tz=timezone.utc)
with Session(throttle_db) as session:
session.add(
LoginThrottle(
key="admin",
scope="ip",
failures=1,
first_failed_at=now,
last_failed_at=now,
)
)
session.add(
LoginThrottle(
key="admin",
scope="user",
failures=3,
first_failed_at=now,
last_failed_at=now,
)
)
session.commit() # must not raise
with Session(throttle_db) as session:
rows = session.query(LoginThrottle).filter_by(key="admin").all()
assert len(rows) == 2
scopes = {r.scope for r in rows}
assert scopes == {"ip", "user"}
def test_next_allowed_at_nullable(throttle_db):
"""next_allowed_at is nullable — a row without it must persist fine."""
now = datetime.now(tz=timezone.utc)
with Session(throttle_db) as session:
row = LoginThrottle(
key="10.0.0.1",
scope="ip",
failures=1,
first_failed_at=now,
last_failed_at=now,
next_allowed_at=None,
)
session.add(row)
session.commit()
session.refresh(row)
assert row.next_allowed_at is None
def test_next_allowed_at_persists_when_set(throttle_db):
"""next_allowed_at can be set and retrieved correctly."""
now = datetime.now(tz=timezone.utc)
with Session(throttle_db) as session:
row = LoginThrottle(
key="10.0.0.2",
scope="ip",
failures=5,
first_failed_at=now,
last_failed_at=now,
next_allowed_at=now,
)
session.add(row)
session.commit()
row_id = row.id
with Session(throttle_db) as session:
fetched = session.get(LoginThrottle, row_id)
assert fetched is not None
assert fetched.next_allowed_at is not None
# ---------------------------------------------------------------------------
# Downgrade / upgrade reversibility test
# ---------------------------------------------------------------------------
def test_downgrade_removes_table(tmp_path: Path):
"""Downgrading the auth_login_throttle revision must cleanly drop the table.
We upgrade to the specific throttle revision (not necessarily head, since later
revisions may have been added), then downgrade one step to verify the table is gone.
"""
db_path = tmp_path / "down_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
throttle_revision = "20260621_07_auth_login_throttle"
command.upgrade(alembic_cfg, throttle_revision)
engine = create_engine(db_url, connect_args={"check_same_thread": False})
assert "auth_login_throttle" in inspect(engine).get_table_names()
engine.dispose()
command.downgrade(alembic_cfg, "-1")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
assert "auth_login_throttle" not in inspect(engine).get_table_names()
engine.dispose()
def test_index_exists_on_scope_key(throttle_db):
"""ix_auth_login_throttle_scope_key composite index must exist."""
inspector = inspect(throttle_db)
indexes = inspector.get_indexes("auth_login_throttle")
index_names = {idx["name"] for idx in indexes}
assert "ix_auth_login_throttle_scope_key" in index_names