mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
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>
155 lines
5.0 KiB
Python
155 lines
5.0 KiB
Python
"""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
|