You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
299 lines
11 KiB
Python
299 lines
11 KiB
Python
# backend/tests/test_phase16_mail.py — phase-16 실계정 메일(Gmail/Outlook OAuth + 멀티계정 + 발송)
|
|
import time
|
|
|
|
import pytest
|
|
from sqlmodel import select
|
|
|
|
from app.config import get_settings
|
|
from app.connectors import oauth
|
|
from app.connectors.base import RawRecord
|
|
from app.connectors.mail.normalize import mail_account_id_for, normalize_email
|
|
from app.connectors.mail.outbound import send_outbound
|
|
from app.connectors.mail.real_outlook import OutlookConnector
|
|
from app.connectors.registry import ConnectorRegistry, _impl
|
|
from app.crypto import encrypt_token
|
|
from app.models import (
|
|
ConnectorAccount,
|
|
ConnectorDomain,
|
|
ConnectorMode,
|
|
ConnState,
|
|
MailAccount,
|
|
OAuthState,
|
|
OutboundMail,
|
|
Sent,
|
|
)
|
|
|
|
|
|
class _FakeResp:
|
|
def __init__(self, status, payload=None):
|
|
self.status_code = status
|
|
self._payload = payload or {}
|
|
|
|
def json(self):
|
|
return self._payload
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f"http {self.status_code}")
|
|
|
|
|
|
@pytest.fixture()
|
|
def oauth_creds(monkeypatch):
|
|
monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid")
|
|
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "g-sec")
|
|
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid")
|
|
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec")
|
|
get_settings.cache_clear()
|
|
yield
|
|
get_settings.cache_clear()
|
|
|
|
|
|
def _mock_token_and_identity(monkeypatch, *, email, token=None):
|
|
tok = token or {"access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "s"}
|
|
monkeypatch.setattr(oauth.httpx, "post", lambda *a, **k: _FakeResp(200, tok))
|
|
# google: gmail/v1/users/me/profile → emailAddress ; microsoft: graph /me → mail
|
|
monkeypatch.setattr(
|
|
oauth.httpx,
|
|
"get",
|
|
lambda *a, **k: _FakeResp(200, {"emailAddress": email, "mail": email}),
|
|
)
|
|
|
|
|
|
# ── OAuth: Outlook provider + tenant URL ──
|
|
def test_outlook_authorize_url_uses_microsoft_tenant(session, oauth_creds):
|
|
s, _ = session
|
|
url = oauth.start_oauth(s, "mail", "outlook", redirect_after="/settings?tab=mail")
|
|
assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
|
|
assert "code_challenge=" in url and "state=" in url # PKCE 보존
|
|
assert "Mail.Read" in url and "Mail.Send" in url and "offline_access" in url
|
|
|
|
|
|
# ── 멀티계정: 이메일별 고유 계정 id + MailAccount upsert ──
|
|
def test_finish_oauth_creates_unique_account_per_email(session, oauth_creds, monkeypatch):
|
|
s, _ = session
|
|
|
|
def connect(provider, email):
|
|
url = oauth.start_oauth(s, "mail", provider)
|
|
state = url.split("state=")[1].split("&")[0]
|
|
_mock_token_and_identity(monkeypatch, email=email)
|
|
return oauth.finish_oauth(s, code="C", state=state)
|
|
|
|
a1 = connect("gmail", "alice@gmail.com")
|
|
a2 = connect("gmail", "bob@gmail.com")
|
|
a3 = connect("outlook", "carol@outlook.com")
|
|
|
|
# 같은 provider 여도 이메일이 다르면 계정 id 가 갈린다(덮어쓰기 없음)
|
|
assert a1.id != a2.id
|
|
assert a1.external_account_id == "alice@gmail.com"
|
|
assert a3.provider == "outlook" and a3.mode == ConnectorMode.real
|
|
# 메일 페이지 노출용 MailAccount 도 함께 생성
|
|
assert s.get(MailAccount, mail_account_id_for("alice@gmail.com")) is not None
|
|
assert s.get(MailAccount, mail_account_id_for("carol@outlook.com")) is not None
|
|
# state row 소거
|
|
assert s.get(OAuthState, "x") is None
|
|
|
|
|
|
# ── normalize: Graph(Outlook) 메시지 → 내부 Email 필드 ──
|
|
def test_normalize_outlook_golden(session):
|
|
acct = ConnectorAccount(
|
|
id="ca-mail-outlook-xyz",
|
|
domain=ConnectorDomain.mail,
|
|
provider="outlook",
|
|
external_account_id="me@outlook.com",
|
|
)
|
|
payload = {
|
|
"id": "AAMk-1",
|
|
"subject": "분기 리포트 검토 부탁",
|
|
"bodyPreview": "안녕하세요, 리포트 초안입니다.",
|
|
"body": {"contentType": "text", "content": "안녕하세요,\n리포트 초안입니다.\n확인 부탁드려요."},
|
|
"from": {"emailAddress": {"name": "현우", "address": "hyunwoo@corp.com"}},
|
|
"toRecipients": [{"emailAddress": {"address": "me@outlook.com"}}],
|
|
"isRead": False,
|
|
"flag": {"flagStatus": "flagged"},
|
|
"hasAttachments": True,
|
|
"categories": ["업무"],
|
|
"receivedDateTime": "2026-06-15T09:00:00Z",
|
|
}
|
|
raw = RawRecord(external_id="AAMk-1", payload=payload, etag="W/1")
|
|
norm = normalize_email(acct, raw, provider="outlook")
|
|
f = norm.fields
|
|
assert f["account"] == mail_account_id_for("me@outlook.com")
|
|
# 발신자 표시 이름 보존('이름 <메일>') — 목록은 이름만, 리더는 이름+주소 노출.
|
|
assert f["from_key"] == "현우 <hyunwoo@corp.com>"
|
|
assert f["subject"] == "분기 리포트 검토 부탁"
|
|
assert f["read"] is False and f["starred"] is True and f["has_attach"] is True
|
|
assert "리포트 초안입니다." in " ".join(f["body"])
|
|
assert f["labels"] == ["업무"]
|
|
|
|
|
|
def test_normalize_outlook_html_body_stripped(session):
|
|
acct = ConnectorAccount(id="ca-o2", domain=ConnectorDomain.mail, provider="outlook")
|
|
payload = {
|
|
"id": "h1",
|
|
"subject": "공지",
|
|
"bodyPreview": "프리뷰",
|
|
"body": {"contentType": "html", "content": "<div><p>안녕하세요</p><p>좋은 하루</p></div>"},
|
|
"from": {"emailAddress": {"address": "x@y.com"}},
|
|
"isRead": True,
|
|
}
|
|
norm = normalize_email(acct, RawRecord(external_id="h1", payload=payload), provider="outlook")
|
|
# 태그 제거 + 블록(<p>) 경계마다 줄바꿈 → 읽기 좋은 다중 줄.
|
|
assert norm.fields["body"] == ["안녕하세요", "좋은 하루"]
|
|
|
|
|
|
# ── registry: provider 로 Gmail/Outlook 분기 ──
|
|
def test_connector_dispatch_by_provider(session):
|
|
s, _ = session
|
|
from app.connectors.mail.real_gmail import GmailConnector
|
|
|
|
assert _impl("mail", "real", "outlook").__name__ == "OutlookConnector"
|
|
assert _impl("mail", "real", "gmail").__name__ == "GmailConnector"
|
|
out_acct = ConnectorAccount(
|
|
id="ca-o", domain=ConnectorDomain.mail, provider="outlook", mode=ConnectorMode.real
|
|
)
|
|
gm_acct = ConnectorAccount(
|
|
id="ca-g", domain=ConnectorDomain.mail, provider="gmail", mode=ConnectorMode.real
|
|
)
|
|
assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookConnector)
|
|
assert isinstance(ConnectorRegistry.get(s, gm_acct), GmailConnector)
|
|
|
|
|
|
# ── 발송: real 연결 계정 → connector.send_mail 호출 ──
|
|
def test_send_outbound_real_calls_connector(session, monkeypatch):
|
|
s, _ = session
|
|
acct = ConnectorAccount(
|
|
id="ca-mail-gmail-abc",
|
|
domain=ConnectorDomain.mail,
|
|
provider="gmail",
|
|
mode=ConnectorMode.real,
|
|
state=ConnState.connected,
|
|
external_account_id="me@gmail.com",
|
|
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
|
|
)
|
|
s.add(acct)
|
|
ob = OutboundMail(
|
|
id="ob-1",
|
|
approval_id="ap-1",
|
|
connector_account_id=acct.id,
|
|
mail_account=mail_account_id_for("me@gmail.com"),
|
|
to="친구 <pal@x.com>",
|
|
subject="안녕",
|
|
body="잘 지내?",
|
|
status="pending",
|
|
)
|
|
s.add(ob)
|
|
s.commit()
|
|
|
|
calls = {}
|
|
from app.connectors.mail.real_gmail import GmailConnector
|
|
|
|
def fake_send(self, session, *, to, subject, body, cc="", bcc="", attachments=None):
|
|
calls.update(to=to, subject=subject, body=body)
|
|
return "sent"
|
|
|
|
monkeypatch.setattr(GmailConnector, "send_mail", fake_send)
|
|
out = send_outbound(s, ob)
|
|
assert out.status == "sent" and out.sent_at is not None
|
|
assert calls == {"to": "친구 <pal@x.com>", "subject": "안녕", "body": "잘 지내?"}
|
|
|
|
|
|
# ── 발송: 미연결 → failed(가짜 발송 없음, phase-16+) ──
|
|
def test_send_outbound_unconnected_fails(session):
|
|
s, _ = session
|
|
ob = OutboundMail(
|
|
id="ob-2",
|
|
approval_id="ap-2",
|
|
connector_account_id=None,
|
|
mail_account="work",
|
|
to="대표님",
|
|
subject="보고",
|
|
body="첨부 확인 부탁드립니다.",
|
|
status="pending",
|
|
)
|
|
s.add(ob)
|
|
s.commit()
|
|
out = send_outbound(s, ob)
|
|
assert out.status == "failed" # 가짜 Sent 행 생성 없음
|
|
assert s.exec(select(Sent)).all() == []
|
|
|
|
|
|
# ── 발송 실패는 failed 로 격리(서버 안 죽음) ──
|
|
def test_send_outbound_failure_isolated(session, monkeypatch):
|
|
s, _ = session
|
|
acct = ConnectorAccount(
|
|
id="ca-fail",
|
|
domain=ConnectorDomain.mail,
|
|
provider="gmail",
|
|
mode=ConnectorMode.real,
|
|
state=ConnState.connected,
|
|
external_account_id="me@gmail.com",
|
|
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
|
|
)
|
|
s.add(acct)
|
|
ob = OutboundMail(
|
|
id="ob-3", approval_id="ap-3", connector_account_id=acct.id, mail_account="x",
|
|
to="a@b.com", subject="s", body="b", status="pending",
|
|
)
|
|
s.add(ob)
|
|
s.commit()
|
|
from app.connectors.mail.real_gmail import GmailConnector
|
|
|
|
def boom(self, session, **k):
|
|
raise RuntimeError("network down")
|
|
|
|
monkeypatch.setattr(GmailConnector, "send_mail", boom)
|
|
out = send_outbound(s, ob)
|
|
assert out.status == "failed" and "network down" in out.error_detail
|
|
|
|
|
|
# ── API: /connectors/providers 구성 여부 ──
|
|
def test_providers_endpoint_unconfigured(client):
|
|
rows = client.get("/api/connectors/providers?domain=mail").json()
|
|
by = {r["provider"]: r for r in rows}
|
|
assert set(by) == {"gmail", "outlook"}
|
|
assert by["gmail"]["configured"] is False and by["outlook"]["configured"] is False
|
|
|
|
|
|
def test_providers_endpoint_configured(client, oauth_creds):
|
|
rows = client.get("/api/connectors/providers").json()
|
|
by = {r["provider"]: r for r in rows}
|
|
assert by["gmail"]["configured"] is True and by["outlook"]["configured"] is True
|
|
|
|
|
|
# ── API: /mail/send (연결된 계정) → 결재 + OutboundMail(pending) 생성 ──
|
|
def test_mail_send_creates_pending_outbound(client, session):
|
|
s, _ = session
|
|
acct = ConnectorAccount(
|
|
id="ca-mail-gmail-x", domain=ConnectorDomain.mail, provider="gmail",
|
|
mode=ConnectorMode.real, state=ConnState.connected, external_account_id="me@gmail.com",
|
|
)
|
|
s.add(acct)
|
|
s.commit()
|
|
slug = mail_account_id_for("me@gmail.com")
|
|
r = client.post(
|
|
"/api/mail/send",
|
|
json={"from_account": slug, "to": "대표님", "subject": "보고", "body": "확인 부탁"},
|
|
)
|
|
assert r.status_code == 200
|
|
aid = r.json()["approval_id"]
|
|
s.expire_all()
|
|
ob = s.exec(select(OutboundMail).where(OutboundMail.approval_id == aid)).first()
|
|
assert ob is not None and ob.status == "pending"
|
|
assert ob.connector_account_id == "ca-mail-gmail-x" # 연결된 real 계정에 묶임
|
|
assert ob.subject == "보고"
|
|
|
|
|
|
def test_mail_send_unconnected_rejected(client, session):
|
|
# 연결된 계정 없으면 400(가짜 발송 없음)
|
|
r = client.post(
|
|
"/api/mail/send",
|
|
json={"from_account": "work", "to": "대표님", "subject": "보고", "body": "x"},
|
|
)
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── 시작 검증: 미구성 outlook 은 400 ──
|
|
def test_oauth_start_outlook_requires_microsoft_client_id(client):
|
|
r = client.get("/api/connectors/oauth/start?domain=mail&provider=outlook")
|
|
assert r.status_code == 400
|