Files
Budget_Tool_Martinhal/backend/tests/test_v7.py
T

106 lines
5.2 KiB
Python

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"