feat(store): branchement API — déverrouillage opérateur, labels patients, audit

- API : /api/lock-status, /api/unlock, /api/lock, PUT /api/devices/{serial}/label,
  /api/audit ; /api/devices enrichi du label patient quand le store est déverrouillé.
- db.py thread-safe (check_same_thread=False + RLock) pour le threadpool FastAPI ;
  ajout lock(), device_labels().
- UI : barre de déverrouillage opérateur, affichage 👤 label, édition ✎ du nom.
- Sécurité : labels chiffrés (jamais en clair en base ni dans l'audit), opérations
  journalisées, store re-verrouillable.
- tests : +5 intégration API↔store (dont non-fuite PII dans l'audit). 19/19.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-18 22:49:43 +02:00
parent 465ddd4e7a
commit 7f999a06f0
4 changed files with 350 additions and 112 deletions

View File

@ -13,16 +13,35 @@ from __future__ import annotations
import base64 import base64
import os import os
import secrets import secrets
import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.responses import Response from starlette.responses import Response
from ..adb import Adb, AdbError from ..adb import Adb, AdbError
from ..provider import ProviderClient from ..provider import ProviderClient
from ..store import BadPassword, Store, StoreLocked
_WEB_DIR = Path(__file__).resolve().parent.parent / "web" _WEB_DIR = Path(__file__).resolve().parent.parent / "web"
# Base chiffrée locale. Défaut sous data/ (ignoré par .gitignore — jamais commité).
_STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db"))
# Identité opérateur pour la piste d'audit (= user d'auth réseau si défini).
_OPERATOR = os.environ.get("KAZEIA_USER") or "local"
def _now_ms() -> int:
return int(time.time() * 1000)
class _Password(BaseModel):
password: str
class _Label(BaseModel):
label: str
def _install_basic_auth(app: FastAPI) -> None: def _install_basic_auth(app: FastAPI) -> None:
@ -50,8 +69,11 @@ def _install_basic_auth(app: FastAPI) -> None:
return await call_next(request) return await call_next(request)
def create_app(adb: Adb | None = None) -> FastAPI: def create_app(adb: Adb | None = None, store: Store | None = None) -> FastAPI:
adb = adb or Adb() adb = adb or Adb()
if store is None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
store = Store(_STORE_PATH)
app = FastAPI(title="Kazeia-central", version="0.0.1") app = FastAPI(title="Kazeia-central", version="0.0.1")
_install_basic_auth(app) _install_basic_auth(app)
@ -63,16 +85,56 @@ def create_app(adb: Adb | None = None) -> FastAPI:
return fn() return fn()
except AdbError as e: except AdbError as e:
raise HTTPException(status_code=502, detail=str(e)) from e raise HTTPException(status_code=502, detail=str(e)) from e
except StoreLocked as e:
raise HTTPException(status_code=423, detail=str(e)) from e
except IndexError as e: # endpoint a renvoyé 0 ligne except IndexError as e: # endpoint a renvoyé 0 ligne
raise HTTPException(status_code=404, detail="aucune donnée") from e raise HTTPException(status_code=404, detail="aucune donnée") from e
# ---- store opérateur (déverrouillage, labels patients, audit) ---------
@app.get("/api/lock-status")
def lock_status():
return {"initialized": store.is_initialized, "unlocked": store.is_unlocked}
@app.post("/api/unlock")
def unlock(body: _Password):
first = not store.is_initialized
try:
store.unlock(body.password)
except BadPassword as e:
raise HTTPException(status_code=401, detail="mot de passe incorrect") from e
store.audit("store_init" if first else "store_unlock",
actor=_OPERATOR, now=_now_ms())
return {"unlocked": True, "initialized_now": first}
@app.post("/api/lock")
def lock():
store.lock()
return {"unlocked": False}
@app.get("/api/audit")
def audit(limit: int = 200):
return _guard(lambda: store.audit_log(limit))
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
return {"ok": True, "version": app.version} return {"ok": True, "version": app.version}
@app.get("/api/devices") @app.get("/api/devices")
def devices(): def devices():
return [d.__dict__ for d in _guard(adb.devices)] devs = [d.__dict__ for d in _guard(adb.devices)]
# Enrichit avec le label patient si le store est déverrouillé.
if store.is_unlocked:
labels = store.device_labels()
for d in devs:
d["label"] = labels.get(d["serial"])
return devs
@app.put("/api/devices/{serial}/label")
def set_label(serial: str, body: _Label):
_guard(lambda: store.set_device_label(serial, body.label, now=_now_ms()))
# NB : ne PAS mettre le label (PII) dans audit.detail (colonne en clair).
store.audit("set_label", actor=_OPERATOR, target=serial, now=_now_ms())
return {"serial": serial, "label": body.label}
@app.get("/api/devices/{serial}/state") @app.get("/api/devices/{serial}/state")
def state(serial: str): def state(serial: str):

View File

@ -9,16 +9,20 @@ Posture :
- Métadonnées (serial, profile_id, session_id, timestamps) **clair**, pour rester - Métadonnées (serial, profile_id, session_id, timestamps) **clair**, pour rester
requêtables (dashboard, collecte incrémentale, fan-out). Protégées au repos par le requêtables (dashboard, collecte incrémentale, fan-out). Protégées au repos par le
chiffrement disque OS (§8). chiffrement disque OS (§8).
- Tout accès d'export est journalisé (`audit`). - Tout accès sensible est journalisé (`audit`).
Le store s'ouvre verrouillé ; `unlock(password)` dérive la clé et la garde en mémoire Le store s'ouvre verrouillé ; `unlock(password)` dérive la clé et la garde en mémoire
pour la session. Sans unlock, seules les métadonnées sont lisibles, jamais le contenu. pour la session. Sans unlock, seules les métadonnées sont lisibles, jamais le contenu.
Thread-safety : les endpoints FastAPI synchrones tournent dans un threadpool
connexion ouverte avec `check_same_thread=False` et accès sérialisé par un `RLock`.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import sqlite3 import sqlite3
import threading
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
@ -76,7 +80,8 @@ class Store:
self.path = str(path) self.path = str(path)
self._kdf_ops = kdf_ops self._kdf_ops = kdf_ops
self._kdf_mem = kdf_mem self._kdf_mem = kdf_mem
self._db = sqlite3.connect(self.path) self._lock = threading.RLock()
self._db = sqlite3.connect(self.path, check_same_thread=False)
self._db.row_factory = sqlite3.Row self._db.row_factory = sqlite3.Row
self._db.execute("PRAGMA journal_mode=WAL") self._db.execute("PRAGMA journal_mode=WAL")
self._db.execute("PRAGMA foreign_keys=ON") self._db.execute("PRAGMA foreign_keys=ON")
@ -85,7 +90,8 @@ class Store:
self._vault: Vault | None = None self._vault: Vault | None = None
def close(self) -> None: def close(self) -> None:
self._db.close() with self._lock:
self._db.close()
def __enter__(self) -> "Store": def __enter__(self) -> "Store":
return self return self
@ -108,7 +114,8 @@ class Store:
@property @property
def is_initialized(self) -> bool: def is_initialized(self) -> bool:
return self._get_meta("kdf_salt") is not None with self._lock:
return self._get_meta("kdf_salt") is not None
@property @property
def is_unlocked(self) -> bool: def is_unlocked(self) -> bool:
@ -118,25 +125,30 @@ class Store:
def unlock(self, password: str) -> None: def unlock(self, password: str) -> None:
"""Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe. """Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe.
Lève `crypto.BadPassword` si incorrect.""" Lève `crypto.BadPassword` si incorrect."""
if not self.is_initialized: with self._lock:
from .crypto import _DEFAULT_MEM, _DEFAULT_OPS if not self._get_meta("kdf_salt"):
vault, params, verifier = Vault.setup( from .crypto import _DEFAULT_MEM, _DEFAULT_OPS
password, vault, params, verifier = Vault.setup(
ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS, password,
mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM, ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS,
mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM,
)
self._set_meta("kdf_salt", params.salt)
self._set_meta("kdf_ops", str(params.ops).encode())
self._set_meta("kdf_mem", str(params.mem).encode())
self._set_meta("verifier", verifier)
self._vault = vault
return
params = KdfParams(
salt=self._get_meta("kdf_salt"),
ops=int(self._get_meta("kdf_ops")),
mem=int(self._get_meta("kdf_mem")),
) )
self._set_meta("kdf_salt", params.salt) self._vault = Vault.unlock(password, params, self._get_meta("verifier"))
self._set_meta("kdf_ops", str(params.ops).encode())
self._set_meta("kdf_mem", str(params.mem).encode()) def lock(self) -> None:
self._set_meta("verifier", verifier) """Oublie la clé en mémoire (re-verrouille sans fermer la base)."""
self._vault = vault self._vault = None
return
params = KdfParams(
salt=self._get_meta("kdf_salt"),
ops=int(self._get_meta("kdf_ops")),
mem=int(self._get_meta("kdf_mem")),
)
self._vault = Vault.unlock(password, params, self._get_meta("verifier"))
def _require_vault(self) -> Vault: def _require_vault(self) -> Vault:
if self._vault is None: if self._vault is None:
@ -145,100 +157,112 @@ class Store:
# ---- mapping serial → label patient (§9) ------------------------------ # ---- mapping serial → label patient (§9) ------------------------------
def set_device_label(self, serial: str, label: str, *, now: int) -> None: def set_device_label(self, serial: str, label: str, *, now: int) -> None:
v = self._require_vault() with self._lock:
self._db.execute( v = self._require_vault()
"INSERT INTO devices(serial, label_enc, created_at, last_seen_at) " self._db.execute(
"VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET " "INSERT INTO devices(serial, label_enc, created_at, last_seen_at) "
"label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at", "VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET "
(serial, v.seal(label), now, now), "label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at",
) (serial, v.seal(label), now, now),
self._db.commit() )
self._db.commit()
def device_label(self, serial: str) -> str | None: def device_label(self, serial: str) -> str | None:
v = self._require_vault() with self._lock:
row = self._db.execute( v = self._require_vault()
"SELECT label_enc FROM devices WHERE serial=?", (serial,) row = self._db.execute(
).fetchone() "SELECT label_enc FROM devices WHERE serial=?", (serial,)
return v.open(row["label_enc"]) if row else None ).fetchone()
return v.open(row["label_enc"]) if row else None
def device_labels(self) -> dict[str, str | None]:
"""Tous les labels {serial: label} — pour enrichir la liste du parc."""
with self._lock:
v = self._require_vault()
return {r["serial"]: v.open(r["label_enc"])
for r in self._db.execute("SELECT serial, label_enc FROM devices")}
def devices(self) -> list[dict[str, Any]]: def devices(self) -> list[dict[str, Any]]:
v = self._require_vault() with self._lock:
out = [] v = self._require_vault()
for r in self._db.execute("SELECT * FROM devices ORDER BY created_at"): return [{
out.append({
"serial": r["serial"], "serial": r["serial"],
"label": v.open(r["label_enc"]), "label": v.open(r["label_enc"]),
"created_at": r["created_at"], "created_at": r["created_at"],
"last_seen_at": r["last_seen_at"], "last_seen_at": r["last_seen_at"],
}) } for r in self._db.execute("SELECT * FROM devices ORDER BY created_at")]
return out
# ---- archive des tours (collecte) ------------------------------------- # ---- archive des tours (collecte) -------------------------------------
def archive_turns(self, serial: str, turns: Iterable[dict[str, Any]], *, now: int) -> int: def archive_turns(self, serial: str, turns: Iterable[dict[str, Any]], *, now: int) -> int:
"""Insère/ignore des tours (idempotent par (serial, session_id, turn_id)). """Insère/ignore des tours (idempotent par (serial, session_id, turn_id)).
Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés.""" Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés."""
v = self._require_vault() with self._lock:
before = self._db.total_changes v = self._require_vault()
self._db.executemany( before = self._db.total_changes
"INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, " self._db.executemany(
"role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) " "INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, "
"VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc," "role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) "
":ttft_ms,:total_ms,:voice_used,:model_used,:archived_at)", "VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc,"
[{ ":ttft_ms,:total_ms,:voice_used,:model_used,:archived_at)",
"serial": serial, [{
"profile_id": t.get("profile_id"), "serial": serial,
"session_id": t["session_id"], "profile_id": t.get("profile_id"),
"turn_id": str(t.get("id", t.get("turn_id"))), "session_id": t["session_id"],
"ts": t.get("timestamp", t.get("ts")), "turn_id": str(t.get("id", t.get("turn_id"))),
"role": t.get("role"), "ts": t.get("timestamp", t.get("ts")),
"text_enc": v.seal(t.get("text")), "role": t.get("role"),
"ttft_ms": t.get("ttft_ms"), "text_enc": v.seal(t.get("text")),
"total_ms": t.get("total_ms"), "ttft_ms": t.get("ttft_ms"),
"voice_used": t.get("voice_used"), "total_ms": t.get("total_ms"),
"model_used": t.get("model_used"), "voice_used": t.get("voice_used"),
"archived_at": now, "model_used": t.get("model_used"),
} for t in turns], "archived_at": now,
) } for t in turns],
self._db.commit() )
return self._db.total_changes - before self._db.commit()
return self._db.total_changes - before
def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]: def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]:
v = self._require_vault() with self._lock:
rows = self._db.execute( v = self._require_vault()
"SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id", rows = self._db.execute(
(serial, session_id), "SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id",
).fetchall() (serial, session_id),
out = [] ).fetchall()
for r in rows: out = []
d = dict(r) for r in rows:
d["text"] = v.open(d.pop("text_enc")) d = dict(r)
out.append(d) d["text"] = v.open(d.pop("text_enc"))
return out out.append(d)
return out
def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None: def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None:
"""Dernier timestamp archivé → borne `since` pour la collecte incrémentale """Dernier timestamp archivé → borne `since` pour la collecte incrémentale
(autonomie). Pas besoin du vault (métadonnée en clair).""" (autonomie). Pas besoin du vault (métadonnée en clair)."""
if session_id is None: with self._lock:
row = self._db.execute( if session_id is None:
"SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,) row = self._db.execute(
).fetchone() "SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,)
else: ).fetchone()
row = self._db.execute( else:
"SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?", row = self._db.execute(
(serial, session_id), "SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?",
).fetchone() (serial, session_id),
return row["m"] if row else None ).fetchone()
return row["m"] if row else None
# ---- audit (§8) ------------------------------------------------------- # ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None, def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
detail: dict[str, Any] | None = None, now: int) -> None: detail: dict[str, Any] | None = None, now: int) -> None:
self._db.execute( with self._lock:
"INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)", self._db.execute(
(now, actor, action, target, json.dumps(detail) if detail else None), "INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)",
) (now, actor, action, target, json.dumps(detail) if detail else None),
self._db.commit() )
self._db.commit()
def audit_log(self, limit: int = 200) -> list[dict[str, Any]]: def audit_log(self, limit: int = 200) -> list[dict[str, Any]]:
return [dict(r) for r in self._db.execute( with self._lock:
"SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,) return [dict(r) for r in self._db.execute(
)] "SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)
)]

