feat(api): exposer le blueprint HTTP sécurisé Home Assistant (étape 4)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
This commit is contained in:
@@ -18,7 +18,7 @@ import tomllib
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import Flask
|
from flask import Flask, request
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
@@ -197,6 +197,7 @@ def create_app(
|
|||||||
app.jinja_env.filters["date_fr"] = _date_fr
|
app.jinja_env.filters["date_fr"] = _date_fr
|
||||||
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
app.jinja_env.filters["day_type_fr"] = _day_type_fr
|
||||||
|
|
||||||
|
from app.api import bp as api_bp
|
||||||
from app.routes.dashboard import bp as dashboard_bp
|
from app.routes.dashboard import bp as dashboard_bp
|
||||||
from app.routes.entries import bp as entries_bp
|
from app.routes.entries import bp as entries_bp
|
||||||
from app.routes.reports import bp as reports_bp
|
from app.routes.reports import bp as reports_bp
|
||||||
@@ -204,6 +205,13 @@ def create_app(
|
|||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(entries_bp)
|
app.register_blueprint(entries_bp)
|
||||||
app.register_blueprint(reports_bp)
|
app.register_blueprint(reports_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def add_api_cache_policy(response):
|
||||||
|
if request.path.startswith("/api/v1/"):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
_migrate_db(app)
|
_migrate_db(app)
|
||||||
|
|||||||
166
app/api.py
Normal file
166
app/api.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""API JSON pour les événements de présence Home Assistant."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from threading import Lock
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, current_app, jsonify, request
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.business.presence_service import (
|
||||||
|
ArrivalAlreadyOpenError,
|
||||||
|
DepartureWithoutArrivalError,
|
||||||
|
IdempotencyConflictError,
|
||||||
|
InvalidPresenceEventError,
|
||||||
|
record_presence_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
bp = Blueprint("api", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
_MAX_BODY_BYTES = 4096
|
||||||
|
_RATE_LIMIT = 30
|
||||||
|
_RATE_WINDOW_SECONDS = 60.0
|
||||||
|
_rate_lock = Lock()
|
||||||
|
_rate_requests: defaultdict[str, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_rate_limiter() -> None:
|
||||||
|
"""Réinitialise la protection mémoire, notamment pour les tests."""
|
||||||
|
with _rate_lock:
|
||||||
|
_rate_requests.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _response(payload: dict[str, Any], status: int) -> Response:
|
||||||
|
response = jsonify(payload)
|
||||||
|
response.status_code = status
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _error(status: int, message: str) -> Response:
|
||||||
|
return _response({"error": message}, status)
|
||||||
|
|
||||||
|
|
||||||
|
def _authenticated() -> tuple[str | None, Response | None]:
|
||||||
|
configured_token = os.environ.get("WORKLOG_API_TOKEN")
|
||||||
|
if not configured_token:
|
||||||
|
return None, _error(503, "API indisponible")
|
||||||
|
|
||||||
|
authorization = request.headers.get("Authorization", "")
|
||||||
|
scheme, separator, supplied_token = authorization.partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or not separator or not supplied_token:
|
||||||
|
return None, _error(401, "Authentification requise")
|
||||||
|
if not hmac.compare_digest(supplied_token, configured_token):
|
||||||
|
return None, _error(401, "Authentification requise")
|
||||||
|
return supplied_token, None
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_allowed(token: str) -> bool:
|
||||||
|
# Cette limite est par processus ; HAProxy ou une solution partagée est nécessaire
|
||||||
|
# pour garantir la limite à l'échelle de plusieurs workers.
|
||||||
|
now = monotonic()
|
||||||
|
with _rate_lock:
|
||||||
|
requests_for_token = _rate_requests[token]
|
||||||
|
requests_for_token[:] = [
|
||||||
|
timestamp for timestamp in requests_for_token if now - timestamp < _RATE_WINDOW_SECONDS
|
||||||
|
]
|
||||||
|
if len(requests_for_token) >= _RATE_LIMIT:
|
||||||
|
return False
|
||||||
|
requests_for_token.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/workplace-presence", methods=["POST"])
|
||||||
|
def workplace_presence() -> Response:
|
||||||
|
"""Réceptionne un événement d'arrivée ou de départ authentifié."""
|
||||||
|
if current_app.config.get("HOME_ASSISTANT") is None:
|
||||||
|
return _error(404, "API indisponible")
|
||||||
|
|
||||||
|
token, authentication_error = _authenticated()
|
||||||
|
if authentication_error is not None:
|
||||||
|
return authentication_error
|
||||||
|
assert token is not None
|
||||||
|
if not _rate_allowed(token):
|
||||||
|
return _error(429, "Trop de requêtes")
|
||||||
|
|
||||||
|
if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
|
||||||
|
return _error(413, "Requête trop volumineuse")
|
||||||
|
raw_body = request.get_data(cache=True)
|
||||||
|
if len(raw_body) > _MAX_BODY_BYTES:
|
||||||
|
return _error(413, "Requête trop volumineuse")
|
||||||
|
if request.mimetype != "application/json":
|
||||||
|
return _error(415, "Content-Type invalide")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = request.get_json(silent=False)
|
||||||
|
except Exception:
|
||||||
|
return _error(400, "JSON invalide")
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
|
||||||
|
allowed_keys = {"event", "occurred_at", "idempotency_key"}
|
||||||
|
if (
|
||||||
|
not set(payload).issubset(allowed_keys)
|
||||||
|
or "event" not in payload
|
||||||
|
or "occurred_at" not in payload
|
||||||
|
):
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
event = payload["event"]
|
||||||
|
occurred_at = payload["occurred_at"]
|
||||||
|
body_key = payload.get("idempotency_key")
|
||||||
|
header_key = request.headers.get("X-Idempotency-Key")
|
||||||
|
if body_key is not None and (not isinstance(body_key, str) or not body_key.strip()):
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
if header_key is not None and not header_key.strip():
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
if body_key is not None and header_key is not None and body_key != header_key:
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
idempotency_key = body_key if body_key is not None else header_key
|
||||||
|
if not all(
|
||||||
|
isinstance(value, str) and value.strip() for value in (event, occurred_at, idempotency_key)
|
||||||
|
):
|
||||||
|
return _error(422, "Schéma invalide")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = record_presence_event(
|
||||||
|
db.session,
|
||||||
|
current_app.config["HOME_ASSISTANT"],
|
||||||
|
event,
|
||||||
|
occurred_at,
|
||||||
|
idempotency_key,
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
except InvalidPresenceEventError:
|
||||||
|
db.session.rollback()
|
||||||
|
return _error(422, "Événement invalide")
|
||||||
|
except (ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError):
|
||||||
|
db.session.rollback()
|
||||||
|
return _error(409, "Conflit métier")
|
||||||
|
except Exception:
|
||||||
|
db.session.rollback()
|
||||||
|
return _error(500, "Erreur interne")
|
||||||
|
|
||||||
|
status = "replayed" if result.replayed else "created"
|
||||||
|
http_status = 200 if result.replayed or event == "departure" else 201
|
||||||
|
return _response(
|
||||||
|
{
|
||||||
|
"status": status,
|
||||||
|
"event_id": result.event_id,
|
||||||
|
"entry_id": result.entry_id,
|
||||||
|
"time_slot_id": result.time_slot_id,
|
||||||
|
"event_type": event,
|
||||||
|
},
|
||||||
|
http_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.app_errorhandler(405)
|
||||||
|
def method_not_allowed(error):
|
||||||
|
if request.path.startswith("/api/v1/"):
|
||||||
|
return _error(405, "Méthode non autorisée")
|
||||||
|
return error
|
||||||
162
tests/test_api.py
Normal file
162
tests/test_api.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.api import reset_rate_limiter
|
||||||
|
from app.models import TimeSlot, WorkplacePresenceEvent
|
||||||
|
|
||||||
|
TOKEN = "test-api-token"
|
||||||
|
URL = "/api/v1/workplace-presence"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def api_state(monkeypatch):
|
||||||
|
monkeypatch.setenv("WORKLOG_API_TOKEN", TOKEN)
|
||||||
|
reset_rate_limiter()
|
||||||
|
yield
|
||||||
|
reset_rate_limiter()
|
||||||
|
|
||||||
|
|
||||||
|
def post(client, payload, **headers):
|
||||||
|
return client.post(URL, json=payload, headers={"Authorization": f"Bearer {TOKEN}", **headers})
|
||||||
|
|
||||||
|
|
||||||
|
def test_arrival_and_departure_create_expected_records(client, app):
|
||||||
|
arrival = post(
|
||||||
|
client,
|
||||||
|
{"event": "arrival", "occurred_at": "2026-08-13T08:00:00+02:00", "idempotency_key": "a"},
|
||||||
|
)
|
||||||
|
assert arrival.status_code == 201
|
||||||
|
assert arrival.json["status"] == "created"
|
||||||
|
assert arrival.json["time_slot_id"] is None
|
||||||
|
|
||||||
|
departure = post(
|
||||||
|
client,
|
||||||
|
{"event": "departure", "occurred_at": "2026-08-13T17:00:00+02:00", "idempotency_key": "d"},
|
||||||
|
)
|
||||||
|
assert departure.status_code == 200
|
||||||
|
assert departure.json["time_slot_id"] is not None
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.query(TimeSlot).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_does_not_duplicate(client, app):
|
||||||
|
payload = {
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T08:00:00+02:00",
|
||||||
|
"idempotency_key": "same",
|
||||||
|
}
|
||||||
|
assert post(client, payload).status_code == 201
|
||||||
|
replay = post(client, payload)
|
||||||
|
assert replay.status_code == 200
|
||||||
|
assert replay.json["status"] == "replayed"
|
||||||
|
with app.app_context():
|
||||||
|
assert db.session.query(WorkplacePresenceEvent).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflicts_and_idempotency_header_mismatch(client):
|
||||||
|
departure = post(
|
||||||
|
client,
|
||||||
|
{"event": "departure", "occurred_at": "2026-08-13T08:00:00+02:00", "idempotency_key": "d"},
|
||||||
|
)
|
||||||
|
assert departure.status_code == 409
|
||||||
|
mismatch = post(
|
||||||
|
client,
|
||||||
|
{"event": "arrival", "occurred_at": "2026-08-13T09:00:00+02:00", "idempotency_key": "body"},
|
||||||
|
**{"X-Idempotency-Key": "header"},
|
||||||
|
)
|
||||||
|
assert mismatch.status_code == 422
|
||||||
|
assert (
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T09:00:00+02:00",
|
||||||
|
"idempotency_key": "a",
|
||||||
|
},
|
||||||
|
).status_code
|
||||||
|
== 201
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
post(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T10:00:00+02:00",
|
||||||
|
"idempotency_key": "b",
|
||||||
|
},
|
||||||
|
).status_code
|
||||||
|
== 409
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload", "status"),
|
||||||
|
[
|
||||||
|
({"event": "arrival", "occurred_at": "bad", "idempotency_key": "a"}, 422),
|
||||||
|
({"event": "arrival", "occurred_at": "2026-08-13T08:00:00+02:00", "extra": "x"}, 422),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T08:00:00+02:00",
|
||||||
|
"idempotency_key": "a",
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validation_and_header_idempotency(client, payload, status):
|
||||||
|
headers = {"X-Idempotency-Key": "a"} if "idempotency_key" not in payload else {}
|
||||||
|
response = post(client, payload, **headers)
|
||||||
|
assert response.status_code == status
|
||||||
|
|
||||||
|
|
||||||
|
def test_authentication_and_disabled_config(client, app, monkeypatch):
|
||||||
|
monkeypatch.delenv("WORKLOG_API_TOKEN")
|
||||||
|
assert post(client, {}).status_code == 503
|
||||||
|
monkeypatch.setenv("WORKLOG_API_TOKEN", TOKEN)
|
||||||
|
assert client.post(URL).status_code == 401
|
||||||
|
app.config["HOME_ASSISTANT"] = None
|
||||||
|
assert post(client, {}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_errors_are_json_uncached_and_do_not_leak(client):
|
||||||
|
response = client.post(
|
||||||
|
URL, data="{}", content_type="text/plain", headers={"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 415
|
||||||
|
assert response.headers["Cache-Control"] == "no-store"
|
||||||
|
assert "traceback" not in response.get_data(as_text=True).lower()
|
||||||
|
assert TOKEN not in response.get_data(as_text=True)
|
||||||
|
|
||||||
|
malformed = client.post(
|
||||||
|
URL,
|
||||||
|
data="{",
|
||||||
|
content_type="application/json",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||||
|
)
|
||||||
|
assert malformed.status_code == 400
|
||||||
|
assert client.get(URL).status_code == 405
|
||||||
|
|
||||||
|
|
||||||
|
def test_body_limit_and_rate_limit(client):
|
||||||
|
oversized = post(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T08:00:00+02:00",
|
||||||
|
"idempotency_key": "x" * 4020,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert oversized.status_code == 413
|
||||||
|
reset_rate_limiter()
|
||||||
|
for index in range(30):
|
||||||
|
response = post(
|
||||||
|
client,
|
||||||
|
{
|
||||||
|
"event": "arrival",
|
||||||
|
"occurred_at": "2026-08-13T08:00:00+02:00",
|
||||||
|
"idempotency_key": f"rate-{index}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code != 429
|
||||||
|
assert post(client, {}).status_code == 429
|
||||||
Reference in New Issue
Block a user