Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et finances personnelles avec import de relevés bancaires. Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS + Vite + Tailwind + ECharts, déploiement Docker Compose. Modules auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework de connecteurs à deux voies (importeurs de fichiers + ingestion JSON) pour brancher de nouvelles sources sans toucher au noyau. Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16 (28 tables, SPA servie par nginx, wizard de premier démarrage). Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les documents de conception et de recherche dans docs/. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
from fastapi.testclient import TestClient
|
||
|
||
from app.tests.conftest import TEST_USER_EMAIL, TEST_USER_PASSWORD
|
||
|
||
|
||
def test_status_reports_setup_required_then_not(client: TestClient) -> None:
|
||
res = client.get("/api/auth/status")
|
||
assert res.status_code == 200
|
||
body = res.json()
|
||
assert body["setup_required"] is True
|
||
assert body["needs_setup"] is True
|
||
|
||
res = client.post(
|
||
"/api/auth/setup",
|
||
json={
|
||
"email": "first@lifetrack.local",
|
||
"password": "long-enough-pw",
|
||
"display_name": "Premier",
|
||
},
|
||
)
|
||
assert res.status_code == 201
|
||
body = res.json()
|
||
assert body["token_type"] == "bearer"
|
||
assert body["access_token"]
|
||
assert body["user"]["email"] == "first@lifetrack.local"
|
||
|
||
res = client.get("/api/auth/status")
|
||
assert res.json()["setup_required"] is False
|
||
|
||
|
||
def test_setup_forbidden_once_a_user_exists(client: TestClient, user) -> None:
|
||
res = client.post(
|
||
"/api/auth/setup",
|
||
json={
|
||
"email": "other@lifetrack.local",
|
||
"password": "long-enough-pw",
|
||
"display_name": "Intrus",
|
||
},
|
||
)
|
||
assert res.status_code == 403
|
||
body = res.json()
|
||
assert body["error"]["code"] == "forbidden"
|
||
assert body["error"]["message"]
|
||
|
||
|
||
def test_login_ok_and_wrong_password(client: TestClient, user) -> None:
|
||
res = client.post(
|
||
"/api/auth/login",
|
||
json={"email": TEST_USER_EMAIL, "password": TEST_USER_PASSWORD},
|
||
)
|
||
assert res.status_code == 200
|
||
assert res.json()["access_token"]
|
||
|
||
res = client.post(
|
||
"/api/auth/login",
|
||
json={"email": TEST_USER_EMAIL, "password": "wrong-password"},
|
||
)
|
||
assert res.status_code == 401
|
||
assert res.json()["error"]["code"] == "unauthorized"
|
||
|
||
|
||
def test_me_requires_auth(client: TestClient, user, auth_headers) -> None:
|
||
res = client.get("/api/auth/me")
|
||
assert res.status_code == 401
|
||
assert res.json()["error"]["code"] == "unauthorized"
|
||
|
||
res = client.get("/api/auth/me", headers=auth_headers)
|
||
assert res.status_code == 200
|
||
body = res.json()
|
||
assert body["email"] == TEST_USER_EMAIL
|
||
assert "password_hash" not in body
|
||
|
||
|
||
def test_patch_me_password_change_requires_current(
|
||
client: TestClient, user, auth_headers
|
||
) -> None:
|
||
res = client.patch(
|
||
"/api/auth/me",
|
||
headers=auth_headers,
|
||
json={"password": "new-password-123"},
|
||
)
|
||
assert res.status_code == 422
|
||
|
||
res = client.patch(
|
||
"/api/auth/me",
|
||
headers=auth_headers,
|
||
json={
|
||
"password": "new-password-123",
|
||
"current_password": TEST_USER_PASSWORD,
|
||
"display_name": "Renommé",
|
||
},
|
||
)
|
||
assert res.status_code == 200
|
||
assert res.json()["display_name"] == "Renommé"
|
||
|
||
res = client.post(
|
||
"/api/auth/login",
|
||
json={"email": TEST_USER_EMAIL, "password": "new-password-123"},
|
||
)
|
||
assert res.status_code == 200
|
||
|
||
|
||
def test_device_key_lifecycle(client: TestClient, user, auth_headers) -> None:
|
||
# Create: plaintext key returned exactly once, shaped ltk_<prefix>_<secret>.
|
||
res = client.post(
|
||
"/api/auth/device-keys",
|
||
headers=auth_headers,
|
||
json={"name": "Pixel 8 – pont Health Connect", "scopes": ["ingest:health"]},
|
||
)
|
||
assert res.status_code == 201
|
||
created = res.json()
|
||
key = created["key"]
|
||
assert key.startswith("ltk_")
|
||
prefix = key.split("_", 2)[1]
|
||
assert len(prefix) == 8
|
||
assert created["key_prefix"] == prefix
|
||
assert created["scopes"] == ["ingest:health"]
|
||
|
||
# List never exposes the plaintext key.
|
||
res = client.get("/api/auth/device-keys", headers=auth_headers)
|
||
assert res.status_code == 200
|
||
keys = res.json()
|
||
assert len(keys) == 1
|
||
assert "key" not in keys[0]
|
||
assert keys[0]["key_prefix"] == prefix
|
||
assert keys[0]["revoked_at"] is None
|
||
|
||
# Revoke (DELETE keeps the row, sets revoked_at).
|
||
res = client.delete(f"/api/auth/device-keys/{created['id']}", headers=auth_headers)
|
||
assert res.status_code == 204
|
||
res = client.get("/api/auth/device-keys", headers=auth_headers)
|
||
assert res.json()[0]["revoked_at"] is not None
|
||
|
||
|
||
def test_device_key_invalid_scope_rejected(
|
||
client: TestClient, user, auth_headers
|
||
) -> None:
|
||
res = client.post(
|
||
"/api/auth/device-keys",
|
||
headers=auth_headers,
|
||
json={"name": "Mauvaise clé", "scopes": ["admin:*"]},
|
||
)
|
||
assert res.status_code == 422
|
||
|
||
|
||
def test_error_shape_on_unknown_route(client: TestClient) -> None:
|
||
res = client.get("/api/definitely-not-a-route")
|
||
assert res.status_code == 404
|
||
assert set(res.json()["error"].keys()) == {"code", "message", "details"}
|