diff --git a/app/__init__.py b/app/__init__.py index fbedfe9..9ca143d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -18,7 +18,7 @@ import tomllib from collections.abc import Mapping import sqlalchemy as sa -from flask import Flask +from flask import Flask, request from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() @@ -197,6 +197,7 @@ def create_app( app.jinja_env.filters["date_fr"] = _date_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.entries import bp as entries_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(entries_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(): _migrate_db(app) diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..d7c555c --- /dev/null +++ b/app/api.py @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..608f920 --- /dev/null +++ b/tests/test_api.py @@ -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