This commit is contained in:
2026-09-09 17:49:01 +01:00
commit 99c15e23bb
30 changed files with 3028 additions and 0 deletions
+88
View File
@@ -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
+43
View File
@@ -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", "")
+117
View File
@@ -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()
+86
View File
@@ -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
+56
View File
@@ -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()