Files
CIAgent/apps/api/tests/unit/test_unban_admin.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
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).
2026-08-05 10:48:20 -04:00

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