Skip to main content

koprogo_api/infrastructure/web/handlers/
health.rs

1use actix_web::{get, HttpResponse, Responder};
2use serde_json::json;
3use std::sync::OnceLock;
4use std::time::{SystemTime, UNIX_EPOCH};
5use uuid::Uuid;
6
7/// L'empreinte de CETTE instance, posée une fois et jamais renouvelée.
8///
9/// ── Ce qu'elle sert à distinguer ──────────────────────────────────────────
10///
11/// Une campagne e2e menée contre un backend en rechargement à chaud peut être
12/// coupée en deux par une recompilation. Le 2026-09-13, cela a produit
13/// **quatre-vingt-quinze spécifications rouges** qu'aucun artefact ne
14/// distinguait d'une régression : le rapport HTML montrait 95 échecs, le code
15/// de sortie valait 2 dans les deux cas, et il fallait *savoir* qu'il y avait
16/// eu une coupure pour aller chercher l'heure dans les journaux du conteneur
17/// (#880).
18///
19/// Un relecteur de promotion concluait à une régression massive. Un agent du
20/// fan-out concluait que sa story avait tout cassé.
21///
22/// Deux valeurs suffisent à trancher : un identifiant de processus et un
23/// horodatage. Si l'empreinte relevée à la fin d'une campagne diffère de
24/// celle du début, **le serveur a redémarré en cours de route** — et les
25/// échecs postérieurs à la coupure ne prouvent rien.
26///
27/// ── Ce qu'elle ne divulgue pas ────────────────────────────────────────────
28///
29/// Aucun secret, aucun chemin d'hôte, aucune version : un UUID tiré au
30/// démarrage et une date. C'est la contrainte `@security` de #880, et elle
31/// est facile à tenir — pour répondre « est-ce le même processus ? », il
32/// suffit que la valeur change quand il change.
33fn empreinte() -> &'static (String, u64) {
34    static EMPREINTE: OnceLock<(String, u64)> = OnceLock::new();
35    EMPREINTE.get_or_init(|| {
36        let demarre_a = SystemTime::now()
37            .duration_since(UNIX_EPOCH)
38            .map(|d| d.as_secs())
39            .unwrap_or(0);
40        (Uuid::new_v4().to_string(), demarre_a)
41    })
42}
43
44/// Health check endpoint
45///
46/// Returns system health status. No authentication required.
47///
48/// Also carries this instance's fingerprint: `instance_id`, a UUID drawn once
49/// at boot, and `started_at`, a Unix timestamp. Two calls that return
50/// different values mean the server restarted in between — which lets a test
51/// campaign tell an interrupted run from a real regression (#880).
52///
53/// The fingerprint discloses no secret and no host path: answering "is this
54/// the same process?" needs nothing more than a value that changes when the
55/// process does.
56#[utoipa::path(
57    get,
58    path = "/api/v1/health",
59    tag = "Health",
60    responses(
61        (status = 200, description = "System is healthy", body = serde_json::Value,
62            example = json!({
63                "status": "ok",
64                "service": "koprogo-api",
65                "instance_id": "3f2a0c1e-…",
66                "started_at": 1789900000_u64
67            }))
68    )
69)]
70#[get("/health")]
71pub async fn health_check() -> impl Responder {
72    let (instance_id, started_at) = empreinte();
73    HttpResponse::Ok().json(json!({
74        "status": "ok",
75        "service": "koprogo-api",
76        "instance_id": instance_id,
77        "started_at": started_at,
78    }))
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    /// L'empreinte ne change pas d'un appel à l'autre.
86    ///
87    /// C'est toute sa valeur. Si elle variait au sein d'un même processus, un
88    /// harnais conclurait à un redémarrage qui n'a pas eu lieu — et écarterait
89    /// des échecs bien réels en les attribuant à une coupure. Le faux négatif
90    /// serait pire que le défaut qu'on corrige.
91    #[test]
92    fn lempreinte_est_stable_dans_un_meme_processus() {
93        let (id1, debut1) = empreinte();
94        let (id2, debut2) = empreinte();
95        assert_eq!(
96            id1, id2,
97            "l'identifiant d'instance a changé sans redémarrage"
98        );
99        assert_eq!(debut1, debut2, "l'horodatage de démarrage a changé");
100    }
101
102    /// Elle ne porte QUE ce qui répond à « est-ce le même processus ? ».
103    #[test]
104    fn security_lempreinte_ne_divulgue_ni_secret_ni_chemin() {
105        let (id, _) = empreinte();
106        assert!(
107            Uuid::parse_str(id).is_ok(),
108            "l'identifiant d'instance doit être un UUID, et rien d'autre : \
109             un format libre finirait par porter un nom d'hôte, une version \
110             ou un chemin"
111        );
112    }
113}