Files
webmail-client/backend/tests/sync/test_account_health.py
T

153 lines
6.0 KiB
Python
Raw Normal View History

"""An account whose mail server is unreachable, or refuses the password."""
from __future__ import annotations
import contextlib
import uuid
from collections.abc import AsyncIterator
import aiosmtplib
import pytest_asyncio
from aioimaplib import aioimaplib
from sqlalchemy import delete
from app.db import session_scope
from app.identity.passwords import hash_password
from app.models import Account, User
from app.providers.base import (
ProviderAuthError,
ProviderConnectError,
ProviderRejectedError,
)
from app.providers.imap_smtp.provider import _smtp_error
from app.sync.health import AUTH_GIVE_UP_AFTER, UNREACHABLE_AFTER, AccountHealth, is_temporary
@pytest_asyncio.fixture
async def account_id() -> AsyncIterator[uuid.UUID]:
async with session_scope() as session:
user = User(
handle=f"hl{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
session.add(user)
await session.flush()
account = Account(
user_id=user.id,
email="me@example.com",
imap_config={"host": "imap.example.com", "port": 993, "tls": "ssl"},
smtp_config={"host": "smtp.example.com", "port": 465, "tls": "ssl"},
secret_ct=b"",
discovery_src="manual",
state="live",
)
session.add(account)
await session.flush()
user_id, aid = user.id, account.id
try:
yield aid
finally:
async with session_scope() as session:
await session.execute(delete(User).where(User.id == user_id))
async def _account(aid: uuid.UUID) -> Account:
async with session_scope() as session:
account = await session.get(Account, aid)
assert account is not None
return account
def test_what_counts_as_temporary() -> None:
assert is_temporary(ProviderConnectError("cannot connect"))
assert is_temporary(TimeoutError())
assert is_temporary(ConnectionResetError())
assert is_temporary(aioimaplib.CommandTimeout("uid move"))
# After the connection drops, aioimaplib refuses commands this way.
assert is_temporary(aioimaplib.Abort("command UID illegal in state LOGOUT"))
assert not is_temporary(aioimaplib.Abort("server has not MOVE capability"))
assert not is_temporary(ProviderAuthError("bad password"))
assert not is_temporary(ProviderRejectedError("550 no such user"))
assert not is_temporary(ValueError("bug"))
def test_smtp_errors_say_whether_to_retry() -> None:
assert isinstance(_smtp_error(aiosmtplib.SMTPConnectError("refused")), ProviderConnectError)
assert isinstance(_smtp_error(aiosmtplib.SMTPServerDisconnected("gone")), ProviderConnectError)
assert isinstance(
_smtp_error(aiosmtplib.SMTPResponseException(451, "try later")), ProviderConnectError
)
assert isinstance(
_smtp_error(aiosmtplib.SMTPResponseException(554, "rejected")), ProviderRejectedError
)
assert isinstance(
_smtp_error(aiosmtplib.SMTPAuthenticationError(535, "bad credentials")), ProviderAuthError
)
refused = aiosmtplib.SMTPRecipientsRefused(
[aiosmtplib.SMTPRecipientRefused(550, "no such user", "x@example.com")]
)
assert isinstance(_smtp_error(refused), ProviderRejectedError)
greylisted = aiosmtplib.SMTPRecipientsRefused(
[aiosmtplib.SMTPRecipientRefused(450, "greylisted", "x@example.com")]
)
assert isinstance(_smtp_error(greylisted), ProviderConnectError)
async def test_unreachable_after_failures_in_a_row_then_clears(account_id: uuid.UUID) -> None:
health = AccountHealth(account_id)
for _ in range(UNREACHABLE_AFTER - 1):
await health.failed(ProviderConnectError("cannot connect"))
assert (await _account(account_id)).sync_error is None # a blip is not an outage
await health.failed(ProviderConnectError("cannot connect"))
account = await _account(account_id)
assert account.sync_error == "unreachable" and account.sync_error_since is not None
assert account.state == "live" # still synced: retried until it answers
await health.ok()
account = await _account(account_id)
assert account.sync_error is None and account.sync_error_since is None
async def test_mark_survives_a_restart_and_clears_on_success(account_id: uuid.UUID) -> None:
first = AccountHealth(account_id)
for _ in range(UNREACHABLE_AFTER):
await first.failed(ProviderConnectError("cannot connect"))
# A new worker knows nothing of the mark, yet its first success clears it.
await AccountHealth(account_id).ok()
assert (await _account(account_id)).sync_error is None
async def test_refused_sign_in_stops_syncing(account_id: uuid.UUID) -> None:
health = AccountHealth(account_id)
for _ in range(AUTH_GIVE_UP_AFTER - 1):
await health.failed(ProviderAuthError("AUTHENTICATIONFAILED"))
assert (await _account(account_id)).state == "live"
await health.failed(ProviderAuthError("AUTHENTICATIONFAILED"))
account = await _account(account_id)
assert account.state == "auth_failed" and "AUTHENTICATIONFAILED" in account.state_detail
async def test_ssl_connections_really_use_tls(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""Regression (v1.3.26): our IMAP4_SSL subclass skipped aioimaplib's TLS
setup, so port 993 got plain TCP and every account timed out."""
import asyncio
import ssl
from app.providers.imap_smtp.provider import _IMAP4, _IMAP4_SSL
seen: list[object] = []
loop = asyncio.get_running_loop()
async def fake_create_connection(factory, host, port, ssl=None): # type: ignore[no-untyped-def]
seen.append(ssl)
raise ConnectionRefusedError
monkeypatch.setattr(loop, "create_connection", fake_create_connection)
for cls in (_IMAP4_SSL, _IMAP4):
client = cls(host="imap.example.com", port=993, timeout=1)
with contextlib.suppress(ConnectionRefusedError):
await client.connect_task
assert isinstance(seen[0], ssl.SSLContext) # SSL: a TLS context
assert seen[1] is None # plain / STARTTLS: none up front