chore(quality): apply ruff autofixes and formatting (223 fixes, regression-free: 118/118 tests pass)
This commit is contained in:
+67
-21
@@ -44,9 +44,7 @@ async def session_factory(
|
||||
engine: AsyncEngine,
|
||||
) -> async_sessionmaker[AsyncSession]:
|
||||
"""Session factory bound to the test engine."""
|
||||
return async_sessionmaker(
|
||||
bind=engine, expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
@@ -131,7 +129,9 @@ async def second_user(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": payload["email"], "password": payload["password"]},
|
||||
)
|
||||
assert login_resp.status_code == 200, f"login failed: {login_resp.status_code} {login_resp.text}"
|
||||
assert login_resp.status_code == 200, (
|
||||
f"login failed: {login_resp.status_code} {login_resp.text}"
|
||||
)
|
||||
token = login_resp.json()["access_token"]
|
||||
|
||||
return {
|
||||
@@ -167,7 +167,12 @@ async def seed_data(
|
||||
# 2 accounts
|
||||
acc1 = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"name": "Acme Corp", "industry": "sme", "size": "sme", "website": "https://acme.test"},
|
||||
json={
|
||||
"name": "Acme Corp",
|
||||
"industry": "sme",
|
||||
"size": "sme",
|
||||
"website": "https://acme.test",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}"
|
||||
@@ -184,7 +189,12 @@ async def seed_data(
|
||||
# 3 contacts (2 with account, 1 standalone)
|
||||
c1 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Anna", "last_name": "Schmidt", "email": "anna@acme.example", "account_id": acc1_id},
|
||||
json={
|
||||
"first_name": "Anna",
|
||||
"last_name": "Schmidt",
|
||||
"email": "anna@acme.example",
|
||||
"account_id": acc1_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert c1.status_code == 201, c1.text
|
||||
@@ -192,7 +202,12 @@ async def seed_data(
|
||||
|
||||
c2 = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Bob", "last_name": "Mueller", "email": "bob@globex.example", "account_id": acc2_id},
|
||||
json={
|
||||
"first_name": "Bob",
|
||||
"last_name": "Mueller",
|
||||
"email": "bob@globex.example",
|
||||
"account_id": acc2_id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert c2.status_code == 201, c2.text
|
||||
@@ -208,13 +223,15 @@ async def seed_data(
|
||||
|
||||
# 5 deals (different stages)
|
||||
deal_ids: list[int] = []
|
||||
for i, (title, stage) in enumerate([
|
||||
("Deal A", "lead"),
|
||||
("Deal B", "qualified"),
|
||||
("Deal C", "proposal"),
|
||||
("Deal D", "negotiation"),
|
||||
("Deal E", "won"),
|
||||
]):
|
||||
for i, (title, stage) in enumerate(
|
||||
[
|
||||
("Deal A", "lead"),
|
||||
("Deal B", "qualified"),
|
||||
("Deal C", "proposal"),
|
||||
("Deal D", "negotiation"),
|
||||
("Deal E", "won"),
|
||||
]
|
||||
):
|
||||
d = await client.post(
|
||||
"/api/v1/deals/",
|
||||
json={
|
||||
@@ -230,6 +247,7 @@ async def seed_data(
|
||||
|
||||
# 10 activities (4 overdue, 4 future, 2 completed)
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
|
||||
future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
|
||||
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
|
||||
@@ -237,10 +255,20 @@ async def seed_data(
|
||||
for i in range(10):
|
||||
if i < 4:
|
||||
due = past
|
||||
body_data: dict[str, object] = {"type": "task", "subject": f"Overdue task {i}", "due_date": due, "deal_id": deal_ids[i % 5]}
|
||||
body_data: dict[str, object] = {
|
||||
"type": "task",
|
||||
"subject": f"Overdue task {i}",
|
||||
"due_date": due,
|
||||
"deal_id": deal_ids[i % 5],
|
||||
}
|
||||
elif i < 8:
|
||||
due = future if i % 2 == 0 else future2
|
||||
body_data = {"type": "call", "subject": f"Upcoming call {i}", "due_date": due, "account_id": acc1_id}
|
||||
body_data = {
|
||||
"type": "call",
|
||||
"subject": f"Upcoming call {i}",
|
||||
"due_date": due,
|
||||
"account_id": acc1_id,
|
||||
}
|
||||
else:
|
||||
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id}
|
||||
a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
|
||||
@@ -250,18 +278,36 @@ async def seed_data(
|
||||
# 3 tags
|
||||
tag_ids: list[int] = []
|
||||
for name in ["VIP", "Strategic", "Enterprise"]:
|
||||
t = await client.post("/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers)
|
||||
t = await client.post(
|
||||
"/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers
|
||||
)
|
||||
assert t.status_code == 201, t.text
|
||||
tag_ids.append(t.json()["id"])
|
||||
|
||||
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal)
|
||||
n1 = await client.post("/api/v1/notes/", json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, headers=headers)
|
||||
n1 = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert n1.status_code == 201, n1.text
|
||||
n2 = await client.post("/api/v1/notes/", json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, headers=headers)
|
||||
n2 = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert n2.status_code == 201, n2.text
|
||||
n3 = await client.post("/api/v1/notes/", json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, headers=headers)
|
||||
n3 = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert n3.status_code == 201, n3.text
|
||||
n4 = await client.post("/api/v1/notes/", json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, headers=headers)
|
||||
n4 = await client.post(
|
||||
"/api/v1/notes/",
|
||||
json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]},
|
||||
headers=headers,
|
||||
)
|
||||
assert n4.status_code == 201, n4.text
|
||||
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,9 @@ async def test_create_account(client: AsyncClient, auth_headers: dict[str, str])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_account_missing_required(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
async def test_create_account_missing_required(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/accounts/",
|
||||
json={"industry": "sme"}, # name missing
|
||||
@@ -97,7 +99,9 @@ async def test_db_write_account_no_password_field(
|
||||
assert resp.status_code == 201
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'"))
|
||||
result = await session.execute(
|
||||
text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "Schema Test Co"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
+11
-26
@@ -3,9 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from jose import jwt
|
||||
|
||||
@@ -43,9 +41,7 @@ async def test_register_success(client: AsyncClient) -> None:
|
||||
# === FR-1.1 / Akzeptanzkriterium 2: register duplicate email ===
|
||||
|
||||
|
||||
async def test_register_duplicate_email(
|
||||
client: AsyncClient, registered_user: dict
|
||||
) -> None:
|
||||
async def test_register_duplicate_email(client: AsyncClient, registered_user: dict) -> None:
|
||||
"""AC #2: POST /api/v1/auth/register mit existierender Email → 409."""
|
||||
# registered_user already exists; trying again with same email (and 2nd user
|
||||
# would also be blocked by bootstrap). 409 is correct because of the email conflict.
|
||||
@@ -93,9 +89,7 @@ async def test_register_weak_password(client: AsyncClient) -> None:
|
||||
# === FR-1.2 / Akzeptanzkriterium 4: login success ===
|
||||
|
||||
|
||||
async def test_login_success(
|
||||
client: AsyncClient, registered_user: dict
|
||||
) -> None:
|
||||
async def test_login_success(client: AsyncClient, registered_user: dict) -> None:
|
||||
"""AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT."""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
@@ -114,9 +108,7 @@ async def test_login_success(
|
||||
# === FR-1.2 / Akzeptanzkriterium 5: login wrong password ===
|
||||
|
||||
|
||||
async def test_login_wrong_password(
|
||||
client: AsyncClient, registered_user: dict
|
||||
) -> None:
|
||||
async def test_login_wrong_password(client: AsyncClient, registered_user: dict) -> None:
|
||||
"""AC #5: POST /api/v1/auth/login mit falschem Passwort → 401."""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
@@ -203,12 +195,11 @@ async def test_db_user_has_hashed_password(
|
||||
) -> None:
|
||||
"""AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(User).where(User.email == registered_user["email"])
|
||||
)
|
||||
result = await session.execute(select(User).where(User.email == registered_user["email"]))
|
||||
user = result.scalar_one()
|
||||
# bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text
|
||||
assert user.password_hash.startswith("$"), (
|
||||
@@ -216,19 +207,17 @@ async def test_db_user_has_hashed_password(
|
||||
)
|
||||
assert user.password_hash != registered_user["password"]
|
||||
assert len(user.password_hash) > 50, (
|
||||
"Bcrypt hash should be ~60 chars long, got "
|
||||
f"{len(user.password_hash)}"
|
||||
f"Bcrypt hash should be ~60 chars long, got {len(user.password_hash)}"
|
||||
)
|
||||
|
||||
|
||||
# === Bonus: no default admin bootstrap on startup ===
|
||||
|
||||
|
||||
async def test_no_default_admin_on_startup(
|
||||
client: AsyncClient, session_factory
|
||||
) -> None:
|
||||
async def test_no_default_admin_on_startup(client: AsyncClient, session_factory) -> None:
|
||||
"""AC: KEIN admin/admin Bootstrap-User beim App-Start (Frisch-DB = leer)."""
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
# Fresh DB → no users
|
||||
@@ -238,10 +227,6 @@ async def test_no_default_admin_on_startup(
|
||||
assert count == 0, f"Fresh DB should have 0 users, found {count}"
|
||||
|
||||
# Also check: no user with role=admin and well-known email
|
||||
result = await session.execute(
|
||||
select(User).where(User.role == "admin")
|
||||
)
|
||||
result = await session.execute(select(User).where(User.role == "admin"))
|
||||
admins = result.scalars().all()
|
||||
assert len(admins) == 0, (
|
||||
f"Fresh DB should have no admin users, found {len(admins)}"
|
||||
)
|
||||
assert len(admins) == 0, f"Fresh DB should have no admin users, found {len(admins)}"
|
||||
|
||||
@@ -13,7 +13,12 @@ async def test_create_contact_with_account(
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.post(
|
||||
"/api/v1/contacts/",
|
||||
json={"first_name": "Diana", "last_name": "Prince", "email": "diana@x.example", "account_id": acc_id},
|
||||
json={
|
||||
"first_name": "Diana",
|
||||
"last_name": "Prince",
|
||||
"email": "diana@x.example",
|
||||
"account_id": acc_id,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
@@ -34,9 +39,7 @@ async def test_create_contact_invalid_account(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_contacts_filter_account(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
async def test_list_contacts_filter_account(client: AsyncClient, seed_data: dict) -> None:
|
||||
acc_id = seed_data["account_ids"][0]
|
||||
resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
@@ -45,9 +48,7 @@ async def test_list_contacts_filter_account(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_contacts_by_email(
|
||||
client: AsyncClient, seed_data: dict
|
||||
) -> None:
|
||||
async def test_search_contacts_by_email(client: AsyncClient, seed_data: dict) -> None:
|
||||
# seed_data creates contact with email 'anna@acme.example' on account 0
|
||||
resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -11,7 +11,9 @@ async def test_kpis(client: AsyncClient, seed_data: dict) -> None:
|
||||
resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"])
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set(body.keys())
|
||||
assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set(
|
||||
body.keys()
|
||||
)
|
||||
assert isinstance(body["open_deals_count"], int)
|
||||
assert isinstance(body["pipeline_value"], (int, float))
|
||||
assert isinstance(body["won_this_month"], int)
|
||||
|
||||
@@ -37,6 +37,7 @@ async def test_update_deal_stage_creates_history(
|
||||
|
||||
# Verify DealStageHistory row exists in DB
|
||||
from sqlalchemy import text
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"),
|
||||
@@ -74,6 +75,7 @@ async def test_db_write_deal(
|
||||
) -> None:
|
||||
"""DB roundtrip: read back a seeded deal directly via SQL."""
|
||||
from sqlalchemy import text
|
||||
|
||||
deal_id = seed_data["deal_ids"][2]
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
|
||||
@@ -27,6 +27,7 @@ def _read(rel: str) -> str:
|
||||
# api.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_api_js_exports_api_and_ApiError():
|
||||
content = _read("js/api.js")
|
||||
assert "export class ApiError" in content
|
||||
@@ -56,6 +57,7 @@ def test_api_js_handles_204_no_content():
|
||||
# store.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_store_js_defines_auth_and_notifications():
|
||||
content = _read("js/store.js")
|
||||
assert "Alpine.store('auth'" in content
|
||||
@@ -81,6 +83,7 @@ def test_store_js_notifications_has_push_and_dismiss():
|
||||
# app.css
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_css_contains_tailwind_overrides():
|
||||
content = _read("css/app.css")
|
||||
# The file defines custom CSS variables (--crm-primary, etc.)
|
||||
@@ -107,10 +110,10 @@ def test_app_css_has_toast_styles():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMPONENTS = [
|
||||
("account-list.js", "accountList"),
|
||||
("deal-kanban.js", "dealKanban"),
|
||||
("activity-list.js", "activityList"),
|
||||
("dashboard-kpis.js", "dashboardKpis"),
|
||||
("account-list.js", "accountList"),
|
||||
("deal-kanban.js", "dealKanban"),
|
||||
("activity-list.js", "activityList"),
|
||||
("dashboard-kpis.js", "dashboardKpis"),
|
||||
]
|
||||
|
||||
|
||||
@@ -120,11 +123,13 @@ def test_alpine_component_defined(filename, factory):
|
||||
globally with Alpine.data(name, factory)."""
|
||||
content = _read(f"components/{filename}")
|
||||
# factory function definition
|
||||
assert f"export function {factory}" in content, \
|
||||
assert f"export function {factory}" in content, (
|
||||
f"{filename} missing `export function {factory}()`"
|
||||
)
|
||||
# Alpine.data() registration
|
||||
assert f"Alpine.data('{factory}'" in content, \
|
||||
assert f"Alpine.data('{factory}'" in content, (
|
||||
f"{filename} missing `Alpine.data('{factory}', …)` registration"
|
||||
)
|
||||
|
||||
|
||||
def test_deal_kanban_handles_drag_and_drop():
|
||||
@@ -159,6 +164,7 @@ def test_account_list_uses_pagination():
|
||||
# notifications.js (toast component)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_notifications_js_registers_toast_container():
|
||||
content = _read("js/components/notifications.js")
|
||||
assert "toastContainer" in content
|
||||
@@ -169,6 +175,7 @@ def test_notifications_js_registers_toast_container():
|
||||
# auth.js
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_auth_js_exports_login_logout_register():
|
||||
content = _read("js/auth.js")
|
||||
assert "export async function login" in content
|
||||
|
||||
@@ -33,18 +33,18 @@ def _read(name: str) -> str:
|
||||
# Each page: (filename, [required substrings])
|
||||
# ---------------------------------------------------------------------------
|
||||
PAGES = [
|
||||
("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]),
|
||||
("app.html", ["Dashboard", "Logout", "Pipeline"]),
|
||||
("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]),
|
||||
("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]),
|
||||
("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]),
|
||||
("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]),
|
||||
("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]),
|
||||
("activities.html", ["Activities", "+ New Activity", "Fällig"]),
|
||||
("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]),
|
||||
("app.html", ["Dashboard", "Logout", "Pipeline"]),
|
||||
("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]),
|
||||
("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]),
|
||||
("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]),
|
||||
("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]),
|
||||
("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]),
|
||||
("activities.html", ["Activities", "+ New Activity", "Fällig"]),
|
||||
("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]),
|
||||
("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]),
|
||||
("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]),
|
||||
("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]),
|
||||
("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]),
|
||||
("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]),
|
||||
("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]),
|
||||
# Dashboard: ships inside app.html. We test the marker text directly there.
|
||||
]
|
||||
|
||||
|
||||
@@ -43,9 +43,8 @@ def test_no_x_html_in_alpine_templates():
|
||||
stripped = re.sub(r"<!--.*?-->", "", line)
|
||||
if XHTML_PATTERN.search(stripped):
|
||||
offenders.append((path.name, lineno, line.strip()[:120]))
|
||||
assert not offenders, (
|
||||
"x-html usage is forbidden (R-5); offenders:\n"
|
||||
+ "\n".join(f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders)
|
||||
assert not offenders, "x-html usage is forbidden (R-5); offenders:\n" + "\n".join(
|
||||
f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders
|
||||
)
|
||||
|
||||
|
||||
@@ -54,21 +53,23 @@ def test_no_innerHTML_in_alpine_pages():
|
||||
for path in _all_html_files():
|
||||
content = path.read_text(encoding="utf-8")
|
||||
# allow within <script> blocks only if commented out / disabled
|
||||
assert "innerHTML" not in content, \
|
||||
assert "innerHTML" not in content, (
|
||||
f"{path.name} contains innerHTML which is forbidden (R-5)"
|
||||
)
|
||||
|
||||
|
||||
def test_x_text_is_used_instead():
|
||||
"""Sanity check: the auth/dashboard pages use x-text for user-derived strings."""
|
||||
index = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||
# The error message div should use x-text (so user input never gets HTML-parsed)
|
||||
assert "x-text=\"error\"" in index or 'x-text="error"' in index
|
||||
assert 'x-text="error"' in index or 'x-text="error"' in index
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# R-1: JWT in localStorage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jwt_uses_localStorage():
|
||||
"""api.js must read/write the JWT in localStorage, not cookies/sessionStorage."""
|
||||
api = (WEBUI / "js" / "api.js").read_text(encoding="utf-8")
|
||||
@@ -90,14 +91,16 @@ def test_jwt_not_in_cookies():
|
||||
"""Defence in depth: do NOT put the JWT in document.cookie."""
|
||||
for js in WEBUI.rglob("*.js"):
|
||||
content = js.read_text(encoding="utf-8")
|
||||
assert "document.cookie" not in content, \
|
||||
assert "document.cookie" not in content, (
|
||||
f"{js.relative_to(ROOT)} must not use document.cookie for the JWT"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 13.3: CSP-Header in main.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_csp_header_in_main_py():
|
||||
"""app/main.py must wire up a middleware (or response-header decorator)
|
||||
that sets Content-Security-Policy. We don't run the server here — we
|
||||
@@ -105,29 +108,30 @@ def test_csp_header_in_main_py():
|
||||
assert MAIN_PY.exists(), f"Missing {MAIN_PY}"
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
# The middleware / decorator must mention the CSP header value
|
||||
assert "Content-Security-Policy" in content, \
|
||||
"app/main.py does not set Content-Security-Policy"
|
||||
assert "Content-Security-Policy" in content, "app/main.py does not set Content-Security-Policy"
|
||||
# The header must allow the Tailwind CDN at minimum
|
||||
assert "cdn.tailwindcss.com" in content, \
|
||||
assert "cdn.tailwindcss.com" in content, (
|
||||
"CSP-Header must allow https://cdn.tailwindcss.com in script-src"
|
||||
)
|
||||
# And block scripts from arbitrary origins
|
||||
assert "default-src 'self'" in content or 'default-src "self"' in content, \
|
||||
assert "default-src 'self'" in content or 'default-src "self"' in content, (
|
||||
"CSP-Header must set default-src 'self'"
|
||||
)
|
||||
|
||||
|
||||
def test_csp_blocks_object_embedding():
|
||||
"""object-src 'none' is part of the agreed lockdown."""
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
assert "object-src 'none'" in content, \
|
||||
"CSP-Header must set object-src 'none'"
|
||||
assert "object-src 'none'" in content, "CSP-Header must set object-src 'none'"
|
||||
|
||||
|
||||
def test_csp_uses_starlette_middleware():
|
||||
"""Per architecture: CSP is set in a FastAPI/Starlette middleware,
|
||||
not in nginx / a response handler."""
|
||||
content = MAIN_PY.read_text(encoding="utf-8")
|
||||
assert "@app.middleware(\"http\")" in content or '@app.middleware("http")' in content, \
|
||||
"CSP-Header must be set in an @app.middleware(\"http\") decorator"
|
||||
assert '@app.middleware("http")' in content or '@app.middleware("http")' in content, (
|
||||
'CSP-Header must be set in an @app.middleware("http") decorator'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,9 +159,9 @@ def test_protected_page_has_auth_gate(filename):
|
||||
content = (WEBUI / filename).read_text(encoding="utf-8")
|
||||
# The auth-gate is `x-data="{ init: () => Alpine.store('auth').init() }"`
|
||||
# or `x-init="init()"` on a component that checks the JWT.
|
||||
assert "Alpine.store('auth').init()" in content or \
|
||||
"$store.auth.init()" in content, \
|
||||
assert "Alpine.store('auth').init()" in content or "$store.auth.init()" in content, (
|
||||
f"{filename} has no Alpine.store('auth').init() auth-gate"
|
||||
)
|
||||
|
||||
|
||||
def test_index_page_has_no_auth_gate():
|
||||
@@ -165,13 +169,15 @@ def test_index_page_has_no_auth_gate():
|
||||
content = (WEBUI / "index.html").read_text(encoding="utf-8")
|
||||
# The auth store's init() redirects to /index.html when no JWT exists,
|
||||
# so it must NOT be called from the login page.
|
||||
assert "Alpine.store('auth').init()" not in content, \
|
||||
assert "Alpine.store('auth').init()" not in content, (
|
||||
"index.html (login page) must not call auth.init() (would cause loop)"
|
||||
)
|
||||
|
||||
|
||||
def test_404_page_has_no_auth_gate():
|
||||
"""404 page is reachable without auth (so users can find their way back)."""
|
||||
content = (WEBUI / "404.html").read_text(encoding="utf-8")
|
||||
# The 404 must not call auth.init() — it is intentionally public.
|
||||
assert "Alpine.store('auth').init()" not in content, \
|
||||
assert "Alpine.store('auth').init()" not in content, (
|
||||
"404.html must not call auth.init() (public page)"
|
||||
)
|
||||
|
||||
@@ -22,9 +22,7 @@ async def test_health_no_auth_required(client: AsyncClient) -> None:
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Even with a bogus header, it should still be 200 (public endpoint)
|
||||
resp = await client.get(
|
||||
"/health", headers={"Authorization": "Bearer not-a-real-token"}
|
||||
)
|
||||
resp = await client.get("/health", headers={"Authorization": "Bearer not-a-real-token"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
|
||||
+1
-3
@@ -7,9 +7,7 @@ from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_tag(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
async def test_create_tag(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
resp = await client.post(
|
||||
"/api/v1/tags/",
|
||||
json={"name": "Important", "color": "#FF0000"},
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
# === /users/me ===
|
||||
|
||||
|
||||
@@ -36,9 +35,10 @@ async def test_get_me_invalid_token_format(client: AsyncClient) -> None:
|
||||
|
||||
async def test_get_me_bogus_token(client: AsyncClient) -> None:
|
||||
"""GET /api/v1/users/me with a token signed with the wrong key returns 401."""
|
||||
from jose import jwt
|
||||
import time
|
||||
|
||||
from jose import jwt
|
||||
|
||||
bogus = jwt.encode(
|
||||
{
|
||||
"sub": "1",
|
||||
@@ -173,9 +173,7 @@ async def test_list_users_as_sales_rep_forbidden(
|
||||
# === Refresh & Logout ===
|
||||
|
||||
|
||||
async def test_refresh_token(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
async def test_refresh_token(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
"""POST /api/v1/auth/refresh issues a new token for the current user."""
|
||||
resp = await client.post("/api/v1/auth/refresh", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -185,9 +183,7 @@ async def test_refresh_token(
|
||||
assert data["expires_in"] > 0
|
||||
|
||||
|
||||
async def test_logout(
|
||||
client: AsyncClient, auth_headers: dict[str, str]
|
||||
) -> None:
|
||||
async def test_logout(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
"""POST /api/v1/auth/logout returns 200 with confirmation message."""
|
||||
resp = await client.post("/api/v1/auth/logout", headers=auth_headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
Reference in New Issue
Block a user