128 lines
5.0 KiB
Python
128 lines
5.0 KiB
Python
"""An account whose mail server is unreachable, or refuses the password."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
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
|