View File

@ -17,16 +17,19 @@
display:flex; align-items:center; gap:12px; } display:flex; align-items:center; gap:12px; }
header h1 { font-size:16px; margin:0; font-weight:600; } header h1 { font-size:16px; margin:0; font-weight:600; }
header .sub { color:var(--muted); font-size:12px; } header .sub { color:var(--muted); font-size:12px; }
header button { margin-left:auto; } header .right { margin-left:auto; display:flex; align-items:center; gap:10px; }
.layout { display:flex; height:calc(100vh - 53px); } .layout { display:flex; height:calc(100vh - 53px); }
aside { width:300px; border-right:1px solid var(--line); overflow:auto; padding:10px; } aside { width:310px; border-right:1px solid var(--line); overflow:auto; padding:10px; }
main { flex:1; overflow:auto; padding:18px 22px; } main { flex:1; overflow:auto; padding:18px 22px; }
.dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px; .dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
margin-bottom:8px; cursor:pointer; background:var(--panel); } margin-bottom:8px; cursor:pointer; background:var(--panel); }
.dev:hover { border-color:var(--accent); } .dev:hover { border-color:var(--accent); }
.dev.sel { border-color:var(--accent); background:var(--panel2); } .dev.sel { border-color:var(--accent); background:var(--panel2); }
.dev .serial { font-family:ui-monospace,monospace; font-weight:600; } .dev .name { font-weight:600; display:flex; align-items:center; gap:6px; }
.dev .meta { color:var(--muted); font-size:12px; } .dev .serial { font-family:ui-monospace,monospace; color:var(--muted); font-size:12px; }
.dev .meta { color:var(--muted); font-size:12px; margin-top:2px; }
.edit { margin-left:auto; opacity:.5; font-size:12px; padding:1px 6px; }
.dev:hover .edit { opacity:1; }
.badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; .badge { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px;
border:1px solid var(--line); } border:1px solid var(--line); }
.badge.device { color:var(--ok); border-color:var(--ok); } .badge.device { color:var(--ok); border-color:var(--ok); }
@ -34,9 +37,18 @@
button { background:var(--panel2); color:var(--txt); border:1px solid var(--line); button { background:var(--panel2); color:var(--txt); border:1px solid var(--line);
padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; } padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; }
button:hover { border-color:var(--accent); } button:hover { border-color:var(--accent); }
input { background:var(--bg); color:var(--txt); border:1px solid var(--line);
padding:6px 9px; border-radius:7px; font-size:13px; }
#vault { background:var(--panel); border:1px solid var(--line); border-radius:8px;
padding:10px; margin-bottom:10px; font-size:13px; }
#vault.locked { border-color:var(--warn); }
#vault .row { display:flex; gap:6px; margin-top:7px; }
#vault input { flex:1; }
.lockchip { font-size:12px; padding:2px 9px; border-radius:10px; border:1px solid var(--line); }
.lockchip.on { color:var(--ok); border-color:var(--ok); }
.lockchip.off { color:var(--warn); border-color:var(--warn); }
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; .card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
padding:14px 16px; }
.card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase; .card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase;
letter-spacing:.04em; font-weight:600; } letter-spacing:.04em; font-weight:600; }
.kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0; .kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0;
@ -58,27 +70,83 @@
<header> <header>
<h1>Kazeia-central</h1> <h1>Kazeia-central</h1>
<span class="sub" id="sub">console de flotte</span> <span class="sub" id="sub">console de flotte</span>
<button onclick="loadDevices()">↻ Rafraîchir le parc</button> <div class="right">
<span id="lockchip"></span>
<button onclick="loadDevices()">↻ Parc</button>
</div>
</header> </header>
<div class="layout"> <div class="layout">
<aside><div id="devices" class="muted">chargement…</div></aside> <aside>
<div id="vault"></div>
<div id="devices" class="muted">chargement…</div>
</aside>
<main><div id="detail"><div id="placeholder">← sélectionne une tablette</div></div></main> <main><div id="detail"><div id="placeholder">← sélectionne une tablette</div></div></main>
</div> </div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let selected = null; let selected = null, unlocked = false;
async function api(path) { async function api(path, opts) {
const r = await fetch("/api" + path); const r = await fetch("/api" + path, opts);
if (!r.ok) { if (!r.ok) {
let detail = r.status; let detail = r.status;
try { detail = (await r.json()).detail || detail; } catch {} try { detail = (await r.json()).detail || detail; } catch {}
throw new Error(detail); const e = new Error(detail); e.status = r.status; throw e;
} }
return r.json(); return r.status === 204 ? null : r.json();
} }
// ---- coffre opérateur ----------------------------------------------------
async function refreshLock() {
const st = await api("/lock-status");
unlocked = st.unlocked;
$("lockchip").className = "lockchip " + (unlocked ? "on" : "off");
$("lockchip").textContent = unlocked ? "🔓 store ouvert" : "🔒 store verrouillé";
const v = $("vault");
if (unlocked) {
v.className = "";
v.innerHTML = `<b>Store déverrouillé</b> — labels patients actifs.
<div class="row"><button onclick="lockStore()">Verrouiller</button></div>`;
} else {
v.className = "locked";
v.innerHTML = `<b>${st.initialized ? "Déverrouiller" : "Initialiser"} le store</b>
<div class="muted" style="font-size:12px">${st.initialized
? "mot de passe opérateur" : "choisis un mot de passe opérateur (1ʳᵉ fois)"}</div>
<div class="row">
<input id="pw" type="password" placeholder="mot de passe"
onkeydown="if(event.key==='Enter')doUnlock()">
<button onclick="doUnlock()">OK</button>
</div><div id="vaulterr" class="err"></div>`;
}
}
async function doUnlock() {
$("vaulterr").textContent = "";
try {
await api("/unlock", {method:"POST", headers:{"content-type":"application/json"},
body: JSON.stringify({password: $("pw").value})});
await refreshLock();
await loadDevices();
} catch (e) { $("vaulterr").textContent = e.message; }
}
async function lockStore() {
await api("/lock", {method:"POST"});
await refreshLock();
await loadDevices();
}
async function setLabel(serial, current, ev) {
ev.stopPropagation();
const label = prompt(`Nom du patient pour ${serial} :`, current || "");
if (label === null) return;
await api(`/devices/${serial}/label`, {method:"PUT",
headers:{"content-type":"application/json"}, body: JSON.stringify({label})});
await loadDevices();
}
// ---- parc ----------------------------------------------------------------
async function loadDevices() { async function loadDevices() {
const box = $("devices"); const box = $("devices");
try { try {
@ -87,15 +155,21 @@ async function loadDevices() {
if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; } if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; }
box.innerHTML = ""; box.innerHTML = "";
for (const d of devs) { for (const d of devs) {
const label = d.label || null;
const el = document.createElement("div"); const el = document.createElement("div");
el.className = "dev" + (d.serial === selected ? " sel" : ""); el.className = "dev" + (d.serial === selected ? " sel" : "");
el.innerHTML = `<div class="serial">${d.serial}</div> el.innerHTML = `
<div class="name">${label ? `👤 ${label}` : `<span class="serial">${d.serial}</span>`}
${unlocked ? `<button class="edit" title="nommer"></button>` : ""}</div>
${label ? `<div class="serial">${d.serial}</div>` : ""}
<div class="meta">${d.props?.model || "?"} · <span class="badge ${d.state}">${d.state}</span></div>`; <div class="meta">${d.props?.model || "?"} · <span class="badge ${d.state}">${d.state}</span></div>`;
el.onclick = () => selectDevice(d.serial); el.onclick = () => selectDevice(d.serial);
const edit = el.querySelector(".edit");
if (edit) edit.onclick = (ev) => setLabel(d.serial, label, ev);
box.appendChild(el); box.appendChild(el);
} }
} catch (e) { } catch (e) {
box.innerHTML = `<div class="err">erreur adb : ${e.message}</div>`; box.innerHTML = `<div class="err">erreur : ${e.message}</div>`;
} }
} }
@ -139,7 +213,7 @@ async function selectDevice(serial) {
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`; d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
} }
loadDevices(); (async () => { await refreshLock(); await loadDevices(); })();
</script> </script>
</body> </body>
</html> </html>

78
tests/test_api_store.py Normal file
View File

@ -0,0 +1,78 @@
"""Tests d'intégration API ↔ store (unlock, labels patients, audit)."""
import nacl.pwhash
from fastapi.testclient import TestClient
from kazeia_central.adb import Adb
from kazeia_central.adb.client import AdbDevice
from kazeia_central.api.app import create_app
from kazeia_central.store import Store
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
class _FakeAdb(Adb):
def __init__(self):
super().__init__()
def devices(self):
return [AdbDevice(serial="ABC123", state="device", props={"model": "Pad3"})]
def _client(tmp_path):
store = Store(tmp_path / "central.db", kdf_ops=_OPS, kdf_mem=_MEM)
return TestClient(create_app(adb=_FakeAdb(), store=store))
def test_lock_unlock_flow(tmp_path):
c = _client(tmp_path)
st = c.get("/api/lock-status").json()
assert st == {"initialized": False, "unlocked": False}
r = c.post("/api/unlock", json={"password": "oppass"}).json()
assert r["unlocked"] and r["initialized_now"]
assert c.get("/api/lock-status").json()["unlocked"] is True
def test_label_requires_unlock_then_persists(tmp_path):
c = _client(tmp_path)
# verrouillé → 423
assert c.put("/api/devices/ABC123/label", json={"label": "X"}).status_code == 423
c.post("/api/unlock", json={"password": "pw"})
# device listé sans label au départ
devs = c.get("/api/devices").json()
assert devs[0]["serial"] == "ABC123" and devs[0]["label"] is None
# on nomme
c.put("/api/devices/ABC123/label", json={"label": "Mme D."})
devs = c.get("/api/devices").json()
assert devs[0]["label"] == "Mme D."
def test_wrong_password_401(tmp_path):
c = _client(tmp_path)
c.post("/api/unlock", json={"password": "right"})
# re-lock puis mauvais mdp
c.post("/api/lock")
assert c.post("/api/unlock", json={"password": "wrong"}).status_code == 401
def test_audit_trace(tmp_path):
c = _client(tmp_path)
c.post("/api/unlock", json={"password": "pw"})
c.put("/api/devices/ABC123/label", json={"label": "Mme D."})
actions = [a["action"] for a in c.get("/api/audit").json()]
assert "store_init" in actions and "set_label" in actions
# le label (PII) ne doit PAS apparaître en clair dans l'audit
raw = c.get("/api/audit").text
assert "Mme D." not in raw
def test_locked_hides_labels(tmp_path):
c = _client(tmp_path)
c.post("/api/unlock", json={"password": "pw"})
c.put("/api/devices/ABC123/label", json={"label": "Secret"})
c.post("/api/lock")
devs = c.get("/api/devices").json()
assert "label" not in devs[0] # verrouillé → pas d'enrichissement