"""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