Compare commits

...

4 Commits

Author SHA1 Message Date
fc3250f431 docs(home-assistant): documenter l'intégration présence Home Assistant
Guide complet de configuration de l'intégration Home Assistant avec
l'API REST de présence : prérequis, secrets, rest_command, zones,
automatisations et dépannage.

Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:36:26 +02:00
bca00a06bf docs(api): documenter l'API REST (endpoints, exemples, schémas)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:24:33 +02:00
704e73b936 test(api): couvrir l'édition Web d'une journée créée par l'API (étape 5)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:11:56 +02:00
874328006a feat(api): exposer le blueprint HTTP sécurisé Home Assistant (étape 4)
Co-authored-by: OpenAI/GPT-5.6-Luna-Pro <vibecoder@antoineve.me>
2026-08-15 12:03:53 +02:00
5 changed files with 835 additions and 1 deletions

View File

@@ -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)

166
app/api.py Normal file
View 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

203
docs/api.md Normal file
View File

@@ -0,0 +1,203 @@
# Documentation de l'API REST (Intégration Présence)
Ce document fournit la documentation technique, autonome, exacte et vérifiable de l'API REST implémentée dans l'application **Tableau de bord pro**.
---
## 1. But et périmètre
L'API REST expose un endpoint unique destiné à recevoir des événements de présence automatisés (par exemple en provenance d'un système domotique comme Home Assistant). Elle permet de :
- Enregistrer l'**arrivée** d'un collaborateur sur son lieu de travail (création automatique d'une entrée de journée `WorkEntry` si elle n'existe pas et d'un événement `WorkplacePresenceEvent`).
- Enregistrer le **départ** du lieu de travail (fermeture de l'arrivée ouverte, création d'une plage horaire `TimeSlot` et de l'événement de départ associé).
- Garantir l'**idempotence** des requêtes via une clé unique pour éviter les doublons en cas de rejeu réseau.
---
## 2. Prérequis de configuration (`[home_assistant]`)
L'activation de l'API dépend de la présence et de la validité de la section `[home_assistant]` dans le fichier de configuration `config.toml`. Si cette section est absente, l'API est désactivée et renvoie un code `404`.
Exemple de configuration valide dans `config.toml` :
```toml
[home_assistant]
timezone = "Europe/Paris"
default_day_type = "WORK"
default_journey_profile_id = "moteur_seul"
default_motor_vehicle_id = "citadine"
```
### Règles de validation de la configuration :
- **`timezone`** : Doit être un fuseau horaire valide reconnu par `zoneinfo.ZoneInfo` (ex: `"Europe/Paris"`).
- **`default_day_type`** : Doit correspondre à un type de jour valide (`WORK`, `TT`, `GARDE`, `ASTREINTE`, `FORMATION`, `RTT`, `CONGE`, `MALADE`, `FERIE`).
- **`default_journey_profile_id`** : Doit être un identifiant de trajet existant dans la section `[journeys]`.
- **`default_motor_vehicle_id`** : Doit être un identifiant de véhicule existant dans la section `[vehicles]` dont le type est `"moteur"`.
---
## 3. Sécurité, Authentification et Transport
### Authentification par Jeton (Bearer Token)
- L'API exige un jeton d'authentification transmis via l'en-tête HTTP `Authorization` au format Bearer :
```http
Authorization: Bearer <WORKLOG_API_TOKEN>
```
- La variable d'environnement **`WORKLOG_API_TOKEN`** doit être définie sur le serveur d'exécution. Si cette variable n'est pas configurée, l'API refuse toute requête et renvoie le statut `503`.
- La comparaison du jeton fourni avec la valeur configurée est réalisée de manière sécurisée en temps constant via `hmac.compare_digest` pour prévenir les attaques temporelles.
### Transport et Proxy (HTTPS / HAProxy)
- L'application Flask n'implémente pas nativement le chiffrement TLS ni de mécanisme de rotation de jeton.
- En production, la sécurité du transport (HTTPS) et le routage sont entièrement délégués au serveur amont **HAProxy**.
---
## 4. Spécifications techniques de l'endpoint
- **URL** : `/api/v1/workplace-presence`
- **Méthode** : `POST`
- **Content-Type requis** : `application/json`
- **Taille maximale du corps** : `4096` octets (`_MAX_BODY_BYTES`). Au-delà, l'API rejette la requête avec le code `413`.
### Limitation de débit (Rate-Limit)
- Chaque jeton d'authentification est soumis à une limitation de débit par processus de **30 requêtes par fenêtre glissante de 60 secondes** (`_RATE_LIMIT = 30`, `_RATE_WINDOW_SECONDS = 60.0`).
- *Note d'architecture* : Ce compteur est strictement local au processus d'exécution Flask/Gunicorn. Dans un déploiement multi-workers, une limitation globale nécessite un mécanisme amont (tel que HAProxy) ou un stockage partagé.
---
## 5. Structure du Corps de la Requête (JSON)
Le corps de la requête doit être un objet JSON valide contenant les champs suivants :
| Champ | Type | Obligatoire | Description |
| :--- | :--- | :--- | :--- |
| `event` | `string` | Oui | Type d'événement : `"arrival"` ou `"departure"`. |
| `occurred_at` | `string` | Oui | Horodatage au format ISO 8601 comportant obligatoirement un offset de fuseau horaire (ex: `2026-08-13T08:00:00+02:00`). |
| `idempotency_key` | `string` | Oui | Clé d'idempotence unique (limitée à 255 caractères). **Obligatoire** : transmise soit dans le corps JSON (`idempotency_key`), soit via l'en-tête HTTP `X-Idempotency-Key`. |
### Règle sur la clé d'idempotence :
La clé d'idempotence est **obligatoire**. Elle doit être fournie soit dans le corps JSON (`idempotency_key`), soit via l'en-tête HTTP `X-Idempotency-Key`. Si la clé est fournie à la fois dans le corps JSON et dans l'en-tête, **leurs valeurs doivent être strictement identiques**, sous peine d'un rejet avec le code `422`. Ne jamais considérer ce champ comme facultatif.
---
## 6. Gestion du Temps et Règles Métier
- **Fuseau horaire** : Les horodatages `occurred_at` sont convertis dans le fuseau horaire configuré dans `[home_assistant].timezone` (la configuration explicite de `timezone` est obligatoire dans la section `[home_assistant]` ; `"Europe/Paris"` n'est qu'un exemple de valeur fourni dans la configuration de référence du projet).
- **Arrivée (`arrival`)** :
- Crée une entrée de journée (`WorkEntry`) à la date locale si elle n'existe pas, en appliquant les valeurs par défaut de la configuration (`default_day_type`, `default_journey_profile_id`, `default_motor_vehicle_id`).
- Une seule arrivée peut être ouverte (`time_slot_id IS NULL`) simultanément dans toute l'application.
- **Départ (`departure`)** :
- Ferme l'arrivée ouverte correspondante.
- Crée un intervalle de temps (`TimeSlot`) rattaché à l'entrée de journée, défini entre l'heure de l'arrivée et l'heure du départ.
- L'heure du départ doit être strictement postérieure à celle de l'arrivée locale (`occurred_local > arrival_local`), sinon un code `422` est renvoyé.
---
## 7. Réponses de Succès
### Codes de statut de succès :
- **`201 Created`** : Événement d'arrivée créé avec succès (nouvelle ressource).
- **`200 OK`** : Événement de départ enregistré avec succès, ou **rejeu (replay)** d'un événement existant identique (idempotence).
### Format de la réponse JSON :
```json
{
"status": "created",
"event_id": 42,
"entry_id": 12,
"time_slot_id": null,
"event_type": "arrival"
}
```
*Champs retournés :*
- `status` : `"created"` (nouvel enregistrement) ou `"replayed"` (requête idempotente rejouée).
- `event_id` : Identifiant unique de l'événement de présence enregistré.
- `entry_id` : Identifiant de la journée de travail associée (`WorkEntry`).
- `time_slot_id` : Identifiant de la plage horaire créée (`null` pour une arrivée non fermée, entier pour un départ).
- `event_type` : Rappel du type d'événement traité (`"arrival"` ou `"departure"`).
---
## 8. Gestion des Erreurs
L'API renvoie un format d'erreur homogène sous forme d'objet JSON :
```json
{
"error": "Message explicatif de l'erreur"
}
```
Toutes les réponses de l'API (succès et erreurs) comportent l'en-tête `Cache-Control: no-store`.
### Codes de statut HTTP d'erreur pris en charge :
| Code | Message d'erreur associé | Cause / Description |
| :--- | :--- | :--- |
| **`400`** | `"JSON invalide"` | Le corps de la requête n'est pas un JSON syntaxiquement valide. |
| **`401`** | `"Authentification requise"` | En-tête `Authorization` absent, schéma non Bearer, ou jeton invalide. |
| **`404`** | `"API indisponible"` | Section `[home_assistant]` absente de `config.toml`. |
| **`405`** | `"Méthode non autorisée"` | Utilisation d'une méthode HTTP autre que `POST` sur l'endpoint `/api/v1/...`. |
| **`409`** | `"Conflit métier"` | Conflit logique : tentative d'enregistrement d'un départ sans arrivée ouverte, double arrivée, ou réutilisation d'une clé d'idempotence avec des paramètres différents. |
| **`413`** | `"Requête trop volumineuse"` | La taille du corps de la requête dépasse la limite de 4096 octets. |
| **`415`** | `"Content-Type invalide"` | L'en-tête `Content-Type` est absent ou différent de `application/json`. |
| **`422`** | `"Schéma invalide"` ou `"Événement invalide"` | Données non conformes : clés non autorisées, format `occurred_at` invalide (absence d'offset ou ISO 8601 incorrect), types incorrects, ou départ antérieur à l'arrivée. |
| **`429`** | `"Trop de requêtes"` | Dépassement de la limite de débit (Rate-limit de 30 req / 60s par processus). |
| **`500`** | `"Erreur interne"` | Exception inattendue lors du traitement en base de données. |
| **`503`** | `"API indisponible"` | Variable d'environnement `WORKLOG_API_TOKEN` non définie sur le serveur. |
---
## 9. Idempotence
- L'API garantit l'idempotence par le biais de la **clé d'idempotence obligatoire** (`idempotency_key` dans le corps ou en-tête `X-Idempotency-Key`).
- Si une requête est rejouée avec une clé d'idempotence déjà enregistrée :
- Si les paramètres (`event` et `occurred_at`) sont strictement identiques, l'API renvoie le résultat précédent avec le statut `200 OK` et `"status": "replayed"`, sans dupliquer les enregistrements.
- Si les paramètres diffèrent pour une même clé, l'API rejette la requête avec le code `409` (`"Conflit métier"`).
---
## 10. Comportement de l'Interface Web après l'API
- Les journées (`WorkEntry`) créées automatiquement par l'API suite à une arrivée sont pleinement intégrées dans l'application.
- L'utilisateur peut consulter et modifier ces entrées via l'interface Web (formulaire d'édition `/entries/<id>/edit`) :
- Modification du type de journée (ex: passer de `WORK` à `FORMATION`), du profil de trajet ou du véhicule à moteur.
- Modification, ajout ou suppression de plages horaires (`TimeSlot`).
- *Cohérence des événements de présence (comportement validé par les tests d'intégration)* : Lorsque les plages horaires d'une entrée sont modifiées ou recréées depuis l'interface Web (formulaire d'édition), les événements de présence associés (`WorkplacePresenceEvent`) conservent leur traçabilité (rattachés à l'entrée `WorkEntry`), mais leurs identifiants de plage (`time_slot_id`) sont dissociés (`NULL`) pour refléter la réorganisation manuelle de la journée, conformément aux tests d'intégration existants. Aucune autre promesse au-delà de cette dissociation (`NULL`) et du maintien des événements n'est garantie.
---
## 11. Exemples d'utilisation `curl` sécurisés
> **Avertissement de sécurité** : N'écrivez jamais de secret en clair dans des scripts ou des documentations. Utilisez toujours une variable shell (ex: `$WORKLOG_API_TOKEN`).
### 1. Enregistrer une arrivée :
```bash
curl -i -X POST "http://localhost:5000/api/v1/workplace-presence" \
-H "Authorization: Bearer $WORKLOG_API_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: arrival-2026-08-13-01" \
-d '{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00"
}'
```
### 2. Enregistrer un départ :
```bash
curl -i -X POST "http://localhost:5000/api/v1/workplace-presence" \
-H "Authorization: Bearer $WORKLOG_API_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: departure-2026-08-13-01" \
-d '{
"event": "departure",
"occurred_at": "2026-08-13T17:00:00+02:00"
}'
```
---
## 12. Périmètre et Limites Connues
- **Fonctionnalités non implémentées** :
- Pas de support CORS (Cross-Origin Resource Sharing) pour des appels directs depuis un navigateur web.
- Pas de rotation automatique des jetons d'API.
- Pas de limite de débit distribuée (le rate-limit est confiné à chaque processus/worker Gunicorn).
- Pas d'import CSV en masse via l'API REST.
- **Exemple Home Assistant** : La configuration d'automatisation Home Assistant (YAML) n'est pas documentée dans ce fichier (cette documentation fait l'objet d'une étape ultérieure).

143
docs/home-assistant.md Normal file
View File

@@ -0,0 +1,143 @@
# Documentation Home Assistant (Intégration Présence)
Ce document fournit le guide complet et exact pour configurer une intégration Home Assistant avec l'API REST de présence du **Tableau de bord pro**. L'automatisation s'appuie sur le suivi d'une entité de type `person` et d'une zone géographique dédiée pour enregistrer automatiquement les arrivées et départs sur le lieu de travail.
---
## 1. Prérequis
Avant de configurer les automatisations dans Home Assistant, assurez-vous des éléments suivants :
1. **Zone géographique** : Home Assistant doit posséder une zone dont l'identifiant (ID) exact est `lieu_de_travail` (dans l'interface ou `zones.yaml`). Le nom de l'entité de zone doit correspondre à `zone.lieu_de_travail`.
2. **URL HTTPS** : L'application Flask (exposée via HAProxy en production) doit être accessible par Home Assistant via une URL HTTPS valide (ex: `https://tableau-de-bord-pro.example.com/api/v1/workplace-presence`).
3. **Jeton d'authentification (`secrets.yaml`)** : Le jeton secret (`WORKLOG_API_TOKEN`) généré pour l'API doit être stocké de manière sécurisée dans le fichier `secrets.yaml` de Home Assistant.
4. **Vérification SSL** : L'utilisation de `verify_ssl: true` est obligatoire pour garantir la sécurité des échanges HTTPS.
---
## 2. Configuration des Secrets (`secrets.yaml`)
Dans le fichier `secrets.yaml` de votre instance Home Assistant, ajoutez votre jeton d'API :
```yaml
worklog_api_token: "votre_jeton_api_securise_ici"
```
---
## 3. Configuration de la Commande REST (`rest_command`)
Déclarez la commande REST dans votre fichier `configuration.yaml` (ou dans votre fichier de configuration des commandes REST) pour permettre à Home Assistant d'effectuer les requêtes `POST` vers l'API.
```yaml
rest_command:
worklog_presence:
url: "https://tableau-de-bord-pro.example.com/api/v1/workplace-presence"
method: "post"
headers:
authorization: "Bearer !secret worklog_api_token"
content_type: "application/json"
payload: '{"event": "{{ event }}", "occurred_at": "{{ occurred_at }}", "idempotency_key": "{{ idempotency_key }}"}'
verify_ssl: true
```
---
## 4. Automations Home Assistant
Les deux automatisations ci-dessous surveillent l'entité `person.<nom>` (à adapter selon votre configuration, par exemple `person.antoine`) par rapport à la zone `zone.lieu_de_travail`.
### Règle d'idempotence et de cohérence temporelle :
Pour éviter toute divergence entre l'horodatage (`occurred_at`) et la clé d'idempotence (`idempotency_key`), les automatisations utilisent des variables locales basées sur l'horodatage du déclenchement (`trigger.to_state.last_updated`). **Ne jamais utiliser `now()` de manière éclatée ou divergente** à plusieurs endroits d'un même payload.
---
### A. Automation d'Arrivée
Cette automatisation se déclenche lorsque la personne entre dans la zone `lieu_de_travail` (l'état passe à `lieu_de_travail`).
```yaml
alias: "Travail - Enregistrer Arrivée"
description: "Enregistre automatiquement l'arrivée sur le lieu de travail via l'API REST."
trigger:
- platform: state
entity_id: person.antoine
to: "lieu_de_travail"
action:
- variables:
event_timestamp: "{{ trigger.to_state.last_updated.isoformat() }}"
idempoteno: "{{ trigger.to_state.last_updated.strftime('%Y%m%d-%H%M%S') }}"
idempotency: "ha-arrival-{{ idempoteno }}"
- service: rest_command.worklog_presence
response_variable: response
data:
event: "arrival"
occurred_at: "{{ event_timestamp }}"
idempotency_key: "{{ idempotency }}"
- choose:
- conditions: "{{ response.status not in [200, 201] }}"
sequence:
- service: persistent_notification.create
data:
title: "Échec Arrivée - Tableau de bord pro"
message: >
L'enregistrement de l'arrivée a échoué (Statut HTTP : {{ response.status }}).
Vérifiez les journaux de l'application.
mode: single
```
---
### B. Automation de Départ
Cette automatisation se déclenche lorsque la personne quitte la zone `lieu_de_travail` (l'état quitte `lieu_de_travail`, par exemple vers `not_home` ou un autre lieu).
```yaml
alias: "Travail - Enregistrer Départ"
description: "Enregistre automatiquement le départ du lieu de travail via l'API REST."
trigger:
- platform: state
entity_id: person.antoine
from: "lieu_de_travail"
action:
- variables:
event_timestamp: "{{ trigger.to_state.last_updated.isoformat() }}"
idempoteno: "{{ trigger.to_state.last_updated.strftime('%Y%m%d-%H%M%S') }}"
idempotency: "ha-departure-{{ idempoteno }}"
- service: rest_command.worklog_presence
response_variable: response
data:
event: "departure"
occurred_at: "{{ event_timestamp }}"
idempotency_key: "{{ idempotency }}"
- choose:
- conditions: "{{ response.status not in [200, 201] }}"
sequence:
- service: persistent_notification.create
data:
title: "Échec Départ - Tableau de bord pro"
message: >
L'enregistrement du départ a échoué (Statut HTTP : {{ response.status }}).
Vérifiez les journaux de l'application.
mode: single
```
---
## 5. Gestion des Réponses et de la Sécurité
- **Succès (200 / 201)** : Les codes de succès sont validés silencieusement. En cas de rejeu (réseau instable), l'API renvoie un statut `200` avec le statut `"replayed"`, ce qui est géré sans erreur par l'automatisation.
- **Erreurs et Notifications** : Si l'API renvoie un code d'erreur (`400`, `401`, `404`, `405`, `409`, `413`, `415`, `422`, `429`, `500`, `503`), une notification persistante est créée dans Home Assistant. **Le jeton d'authentification n'est jamais inclus** dans les messages de notification pour des raisons évidentes de sécurité.
---
## 6. Limites, Risques et Points de Vigilance
1. **Maximum d'une arrivée ouverte** : L'application métier n'autorise qu'une seule arrivée ouverte (`time_slot_id IS NULL`) simultanément. Si une arrivée précédente n'a pas été fermée (ou en cas de faux départ/réarrivée manqués), une tentative de nouvelle arrivée provoquera un conflit `409` (`ArrivalAlreadyOpenError`).
2. **Précision GPS et Oscillations (Doublons / Faux déclenchements)** : Les capteurs de géolocalisation (GPS mobile, intégration companion app) peuvent osciller aux abords de la zone `lieu_de_travail` (mauvaise réception GPS, rebonds d'antenne). Cela peut générer des micro-entrées et sorties successives. Bien que la clé d'idempotence et la règle chronologique protègent contre les doublons stricts basés sur le même horodatage, des entrées/sorties rapprochées multiples peuvent nécessiter des corrections manuelles dans l'interface web du Tableau de bord pro.
3. **Limitation de débit (Rate-limit)** : L'API applique une limite stricte de **30 requêtes par fenêtre de 60 secondes** par processus. En usage normal avec un unique tracker `person`, cette limite ne risque pas d'être atteinte, mais un dysfonctionnement d'automatisation en boucle pourrait déclencher un blocage `429`.
4. **Alignement des fuseaux horaires** : Le fuseau horaire configuré dans la section `[home_assistant]` de `config.toml` (ex: `Europe/Paris`) doit être cohérent avec celui de l'instance Home Assistant pour que les conversions d'horodatages ISO 8601 avec offset (`occurred_at`) s'alignent parfaitement avec les journées de travail locales.
5. **Disponibilité de l'API (404 / 503)** :
- Si la section `[home_assistant]` est absente de `config.toml`, l'API renvoie `404`.
- Si la variable d'environnement `WORKLOG_API_TOKEN` n'est pas définie sur le serveur Flask, l'API renvoie `503`.
6. **Sécurité et Traces Administrateur** : Les administrateurs de l'instance Home Assistant ont accès aux fichiers `secrets.yaml` et aux journaux de traces (traces d'exécution des automatisations). Veillez à ne jamais consigner le jeton d'API en clair dans les messages de log personnalisés ou les notifications.

314
tests/test_api.py Normal file
View File

@@ -0,0 +1,314 @@
import re
import pytest
from app import db
from app.api import reset_rate_limiter
from app.models import TimeSlot, WorkEntry, 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_arrival_entry_can_be_fully_edited_from_web_form(client, app):
arrival = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-13T08:00:00+02:00",
"idempotency_key": "web-edit-arrival",
},
)
assert arrival.status_code == 201
entry_id = arrival.json["entry_id"]
form = client.get(f"/entries/{entry_id}/edit")
assert form.status_code == 200
assert "Véhicule à moteur seul" in form.text
assert re.search(
r'<option value="moteur_seul".*?data-has-motor="true".*?selected',
form.text,
re.DOTALL,
)
assert "Citadine électrique" in form.text
assert re.search(r'name="motor_vehicle_id" value="citadine".*?checked', form.text, re.DOTALL)
assert 'name="start_time"' in form.text
assert 'name="end_time"' in form.text
formation = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "FORMATION",
"journey_profile_id": "moteur_velo",
"motor_vehicle_id": "familiale",
"start_time": ["08:00"],
"end_time": ["12:00"],
"comment": "Formation",
},
)
assert formation.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.day_type == "FORMATION"
assert entry.journey_profile_id == "moteur_velo"
assert entry.motor_vehicle_id == "familiale"
assert [(slot.start_time.hour, slot.end_time.hour) for slot in entry.time_slots] == [
(8, 12)
]
garde = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "GARDE",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "moto",
"start_time": ["08:00", "13:00", ""],
"end_time": ["12:00", "17:00", ""],
"comment": "Garde",
},
)
assert garde.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.day_type == "GARDE"
assert entry.journey_profile_id == "moteur_seul"
assert entry.motor_vehicle_id == "moto"
assert [(slot.start_time.hour, slot.end_time.hour) for slot in entry.time_slots] == [
(8, 12),
(13, 17),
]
event = db.session.scalar(
db.select(WorkplacePresenceEvent).where(
WorkplacePresenceEvent.idempotency_key == "web-edit-arrival"
)
)
assert event.entry_id == entry_id
assert event.time_slot_id is None
empty_slots = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-13",
"day_type": "GARDE",
"journey_profile_id": "moteur_seul",
"motor_vehicle_id": "moto",
"start_time": [""],
"end_time": [""],
"comment": "Garde sans plage",
},
)
assert empty_slots.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert entry.time_slots == []
assert entry.day_type == "GARDE"
assert db.session.query(WorkplacePresenceEvent).count() == 1
def test_web_edit_recreates_slots_without_losing_arrival_departure_events(client, app):
arrival = post(
client,
{
"event": "arrival",
"occurred_at": "2026-08-14T08:00:00+02:00",
"idempotency_key": "recreate-arrival",
},
)
departure = post(
client,
{
"event": "departure",
"occurred_at": "2026-08-14T17:00:00+02:00",
"idempotency_key": "recreate-departure",
},
)
assert arrival.status_code == 201
assert departure.status_code == 200
entry_id = arrival.json["entry_id"]
response = client.post(
f"/entries/{entry_id}/edit",
data={
"date": "2026-08-14",
"day_type": "FORMATION",
"journey_profile_id": "moteur_velo",
"motor_vehicle_id": "familiale",
"start_time": ["09:00", "13:00", ""],
"end_time": ["12:00", "17:00", ""],
"comment": "Plages recréées",
},
)
assert response.status_code == 302
with app.app_context():
entry = db.session.get(WorkEntry, entry_id)
assert len(entry.time_slots) == 2
events = db.session.scalars(
db.select(WorkplacePresenceEvent)
.where(WorkplacePresenceEvent.entry_id == entry_id)
.order_by(WorkplacePresenceEvent.event_type)
).all()
assert {event.idempotency_key for event in events} == {
"recreate-arrival",
"recreate-departure",
}
assert all(event.time_slot_id is None for event in events)
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