mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 11:08:47 +02:00
Add pytest harness and 55-test suite for core backend logic
Covers: billing-cycle characterization (the BE-06 convention, boundary instants, year wraps, deferred transactions), projection engine (recurring schedules, no-double-count, cumulative balances, overrides, fresh start), auth (JWT roundtrip/expiry, API tokens, constant-time creds, rate-limit window), fail-fast settings, and the BAC paste parser. SQLite in-memory fixtures, exchange rates pinned — no network. Also doubles as the float-behavior baseline for the Phase 2 Decimal migration (plan 1.5). config.py moves to SettingsConfigDict to clear the pydantic deprecation warning from test output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
154
backend/tests/test_auth.py
Normal file
154
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""JWT lifecycle, API-token validation, constant-time creds, login rate limit."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from jose import jwt
|
||||
|
||||
from app import auth as auth_mod
|
||||
from app.auth import (
|
||||
ALGORITHM,
|
||||
LOGIN_RATE_LIMIT,
|
||||
_login_attempts,
|
||||
_validate_token,
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
hash_token,
|
||||
verify_admin_credentials,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.models.models import APIToken
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def make_request(ip: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", ip.encode())],
|
||||
"client": ("127.0.0.1", 1234),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestJWT:
|
||||
def test_roundtrip_and_seven_day_expiry(self):
|
||||
token = create_access_token("charlie")
|
||||
assert _validate_token(token) == "charlie"
|
||||
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
# exp is a true UTC epoch; a naive datetime's .timestamp() would be
|
||||
# interpreted as local time, so compare against an aware now.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp()
|
||||
assert lifetime == pytest.approx(7 * 24 * 3600, abs=60)
|
||||
|
||||
def test_expired_token_rejected(self, engine, monkeypatch):
|
||||
# The API-token fallback path opens a DB session; point it at the
|
||||
# test engine so the lookup runs (and finds nothing).
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
expired = jwt.encode(
|
||||
{"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)},
|
||||
settings.SECRET_KEY,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token(expired)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_garbage_token_rejected(self, engine, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("not-a-jwt")
|
||||
|
||||
|
||||
class TestAPIToken:
|
||||
def test_active_api_token_accepted(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True)
|
||||
)
|
||||
session.commit()
|
||||
assert _validate_token("tok-123") == "api:n8n"
|
||||
|
||||
def test_expired_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="old",
|
||||
token_hash=hash_token("tok-old"),
|
||||
is_active=True,
|
||||
expires_at=utcnow() - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token("tok-old")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_revoked_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="revoked",
|
||||
token_hash=hash_token("tok-rev"),
|
||||
is_active=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("tok-rev")
|
||||
|
||||
|
||||
class TestCredentials:
|
||||
def test_correct_credentials_pass(self):
|
||||
verify_admin_credentials(
|
||||
settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD
|
||||
) # no exception
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user,pw",
|
||||
[
|
||||
("wrong", "test-password"),
|
||||
("testadmin", "wrong"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_wrong_credentials_rejected(self, user, pw):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_admin_credentials(user, pw)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
def setup_method(self):
|
||||
_login_attempts.clear()
|
||||
|
||||
def test_limit_allows_then_blocks(self):
|
||||
req = make_request("203.0.113.1")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_login_rate_limit(req)
|
||||
assert exc.value.status_code == 429
|
||||
assert "Retry-After" in exc.value.headers
|
||||
|
||||
def test_limit_is_per_ip(self):
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(make_request("203.0.113.2"))
|
||||
# A different IP is unaffected.
|
||||
check_login_rate_limit(make_request("203.0.113.3"))
|
||||
|
||||
def test_window_slides(self):
|
||||
req = make_request("203.0.113.4")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
# Age every recorded attempt past the window; the next attempt passes.
|
||||
attempts = _login_attempts["203.0.113.4"]
|
||||
for i in range(len(attempts)):
|
||||
attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1
|
||||
check_login_rate_limit(req) # no exception
|
||||
Reference in New Issue
Block a user