FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
264 lines
9.7 KiB
Python
264 lines
9.7 KiB
Python
"""Unban-request intake + admin IP-ban management (Phase 19). No dedicated
|
|
coverage existed for this endpoint group before - added alongside the
|
|
Mailpit removal, which changed submit_unban_request to notify every
|
|
is_admin=True account instead of a single fixed admin_notification_email."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.main import app
|
|
from app.repositories.ip_throttle_repository import IpThrottleRepository
|
|
from app.repositories.unban_request_repository import UnbanRequestRepository
|
|
from app.repositories.user_repository import UserRepository
|
|
|
|
|
|
def _unique_email() -> str:
|
|
return f"user-{uuid.uuid4().hex[:12]}@example.com"
|
|
|
|
|
|
def _unique_ip() -> str:
|
|
# Randomize all three trailing octets (same convention as
|
|
# test_ip_throttle_service.py) - a single-octet range only has ~250
|
|
# values, which collides often enough across a full suite run (birthday
|
|
# paradox) to cause real, intermittent failures between unrelated tests
|
|
# that happen to share ip_throttle_state rows.
|
|
return f"10.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}.{uuid.uuid4().int % 250}"
|
|
|
|
|
|
def _install_fake_smtp(monkeypatch) -> list[dict]:
|
|
sent: list[dict] = []
|
|
|
|
class FakeSmtp:
|
|
def __init__(self, host, port, timeout=10):
|
|
pass
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def starttls(self):
|
|
pass
|
|
|
|
def login(self, username, password):
|
|
pass
|
|
|
|
def sendmail(self, from_addr, to_addrs, message):
|
|
sent.append({"to": to_addrs, "message": message})
|
|
|
|
monkeypatch.setattr("app.notifications.smtp_email.smtplib.SMTP", FakeSmtp)
|
|
return sent
|
|
|
|
|
|
async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]:
|
|
email = _unique_email()
|
|
client.post(
|
|
"/api/v1/auth/register",
|
|
json={"email": email, "password": "correct-horse-1", "display_name": "Admin"},
|
|
)
|
|
user = await UserRepository(db_session).get_by_email(email)
|
|
user.is_admin = True
|
|
await db_session.commit()
|
|
tokens = client.post(
|
|
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
|
).json()
|
|
return {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
|
|
|
|
def test_unban_request_requires_no_auth_and_returns_204(monkeypatch):
|
|
_install_fake_smtp(monkeypatch)
|
|
ip = _unique_ip()
|
|
with TestClient(app, client=(ip, 51234)) as c:
|
|
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
|
assert resp.status_code == 204
|
|
|
|
|
|
def test_unban_request_cooldown_rejects_second_request_within_24h(monkeypatch):
|
|
_install_fake_smtp(monkeypatch)
|
|
ip = _unique_ip()
|
|
with TestClient(app, client=(ip, 51234)) as c:
|
|
first = c.post("/api/v1/unban-requests", json={"message": "one"})
|
|
assert first.status_code == 204
|
|
|
|
second = c.post("/api/v1/unban-requests", json={"message": "two"})
|
|
assert second.status_code == 429
|
|
|
|
|
|
async def test_unban_request_notifies_every_admin_account_only(monkeypatch, db_session):
|
|
sent = _install_fake_smtp(monkeypatch)
|
|
|
|
admin_repo = UserRepository(db_session)
|
|
admin_one = await admin_repo.create(
|
|
email=_unique_email(),
|
|
password_hash="x",
|
|
display_name="Admin One",
|
|
timezone="UTC",
|
|
is_admin=True,
|
|
email_verified=True,
|
|
)
|
|
admin_two = await admin_repo.create(
|
|
email=_unique_email(),
|
|
password_hash="x",
|
|
display_name="Admin Two",
|
|
timezone="UTC",
|
|
is_admin=True,
|
|
email_verified=True,
|
|
)
|
|
not_admin = await admin_repo.create(
|
|
email=_unique_email(),
|
|
password_hash="x",
|
|
display_name="Not Admin",
|
|
timezone="UTC",
|
|
is_admin=False,
|
|
email_verified=True,
|
|
)
|
|
await db_session.commit()
|
|
|
|
ip = _unique_ip()
|
|
with TestClient(app, client=(ip, 51234)) as c:
|
|
resp = c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
|
assert resp.status_code == 204
|
|
|
|
# Other tests in the same run may have their own admin accounts (plus
|
|
# the fixed local-dev user, always admin) - assert membership, not an
|
|
# exact total count.
|
|
recipients = {msg["to"][0] for msg in sent}
|
|
assert admin_one.email in recipients
|
|
assert admin_two.email in recipients
|
|
assert not_admin.email not in recipients
|
|
|
|
|
|
async def test_admin_ip_ban_endpoints_work_for_an_admin(db_session: AsyncSession):
|
|
ip = _unique_ip()
|
|
with TestClient(app, client=(ip, 51234)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
|
|
bans_resp = c.get("/api/v1/admin/ip-bans", headers=headers)
|
|
assert bans_resp.status_code == 200
|
|
|
|
requests_resp = c.get("/api/v1/admin/unban-requests", headers=headers)
|
|
assert requests_resp.status_code == 200
|
|
|
|
|
|
def test_admin_ip_ban_endpoints_reject_non_admin(client: TestClient):
|
|
email = _unique_email()
|
|
client.post(
|
|
"/api/v1/auth/register",
|
|
json={"email": email, "password": "correct-horse-1", "display_name": "T"},
|
|
)
|
|
tokens = client.post(
|
|
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
|
|
).json()
|
|
headers = {"Authorization": f"Bearer {tokens['access_token']}"}
|
|
|
|
resp = client.get("/api/v1/admin/ip-bans", headers=headers)
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_admin_can_delete_ip_ban_and_it_clears_throttle_state(db_session: AsyncSession):
|
|
banned_ip = _unique_ip()
|
|
await IpThrottleRepository(db_session).create_ban(banned_ip, "failed_login", datetime.now(UTC))
|
|
await db_session.commit()
|
|
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51235)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.delete(f"/api/v1/admin/ip-bans/{banned_ip}", headers=headers)
|
|
assert resp.status_code == 204
|
|
|
|
ban = await IpThrottleRepository(db_session).get_ban(banned_ip)
|
|
assert ban is None
|
|
|
|
|
|
async def test_admin_can_manually_ban_an_ip(db_session: AsyncSession):
|
|
target_ip = _unique_ip()
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51236)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
|
|
assert resp.status_code == 201
|
|
assert resp.json()["ip_address"] == target_ip
|
|
|
|
ban = await IpThrottleRepository(db_session).get_ban(target_ip)
|
|
assert ban is not None
|
|
|
|
|
|
async def test_admin_ban_ip_rejects_already_banned_ip(db_session: AsyncSession):
|
|
target_ip = _unique_ip()
|
|
await IpThrottleRepository(db_session).create_ban(target_ip, "failed_login", datetime.now(UTC))
|
|
await db_session.commit()
|
|
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51237)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": target_ip}, headers=headers)
|
|
assert resp.status_code == 409
|
|
|
|
|
|
async def test_admin_ban_ip_rejects_malformed_address(db_session: AsyncSession):
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51238)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.post("/api/v1/admin/ip-bans", json={"ip_address": "not-an-ip"}, headers=headers)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
async def test_admin_can_accept_an_unban_request_and_it_unbans_the_ip(db_session: AsyncSession):
|
|
requester_ip = _unique_ip()
|
|
await IpThrottleRepository(db_session).create_ban(
|
|
requester_ip, "failed_login", datetime.now(UTC)
|
|
)
|
|
await db_session.commit()
|
|
|
|
with TestClient(app, client=(requester_ip, 51240)) as c:
|
|
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
|
|
|
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
|
|
assert request is not None
|
|
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51241)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.post(f"/api/v1/admin/unban-requests/{request.id}/accept", headers=headers)
|
|
assert resp.status_code == 204
|
|
|
|
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is None
|
|
assert await UnbanRequestRepository(db_session).get(request.id) is None
|
|
|
|
|
|
async def test_admin_can_reject_an_unban_request_and_the_ip_stays_banned(db_session: AsyncSession):
|
|
requester_ip = _unique_ip()
|
|
await IpThrottleRepository(db_session).create_ban(
|
|
requester_ip, "failed_login", datetime.now(UTC)
|
|
)
|
|
await db_session.commit()
|
|
|
|
with TestClient(app, client=(requester_ip, 51242)) as c:
|
|
c.post("/api/v1/unban-requests", json={"message": "please unban me"})
|
|
|
|
request = await UnbanRequestRepository(db_session).most_recent_for_ip(requester_ip)
|
|
assert request is not None
|
|
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51243)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.delete(f"/api/v1/admin/unban-requests/{request.id}", headers=headers)
|
|
assert resp.status_code == 204
|
|
|
|
assert await IpThrottleRepository(db_session).get_ban(requester_ip) is not None
|
|
assert await UnbanRequestRepository(db_session).get(request.id) is None
|
|
|
|
|
|
async def test_admin_accept_unban_request_404s_for_unknown_id(db_session: AsyncSession):
|
|
admin_ip = _unique_ip()
|
|
with TestClient(app, client=(admin_ip, 51244)) as c:
|
|
headers = await _register_admin_and_login(c, db_session)
|
|
resp = c.post(f"/api/v1/admin/unban-requests/{uuid.uuid4()}/accept", headers=headers)
|
|
assert resp.status_code == 404
|