329 lines
14 KiB
Python
329 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""End-to-end checks for the hosted app.
|
|
|
|
python3 tests/test_auth_smoke.py
|
|
|
|
Runs against in-memory SQLite with email routed to a capture list, so it needs
|
|
no MySQL, no network, and sends no mail. Every model column is a portable type
|
|
precisely so this is possible.
|
|
|
|
Plain asserts and a main(), matching tests/test_golden.py -- no pytest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
WORK = Path(tempfile.mkdtemp(prefix="oob-authtest-"))
|
|
|
|
# Must be set before app.config reads the environment.
|
|
os.environ["EMAIL_PROVIDER"] = "console"
|
|
os.environ["APP_BASE_URL"] = "http://testserver"
|
|
os.environ["WORKSPACE_ROOT"] = str(WORK)
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-at-least-32-characters-long")
|
|
os.environ.setdefault("MYSQL_HOST", "unused-in-this-test")
|
|
os.environ.setdefault("MYSQL_USER", "unused")
|
|
os.environ.setdefault("MYSQL_PASSWORD", "unused")
|
|
os.environ.setdefault("MYSQL_DATABASE", "unused")
|
|
os.environ.setdefault("ADMIN_EMAIL", "admin@example.com")
|
|
os.environ.setdefault("ADMIN_PASSWORD", "admin-password-1")
|
|
|
|
from sqlalchemy import create_engine # noqa: E402
|
|
from sqlalchemy.orm import sessionmaker # noqa: E402
|
|
from sqlalchemy.pool import StaticPool # noqa: E402
|
|
|
|
import app.db as db_module # noqa: E402
|
|
|
|
# Point the app at SQLite before anything imports SessionLocal from it.
|
|
_sqlite = create_engine("sqlite://", connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool)
|
|
db_module.engine = _sqlite
|
|
db_module.SessionLocal = sessionmaker(bind=_sqlite, autoflush=False,
|
|
expire_on_commit=False, future=True)
|
|
|
|
from fastapi.testclient import TestClient # noqa: E402
|
|
|
|
from app.config import get_settings # noqa: E402
|
|
from app.main import app # noqa: E402
|
|
from app.services import email_service # noqa: E402
|
|
from tests import synthetic # noqa: E402
|
|
|
|
SENT: list[tuple[str, str, str]] = []
|
|
email_service._send = lambda to, subject, html, attachment=None: (
|
|
SENT.append((to, subject, html)) or True)
|
|
|
|
FIXTURE = synthetic.build(WORK / "amazon-ads-history_synthetic.xlsx")
|
|
GENERIC_LOGIN_ERROR = "Email or password is incorrect."
|
|
|
|
PASSED: list[str] = []
|
|
FAILED: list[str] = []
|
|
|
|
|
|
def check(label: str, condition: bool, detail: object = "") -> None:
|
|
if condition:
|
|
PASSED.append(label)
|
|
print(f" pass {label}")
|
|
else:
|
|
FAILED.append(label)
|
|
print(f" FAIL {label}" + (f" <- {detail}" if detail else ""))
|
|
|
|
|
|
def link_from_mail(prefix: str) -> str:
|
|
"""Pull the most recent link with this prefix out of the captured HTML."""
|
|
for _to, _subject, html in reversed(SENT):
|
|
i = html.find(prefix)
|
|
if i >= 0:
|
|
return html[i:].split('"')[0].split("<")[0].strip()
|
|
raise AssertionError(f"no email contained a {prefix!r} link")
|
|
|
|
|
|
def token_from_mail(prefix: str) -> str:
|
|
return link_from_mail(prefix).split("token=")[1]
|
|
|
|
|
|
def verify_new_user(client: TestClient, email: str, password: str, name: str = "") -> None:
|
|
client.post("/api/auth/signup",
|
|
json={"email": email, "password": password, "name": name})
|
|
client.post("/api/auth/verify",
|
|
json={"token": token_from_mail("http://testserver/verify?token=")})
|
|
|
|
|
|
def main() -> int:
|
|
with TestClient(app, base_url="http://testserver") as c:
|
|
_anonymous(c)
|
|
_signup_and_verify(c)
|
|
_login(c)
|
|
_dashboard(c)
|
|
_csrf_and_admin_gate(c)
|
|
_isolation(c)
|
|
_logout(c)
|
|
_forgot_and_reset(c)
|
|
_lockout(c)
|
|
_admin(c)
|
|
|
|
print(f"\n{len(PASSED)}/{len(PASSED) + len(FAILED)} passed")
|
|
if FAILED:
|
|
print("failed: " + ", ".join(FAILED))
|
|
return 1 if FAILED else 0
|
|
|
|
|
|
# --------------------------------------------------------------------- checks
|
|
|
|
def _anonymous(c: TestClient) -> None:
|
|
r = c.get("/", follow_redirects=False)
|
|
check("anonymous / redirects to the sign-in page",
|
|
r.status_code == 303 and r.headers["location"] == "/login", r.status_code)
|
|
check("healthz is up", c.get("/healthz").status_code == 200)
|
|
check("readyz reports the database", c.get("/readyz").json().get("db") == "ok")
|
|
check("anonymous api call is 401", c.get("/api/state").status_code == 401)
|
|
|
|
r = c.get("/api/state", headers={"Accept": "text/html"}, follow_redirects=False)
|
|
check("a browser navigation gets redirected, not JSON",
|
|
r.status_code == 303 and "/login" in r.headers["location"], r.status_code)
|
|
|
|
|
|
def _signup_and_verify(c: TestClient) -> None:
|
|
body = {"email": "alice@example.com", "password": "correct-horse-1", "name": "Alice"}
|
|
first = c.post("/api/auth/signup", json=body)
|
|
check("signup succeeds", first.status_code == 200, first.text)
|
|
|
|
repeat = c.post("/api/auth/signup", json=body)
|
|
check("signing up an existing address answers identically",
|
|
repeat.status_code == first.status_code and repeat.json() == first.json(),
|
|
repeat.text)
|
|
|
|
r = c.post("/api/auth/signup", json={"email": "x@example.com", "password": "short"})
|
|
check("a short password is refused", r.status_code == 422, r.status_code)
|
|
|
|
token = token_from_mail("http://testserver/verify?token=")
|
|
r = c.get(f"/verify?token={token}")
|
|
check("the verify page is served as html",
|
|
r.status_code == 200 and "text/html" in r.headers["content-type"])
|
|
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "alice@example.com", "password": "correct-horse-1"})
|
|
check("signing in before confirming is refused with a reason",
|
|
r.status_code == 403 and r.json().get("code") == "email_not_verified", r.text)
|
|
|
|
r = c.post("/api/auth/verify", json={"token": token})
|
|
check("loading the page did not spend the token; posting it does",
|
|
r.status_code == 200, r.text)
|
|
r = c.post("/api/auth/verify", json={"token": token})
|
|
check("a verification token works only once", r.status_code == 400, r.status_code)
|
|
|
|
|
|
def _login(c: TestClient) -> None:
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "alice@example.com", "password": "wrong-password"})
|
|
check("a wrong password is refused without detail",
|
|
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "nobody@example.com", "password": "wrong-password"})
|
|
check("an unknown address gets the very same message",
|
|
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
|
|
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "alice@example.com", "password": "correct-horse-1"})
|
|
check("signing in works", r.status_code == 200, r.text)
|
|
|
|
cookie = r.headers.get("set-cookie", "")
|
|
check("the session cookie is HttpOnly", "httponly" in cookie.lower(), cookie)
|
|
check("the session cookie is SameSite=Lax", "samesite=lax" in cookie.lower(), cookie)
|
|
check("the session cookie is not Secure over plain http",
|
|
"secure" not in cookie.lower(), cookie)
|
|
|
|
state = c.get("/api/state")
|
|
check("the api answers once signed in", state.status_code == 200, state.text)
|
|
check("the state carries who is signed in",
|
|
state.json()["user"]["email"] == "alice@example.com")
|
|
check("the dashboard itself is served", c.get("/").status_code == 200)
|
|
|
|
|
|
def _dashboard(c: TestClient) -> None:
|
|
with FIXTURE.open("rb") as fh:
|
|
r = c.post("/api/upload", files={"file": (FIXTURE.name, fh)},
|
|
data={"kind": "history"})
|
|
check("an export uploads", r.status_code == 200, r.text)
|
|
check("the uploaded file is listed",
|
|
c.get("/api/state").json()["history"] == [FIXTURE.name])
|
|
|
|
r = c.post("/api/analyze", json={"haircut": 0.7, "cap": 3, "merge_gap": 5})
|
|
check("the analysis runs", r.status_code == 200, r.text[:200])
|
|
payload = r.json()
|
|
check("the payload has campaigns in it", bool(payload.get("campaigns")),
|
|
sorted(payload)[:6])
|
|
|
|
r = c.get("/api/export?format=csv")
|
|
check("the csv export downloads",
|
|
r.status_code == 200 and r.content[:3] == b"\xef\xbb\xbf", r.status_code)
|
|
check("the csv is sent as an attachment",
|
|
"attachment" in r.headers.get("content-disposition", ""))
|
|
r = c.get("/api/export?format=xlsx")
|
|
check("the excel export downloads",
|
|
r.status_code == 200 and r.content[:2] == b"PK", r.status_code)
|
|
|
|
r = c.post("/api/upload", files={"file": ("payload.exe", b"x" * 32)},
|
|
data={"kind": "history"})
|
|
check("the file type is enforced on the server, not just in the browser",
|
|
r.status_code == 400, r.status_code)
|
|
|
|
r = c.post("/api/clear")
|
|
check("starting over empties the workspace",
|
|
r.status_code == 200 and c.get("/api/state").json()["history"] == [])
|
|
|
|
|
|
def _csrf_and_admin_gate(c: TestClient) -> None:
|
|
r = c.post("/api/clear", headers={"Origin": "https://evil.example"})
|
|
check("a write from another origin is refused", r.status_code == 403, r.status_code)
|
|
check("an ordinary member cannot reach the admin api",
|
|
c.get("/api/admin/users").status_code == 403)
|
|
r = c.get("/admin", follow_redirects=False)
|
|
check("an ordinary member is bounced off the admin page",
|
|
r.status_code == 303 and r.headers["location"] == "/", r.status_code)
|
|
|
|
|
|
def _isolation(c: TestClient) -> None:
|
|
with TestClient(app, base_url="http://testserver") as other:
|
|
verify_new_user(other, "bob@example.com", "another-good-1", "Bob")
|
|
other.post("/api/auth/login",
|
|
json={"email": "bob@example.com", "password": "another-good-1"})
|
|
with FIXTURE.open("rb") as fh:
|
|
other.post("/api/upload", files={"file": ("bobs-file.xlsx", fh)},
|
|
data={"kind": "history"})
|
|
check("the second account sees its own upload",
|
|
other.get("/api/state").json()["history"] == ["bobs-file.xlsx"])
|
|
check("the first account does not see it",
|
|
c.get("/api/state").json()["history"] == [])
|
|
check("and has no analysis to export from the other's data",
|
|
other.get("/api/export?format=csv").status_code == 400)
|
|
|
|
|
|
def _logout(c: TestClient) -> None:
|
|
check("signing out works", c.post("/api/auth/logout").status_code == 200)
|
|
check("the session is dead afterwards", c.get("/api/state").status_code == 401)
|
|
|
|
|
|
def _forgot_and_reset(c: TestClient) -> None:
|
|
before = len(SENT)
|
|
known = c.post("/api/auth/forgot", json={"email": "alice@example.com"})
|
|
unknown = c.post("/api/auth/forgot", json={"email": "ghost@example.com"})
|
|
check("forgot-password answers the same for a known and unknown address",
|
|
known.status_code == unknown.status_code and known.json() == unknown.json())
|
|
check("but only the real address was actually emailed", len(SENT) == before + 1)
|
|
|
|
token = token_from_mail("http://testserver/reset?token=")
|
|
r = c.post("/api/auth/reset",
|
|
json={"token": token, "password": "brand-new-secret-9"})
|
|
check("the password is reset", r.status_code == 200, r.text)
|
|
r = c.post("/api/auth/reset",
|
|
json={"token": token, "password": "yet-another-one-1"})
|
|
check("a reset token works only once", r.status_code == 400, r.status_code)
|
|
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "alice@example.com", "password": "correct-horse-1"})
|
|
check("the old password stops working", r.status_code == 401)
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "alice@example.com", "password": "brand-new-secret-9"})
|
|
check("the new password works", r.status_code == 200, r.text)
|
|
c.post("/api/auth/logout")
|
|
|
|
|
|
def _lockout(c: TestClient) -> None:
|
|
s = get_settings()
|
|
for _ in range(s.MAX_FAILED_LOGINS + 1):
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "bob@example.com", "password": "definitely-wrong"})
|
|
check("a locked account is not announced as locked",
|
|
r.status_code == 401 and r.json()["error"] == GENERIC_LOGIN_ERROR, r.text)
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "bob@example.com", "password": "another-good-1"})
|
|
check("even the right password is refused while locked",
|
|
r.status_code == 401, r.status_code)
|
|
|
|
|
|
def _admin(c: TestClient) -> None:
|
|
from app.bootstrap import seed_admin
|
|
|
|
s = get_settings()
|
|
with db_module.SessionLocal() as db:
|
|
seed_admin(db, s)
|
|
|
|
r = c.post("/api/auth/login",
|
|
json={"email": s.ADMIN_EMAIL,
|
|
"password": s.ADMIN_PASSWORD.get_secret_value()})
|
|
check("the seeded admin can sign in", r.status_code == 200, r.text)
|
|
|
|
r = c.get("/api/admin/users")
|
|
check("the admin can list accounts",
|
|
r.status_code == 200 and len(r.json()["users"]) >= 3, r.text[:200])
|
|
check("the admin page renders", c.get("/admin").status_code == 200)
|
|
|
|
users = r.json()["users"]
|
|
bob = next(u for u in users if u["email"] == "bob@example.com")
|
|
check("the admin can clear a lockout",
|
|
c.post(f"/api/admin/users/{bob['id']}/activate").status_code == 200)
|
|
check("the admin can disable an account",
|
|
c.post(f"/api/admin/users/{bob['id']}/deactivate").status_code == 200)
|
|
|
|
r = c.post("/api/auth/login",
|
|
json={"email": "bob@example.com", "password": "another-good-1"})
|
|
check("a disabled account cannot sign in", r.status_code == 401, r.status_code)
|
|
|
|
me = c.get("/api/auth/me").json()["user"]
|
|
check("the admin cannot disable themselves",
|
|
c.post(f"/api/admin/users/{me['id']}/deactivate").status_code == 400)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
finally:
|
|
import shutil
|
|
shutil.rmtree(WORK, ignore_errors=True)
|