Files
Loki/internal/loki/mem_migrate_test.go
MichaelandClaude Opus 5.5 127013b32b Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00

346 lines
11 KiB
Go

package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// seedPages écrit quelques pages en clair dans un $LOKI_HOME de test.
func seedPages(t *testing.T) map[string]string {
t.Helper()
pages := map[string]string{
"projet.md": "# Projet\n\nUn secret très important à ne jamais perdre.\n",
"copine.md": "# Perso\n\nAnniversaire le 3 mars. éàçù ✔\n",
"vide.md": "# Vide\n",
}
for name, content := range pages {
if err := MemAdd(name, content); err != nil {
t.Fatalf("MemAdd %s: %v", name, err)
}
}
// MemAdd maintient désormais l'index MEMORY.md par le code : il existe donc sur
// disque comme les autres pages. On l'ajoute à l'ensemble attendu (avec son
// contenu réel) pour que les comptes et les lectures le prennent en compte.
if idx := MemContent(memIndexFile); idx != "" {
pages[memIndexFile] = idx
}
return pages
}
func TestEnableEncryptReadDisable(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
rec, err := EnableMemEncryption("motdepasse-fort")
if err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if strings.TrimSpace(rec) == "" {
t.Fatal("clé de récupération vide")
}
if !memEncActive() || !memUnlocked() {
t.Fatal("devrait être actif et déverrouillé après activation")
}
// Sur disque, les pages doivent être chiffrées (magic présent).
for name := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatalf("page %s pas chiffrée sur disque", name)
}
}
// Mais lisibles via l'API (déverrouillé).
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après chiffrement: %q != %q", name, got, want)
}
}
// Verrouillage : plus rien de lisible, mais on ne perd rien.
clearMemDEK()
if got := MemContent("projet.md"); got != "" {
t.Fatal("une page chiffrée verrouillée ne devrait pas être lisible")
}
h := memHealth()
if !h.Locked || h.Readable != 0 || h.Unreadable != len(pages) {
t.Fatalf("santé verrouillée inattendue: %+v", h)
}
// Déverrouillage via mot de passe.
v, _ := loadVault()
dek, _, err := v.unlockWith("motdepasse-fort")
if err != nil {
t.Fatalf("unlock: %v", err)
}
setMemDEK(dek)
if got := MemContent("copine.md"); got != pages["copine.md"] {
t.Fatal("contenu illisible après déverrouillage")
}
// Déchiffrement complet : retour en clair, keyvault retiré.
if err := DisableMemEncryption(); err != nil {
t.Fatalf("DisableMemEncryption: %v", err)
}
if memEncActive() {
t.Fatal("chiffrement devrait être désactivé")
}
for name, want := range pages {
p, _ := safeMemPath(name)
raw, _ := os.ReadFile(p)
if looksEncrypted(raw) {
t.Fatalf("page %s encore chiffrée après déchiffrement", name)
}
if got := MemContent(name); got != want {
t.Fatalf("contenu %s après déchiffrement altéré", name)
}
}
if vaultExists() {
t.Fatal("le keyvault aurait dû être retiré")
}
}
// Récupération : après chiffrement, on doit pouvoir rouvrir avec la clé de
// récupération même sans le mot de passe.
func TestUnlockWithRecoveryAfterEnable(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
rec, err := EnableMemEncryption("pw")
if err != nil {
t.Fatal(err)
}
clearMemDEK()
v, _ := loadVault()
if _, _, err := v.unlockWith(normalizeRecovery(rec)); err != nil {
t.Fatalf("récupération après activation: %v", err)
}
}
// Sécurité : quand la mémoire est chiffrée ET verrouillée, toute écriture doit
// être REFUSÉE (jamais écrire en clair une donnée censée être chiffrée).
func TestWriteRefusedWhenLocked(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
clearMemDEK() // verrouille
if err := MemAdd("nouvelle.md", "contenu"); err == nil {
t.Fatal("MemAdd aurait dû être refusé (mémoire verrouillée)")
}
if err := MemSave("projet.md", "", "écrasé"); err == nil {
t.Fatal("MemSave aurait dû être refusé (mémoire verrouillée)")
}
// Le fichier d'origine ne doit pas avoir été touché en clair.
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la page d'origine a été altérée alors que la mémoire était verrouillée")
}
}
// Après un chiffrement réussi, AUCUN texte en clair ne doit subsister : ni .bak
// clair, ni snapshot « avant-chiffrement ».
func TestNoPlaintextResidueAfterEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(memoryDir())
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".bak") {
b, _ := os.ReadFile(memoryDir() + "/" + e.Name())
if !looksEncrypted(b) {
t.Fatalf(".bak en clair résiduel : %s", e.Name())
}
}
}
for _, s := range listSnapshots() {
if strings.Contains(s.Reason, "avant-chiffrement") {
t.Fatalf("snapshot en clair avant-chiffrement resté : %s", s.ID)
}
}
}
// Reprise d'un chiffrement interrompu : journal présent + une page restée en
// clair, mémoire déverrouillée → resume doit finir le chiffrement.
func TestResumeInterruptedEncrypt(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
// Simule une interruption : on repose une page en clair et on rearme le journal.
if err := writeMemPlain("projet.md", []byte("# Projet\n\nclair résiduel\n")); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("encrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
p, _ := safeMemPath("projet.md")
raw, _ := os.ReadFile(p)
if !looksEncrypted(raw) {
t.Fatal("la reprise n'a pas re-chiffré la page restée en clair")
}
if _, busy := readMigrationJournal(); busy {
t.Fatal("le journal aurait dû être levé après reprise réussie")
}
}
// Un round-trip via snapshot : on prend une copie, on casse la mémoire, on
// restaure.
func TestSnapshotRestore(t *testing.T) {
testHome(t)
clearMemDEK()
pages := seedPages(t)
id, err := snapshotMemory("test")
if err != nil || id == "" {
t.Fatalf("snapshot: %v (id=%q)", err, id)
}
// Corrompt une page et en supprime une autre.
pApath, _ := safeMemPath("projet.md")
os.WriteFile(pApath, []byte("CORROMPU"), 0o600)
MemDelete("copine.md")
if err := restoreSnapshot(id); err != nil {
t.Fatalf("restore: %v", err)
}
for name, want := range pages {
if got := MemContent(name); got != want {
t.Fatalf("après restauration %s: %q != %q", name, got, want)
}
}
}
// Activer le chiffrement ne doit pas rendre illisibles les clés-pointeurs du
// bucket des discussions (discussion active, mode Code) ni les critères. Une
// version précédente les chiffrait : la discussion active devenait
// introuvable. Celles déjà chiffrées sont réparées au déverrouillage.
func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
testHome(t)
clearMemDEK()
id := convEnsureActive()
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
toolCriteria(map[string]any{"action": "add", "texts": []any{"le build compile"}}, false)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatalf("EnableMemEncryption: %v", err)
}
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("discussion active illisible après chiffrement : %q", got)
}
if !convCodeMode() {
t.Fatal("mode Code perdu après chiffrement")
}
if l := critList(id); len(l) != 1 {
t.Fatalf("critères perdus après chiffrement : %+v", l)
}
if !looksEncrypted(getBytes(bkChat, critKey(id))) {
t.Fatal("critères en clair alors que les discussions sont chiffrées")
}
if !bucketFullyEncrypted(bkChat) {
t.Fatal("les clés-pointeurs en clair font croire à un chiffrement incomplet")
}
// Base d'une version précédente : le pointeur a été chiffré.
enc, err := encodeMemContent([]byte(id))
if err != nil {
t.Fatal(err)
}
_ = putBytes(bkChat, ckActive, enc)
healPlainStoreKeys()
if got := getStr(bkChat, ckActive); got != id {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
// quarantaine, et la désactivation aboutit.
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
t.Fatal(err)
}
img := []byte("\x89PNG octets d'image de test")
ref, err := storeChatImage(img, "image/png")
if err != nil {
t.Fatal(err)
}
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
t.Fatal("l'image devrait être chiffrée")
}
other := make([]byte, 32)
other[0] = 7
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "vieille", alien); err != nil {
t.Fatal(err)
}
if err := DisableMemEncryption(); err != nil {
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
}
if memEncActive() {
t.Fatal("le chiffrement devrait être désactivé")
}
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
t.Fatalf("image pas déchiffrée : %q", b)
}
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
t.Fatal("la valeur illisible aurait dû quitter la base")
}
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
if len(q) != 1 {
t.Fatalf("quarantaine attendue, trouvée : %v", q)
}
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
t.Fatal("la quarantaine doit contenir la valeur illisible")
}
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
}
}
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
// après les seules pages, et les conversations restaient chiffrées pour toujours.
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "conv:x", enc); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("decrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
if memEncActive() {
t.Fatal("la reprise aurait dû terminer le déchiffrement")
}
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
}
}