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

View File

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

View File

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

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