Files
Ten31-Portal/backend/tests/conftest.py
T
Jonathan KirkwoodandClaude Opus 4.8 69f12b0519 0.2.26: security hardening from audit (P2/P3 fixes)
Address the security-auditor findings the user selected (items 2, 3, 4):

Default admin credentials (P2): remove the fixed `Ten31` default. First
boot now generates a strong random admin password (secrets.token_urlsafe),
records it 0600 at /data/.admin-password, and surfaces it once via a new
"Show Initial Admin Password" StartOS action (CLI `show-admin-password`).
The stored password is cleared when the admin is reset (CLI reset-password)
or self-changes it (change-password endpoint).

Login hardening (P2): add a per-IP in-memory sliding-window rate limiter
(10 failures / 5 min -> 429 + Retry-After) in ratelimit.py; run a dummy
argon2 verify when the user is unknown so timing can't enumerate usernames;
keep a single generic 401 for unknown-user and wrong-password.

Hardening (P3): server process now runs unprivileged -- Dockerfile adds
uid 10001 appuser; start.sh (still root) chowns the mounted /data then
drops via `setpriv` before exec'ing uvicorn. Spreadsheet imports are
size-capped via storage.read_capped (413 past MAX_UPLOAD_SIZE) in the
schedule, capital preview, and batch paths. batch_import no longer returns
raw exception text (generic per-file messages).

Verified in the packed amd64 container: PID1 uvicorn runs as uid 10001,
/data owned 10001 with 0600 secrets; generated admin password retrievable
via CLI and logs in (200); 11th bad login -> 429; admin reset clears the
stored password. Tests: test_auth_hardening.py (4). Full suite 21 passed;
frontend tsc + StartOS bundle clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:09:27 -05:00

88 lines
2.5 KiB
Python

"""Shared pytest fixtures: an in-memory DB and a FastAPI TestClient.
The client is created WITHOUT the context-manager form on purpose, so the app lifespan (which
runs real Alembic migrations against the configured DB_PATH) never fires. Tables come from
SQLModel.metadata.create_all against a throwaway in-memory database instead.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
import ten31portal.models # noqa: F401 — importing registers every table on SQLModel.metadata
from ten31portal.auth import hash_password
from ten31portal.database import get_session
from ten31portal.main import app
from ten31portal.models import User, UserRole
@pytest.fixture
def engine():
eng = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool, # one shared in-memory connection across sessions
)
SQLModel.metadata.create_all(eng)
yield eng
SQLModel.metadata.drop_all(eng)
@pytest.fixture
def session(engine):
with Session(engine) as s:
yield s
@pytest.fixture
def client(engine):
def override_get_session():
with Session(engine) as s:
yield s
app.dependency_overrides[get_session] = override_get_session
yield TestClient(app)
app.dependency_overrides.clear()
def make_user(session, *, username="admin", password="password123",
role=UserRole.approver, name="Test User", email=None, **kwargs):
user = User(
name=name,
username=username,
email=email,
password_hash=hash_password(password),
role=role,
**kwargs,
)
session.add(user)
session.commit()
session.refresh(user)
return user
@pytest.fixture
def approver(session):
return make_user(session, username="approver", role=UserRole.approver)
@pytest.fixture
def auth_client(client, approver):
"""A TestClient already logged in as an approver (a writer)."""
resp = client.post(
"/api/auth/login", json={"login": "approver", "password": "password123"}
)
assert resp.status_code == 200, resp.text
return client
@pytest.fixture(autouse=True)
def _reset_login_limiter():
"""The login rate-limiter is a process-global; clear the TestClient's key around each test
so failed-login tests can't throttle unrelated ones."""
from ten31portal.routers.auth_router import _login_limiter
_login_limiter.reset("testclient")
yield
_login_limiter.reset("testclient")