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:
parent
465ddd4e7a
commit
7f999a06f0
|
|
@ -13,16 +13,35 @@ from __future__ import annotations
|
|||
import base64
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import Response
|
||||
|
||||
from ..adb import Adb, AdbError
|
||||
from ..provider import ProviderClient
|
||||
from ..store import BadPassword, Store, StoreLocked
|
||||
|
||||
_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:
|
||||
|
|
@ -50,8 +69,11 @@ def _install_basic_auth(app: FastAPI) -> None:
|
|||
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()
|
||||
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")
|
||||
_install_basic_auth(app)
|
||||
|
||||
|
|
@ -63,16 +85,56 @@ def create_app(adb: Adb | None = None) -> FastAPI:
|
|||
return fn()
|
||||
except AdbError as 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
|
||||
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")
|
||||
def health():
|
||||
return {"ok": True, "version": app.version}
|
||||
|
||||
@app.get("/api/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")
|
||||
def state(serial: str):
|
||||
|
|
|
|||
|
|
@ -9,16 +9,20 @@ Posture :
|
|||
- 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
|
||||
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
|
||||
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
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
|
@ -76,7 +80,8 @@ class Store:
|
|||
self.path = str(path)
|
||||
self._kdf_ops = kdf_ops
|
||||
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.execute("PRAGMA journal_mode=WAL")
|
||||
self._db.execute("PRAGMA foreign_keys=ON")
|
||||
|
|
@ -85,7 +90,8 @@ class Store:
|
|||
self._vault: Vault | None = None
|
||||
|
||||
def close(self) -> None:
|
||||
self._db.close()
|
||||
with self._lock:
|
||||
self._db.close()
|
||||
|
||||
def __enter__(self) -> "Store":
|
||||
return self
|
||||
|
|
@ -108,7 +114,8 @@ class Store:
|
|||
|
||||
@property
|
||||
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
|
||||
def is_unlocked(self) -> bool:
|
||||
|
|
@ -118,25 +125,30 @@ class Store:
|
|||
def unlock(self, password: str) -> None:
|
||||
"""Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe.
|
||||
Lève `crypto.BadPassword` si incorrect."""
|
||||
if not self.is_initialized:
|
||||
from .crypto import _DEFAULT_MEM, _DEFAULT_OPS
|
||||
vault, params, verifier = Vault.setup(
|
||||
password,
|
||||
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,
|
||||
with self._lock:
|
||||
if not self._get_meta("kdf_salt"):
|
||||
from .crypto import _DEFAULT_MEM, _DEFAULT_OPS
|
||||
vault, params, verifier = Vault.setup(
|
||||
password,
|
||||
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._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._vault = Vault.unlock(password, params, self._get_meta("verifier"))
|
||||
self._vault = Vault.unlock(password, params, self._get_meta("verifier"))
|
||||
|
||||
def lock(self) -> None:
|
||||
"""Oublie la clé en mémoire (re-verrouille sans fermer la base)."""
|
||||
self._vault = None
|
||||
|
||||
def _require_vault(self) -> Vault:
|
||||
if self._vault is None:
|
||||
|
|
@ -145,100 +157,112 @@ class Store:
|
|||
|
||||
# ---- mapping serial → label patient (§9) ------------------------------
|
||||
def set_device_label(self, serial: str, label: str, *, now: int) -> None:
|
||||
v = self._require_vault()
|
||||
self._db.execute(
|
||||
"INSERT INTO devices(serial, label_enc, created_at, last_seen_at) "
|
||||
"VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET "
|
||||
"label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at",
|
||||
(serial, v.seal(label), now, now),
|
||||
)
|
||||
self._db.commit()
|
||||
with self._lock:
|
||||
v = self._require_vault()
|
||||
self._db.execute(
|
||||
"INSERT INTO devices(serial, label_enc, created_at, last_seen_at) "
|
||||
"VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET "
|
||||
"label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at",
|
||||
(serial, v.seal(label), now, now),
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def device_label(self, serial: str) -> str | None:
|
||||
v = self._require_vault()
|
||||
row = self._db.execute(
|
||||
"SELECT label_enc FROM devices WHERE serial=?", (serial,)
|
||||
).fetchone()
|
||||
return v.open(row["label_enc"]) if row else None
|
||||
with self._lock:
|
||||
v = self._require_vault()
|
||||
row = self._db.execute(
|
||||
"SELECT label_enc FROM devices WHERE serial=?", (serial,)
|
||||
).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]]:
|
||||
v = self._require_vault()
|
||||
out = []
|
||||
for r in self._db.execute("SELECT * FROM devices ORDER BY created_at"):
|
||||
out.append({
|
||||
with self._lock:
|
||||
v = self._require_vault()
|
||||
return [{
|
||||
"serial": r["serial"],
|
||||
"label": v.open(r["label_enc"]),
|
||||
"created_at": r["created_at"],
|
||||
"last_seen_at": r["last_seen_at"],
|
||||
})
|
||||
return out
|
||||
} for r in self._db.execute("SELECT * FROM devices ORDER BY created_at")]
|
||||
|
||||
# ---- archive des tours (collecte) -------------------------------------
|
||||
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)).
|
||||
Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés."""
|
||||
v = self._require_vault()
|
||||
before = self._db.total_changes
|
||||
self._db.executemany(
|
||||
"INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, "
|
||||
"role, text_enc, 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"),
|
||||
"session_id": t["session_id"],
|
||||
"turn_id": str(t.get("id", t.get("turn_id"))),
|
||||
"ts": t.get("timestamp", t.get("ts")),
|
||||
"role": t.get("role"),
|
||||
"text_enc": v.seal(t.get("text")),
|
||||
"ttft_ms": t.get("ttft_ms"),
|
||||
"total_ms": t.get("total_ms"),
|
||||
"voice_used": t.get("voice_used"),
|
||||
"model_used": t.get("model_used"),
|
||||
"archived_at": now,
|
||||
} for t in turns],
|
||||
)
|
||||
self._db.commit()
|
||||
return self._db.total_changes - before
|
||||
with self._lock:
|
||||
v = self._require_vault()
|
||||
before = self._db.total_changes
|
||||
self._db.executemany(
|
||||
"INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, "
|
||||
"role, text_enc, 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"),
|
||||
"session_id": t["session_id"],
|
||||
"turn_id": str(t.get("id", t.get("turn_id"))),
|
||||
"ts": t.get("timestamp", t.get("ts")),
|
||||
"role": t.get("role"),
|
||||
"text_enc": v.seal(t.get("text")),
|
||||
"ttft_ms": t.get("ttft_ms"),
|
||||
"total_ms": t.get("total_ms"),
|
||||
"voice_used": t.get("voice_used"),
|
||||
"model_used": t.get("model_used"),
|
||||
"archived_at": now,
|
||||
} for t in turns],
|
||||
)
|
||||
self._db.commit()
|
||||
return self._db.total_changes - before
|
||||
|
||||
def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]:
|
||||
v = self._require_vault()
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id",
|
||||
(serial, session_id),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["text"] = v.open(d.pop("text_enc"))
|
||||
out.append(d)
|
||||
return out
|
||||
with self._lock:
|
||||
v = self._require_vault()
|
||||
rows = self._db.execute(
|
||||
"SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id",
|
||||
(serial, session_id),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["text"] = v.open(d.pop("text_enc"))
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None:
|
||||
"""Dernier timestamp archivé → borne `since` pour la collecte incrémentale
|
||||
(autonomie). Pas besoin du vault (métadonnée en clair)."""
|
||||
if session_id is None:
|
||||
row = self._db.execute(
|
||||
"SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,)
|
||||
).fetchone()
|
||||
else:
|
||||
row = self._db.execute(
|
||||
"SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?",
|
||||
(serial, session_id),
|
||||
).fetchone()
|
||||
return row["m"] if row else None
|
||||
with self._lock:
|
||||
if session_id is None:
|
||||
row = self._db.execute(
|
||||
"SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,)
|
||||
).fetchone()
|
||||
else:
|
||||
row = self._db.execute(
|
||||
"SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?",
|
||||
(serial, session_id),
|
||||
).fetchone()
|
||||
return row["m"] if row else None
|
||||
|
||||
# ---- audit (§8) -------------------------------------------------------
|
||||
def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
|
||||
detail: dict[str, Any] | None = None, now: int) -> None:
|
||||
self._db.execute(
|
||||
"INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)",
|
||||
(now, actor, action, target, json.dumps(detail) if detail else None),
|
||||
)
|
||||
self._db.commit()
|
||||
with self._lock:
|
||||
self._db.execute(
|
||||
"INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)",
|
||||
(now, actor, action, target, json.dumps(detail) if detail else None),
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def audit_log(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||
return [dict(r) for r in self._db.execute(
|
||||
"SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)
|
||||
)]
|
||||
with self._lock:
|
||||
return [dict(r) for r in self._db.execute(
|
||||
"SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -17,16 +17,19 @@
|
|||
display:flex; align-items:center; gap:12px; }
|
||||
header h1 { font-size:16px; margin:0; font-weight:600; }
|
||||
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); }
|
||||
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; }
|
||||
.dev { padding:10px 12px; border:1px solid var(--line); border-radius:8px;
|
||||
margin-bottom:8px; cursor:pointer; background:var(--panel); }
|
||||
.dev:hover { border-color:var(--accent); }
|
||||
.dev.sel { border-color:var(--accent); background:var(--panel2); }
|
||||
.dev .serial { font-family:ui-monospace,monospace; font-weight:600; }
|
||||
.dev .meta { color:var(--muted); font-size:12px; }
|
||||
.dev .name { font-weight:600; display:flex; align-items:center; gap:6px; }
|
||||
.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;
|
||||
border:1px solid var(--line); }
|
||||
.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);
|
||||
padding:6px 12px; border-radius:7px; cursor:pointer; font-size:13px; }
|
||||
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; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px;
|
||||
padding:14px 16px; }
|
||||
.card { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
|
||||
.card h3 { margin:0 0 10px; font-size:13px; color:var(--muted); text-transform:uppercase;
|
||||
letter-spacing:.04em; font-weight:600; }
|
||||
.kv { display:flex; justify-content:space-between; gap:10px; padding:3px 0;
|
||||
|
|
@ -58,27 +70,83 @@
|
|||
<header>
|
||||
<h1>Kazeia-central</h1>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let selected = null;
|
||||
let selected = null, unlocked = false;
|
||||
|
||||
async function api(path) {
|
||||
const r = await fetch("/api" + path);
|
||||
async function api(path, opts) {
|
||||
const r = await fetch("/api" + path, opts);
|
||||
if (!r.ok) {
|
||||
let detail = r.status;
|
||||
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() {
|
||||
const box = $("devices");
|
||||
try {
|
||||
|
|
@ -87,15 +155,21 @@ async function loadDevices() {
|
|||
if (!devs.length) { box.innerHTML = '<div class="muted">aucune tablette branchée</div>'; return; }
|
||||
box.innerHTML = "";
|
||||
for (const d of devs) {
|
||||
const label = d.label || null;
|
||||
const el = document.createElement("div");
|
||||
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>`;
|
||||
el.onclick = () => selectDevice(d.serial);
|
||||
const edit = el.querySelector(".edit");
|
||||
if (edit) edit.onclick = (ev) => setLabel(d.serial, label, ev);
|
||||
box.appendChild(el);
|
||||
}
|
||||
} 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>`;
|
||||
}
|
||||
|
||||
loadDevices();
|
||||
(async () => { await refreshLock(); await loadDevices(); })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue