Batman
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Point settings at a fresh temp data dir and a known secret for every test."""
|
||||
monkeypatch.setenv("BIKE_ADMIN_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("BIKE_ADMIN_SECRET_KEY", "test-key-1234")
|
||||
monkeypatch.setenv("BIKE_ADMIN_USERNAME", "admin")
|
||||
monkeypatch.setenv("BIKE_ADMIN_PASSWORD", "correct-horse-battery-staple")
|
||||
monkeypatch.setenv("BIKE_ADMIN_LOGIN_MAX_ATTEMPTS", "1000")
|
||||
monkeypatch.delenv("BIKE_ADMIN_IS_PRODUCTION", raising=False)
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
class Client:
|
||||
"""A TestClient with small helpers for logging in and filling forms."""
|
||||
|
||||
PW = "correct-horse-battery-staple"
|
||||
|
||||
def __init__(self, raw: TestClient):
|
||||
self.raw = raw
|
||||
|
||||
# --- pass-through HTTP ------------------------------------------------
|
||||
def get(self, path: str, **kw):
|
||||
return self.raw.get(path, **kw)
|
||||
|
||||
def post(self, path: str, **kw):
|
||||
return self.raw.post(path, **kw)
|
||||
|
||||
# --- auth -------------------------------------------------------------
|
||||
def login(self, user: str = "admin", pw: str | None = None) -> "object":
|
||||
r = self.raw.post("/login", data={"username": user, "password": pw or self.PW, "next": "/"})
|
||||
assert r.status_code == 303, f"login failed: {r.status_code} {r.text[:300]}"
|
||||
return r
|
||||
|
||||
def logout(self):
|
||||
return self.raw.get("/logout")
|
||||
|
||||
# --- helpers ----------------------------------------------------------
|
||||
@staticmethod
|
||||
def csrf(html: str) -> str:
|
||||
m = re.search(r'name="csrf"[^>]*value="([^"]+)"', html)
|
||||
assert m, "CSRF token not found in page"
|
||||
return m.group(1)
|
||||
|
||||
def create_bike(self, name: str = "Roadie", interval: int | None = None) -> int:
|
||||
page = self.raw.get("/bikes/new")
|
||||
data = {"csrf": self.csrf(page.text), "name": name}
|
||||
if interval is not None:
|
||||
data["service_interval_miles"] = str(interval)
|
||||
resp = self.raw.post("/bikes/new", data=data)
|
||||
assert resp.status_code == 303, resp.text[:500]
|
||||
m = re.search(r"/bikes/(\d+)", resp.headers["location"])
|
||||
assert m
|
||||
return int(m.group(1))
|
||||
|
||||
def create_event(self, bike_id: int, **data) -> dict:
|
||||
page = self.raw.get(f"/bikes/{bike_id}")
|
||||
payload = {"csrf": self.csrf(page.text)}
|
||||
payload.update(data)
|
||||
resp = self.raw.post(f"/bikes/{bike_id}/events", data=payload)
|
||||
assert resp.status_code in (200, 303), f"{resp.status_code} {resp.text[:500]}"
|
||||
return {"status": resp.status_code, "text": resp.text}
|
||||
|
||||
def delete_event(self, event_id: int) -> "object":
|
||||
page = self.raw.get("/bikes/1") # just need any page to read the csrf token
|
||||
resp = self.raw.post(f"/events/{event_id}/delete", data={"csrf": self.csrf(page.text)})
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Iterator[Client]:
|
||||
from app.main import app
|
||||
|
||||
raw = TestClient(app, raise_server_exceptions=True, follow_redirects=False)
|
||||
c = Client(raw)
|
||||
with raw:
|
||||
yield c
|
||||
@@ -0,0 +1,43 @@
|
||||
PW = "correct-horse-battery-staple"
|
||||
|
||||
|
||||
def test_login_success_sets_cookie(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": PW})
|
||||
assert r.status_code == 303
|
||||
set_cookie = r.headers.get("set-cookie", "")
|
||||
assert "bike_session=" in set_cookie
|
||||
assert "httponly" in set_cookie.lower()
|
||||
|
||||
|
||||
def test_login_failure_generic_error(client):
|
||||
r = client.post("/login", data={"username": "admin", "password": "wrong"})
|
||||
assert r.status_code == 200
|
||||
# Generic error - does not reveal whether the user exists.
|
||||
assert "Invalid username or password" in r.text
|
||||
|
||||
|
||||
def test_login_failure_unknown_user_same_message(client):
|
||||
r = client.post("/login", data={"username": "ghost", "password": "x"})
|
||||
assert r.status_code == 200
|
||||
assert "Invalid username or password" in r.text
|
||||
|
||||
|
||||
def test_dashboard_requires_auth(client):
|
||||
r = client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
assert r.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
def test_dashboard_when_authed(client):
|
||||
client.post("/login", data={"username": "admin", "password": PW})
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert "Your bikes" in r.text
|
||||
|
||||
|
||||
def test_logout_clears_session(client):
|
||||
client.post("/login", data={"username": "admin", "password": PW})
|
||||
r = client.get("/logout")
|
||||
assert r.status_code == 200
|
||||
# Session cookie should be cleared (max-age 0).
|
||||
assert "bike_session=" in r.headers.get("set-cookie", "")
|
||||
@@ -0,0 +1,117 @@
|
||||
from datetime import date
|
||||
|
||||
from app import db as d
|
||||
from app.config import get_settings
|
||||
from app.services import compute_service_due
|
||||
|
||||
|
||||
def _conn():
|
||||
settings = get_settings()
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
d.init_schema(settings.resolved_db_path)
|
||||
return d.connect(settings.resolved_db_path)
|
||||
|
||||
|
||||
def _mk_bike(conn, interval):
|
||||
return d.create_bike(conn, "B", interval)
|
||||
|
||||
|
||||
def _event(conn, bike_id, type, day, mileage, cost=None):
|
||||
d.create_event(conn, bike_id, type, day, mileage, cost, "n")
|
||||
|
||||
|
||||
def test_no_data_when_no_service():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "tyre", date(2026, 1, 1), 10000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "no-data"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_ok_when_under_interval():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
_event(conn, bike_id, "tyre", date(2026, 6, 1), 12000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "ok"
|
||||
assert due.miles_since == 2000
|
||||
assert due.miles_until_due == 3000
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_overdue_when_over_interval():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
_event(conn, bike_id, "other", date(2026, 6, 1), 16000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "overdue"
|
||||
assert due.miles_since == 6000
|
||||
assert due.miles_until_due == -1000
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_exactly_at_interval_is_due_soon():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
_event(conn, bike_id, "other", date(2026, 6, 1), 15000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "due-soon"
|
||||
assert due.miles_until_due == 0
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_within_lead_time_is_due_soon():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
_event(conn, bike_id, "other", date(2026, 6, 1), 14900) # 100 mi left
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "due-soon"
|
||||
assert due.miles_until_due == 100
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_just_outside_lead_time_is_ok():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
_event(conn, bike_id, "other", date(2026, 6, 1), 14750) # 250 mi left
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "ok"
|
||||
assert due.miles_until_due == 250
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_no_interval_set_is_no_data():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, None)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "no-data"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_latest_service_used_not_earliest():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2025, 1, 1), 8000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), 10000) # most recent
|
||||
_event(conn, bike_id, "other", date(2026, 6, 1), 14000)
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "ok"
|
||||
assert due.miles_since == 4000
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_service_without_mileage_is_ignored():
|
||||
conn = _conn()
|
||||
bike_id = _mk_bike(conn, 5000)
|
||||
_event(conn, bike_id, "service", date(2026, 1, 1), None) # no mileage
|
||||
due = compute_service_due(conn, d.get_bike(conn, bike_id))
|
||||
assert due.status == "no-data"
|
||||
assert due.last_service is None
|
||||
conn.close()
|
||||
@@ -0,0 +1,86 @@
|
||||
def test_create_bike_requires_name(client):
|
||||
client.login()
|
||||
csrf = client.csrf(client.get("/bikes/new").text)
|
||||
resp = client.post("/bikes/new", data={"csrf": csrf, "name": ""})
|
||||
assert resp.status_code == 200
|
||||
assert "Name must be" in resp.text
|
||||
|
||||
|
||||
def test_create_and_list_bike(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
assert bike_id >= 1
|
||||
assert "Roadie" in client.get("/").text
|
||||
d = client.get(f"/bikes/{bike_id}")
|
||||
assert "Service status" in d.text
|
||||
|
||||
|
||||
def test_update_bike(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
csrf = client.csrf(client.get(f"/bikes/{bike_id}/edit").text)
|
||||
resp = client.post(
|
||||
f"/bikes/{bike_id}/edit",
|
||||
data={"csrf": csrf, "name": "Roadie 2", "service_interval_miles": "3000"},
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
assert "Roadie 2" in client.get(f"/bikes/{bike_id}").text
|
||||
|
||||
|
||||
def test_create_event_and_list(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
result = client.create_event(
|
||||
bike_id, type="service", event_date="2026-09-09", mileage="15000", cost="150"
|
||||
)
|
||||
assert result["status"] == 200
|
||||
assert "15,000" in result["text"]
|
||||
assert "service" in result["text"]
|
||||
|
||||
|
||||
def test_event_rejects_future_date(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
result = client.create_event(bike_id, type="service", event_date="2999-01-01")
|
||||
assert "future" in result["text"].lower()
|
||||
|
||||
|
||||
def test_event_rejects_negative_mileage(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
result = client.create_event(bike_id, type="other", event_date="2026-09-09", mileage="-5")
|
||||
assert "negative" in result["text"].lower()
|
||||
|
||||
|
||||
def test_event_requires_known_type(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
result = client.create_event(bike_id, type="bogus", event_date="2026-09-09")
|
||||
assert "must be one of" in result["text"].lower()
|
||||
|
||||
|
||||
def test_delete_event(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
client.create_event(bike_id, type="service", event_date="2026-09-09", mileage="15000")
|
||||
page = client.get(f"/bikes/{bike_id}").text
|
||||
import re
|
||||
|
||||
m = re.search(r"/events/(\d+)/delete", page)
|
||||
assert m, "delete link not found"
|
||||
event_id = m.group(1)
|
||||
csrf = client.csrf(page)
|
||||
resp = client.post(f"/events/{event_id}/delete", data={"csrf": csrf})
|
||||
assert resp.status_code == 303
|
||||
assert "No events yet" in client.get(f"/bikes/{bike_id}").text
|
||||
|
||||
|
||||
def test_delete_bike_cascades(client):
|
||||
client.login()
|
||||
bike_id = client.create_bike("Roadie")
|
||||
client.create_event(bike_id, type="service", event_date="2026-09-09", mileage="15000")
|
||||
page = client.get(f"/bikes/{bike_id}/edit").text
|
||||
csrf = client.csrf(page)
|
||||
resp = client.post(f"/bikes/{bike_id}/delete", data={"csrf": csrf})
|
||||
assert resp.status_code == 303
|
||||
assert client.get(f"/bikes/{bike_id}").status_code == 303
|
||||
@@ -0,0 +1,56 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_failed_login_generic_and_no_cookie():
|
||||
pass # covered in test_auth; kept for grouping
|
||||
|
||||
|
||||
def test_csrf_required_on_bike_create(client):
|
||||
client.login()
|
||||
# No csrf token at all -> must be rejected, bike NOT created.
|
||||
resp = client.post("/bikes/new", data={"name": "Sneaky"})
|
||||
assert "CSRF check failed" in resp.text
|
||||
|
||||
|
||||
def test_wrong_csrf_rejected(client):
|
||||
client.login()
|
||||
resp = client.post("/bikes/new", data={"csrf": "not-the-right-token", "name": "Sneaky"})
|
||||
assert "CSRF check failed" in resp.text
|
||||
|
||||
|
||||
def test_login_cookie_attributes(client):
|
||||
r = client.login()
|
||||
sc = r.headers.get("set-cookie", "")
|
||||
low = sc.lower()
|
||||
assert "httponly" in low
|
||||
assert "samesite=lax" in low
|
||||
# Dev (not production, http) => no Secure flag.
|
||||
assert "secure" not in low
|
||||
|
||||
|
||||
def test_production_sets_secure_cookie(monkeypatch):
|
||||
monkeypatch.setenv("BIKE_ADMIN_IS_PRODUCTION", "true")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app, follow_redirects=False) as c: # lifespan seeds the user
|
||||
r = c.post("/login", data={"username": "admin", "password": "correct-horse-battery-staple"})
|
||||
sc = r.headers.get("set-cookie", "").lower()
|
||||
assert r.status_code == 303
|
||||
assert "secure" in sc
|
||||
|
||||
|
||||
def test_login_throttle(client, monkeypatch):
|
||||
monkeypatch.setenv("BIKE_ADMIN_LOGIN_MAX_ATTEMPTS", "3")
|
||||
from app.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
for _ in range(3):
|
||||
client.post("/login", data={"username": "admin", "password": "wrong"})
|
||||
# 4th attempt: even the correct password is blocked.
|
||||
pw = "correct-horse-battery-staple"
|
||||
r = client.post("/login", data={"username": "admin", "password": pw})
|
||||
assert r.status_code == 200
|
||||
assert "too many" in r.text.lower()
|
||||
Reference in New Issue
Block a user