Unzip budget-app v7 into repo root
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import email
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import mailer
|
||||
from app.main import app
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
class FakeSMTP:
|
||||
sent: list = []
|
||||
fail_login = False
|
||||
|
||||
def __init__(self, host, port, timeout=None, context=None):
|
||||
self.host, self.port = host, port
|
||||
|
||||
def ehlo(self):
|
||||
pass
|
||||
|
||||
def starttls(self, context=None):
|
||||
pass
|
||||
|
||||
def login(self, user, password):
|
||||
if FakeSMTP.fail_login or password != "smtp-secret":
|
||||
import smtplib
|
||||
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
|
||||
|
||||
def send_message(self, msg, from_addr=None, to_addrs=None):
|
||||
FakeSMTP.sent.append((from_addr, list(to_addrs), msg))
|
||||
return {}
|
||||
|
||||
def quit(self):
|
||||
pass
|
||||
|
||||
|
||||
class FakeIMAP:
|
||||
appended: list = []
|
||||
folders = [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Itens Enviados"',
|
||||
b'(\\HasNoChildren) "/" "Sent"']
|
||||
|
||||
def __init__(self, host, port, ssl_context=None, timeout=None):
|
||||
pass
|
||||
|
||||
def login(self, user, password):
|
||||
assert password == "smtp-secret"
|
||||
|
||||
def list(self):
|
||||
return "OK", list(FakeIMAP.folders)
|
||||
|
||||
def append(self, folder, flags, date, data):
|
||||
FakeIMAP.appended.append((folder, flags, data))
|
||||
return "OK", [b"APPEND completed"]
|
||||
|
||||
def logout(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_mail(monkeypatch):
|
||||
FakeSMTP.sent, FakeIMAP.appended, FakeSMTP.fail_login = [], [], False
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP)
|
||||
monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP)
|
||||
monkeypatch.setattr(mailer.imaplib, "IMAP4_SSL", FakeIMAP)
|
||||
monkeypatch.setattr(mailer.imaplib, "IMAP4", FakeIMAP)
|
||||
|
||||
|
||||
def configure(client, **extra):
|
||||
body = {"enabled": True, "smtp_host": "mail.local", "smtp_port": 587, "smtp_security": "starttls",
|
||||
"smtp_username": "budget@local.test", "smtp_password": "smtp-secret",
|
||||
"from_address": "budget@local.test", "from_name": "Budget", "save_to_sent": True,
|
||||
"imap_host": "mail.local", "imap_port": 993, "imap_security": "ssl", **extra}
|
||||
r = client.put("/api/mail/settings", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_settings_hide_and_encrypt_passwords(client, fake_mail):
|
||||
out = configure(client)
|
||||
assert out["smtp_password_set"] and "smtp_password" not in out and out["ready"]
|
||||
from app.db import SessionLocal
|
||||
from app.models import MailSettings
|
||||
with SessionLocal() as db:
|
||||
stored = db.get(MailSettings, 1).smtp_password
|
||||
assert stored and "smtp-secret" not in stored and mailer.decrypt(stored) == "smtp-secret"
|
||||
# saving again without a password keeps it
|
||||
configure(client, smtp_password="")
|
||||
assert client.post("/api/mail/test", json={}).json() == {"smtp": "ok", "imap": "ok", "sent_folder": "Itens Enviados"}
|
||||
|
||||
|
||||
def test_send_report_saves_sent_copy_and_logs(client, imported, fake_mail):
|
||||
configure(client)
|
||||
r = client.post(f"/api/years/{imported}/email", json={
|
||||
"to": ["cfo@example.com; ops@example.com"], "cc": ["boss@example.com"], "subject": "Budget 2027",
|
||||
"message": "Segue o orçamento.", "lang": "pt", "copy_me": True,
|
||||
"reports": [{"kind": "cost-report", "fmt": "pdf"}, {"kind": "budget", "fmt": "xlsx"}]})
|
||||
assert r.status_code == 200, r.text
|
||||
log = r.json()
|
||||
assert log["status"] == "sent" and log["sent_copy"] == "saved" and log["sent_copy_detail"] == "Itens Enviados"
|
||||
assert log["to"] == ["cfo@example.com", "ops@example.com"]
|
||||
assert log["cc"] == ["boss@example.com", "admin@test.local"]
|
||||
|
||||
sender, rcpts, msg = FakeSMTP.sent[0]
|
||||
assert sender == "budget@local.test" and len(rcpts) == 4
|
||||
names = [p.get_filename() for p in msg.iter_attachments()]
|
||||
assert names == ["Budget_IT_2027.xlsx", "Capex_e_Custos_IT_2027.pdf"]
|
||||
folder, flags, data = FakeIMAP.appended[0]
|
||||
assert folder == '"Itens Enviados"' and flags == r"(\Seen)"
|
||||
copy = email.message_from_bytes(data)
|
||||
assert copy["Message-ID"] == msg["Message-ID"] and copy["Subject"] == "Budget 2027"
|
||||
|
||||
rows = client.get("/api/mail/log").json()
|
||||
assert rows["total"] == 1 and rows["rows"][0]["user"] == "Administrator"
|
||||
assert [a["name"] for a in rows["rows"][0]["attachments"]] == names
|
||||
assert "cfo@example.com" in client.get("/api/mail/log.csv").text
|
||||
acts = client.get("/api/activity?entity=mail").json()["rows"]
|
||||
assert acts[0]["action"] == "send" and "cfo@example.com" in acts[0]["summary"]
|
||||
|
||||
|
||||
def test_failed_send_is_logged(client, imported, fake_mail):
|
||||
configure(client)
|
||||
FakeSMTP.fail_login = True
|
||||
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert r.status_code == 502 and r.json()["detail"] == "smtp_auth"
|
||||
row = client.get("/api/mail/log?status=failed").json()["rows"][0]
|
||||
assert row["error"].startswith("smtp_auth") and row["sent_copy"] == "off"
|
||||
assert not FakeIMAP.appended
|
||||
|
||||
|
||||
def test_sent_folder_found_by_name(fake_mail, monkeypatch):
|
||||
monkeypatch.setattr(FakeIMAP, "folders", [b'(\\HasNoChildren) "." "INBOX"', b'(\\HasNoChildren) "." "INBOX.Sent"'])
|
||||
assert mailer.find_sent_folder(FakeIMAP("h", 1), "") == "INBOX.Sent"
|
||||
with pytest.raises(mailer.MailError) as exc:
|
||||
mailer.find_sent_folder(FakeIMAP("h", 1), "Outbox")
|
||||
assert exc.value.code == "sent_folder_not_found"
|
||||
|
||||
|
||||
def test_mail_rules(client, imported, fake_mail):
|
||||
# not configured yet
|
||||
r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert r.status_code == 409
|
||||
configure(client)
|
||||
bad = client.post(f"/api/years/{imported}/email", json={"to": ["not-an-address"],
|
||||
"reports": [{"kind": "budget", "fmt": "pdf"}]})
|
||||
assert bad.status_code == 400 and bad.json()["detail"].startswith("invalid_address")
|
||||
assert client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], "reports": []}).status_code == 400
|
||||
|
||||
client.post("/api/users", json={"email": "v@t.local", "name": "Viewer", "role": "viewer",
|
||||
"password": "password-123"})
|
||||
viewer = TestClient(app)
|
||||
viewer.headers.update(H)
|
||||
viewer.post("/api/auth/login", json={"email": "v@t.local", "password": "password-123"})
|
||||
# any signed-in user may email a report; only admins see the settings and the logs
|
||||
assert viewer.get("/api/mail/status").json()["enabled"] is True
|
||||
assert viewer.post(f"/api/years/{imported}/email", json={
|
||||
"to": ["a@example.com"], "reports": [{"kind": "budget", "fmt": "xlsx"}]}).status_code == 200
|
||||
for url in ("/api/mail/settings", "/api/mail/log", "/api/mail/log.csv", "/api/activity", "/api/activity.csv"):
|
||||
assert viewer.get(url).status_code == 403, url
|
||||
assert viewer.put("/api/mail/settings", json={"enabled": False}).status_code == 403
|
||||
|
||||
|
||||
def test_only_admin_deletes_budgets(client, imported):
|
||||
client.post("/api/users", json={"email": "ap@t.local", "name": "Approver", "role": "approver",
|
||||
"password": "password-123", "department_id": 1})
|
||||
ap = TestClient(app)
|
||||
ap.headers.update(H)
|
||||
ap.post("/api/auth/login", json={"email": "ap@t.local", "password": "password-123"})
|
||||
assert ap.get(f"/api/years/{imported}").json()["actions"]["delete"] is False
|
||||
assert ap.delete(f"/api/years/{imported}").status_code == 403
|
||||
# an admin can delete it even after approval
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
|
||||
assert client.get(f"/api/years/{imported}").json()["actions"]["delete"] is True
|
||||
assert client.delete(f"/api/years/{imported}").status_code == 200
|
||||
assert client.get(f"/api/years/{imported}").status_code == 404
|
||||
row = client.get("/api/activity?action=delete&entity=year").json()["rows"][0]
|
||||
assert row["user"] == "Administrator" and "IT 2027" in row["summary"] and row["budget_exists"] is False
|
||||
|
||||
|
||||
def test_activity_log_records_who_does_what(client, imported):
|
||||
client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"})
|
||||
client.get(f"/api/years/{imported}/reports/budget.xlsx")
|
||||
data = client.get("/api/activity").json()
|
||||
actions = [(r["entity"], r["action"]) for r in data["rows"]]
|
||||
assert ("report", "download") in actions and ("user", "login_failed") in actions and ("year", "import") in actions
|
||||
assert data["rows"][0]["budget"] == "IT 2027" or data["rows"][0]["entity"] == "user"
|
||||
only = client.get("/api/activity?entity=report").json()
|
||||
assert only["total"] == 1 and only["rows"][0]["budget"] == "IT 2027"
|
||||
assert "budget.xlsx" in client.get("/api/activity.csv?entity=report").text
|
||||
Reference in New Issue
Block a user