feat(imap): add per-user IMAP ingestion accounts with quota enforcement

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 13:01:14 +00:00
parent f3c85f73c3
commit 43bcfe5653
12 changed files with 1922 additions and 0 deletions
+570
View File
@@ -0,0 +1,570 @@
"""Tests for the per-user IMAP accounts API (app/api/imap_accounts.py)."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import SubscriptionPlan, UserImapAccount, UserProfile
# ---------------------------------------------------------------------------
# Test data constants
# ---------------------------------------------------------------------------
_OWNER = "test_user@example.com"
_BASIC_ACCOUNT = {
"name": "Test Mailbox",
"host": "imap.example.com",
"port": 993,
"username": "user@example.com",
"password": "s3cr3t", # noqa: S105
"use_ssl": True,
"delete_after_process": False,
"is_active": True,
}
# ---------------------------------------------------------------------------
# Shared fixture helpers
# ---------------------------------------------------------------------------
@pytest.fixture()
def imap_engine():
"""In-memory SQLite engine for IMAP account tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def imap_session(imap_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=imap_engine)
session = Session()
yield session
session.close()
def _make_client(imap_engine, owner_id: str = _OWNER):
"""Return a TestClient that injects *owner_id* as the authenticated user."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return owner_id
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
return app, override_db, override_owner
def _make_anon_client(imap_engine):
"""Return a TestClient without an authenticated user (401 expected)."""
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
return app
@pytest.fixture()
def imap_client(imap_engine):
"""TestClient authenticated as _OWNER."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
yield c
app.dependency_overrides.clear()
def _make_profile(session, owner: str = _OWNER, tier: str = "starter") -> UserProfile:
"""Create a UserProfile."""
profile = UserProfile(user_id=owner, subscription_tier=tier)
session.add(profile)
session.commit()
session.refresh(profile)
return profile
def _make_plan(session, tier: str = "starter", max_mailboxes: int = 1) -> SubscriptionPlan:
"""Create a SubscriptionPlan row."""
plan = SubscriptionPlan(
plan_id=tier,
name=tier.title(),
price_monthly=2.99,
price_yearly=28.99,
max_mailboxes=max_mailboxes,
is_active=True,
)
session.add(plan)
session.commit()
session.refresh(plan)
return plan
# ---------------------------------------------------------------------------
# Unit tests quota helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetMaxMailboxes:
"""Unit tests for the _get_max_mailboxes helper."""
def test_free_tier_returns_zero(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "free", "max_mailboxes": 0}) == 0
def test_paid_tier_with_explicit_limit(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "starter", "max_mailboxes": 1}) == 1
def test_paid_tier_unlimited(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "business", "max_mailboxes": 0}) is None
def test_professional_three(self):
from app.api.imap_accounts import _get_max_mailboxes
assert _get_max_mailboxes({"id": "professional", "max_mailboxes": 3}) == 3
# ---------------------------------------------------------------------------
# Integration tests list endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestListImapAccounts:
"""Tests for GET /api/imap-accounts/."""
def test_list_empty(self, imap_client, imap_session):
"""Listing accounts for a user with none returns empty list."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
resp = imap_client.get("/api/imap-accounts/")
assert resp.status_code == 200
assert resp.json() == []
def test_list_returns_own_accounts_only(self, imap_client, imap_session):
"""Only the current user's accounts are returned."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
acct = UserImapAccount(owner_id=_OWNER, name="Mine", host="h", port=993, username="u", password="p")
other = UserImapAccount(
owner_id="other@example.com",
name="Theirs",
host="h2",
port=993,
username="u",
password="p",
)
imap_session.add_all([acct, other])
imap_session.commit()
resp = imap_client.get("/api/imap-accounts/")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "Mine"
def test_list_requires_authentication(self, imap_engine):
"""Unauthenticated requests return 401."""
from fastapi import HTTPException, status
from app.api.imap_accounts import _get_owner_id
from app.main import app
def raise_401():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
def override_db():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = raise_401
try:
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
resp = c.get("/api/imap-accounts/")
assert resp.status_code == 401
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Integration tests create endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestCreateImapAccount:
"""Tests for POST /api/imap-accounts/."""
def test_create_success(self, imap_client, imap_session):
"""A valid create request returns 201 and the new account."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == _BASIC_ACCOUNT["name"]
assert data["host"] == _BASIC_ACCOUNT["host"]
assert "password" not in data
def test_create_increments_count(self, imap_client, imap_session):
"""After creation, the count in the database increases."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=2)
imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
count = imap_session.query(UserImapAccount).filter(UserImapAccount.owner_id == _OWNER).count()
assert count == 1
def test_create_blocked_on_free_tier(self, imap_client, imap_session):
"""Free-tier users cannot add any IMAP accounts."""
_make_profile(imap_session, tier="free")
_make_plan(imap_session, tier="free", max_mailboxes=0)
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 403
assert "plan" in resp.json()["detail"].lower()
def test_create_blocked_at_quota_limit(self, imap_client, imap_session):
"""Users at the quota limit receive a 403."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
existing = UserImapAccount(owner_id=_OWNER, name="Existing", host="h", port=993, username="u", password="p")
imap_session.add(existing)
imap_session.commit()
resp = imap_client.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
assert resp.status_code == 403
def test_create_unlimited_on_power_tier(self, imap_engine, imap_session):
"""Power-tier users can add multiple accounts (unlimited)."""
from app.api.imap_accounts import _get_owner_id
from app.main import app
def override_db():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(bind=imap_engine)
session = Session()
try:
yield session
finally:
session.close()
def override_owner():
return _OWNER
_make_profile(imap_session, tier="business")
_make_plan(imap_session, tier="business", max_mailboxes=0)
app.dependency_overrides[get_db] = override_db
app.dependency_overrides[_get_owner_id] = override_owner
try:
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as c:
resp1 = c.post("/api/imap-accounts/", json=_BASIC_ACCOUNT)
resp2 = c.post("/api/imap-accounts/", json={**_BASIC_ACCOUNT, "name": "Second"})
assert resp1.status_code == 201
assert resp2.status_code == 201
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Integration tests update endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestUpdateImapAccount:
"""Tests for PUT /api/imap-accounts/{id}."""
def _create_account(self, session, owner: str = _OWNER) -> UserImapAccount:
acct = UserImapAccount(owner_id=owner, name="Original", host="h", port=993, username="u", password="p")
session.add(acct)
session.commit()
session.refresh(acct)
return acct
def test_update_name(self, imap_client, imap_session):
"""Updating name only changes the name."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
acct = self._create_account(imap_session)
resp = imap_client.put(f"/api/imap-accounts/{acct.id}", json={"name": "Updated"})
assert resp.status_code == 200
assert resp.json()["name"] == "Updated"
assert resp.json()["host"] == "h"
def test_update_not_found(self, imap_client, imap_session):
"""Updating a non-existent account returns 404."""
resp = imap_client.put("/api/imap-accounts/9999", json={"name": "x"})
assert resp.status_code == 404
def test_cannot_update_other_users_account(self, imap_client, imap_session):
"""A user cannot update another user's account."""
other_acct = self._create_account(imap_session, owner="other@example.com")
resp = imap_client.put(f"/api/imap-accounts/{other_acct.id}", json={"name": "Hijacked"})
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Integration tests delete endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteImapAccount:
"""Tests for DELETE /api/imap-accounts/{id}."""
def _create_account(self, session) -> UserImapAccount:
acct = UserImapAccount(owner_id=_OWNER, name="ToDelete", host="h", port=993, username="u", password="p")
session.add(acct)
session.commit()
session.refresh(acct)
return acct
def test_delete_success(self, imap_client, imap_session):
"""Deleting an account returns 204 and removes the DB row."""
acct = self._create_account(imap_session)
resp = imap_client.delete(f"/api/imap-accounts/{acct.id}")
assert resp.status_code == 204
remaining = imap_session.query(UserImapAccount).filter(UserImapAccount.id == acct.id).first()
assert remaining is None
def test_delete_not_found(self, imap_client, imap_session):
"""Deleting a non-existent account returns 404."""
resp = imap_client.delete("/api/imap-accounts/9999")
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# Integration tests test-connection endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestImapConnectionTest:
"""Tests for POST /api/imap-accounts/test and /{id}/test."""
def test_test_connection_success(self, imap_client, imap_session):
"""A successful IMAP login returns success=True."""
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {"success": True, "message": "Connection successful"}
resp = imap_client.post(
"/api/imap-accounts/test",
json={
"host": "imap.example.com",
"port": 993,
"username": "u@example.com",
"password": "pass", # noqa: S106
"use_ssl": True,
},
)
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_test_connection_failure(self, imap_client, imap_session):
"""A failed IMAP login returns success=False with a message."""
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {
"success": False,
"message": "IMAP error: authentication failed",
}
resp = imap_client.post(
"/api/imap-accounts/test",
json={
"host": "imap.example.com",
"port": 993,
"username": "u@example.com",
"password": "wrong", # noqa: S106
"use_ssl": True,
},
)
assert resp.status_code == 200
assert resp.json()["success"] is False
def test_test_saved_account(self, imap_client, imap_session):
"""Testing a saved account calls the connection helper with stored credentials."""
acct = UserImapAccount(
owner_id=_OWNER,
name="Saved",
host="imap.test.com",
port=993,
username="u",
password="p",
)
imap_session.add(acct)
imap_session.commit()
imap_session.refresh(acct)
with patch("app.api.imap_accounts._test_imap_connection") as mock_test:
mock_test.return_value = {"success": True, "message": "Connection successful"}
resp = imap_client.post(f"/api/imap-accounts/{acct.id}/test")
assert resp.status_code == 200
assert resp.json()["success"] is True
mock_test.assert_called_once_with("imap.test.com", 993, "u", "p", True)
# ---------------------------------------------------------------------------
# Integration tests quota endpoint
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestImapQuota:
"""Tests for GET /api/imap-accounts/quota/."""
def test_quota_free_tier(self, imap_client, imap_session):
"""Free-tier user reports max_mailboxes=0 and can_add=False."""
_make_profile(imap_session, tier="free")
_make_plan(imap_session, tier="free", max_mailboxes=0)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] == 0
assert data["can_add"] is False
def test_quota_starter_tier(self, imap_client, imap_session):
"""Starter-tier user with no accounts reports can_add=True."""
_make_profile(imap_session, tier="starter")
_make_plan(imap_session, tier="starter", max_mailboxes=1)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] == 1
assert data["can_add"] is True
assert data["current_count"] == 0
def test_quota_unlimited(self, imap_client, imap_session):
"""Power-tier user reports max_mailboxes=None and can_add=True."""
_make_profile(imap_session, tier="business")
_make_plan(imap_session, tier="business", max_mailboxes=0)
resp = imap_client.get("/api/imap-accounts/quota/")
assert resp.status_code == 200
data = resp.json()
assert data["max_mailboxes"] is None
assert data["can_add"] is True
# ---------------------------------------------------------------------------
# Unit tests _test_imap_connection helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestTestImapConnection:
"""Unit tests for the _test_imap_connection helper (no network calls)."""
def test_success(self):
"""Successful login returns success=True."""
from app.api.imap_accounts import _test_imap_connection
mock_mail = MagicMock()
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
result = _test_imap_connection(
"imap.example.com",
993,
"user",
"pass",
use_ssl=True, # noqa: S106
)
assert result["success"] is True
mock_mail.login.assert_called_once_with("user", "pass")
mock_mail.logout.assert_called_once()
def test_auth_error(self):
"""An exception raised by IMAP4_SSL returns success=False."""
from app.api.imap_accounts import _test_imap_connection
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
result = _test_imap_connection(
"imap.example.com",
993,
"user",
"badpass",
use_ssl=True, # noqa: S106
)
assert result["success"] is False
assert "auth failed" in result["message"]
def test_network_error(self):
"""An OSError returns success=False with a network error message."""
from app.api.imap_accounts import _test_imap_connection
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
result = _test_imap_connection(
"bad-host",
143,
"user",
"pass",
use_ssl=False, # noqa: S106
)
assert result["success"] is False
assert "connection refused" in result["message"].lower()