feat: add conditional bootstrap login + 23 admin login tests

- Add hash_password/verify_password to app/auth.py using bcrypt
- Fix /login to reject bootstrap creds (403) when admin users exist
- Add normal password-based login path for existing admin users
- Eagerly load User.roles to avoid lazy-load outside async session
- Fix test_user_models.py VARCHAR assertion (SQLAlchemy 2.0 uses 'string')
- Use shared-cache SQLite in conftest for endpoint-level tests
- Add 23 tests covering all 3 required scenarios plus edge cases:
  1. Bootstrap login with hardcoded creds when no admins → PASS (200)
  2. Bootstrap login with hardcoded creds when admins exist → FAIL (403)
  3. Password login for existing admin with hashed password → PASS (200)
- All 130 tests pass (107 existing + 23 new)
This commit is contained in:
Hermes Agent
2026-07-30 06:35:52 +00:00
parent 0eca079f28
commit f22802658e
5 changed files with 515 additions and 30 deletions
+56 -2
View File
@@ -1,14 +1,31 @@
"""Shared test fixtures for kmtnflower backend tests."""
import os
import pytest
from unittest.mock import patch
# Override settings for tests — use SQLite so we don't need PostgreSQL
# Set test environment vars BEFORE any app imports
# This is module-level so it runs when pytest loads this conftest
os.environ.setdefault("KMTN_DATABASE_URL", "sqlite+aiosqlite:///file::memory:?cache=shared")
os.environ.setdefault("KMTN_ENV", "test")
os.environ.setdefault("KMTN_JWT_SECRET_KEY", "test-secret-key-do-not-use")
os.environ.setdefault("KMTN_ADMIN_USERNAME", "testadmin")
os.environ.setdefault("KMTN_ADMIN_PASSWORD", "testpass123")
os.environ.setdefault("KMTN_LOGS_DIR", "/tmp/kmtn_test_logs")
os.environ.setdefault("KMTN_GEOLITE2_DB_PATH", "/nonexistent/GeoLite2-City.mmdb")
os.environ.setdefault("KMTN_THEME_JSON_PATH", "/tmp/kmtn_test_theme.json")
# Use shared-cache in-memory SQLite so all connections within the process
# see the same in-memory database (critical for tests that call the
# login endpoint which opens its own DB connections).
TEST_DB_URL = "sqlite+aiosqlite:///file::memory:?cache=shared"
@pytest.fixture(autouse=True)
def override_settings():
"""Set test-only environment variables before any import of app modules."""
env = {
"KMTN_DATABASE_URL": "sqlite+aiosqlite:///:memory:",
"KMTN_DATABASE_URL": TEST_DB_URL,
"KMTN_ENV": "test",
"KMTN_JWT_SECRET_KEY": "test-secret-key-do-not-use",
"KMTN_ADMIN_USERNAME": "testadmin",
@@ -19,3 +36,40 @@ def override_settings():
}
with patch.dict("os.environ", env, clear=False):
yield env
@pytest.fixture(autouse=True)
async def reconfigure_database():
"""Reconfigure the SQLAlchemy engine to use shared in-memory SQLite.
Uses file::memory:?cache=shared so all connections within the process
see the same in-memory database (critical for tests that call the
login endpoint which opens its own DB connections).
Drops all tables at the START of each test to ensure isolation.
"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app import database
from app.models import Base as ModelsBase
# Create a new SQLite async engine with shared cache
test_engine = create_async_engine(TEST_DB_URL, echo=False)
test_session = async_sessionmaker(
test_engine, class_=database.AsyncSession, expire_on_commit=False
)
# Replace the module-level engine and session factory
database.engine = test_engine
database.async_session = test_session
# Drop all tables to ensure test isolation (shared-cache persists across tests)
# Then recreate them so fixture-dependent tests have a schema to work with.
async with test_engine.begin() as conn:
await conn.run_sync(ModelsBase.metadata.drop_all)
await conn.run_sync(ModelsBase.metadata.create_all)
yield test_engine
# Dispose of the test engine after each test
await test_engine.dispose()