Unzip budget-app v7 into repo root
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
DB = Path(__file__).parent / "test.db"
|
||||
os.environ["DATABASE_URL"] = f"sqlite:///{DB}"
|
||||
os.environ["ADMIN_EMAIL"] = "admin@test.local"
|
||||
os.environ["ADMIN_PASSWORD"] = "admin-pass-123"
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.bootstrap import bootstrap # noqa: E402
|
||||
from app.db import Base, engine # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "budget_2027.xlsx"
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
Base.metadata.drop_all(engine)
|
||||
Base.metadata.create_all(engine)
|
||||
bootstrap()
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/auth/login", json={"email": "admin@test.local", "password": "admin-pass-123"}, headers=H)
|
||||
assert r.status_code == 200
|
||||
c.headers.update(H)
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def imported(client):
|
||||
with FIXTURE.open("rb") as f:
|
||||
r = client.post("/api/years/import", files={"file": ("b.xlsx", f, "application/octet-stream")})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["year"]["id"]
|
||||
Binary file not shown.
@@ -0,0 +1,113 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
|
||||
# Category totals from the "Capex e Custos 2027 v4" PDF. Lines are rounded to cents before
|
||||
# summing, so a category can differ from the PDF by a cent or two.
|
||||
PDF_TOTALS = {
|
||||
"Contract Services": (600758.54, 615940.02),
|
||||
"Cost of Cell Phones": (96532.00, 111011.80),
|
||||
"Cost of Internet Services": (74160.00, 85284.00),
|
||||
"Cost of Local Calls": (5575.00, 6269.13),
|
||||
"Cost of Long Distance Calls": (11415.00, 12488.47),
|
||||
"Other Cost of Services": (176847.00, 185226.36),
|
||||
"Hardware": (37636.00, 83361.00),
|
||||
"Property Operation and Maintenance": (31283.00, 55015.83),
|
||||
"Other IT Equipment": (72566.00, 70747.24),
|
||||
"Travel-Other": (14400.00, 16880.00),
|
||||
}
|
||||
|
||||
|
||||
def test_requires_login():
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
assert TestClient(app).get("/api/years").status_code == 401
|
||||
|
||||
|
||||
def test_writes_need_header(client):
|
||||
r = client.post("/api/years", json={"year": 2030}, headers={"X-Requested-With": ""})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_import_matches_pdf(client, imported):
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
assert y["year"] == 2027 and y["default_rate"] == pytest.approx(0.055)
|
||||
got = {c["category"]["name_en"]: c["totals"] for c in y["categories"] if c["lines"]}
|
||||
for name, (prior, adjusted) in PDF_TOTALS.items():
|
||||
assert got[name]["prior"] == pytest.approx(prior, abs=0.005)
|
||||
assert got[name]["adjusted"] == pytest.approx(adjusted, abs=0.02)
|
||||
assert y["costs"]["prior"] == pytest.approx(1121172.54, abs=0.005)
|
||||
assert y["costs"]["adjusted"] == pytest.approx(1242223.85, abs=0.05)
|
||||
assert sum(len(c["lines"]) for c in y["categories"]) == 54
|
||||
hw = next(c for c in y["categories"] if c["category"]["name_en"] == "Hardware")
|
||||
oriente = next(l for l in hw["lines"] if "ORIENTE" in l["name"])
|
||||
assert oriente["adj_kind"] == "factor" and oriente["adj_value"] == pytest.approx(3.055)
|
||||
assert "Hardware price increase" in oriente["adj_reason"]
|
||||
protel = next(l for c in y["categories"] for l in c["lines"] if l["name"] == "Protel")
|
||||
assert protel["prior_expr"] == "5512*12"
|
||||
|
||||
|
||||
def test_line_crud_and_month_spread(client, imported):
|
||||
cats = client.get("/api/categories").json()
|
||||
training = next(c for c in cats if c["name_en"] == "Training")
|
||||
r = client.post(f"/api/years/{imported}/lines", json={
|
||||
"category_id": training["id"], "name": "Microsoft certification", "prior_expr": "1000",
|
||||
"base_expr": "=4*300", "adj_kind": "percent", "adj_value": 10})
|
||||
assert r.status_code == 200, r.text
|
||||
line_id = r.json()["id"]
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
|
||||
assert line["base"] == 1200 and line["adjusted"] == 1320 and sum(line["months"]) == pytest.approx(1320)
|
||||
assert client.put(f"/api/lines/{line_id}", json={"base_expr": "abc"}).status_code == 400
|
||||
r = client.put(f"/api/lines/{line_id}", json={"monthly_kind": "manual"})
|
||||
assert r.status_code == 200
|
||||
y = client.get(f"/api/years/{imported}").json()
|
||||
line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id)
|
||||
assert line["monthly_values"][0] == 110 and line["adjusted"] == pytest.approx(1320)
|
||||
assert client.delete(f"/api/lines/{line_id}").status_code == 200
|
||||
audit = client.get(f"/api/years/{imported}/audit").json()
|
||||
assert [a["action"] for a in audit[:3]] == ["delete", "update", "create"]
|
||||
|
||||
|
||||
def test_status_workflow_and_roles(client, imported):
|
||||
it = client.get("/api/departments").json()[0]["id"]
|
||||
r = client.post("/api/users", json={"email": "ed@test.local", "name": "Ed", "role": "editor",
|
||||
"password": "editor-pass-1", "department_id": it})
|
||||
assert r.status_code == 200
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
ed = TestClient(app)
|
||||
ed.headers.update({"X-Requested-With": "budget-app"})
|
||||
assert ed.post("/api/auth/login", json={"email": "ed@test.local", "password": "editor-pass-1"}).status_code == 200
|
||||
assert ed.get("/api/users").status_code == 403
|
||||
assert ed.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200
|
||||
assert ed.post(f"/api/years/{imported}/status", json={"action": "approve"}).status_code == 403
|
||||
line = client.get(f"/api/years/{imported}").json()["categories"][1]["lines"][0]
|
||||
assert ed.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 409
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "approve"}).json()["status"] == "approved"
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen"}).status_code == 400 # reason needed
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "reopen", "comment": "new contract"}).json()["status"] == "draft"
|
||||
|
||||
|
||||
def test_rollover(client, imported):
|
||||
r = client.post("/api/years", json={"year": 2028, "copy_from_id": imported})
|
||||
assert r.status_code == 200
|
||||
y = client.get(f"/api/years/{r.json()['id']}").json()
|
||||
assert y["costs"]["prior"] == pytest.approx(1242223.85, abs=0.05)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["cost-report", "budget"])
|
||||
@pytest.mark.parametrize("lang", ["pt", "en"])
|
||||
def test_reports(client, imported, kind, lang):
|
||||
html = client.get(f"/api/years/{imported}/reports/{kind}.html?lang={lang}")
|
||||
assert html.status_code == 200
|
||||
assert ("<svg" if kind == "cost-report" else "<table") in html.text
|
||||
xlsx = client.get(f"/api/years/{imported}/reports/{kind}.xlsx?lang={lang}")
|
||||
assert xlsx.status_code == 200
|
||||
wb = openpyxl.load_workbook(BytesIO(xlsx.content))
|
||||
assert len(wb.sheetnames) >= 2
|
||||
pdf = client.get(f"/api/years/{imported}/reports/{kind}.pdf?lang={lang}")
|
||||
assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF"
|
||||
@@ -0,0 +1,30 @@
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.calc import ExpressionError, evaluate, even_spread
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("=5512*12", "66144"),
|
||||
("3292.56+(12*839.67)+(12*148.19)", "15146.88"),
|
||||
("4 839,65", "4839.65"),
|
||||
("1.234,50", "1234.50"),
|
||||
("1,234.50", "1234.50"),
|
||||
("", "0"),
|
||||
("-10/4", "-2.5"),
|
||||
])
|
||||
def test_evaluate(text, expected):
|
||||
assert evaluate(text) == Decimal(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["__import__('os')", "2**10", "1/0", "abc", "1;2"])
|
||||
def test_evaluate_rejects(text):
|
||||
with pytest.raises(ExpressionError):
|
||||
evaluate(text)
|
||||
|
||||
|
||||
def test_even_spread_adds_up():
|
||||
months = even_spread(Decimal("100.00"))
|
||||
assert sum(months) == Decimal("100.00")
|
||||
assert months[0] == Decimal("8.33") and months[-1] == Decimal("8.37")
|
||||
@@ -0,0 +1,127 @@
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
def login(email, password):
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
assert c.post("/api/auth/login", json={"email": email, "password": password}).status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def org(client, imported):
|
||||
"""IT (with the imported 2027 budget) and F&B, one editor and one approver in each."""
|
||||
it = client.get("/api/departments").json()[0]
|
||||
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
|
||||
users = {}
|
||||
for key, role, dept in (("it_ed", "editor", it), ("it_ap", "approver", it),
|
||||
("fb_ed", "editor", fb), ("fb_ap", "approver", fb)):
|
||||
r = client.post("/api/users", json={"email": f"{key}@t.local", "name": key, "role": role,
|
||||
"password": "password-123", "department_id": dept["id"]})
|
||||
assert r.status_code == 200, r.text
|
||||
users[key] = login(f"{key}@t.local", "password-123")
|
||||
return {"it": it, "fb": fb, "year": imported, **users}
|
||||
|
||||
|
||||
def test_categories_are_per_department(client, org):
|
||||
it_cats = client.get(f"/api/categories?department_id={org['it']['id']}").json()
|
||||
fb_cats = client.get(f"/api/categories?department_id={org['fb']['id']}").json()
|
||||
assert len(it_cats) == len(fb_cats) == 50
|
||||
assert not {c["id"] for c in it_cats} & {c["id"] for c in fb_cats}
|
||||
# a line in an IT budget cannot use an F&B category
|
||||
r = client.post(f"/api/years/{org['year']}/lines", json={"category_id": fb_cats[0]["id"], "name": "x"})
|
||||
assert r.status_code == 400 and r.json()["detail"] == "invalid_category"
|
||||
# the same year can exist once per department
|
||||
r = org["fb_ed"].post("/api/years", json={"year": 2027})
|
||||
assert r.status_code == 200 and r.json()["department"] == "F&B"
|
||||
|
||||
|
||||
def test_other_departments_can_only_view(org):
|
||||
fb = org["fb_ed"]
|
||||
y = fb.get(f"/api/years/{org['year']}").json()
|
||||
assert y["department"] == "IT" and y["actions"]["edit"] is False
|
||||
line = y["categories"][1]["lines"][0]
|
||||
assert fb.put(f"/api/lines/{line['id']}", json={"name": "hack"}).status_code == 403
|
||||
assert fb.post(f"/api/years/{org['year']}/status", json={"action": "submit"}).status_code == 403
|
||||
assert fb.get(f"/api/years/{org['year']}/reports/cost-report.xlsx").status_code == 200
|
||||
assert org["it_ed"].put(f"/api/lines/{line['id']}", json={"remarks": "ok"}).status_code == 200
|
||||
|
||||
|
||||
def test_custom_workflow(client, org):
|
||||
it = org["it"]["id"]
|
||||
users = {u["email"]: u["id"] for u in client.get("/api/users").json()}
|
||||
r = client.put(f"/api/departments/{it}/workflow", json={"steps": [
|
||||
{"name": "Department head", "approver_mode": "role"},
|
||||
{"name": "Finance", "approver_mode": "users", "approver_ids": [users["fb_ap@t.local"]]},
|
||||
]})
|
||||
assert r.status_code == 200 and [s["name"] for s in r.json()["steps"]] == ["Department head", "Finance"]
|
||||
y = org["year"]
|
||||
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}).json()["current_step"] == 0
|
||||
# step 1: only IT approvers; the F&B approver is not on this step
|
||||
assert org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
r = org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
|
||||
assert r.json()["status"] == "in_review" and r.json()["current_step"] == 1
|
||||
# step 2: only the named user
|
||||
assert org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403
|
||||
r = org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"})
|
||||
assert r.json()["status"] == "approved"
|
||||
# the workflow cannot be changed while a budget is in review
|
||||
org["it_ap"].post(f"/api/years/{y}/status", json={"action": "reopen", "comment": "fix a line"})
|
||||
org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"})
|
||||
assert client.put(f"/api/departments/{it}/workflow", json={"steps": []}).status_code == 409
|
||||
|
||||
|
||||
def test_template_roundtrip(client, org):
|
||||
fb = org["fb"]["id"]
|
||||
r = org["fb_ed"].get(f"/api/departments/{fb}/template.xlsx?lang=pt&year=2028&rate=3")
|
||||
assert r.status_code == 200
|
||||
wb = openpyxl.load_workbook(BytesIO(r.content))
|
||||
assert wb.sheetnames == ["Instruções", "Linhas", "Listas"]
|
||||
ws = wb["Linhas"]
|
||||
rows = [
|
||||
["Formação", "Curso de barista", "", "Chiado", 1000, "=4*300", "Percentagem", 10, "", "nota"],
|
||||
["Vinhos", "Carta de vinhos", "", "", 5000, 5000, "Fator", 1.2, "Preços", ""],
|
||||
["Receitas", "Eventos", "", "Grupo", 0, None, None, None, "", ""] + [100] * 12,
|
||||
["Nova categoria", "Algo novo", "", "Lua", 10, 10, "", None, "", ""],
|
||||
]
|
||||
for i, row in enumerate(rows):
|
||||
for j, v in enumerate(row):
|
||||
ws.cell(9 + i, j + 1, v)
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
r = org["fb_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(fb)})
|
||||
assert r.status_code == 200, r.text
|
||||
res = r.json()
|
||||
assert res["lines"] == 4 and res["categories_created"] == ["Nova categoria"]
|
||||
assert any("Lua" in w for w in res["warnings"])
|
||||
y = client.get(f"/api/years/{res['year']['id']}").json()
|
||||
assert y["year"] == 2028 and y["default_rate"] == pytest.approx(0.03) and y["department"] == "F&B"
|
||||
lines = {l["name"]: l for c in y["categories"] for l in c["lines"]}
|
||||
assert lines["Curso de barista"]["adjusted"] == 1320
|
||||
assert lines["Carta de vinhos"]["adjusted"] == 6000
|
||||
assert lines["Eventos"]["adjusted"] == 1200 and lines["Eventos"]["monthly_kind"] == "manual"
|
||||
assert lines["Algo novo"]["adjusted"] == pytest.approx(10.3)
|
||||
# the IT editor cannot import into F&B
|
||||
r = org["it_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(fb), "replace": "true"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_prefilled_template_reimports_same_totals(client, org):
|
||||
it = org["it"]["id"]
|
||||
r = client.get(f"/api/departments/{it}/template.xlsx?lang=en&from_year_id={org['year']}&year=2029&rate=5.5")
|
||||
res = client.post("/api/years/import", files={"file": ("t.xlsx", r.content)}, data={"department_id": str(it)}).json()
|
||||
assert res["lines"] == 54
|
||||
a = client.get(f"/api/years/{org['year']}").json()["costs"]
|
||||
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
|
||||
assert a["adjusted"] == pytest.approx(b["adjusted"], abs=0.01) and a["prior"] == b["prior"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,71 @@
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
import openpyxl
|
||||
|
||||
from app.calc import selected_spread
|
||||
|
||||
|
||||
def test_selected_spread_in_cents():
|
||||
months = selected_spread(Decimal("1000.00"), [1, 2, 3])
|
||||
assert months[:3] == [Decimal("333.33"), Decimal("333.33"), Decimal("333.34")]
|
||||
assert sum(months) == Decimal("1000.00") and months[3:] == [0] * 9
|
||||
assert selected_spread(Decimal("120"), [4, 1, 3, 2, 2]) == [30, 30, 30, 30] + [0] * 8
|
||||
assert selected_spread(Decimal("12"), []) == [1] * 12
|
||||
|
||||
|
||||
def _first_line(client, year_id):
|
||||
return client.get(f"/api/years/{year_id}").json()["categories"][0]["lines"][0]
|
||||
|
||||
|
||||
def test_line_split_over_chosen_months(client, imported):
|
||||
line = _first_line(client, imported)
|
||||
r = client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [4, 1, 2, 3, 3]})
|
||||
assert r.status_code == 200, r.text
|
||||
after = _first_line(client, imported)
|
||||
assert after["monthly_kind"] == "selected" and after["monthly_months"] == [1, 2, 3, 4]
|
||||
assert after["adjusted"] == line["adjusted"] # the year total does not change, only when it is spent
|
||||
share = round(line["adjusted"] / 4, 2)
|
||||
assert after["months"][0] == share and after["months"][4:] == [0] * 8
|
||||
assert round(sum(after["months"]), 2) == line["adjusted"]
|
||||
|
||||
# the year's monthly totals follow; the monthly budget report shows the months
|
||||
year = client.get(f"/api/years/{imported}").json()
|
||||
assert year["costs"]["adjusted"] == client.get(f"/api/years/{imported}").json()["costs"]["adjusted"]
|
||||
assert client.get(f"/api/years/{imported}/reports/budget.xlsx").status_code == 200
|
||||
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": []}).status_code == 400
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"monthly_months": [13]}).status_code == 400
|
||||
|
||||
# rolling over to next year keeps the chosen months
|
||||
nxt = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}).json()
|
||||
copied = _first_line(client, nxt["id"])
|
||||
assert copied["monthly_kind"] == "selected" and copied["monthly_months"] == [1, 2, 3, 4]
|
||||
|
||||
# back to 12 months
|
||||
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "even", "monthly_months": None})
|
||||
assert _first_line(client, imported)["months"][11] != 0
|
||||
|
||||
|
||||
def test_template_keeps_chosen_months(client, imported):
|
||||
line = _first_line(client, imported)
|
||||
client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [1, 2, 3, 4, 7]})
|
||||
dept = client.get("/api/departments").json()[0]["id"]
|
||||
r = client.get(f"/api/departments/{dept}/template.xlsx?lang=pt&from_year_id={imported}&year=2030")
|
||||
ws = openpyxl.load_workbook(BytesIO(r.content))["Linhas"]
|
||||
assert ws.cell(8, 23).value == "Dividir pelos meses"
|
||||
assert "1-4, 7" in [ws.cell(row, 23).value for row in range(9, 70)]
|
||||
# a hand-written month list on a row without typed months
|
||||
free = next(r for r in range(9, 70) if ws.cell(r, 2).value and ws.cell(r, 23).value is None
|
||||
and all(ws.cell(r, c).value is None for c in range(11, 23)))
|
||||
ws.cell(free, 23, "Set-Dez")
|
||||
buf = BytesIO()
|
||||
ws.parent.save(buf)
|
||||
res = client.post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())},
|
||||
data={"department_id": str(dept)}).json()
|
||||
lines = [ln for c in client.get(f"/api/years/{res['year']['id']}").json()["categories"] for ln in c["lines"]]
|
||||
picked = [ln["monthly_months"] for ln in lines if ln["monthly_kind"] == "selected"]
|
||||
assert [1, 2, 3, 4, 7] in picked and [9, 10, 11, 12] in picked
|
||||
a = client.get(f"/api/years/{imported}").json()["costs"]
|
||||
b = client.get(f"/api/years/{res['year']['id']}").json()["costs"]
|
||||
assert abs(a["adjusted"] - b["adjusted"]) < 0.01
|
||||
@@ -0,0 +1,127 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from tests.test_mail import fake_mail # noqa: F401 (fixture)
|
||||
|
||||
H = {"X-Requested-With": "budget-app"}
|
||||
|
||||
|
||||
def user(client, email, role, dept=1):
|
||||
client.post("/api/users", json={"email": email, "name": email.split("@")[0], "role": role,
|
||||
"password": "password-123", "department_id": dept})
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
assert c.post("/api/auth/login", json={"email": email, "password": "password-123"}).status_code == 200
|
||||
return c
|
||||
|
||||
|
||||
def approve(client, year_id):
|
||||
assert client.post(f"/api/years/{year_id}/status", json={"action": "submit"}).status_code == 200
|
||||
while client.get(f"/api/years/{year_id}").json()["status"] == "in_review":
|
||||
assert client.post(f"/api/years/{year_id}/status", json={"action": "approve"}).status_code == 200
|
||||
|
||||
|
||||
def test_reopen_request_needs_reason_and_approver(client, imported):
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approver = user(client, "ap@t.local", "approver")
|
||||
approve(client, imported)
|
||||
y = viewer.get(f"/api/years/{imported}").json()
|
||||
assert y["actions"]["request_reopen"] and not y["actions"]["reopen"]
|
||||
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": ""}).status_code == 400
|
||||
r = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Contrato novo de telecomunicações"})
|
||||
assert r.status_code == 200 and r.json()["reopened"] is False
|
||||
assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "again please"}).status_code == 409
|
||||
y = approver.get(f"/api/years/{imported}").json()
|
||||
assert y["status"] == "approved" and y["reopen_request"]["requested_by"] == "view"
|
||||
assert y["actions"]["decide_reopen"] and not viewer.get(f"/api/years/{imported}").json()["actions"]["decide_reopen"]
|
||||
rid = y["reopen_request"]["id"]
|
||||
assert viewer.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).status_code == 403
|
||||
assert approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False}).status_code == 400 # say why
|
||||
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Já aprovado pela direção"})
|
||||
assert d.json()["request"]["status"] == "declined" and d.json()["year"]["status"] == "approved"
|
||||
# a second request, accepted this time
|
||||
rid = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Falta a linha de licenças"}).json()["request"]["id"]
|
||||
d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).json()
|
||||
assert d["request"]["status"] == "accepted" and d["year"]["status"] == "draft"
|
||||
hist = client.get(f"/api/years/{imported}").json()["reopen_requests"]
|
||||
assert [h["status"] for h in hist] == ["accepted", "declined"]
|
||||
acts = [r["action"] for r in client.get("/api/activity?entity=year").json()["rows"]]
|
||||
assert {"reopen_requested", "reopen_declined", "reopen"} <= set(acts)
|
||||
|
||||
|
||||
def test_approver_reopens_directly_with_reason(client, imported):
|
||||
approver = user(client, "ap@t.local", "approver")
|
||||
approve(client, imported)
|
||||
r = approver.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Correção de valores"}).json()
|
||||
assert r["reopened"] is True and r["year"]["status"] == "draft"
|
||||
|
||||
|
||||
def test_download_and_reupload_archives_old_version(client, imported):
|
||||
# downloads keep the name of the file the budget was loaded from
|
||||
assert 'filename="b.xlsx"' in client.get(f"/api/years/{imported}/workbook.xlsx").headers["content-disposition"]
|
||||
fresh = client.post("/api/years", json={"year": 2031}).json()["id"]
|
||||
assert 'filename="Orcamento_IT_2031.xlsx"' in client.get(f"/api/years/{fresh}/workbook.xlsx?lang=pt").headers["content-disposition"]
|
||||
client.delete(f"/api/years/{fresh}")
|
||||
r = client.get(f"/api/years/{imported}/workbook.xlsx?lang=pt")
|
||||
files = {"file": ("Orcamento_IT_2027.xlsx", r.content)}
|
||||
check = client.post("/api/years/import/check", files=files, data={"department_id": "1"}).json()
|
||||
assert check["year"] == 2027 and check["existing"]["id"] == imported and check["existing"]["can_replace"]
|
||||
assert check["existing"]["archived_title"].startswith("Budget 2027 (")
|
||||
# without confirming, nothing is replaced
|
||||
assert client.post("/api/years/import", files=files, data={"department_id": "1"}).status_code == 409
|
||||
res = client.post("/api/years/import", files=files, data={"department_id": "1", "replace": "true"}).json()
|
||||
new_id = res["year"]["id"]
|
||||
assert new_id != imported and res["year"]["title"] == "Budget 2027" and res["year"]["source_file"] == "Orcamento_IT_2027.xlsx"
|
||||
old = client.get(f"/api/years/{imported}").json()
|
||||
assert old["archived"] and old["title"].startswith("Budget 2027 (") and old["title"] != "Budget 2027"
|
||||
assert not any(v for k, v in old["actions"].items() if k != "delete")
|
||||
line = old["categories"][0]["lines"][0]
|
||||
assert client.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code in (403, 409)
|
||||
assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code in (403, 409)
|
||||
assert [y["id"] for y in client.get("/api/years").json()] == [new_id]
|
||||
assert [y["id"] for y in client.get("/api/years?archived=true").json()] == [imported]
|
||||
assert client.get(f"/api/years/{imported}/reports/budget.pdf").status_code == 200 # still viewable
|
||||
assert abs(old["costs"]["adjusted"] - client.get(f"/api/years/{new_id}").json()["costs"]["adjusted"]) < 0.01
|
||||
|
||||
# the same file again: the downloaded name is kept, and the previous upload gets the date in its file name
|
||||
again = client.get(f"/api/years/{new_id}/workbook.xlsx").content
|
||||
check = client.post("/api/years/import/check", files={"file": ("Orcamento_IT_2027.xlsx", again)},
|
||||
data={"department_id": "1"}).json()
|
||||
assert check["existing"]["same_file"] and check["existing"]["archived_file"].startswith("Orcamento_IT_2027_")
|
||||
client.post("/api/years/import", files={"file": ("Orcamento_IT_2027.xlsx", again)},
|
||||
data={"department_id": "1", "replace": "true"})
|
||||
names = [y["source_file"] for y in client.get("/api/years?archived=true").json()]
|
||||
assert any(n.startswith("Orcamento_IT_2027_2") and n.endswith(".xlsx") for n in names)
|
||||
|
||||
|
||||
def test_approved_budget_cannot_be_replaced(client, imported):
|
||||
data = client.get(f"/api/years/{imported}/workbook.xlsx").content
|
||||
approve(client, imported)
|
||||
check = client.post("/api/years/import/check", files={"file": ("b.xlsx", data)}, data={"department_id": "1"}).json()
|
||||
assert check["existing"]["can_replace"] is False
|
||||
r = client.post("/api/years/import", files={"file": ("b.xlsx", data)}, data={"department_id": "1", "replace": "true"})
|
||||
assert r.status_code == 409 and r.json()["detail"] == "year_not_draft"
|
||||
|
||||
|
||||
def test_approver_reopening_answers_pending_request(client, imported):
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approve(client, imported)
|
||||
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Preciso de mudar a linha X"})
|
||||
r = client.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Concordo, reabrir"}).json()
|
||||
assert r["reopened"] and r["request"]["status"] == "accepted" and r["request"]["requested_by"] == "view"
|
||||
assert len(client.get(f"/api/years/{imported}").json()["reopen_requests"]) == 1
|
||||
|
||||
|
||||
def test_reopen_request_emails_the_approvers(client, imported, fake_mail):
|
||||
from tests.test_mail import FakeSMTP, configure
|
||||
configure(client)
|
||||
user(client, "ap@t.local", "approver")
|
||||
viewer = user(client, "view@t.local", "viewer", dept=None)
|
||||
approve(client, imported)
|
||||
viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Licenças em falta"})
|
||||
_, rcpts, msg = FakeSMTP.sent[-1]
|
||||
assert set(rcpts) == {"admin@test.local", "ap@t.local"} and "Licenças em falta" in msg.get_content()
|
||||
rid = client.get(f"/api/years/{imported}").json()["reopen_request"]["id"]
|
||||
client.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Fica para o próximo ano"})
|
||||
_, rcpts, msg = FakeSMTP.sent[-1]
|
||||
assert rcpts == ["view@t.local"] and "Fica para o próximo ano" in msg.get_content()
|
||||
@@ -0,0 +1,42 @@
|
||||
from tests.test_mail import configure, fake_mail # noqa: F401 (fixture)
|
||||
from tests.test_v5 import approve, user
|
||||
|
||||
|
||||
def test_upper_management_views_downloads_and_emails_every_department(client, imported, fake_mail):
|
||||
fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json()
|
||||
other = client.post("/api/years", json={"department_id": fb["id"], "year": 2027}).json()["id"]
|
||||
boss = user(client, "boss@t.local", "management", dept=None)
|
||||
assert boss.get("/api/auth/me").json()["role"] == "management"
|
||||
years = boss.get("/api/years").json()
|
||||
assert {y["department_id"] for y in years} == {1, fb["id"]}
|
||||
assert [y["id"] for y in boss.get(f"/api/years?department_id={fb['id']}").json()] == [other]
|
||||
for year_id in (imported, other):
|
||||
y = boss.get(f"/api/years/{year_id}").json()
|
||||
assert not any(y["actions"].values()), y["actions"]
|
||||
assert boss.get(f"/api/years/{year_id}/workbook.xlsx").status_code == 200
|
||||
assert boss.get(f"/api/years/{year_id}/reports/cost-report.pdf").status_code == 200
|
||||
configure(client)
|
||||
r = boss.post(f"/api/years/{other}/email", json={"to": ["cfo@example.com"], "reports": [{"kind": "cost-report", "fmt": "pdf"}]})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_upper_management_changes_nothing(client, imported):
|
||||
boss = user(client, "boss@t.local", "management", dept=1) # even with a department
|
||||
line = client.get(f"/api/years/{imported}").json()["categories"][0]["lines"][0]
|
||||
assert boss.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 403
|
||||
assert boss.post("/api/years", json={"year": 2032}).status_code == 403
|
||||
assert boss.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 403
|
||||
assert boss.delete(f"/api/years/{imported}").status_code == 403
|
||||
assert boss.post("/api/categories", json={"department_id": 1, "name_en": "X"}).status_code == 403
|
||||
assert boss.get("/api/users").status_code == 403 and boss.get("/api/activity").status_code == 403
|
||||
# never an approver, even when named in a workflow step
|
||||
me = boss.get("/api/auth/me").json()["id"]
|
||||
client.put("/api/departments/1/workflow", json={"steps": [{"name": "Board", "approver_mode": "users", "approver_ids": [me]}]})
|
||||
y = boss.get(f"/api/years/{imported}").json()
|
||||
assert not y["actions"]["approve"]
|
||||
# and does not ask for reopening: that is for the people who work on budgets
|
||||
client.put("/api/departments/1/workflow", json={"steps": []})
|
||||
approve(client, imported)
|
||||
y = boss.get(f"/api/years/{imported}").json()
|
||||
assert y["status"] == "approved" and not y["actions"]["request_reopen"]
|
||||
assert boss.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Quero rever"}).status_code == 403
|
||||
@@ -0,0 +1,105 @@
|
||||
import time
|
||||
from io import BytesIO
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
from app import mfa
|
||||
from app.main import app
|
||||
from tests.test_v5 import H, user
|
||||
|
||||
|
||||
def login(email, password, code=None):
|
||||
c = TestClient(app)
|
||||
c.headers.update(H)
|
||||
body = {"email": email, "password": password}
|
||||
if code is not None:
|
||||
body["code"] = code
|
||||
return c, c.post("/api/auth/login", json=body)
|
||||
|
||||
|
||||
def now_code(secret, offset=0):
|
||||
return mfa.code_at(secret, int(time.time() // mfa.STEP) + offset)
|
||||
|
||||
|
||||
def test_rfc6238_vectors():
|
||||
import base64
|
||||
s = base64.b32encode(b"12345678901234567890").decode().rstrip("=")
|
||||
assert mfa.code_at(s, 59 // 30) == "287082" and mfa.code_at(s, 1111111109 // 30) == "081804"
|
||||
|
||||
|
||||
def test_mfa_set_up_sign_in_recovery_and_reset(client):
|
||||
ana = user(client, "ana@t.local", "editor")
|
||||
setup = ana.post("/api/auth/mfa/setup").json()
|
||||
assert setup["uri"].startswith("otpauth://totp/") and "<svg" in setup["qr_svg"]
|
||||
secret = setup["secret"]
|
||||
# not on until a code is confirmed
|
||||
assert ana.get("/api/auth/me").json()["mfa_enabled"] is False
|
||||
assert ana.post("/api/auth/mfa/enable", json={"code": "000000" if now_code(secret) != "000000" else "111111"}).status_code == 400
|
||||
r = ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)}).json()
|
||||
codes = r["recovery_codes"]
|
||||
assert r["user"]["mfa_enabled"] and len(codes) == 8 and r["user"]["mfa_recovery_left"] == 8
|
||||
listed = {u["email"]: u["mfa_enabled"] for u in client.get("/api/users").json()}
|
||||
assert listed == {"admin@test.local": False, "ana@t.local": True}
|
||||
|
||||
# sign-in now asks for the code; no session without it
|
||||
c, res = login("ana@t.local", "password-123")
|
||||
assert res.status_code == 200 and res.json() == {"mfa_required": True}
|
||||
assert c.get("/api/auth/me").status_code == 401
|
||||
_, res = login("ana@t.local", "password-123", "123456" if now_code(secret, 1) != "123456" else "654321")
|
||||
assert res.status_code == 401 and res.json()["detail"] == "invalid_mfa_code"
|
||||
# the code from the enable step was used already; the next one works once
|
||||
c, res = login("ana@t.local", "password-123", now_code(secret, 1))
|
||||
assert res.status_code == 200 and res.json()["email"] == "ana@t.local" and c.get("/api/auth/me").status_code == 200
|
||||
_, res = login("ana@t.local", "password-123", now_code(secret, 1))
|
||||
assert res.status_code == 401 # replay refused
|
||||
# a recovery code works once
|
||||
c, res = login("ana@t.local", "password-123", codes[0].upper())
|
||||
assert res.status_code == 200 and res.json()["mfa_recovery_left"] == 7
|
||||
assert login("ana@t.local", "password-123", codes[0])[1].status_code == 401
|
||||
|
||||
# an administrator resets it (lost phone)
|
||||
me = c.get("/api/auth/me").json()["id"]
|
||||
assert ana.post(f"/api/users/{me}/mfa/reset").status_code == 403
|
||||
assert client.post(f"/api/users/{me}/mfa/reset").json()["mfa_enabled"] is False
|
||||
assert login("ana@t.local", "password-123")[1].json()["email"] == "ana@t.local"
|
||||
acts = {r["action"] for r in client.get("/api/activity?entity=user").json()["rows"]}
|
||||
assert {"mfa_enabled", "mfa_failed", "mfa_recovery_used", "mfa_reset"} <= acts
|
||||
|
||||
|
||||
def test_mfa_locks_after_wrong_codes_and_turns_off_with_password_and_code(client):
|
||||
ana = user(client, "ana@t.local", "viewer")
|
||||
secret = ana.post("/api/auth/mfa/setup").json()["secret"]
|
||||
ana.post("/api/auth/mfa/enable", json={"code": now_code(secret)})
|
||||
for _ in range(mfa.MAX_FAILED - 1):
|
||||
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 401
|
||||
assert login("ana@t.local", "password-123", "999999x")[1].status_code == 429
|
||||
assert login("ana@t.local", "password-123", now_code(secret, 1))[1].status_code == 429 # locked for a while
|
||||
assert ana.post("/api/auth/mfa/disable", json={"password": "wrong", "code": ""}).status_code == 400
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
with SessionLocal() as db:
|
||||
u = db.query(User).filter_by(email="ana@t.local").one()
|
||||
u.mfa_locked_until = None
|
||||
db.commit()
|
||||
assert ana.post("/api/auth/mfa/disable", json={"password": "password-123", "code": now_code(secret, 1)}).json()["mfa_enabled"] is False
|
||||
|
||||
|
||||
def test_avatar_upload_resize_and_remove(client):
|
||||
ana = user(client, "ana@t.local", "viewer")
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (900, 600), (200, 30, 30)).save(buf, "PNG")
|
||||
assert ana.put("/api/auth/me/avatar", files={"file": ("x.txt", b"not a picture")}).status_code == 400
|
||||
me = ana.put("/api/auth/me/avatar", files={"file": ("me.png", buf.getvalue(), "image/png")}).json()
|
||||
assert me["avatar_url"].startswith(f"/api/users/{me['id']}/avatar?v=")
|
||||
pic = client.get(me["avatar_url"]) # other users see it in lists
|
||||
assert pic.status_code == 200 and pic.headers["content-type"] == "image/jpeg"
|
||||
assert Image.open(BytesIO(pic.content)).size == (256, 256)
|
||||
assert ana.delete("/api/auth/me/avatar").json()["avatar_url"] is None
|
||||
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
|
||||
|
||||
|
||||
def test_theme_is_saved(client):
|
||||
assert client.get("/api/auth/me").json()["theme"] == "system"
|
||||
assert client.put("/api/auth/me", json={"theme": "dark"}).json()["theme"] == "dark"
|
||||
assert client.put("/api/auth/me", json={"theme": "pink"}).json()["theme"] == "dark"
|
||||
Reference in New Issue
Block a user