mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Les fichiers du chat vivaient dans un pot commun : on changeait de discussion et on revoyait les mêmes pièces jointes, et supprimer une discussion laissait derrière elle tout ce qu'on y avait déposé ou fait écrire à l'agent (seules ses captures, déjà rangées par identifiant, partaient avec). Chaque discussion a désormais son dossier, <workspace>/discussions/<id>/ : - les dépôts (uploads/), les captures (captures/) et ce que l'agent écrit y atterrissent ; le shell et les chemins relatifs du modèle y sont résolus ; - le panneau Fichiers s'ouvre sur ce dossier et n'en sort pas, et se redessine quand la discussion change (bascule ou vidage, signalés par le flux SSE) ; - supprimer une discussion — ou la vider — emporte ses fichiers. Les deux gestes le disent maintenant avant de demander confirmation ; « clear chat » en demandait aucune. La racine du dossier de travail reste la borne de sécurité : les liens des anciens messages (uploads/x.pdf, captures/<id>/y.jpg) continuent d'ouvrir leur fichier par un chemin de repli. Au démarrage, une migration range les captures dans le dossier de leur discussion et rend chaque dépôt à la discussion qui le mentionne dans son journal ; ce que personne ne réclame reste à la racine, atteignable par le bouton « hors discussion » du panneau, qui disparaît une fois le ménage fait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
239 lines
8.3 KiB
Go
239 lines
8.3 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// withWorkspace pointe le dossier de travail sur un dossier temporaire et rend
|
|
// le dossier de la discussion ACTIVE — celui où atterrissent les fichiers et
|
|
// que le panneau ouvre. Le chemin de la racine est mémoïsé par un sync.Once
|
|
// (chat_workspace.go), donc on force la variable directement : c'est le seul
|
|
// moyen d'isoler ces tests.
|
|
//
|
|
// testHome donne au passage une base à part : la discussion active s'y lit, et
|
|
// aucun test ne doit toucher celle de la machine qui les fait tourner.
|
|
func withWorkspace(t *testing.T) string {
|
|
t.Helper()
|
|
testHome(t)
|
|
dir := t.TempDir()
|
|
// EvalSymlinks : sur macOS /var est un lien vers /private/var, et
|
|
// workspaceRel compare des chemins résolus. Sans ça tout serait « hors du
|
|
// dossier de travail ».
|
|
if r, err := filepath.EvalSymlinks(dir); err == nil {
|
|
dir = r
|
|
}
|
|
// On laisse d'abord agentWorkspace s'initialiser NORMALEMENT : sinon on
|
|
// consomme le sync.Once avec une valeur vide, et tout test ultérieur qui
|
|
// appelle agentWorkspace récupère "" après restauration — une panne à
|
|
// distance, dans un autre fichier, sans rapport visible avec celui-ci.
|
|
prevPath := agentWorkspace()
|
|
workspacePath = dir
|
|
t.Cleanup(func() { workspacePath = prevPath })
|
|
return convWorkspace()
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func listFiles(t *testing.T, dir string) map[string]any {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
handleChatFiles(rec, httptest.NewRequest("GET", "/api/chat/files?dir="+dir, nil))
|
|
var out map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
|
|
}
|
|
out["_code"] = rec.Code
|
|
return out
|
|
}
|
|
|
|
func TestFilesListSortsAndSizes(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
|
|
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
|
|
writeFile(t, filepath.Join(ws, "captures", "conv1", "b.jpg"), "01234")
|
|
|
|
out := listFiles(t, "")
|
|
if out["ok"] != true {
|
|
t.Fatalf("liste refusée : %v", out)
|
|
}
|
|
entries := out["entries"].([]any)
|
|
if len(entries) != 2 {
|
|
t.Fatalf("attendu 2 entrées à la racine, obtenu %d : %v", len(entries), entries)
|
|
}
|
|
// Les dossiers passent devant, quelle que soit leur date.
|
|
first := entries[0].(map[string]any)
|
|
if first["name"] != "captures" || first["dir"] != true {
|
|
t.Errorf("le dossier doit être en tête, obtenu %v", first)
|
|
}
|
|
// La taille d'un dossier est celle de TOUT son contenu : c'est ce qu'on
|
|
// libère en le supprimant, donc c'est le chiffre qui compte.
|
|
if got := first["size"].(float64); got != 15 {
|
|
t.Errorf("taille du dossier = %v, attendu 15 (10+5)", got)
|
|
}
|
|
if got := first["items"].(float64); got != 2 {
|
|
t.Errorf("items = %v, attendu 2", got)
|
|
}
|
|
// Le total porte sur le dossier de travail entier, pas sur le dossier affiché.
|
|
if got := out["total"].(float64); got != 22 {
|
|
t.Errorf("total = %v, attendu 22 (7+10+5)", got)
|
|
}
|
|
if out["at_root"] != true || out["parent"] != "" {
|
|
t.Errorf("racine mal signalée : %v", out)
|
|
}
|
|
}
|
|
|
|
func TestFilesListSubdirAndImageFlag(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
writeFile(t, filepath.Join(ws, "captures", "conv1", "shot.jpg"), "x")
|
|
writeFile(t, filepath.Join(ws, "captures", "conv1", "notes.txt"), "y")
|
|
|
|
out := listFiles(t, "captures/conv1")
|
|
if out["ok"] != true {
|
|
t.Fatalf("liste refusée : %v", out)
|
|
}
|
|
if out["dir"] != "captures/conv1" || out["parent"] != "captures" {
|
|
t.Errorf("fil d'Ariane cassé : dir=%v parent=%v", out["dir"], out["parent"])
|
|
}
|
|
img := map[string]bool{}
|
|
for _, e := range out["entries"].([]any) {
|
|
m := e.(map[string]any)
|
|
img[m["name"].(string)] = m["image"] == true
|
|
// Le chemin renvoyé doit être utilisable tel quel par /api/chat/file.
|
|
if !strings.HasPrefix(m["path"].(string), "captures/conv1/") {
|
|
t.Errorf("chemin non préfixé : %v", m["path"])
|
|
}
|
|
}
|
|
if !img["shot.jpg"] {
|
|
t.Error("shot.jpg devrait être marqué image")
|
|
}
|
|
if img["notes.txt"] {
|
|
t.Error("notes.txt ne devrait pas être marqué image")
|
|
}
|
|
}
|
|
|
|
// Le bornage est la partie qui compte : ces routes lisent et SUPPRIMENT sur
|
|
// disque à partir d'un chemin fourni par le navigateur.
|
|
func TestFilesRejectsEscapes(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
writeFile(t, filepath.Join(ws, "dedans.txt"), "ok")
|
|
secret := filepath.Join(filepath.Dir(ws), "dehors.txt")
|
|
writeFile(t, secret, "secret")
|
|
|
|
for _, bad := range []string{"..", "../", "../..", "/etc", "captures/../..", `..\..`} {
|
|
out := listFiles(t, bad)
|
|
if out["ok"] == true {
|
|
t.Errorf("dir=%q accepté alors qu'il sort du dossier de travail : %v", bad, out)
|
|
}
|
|
}
|
|
|
|
// Suppression : même bornage, et le fichier voisin doit survivre.
|
|
for _, bad := range []string{"../dehors.txt", "/etc/passwd", "..", ""} {
|
|
rec := httptest.NewRecorder()
|
|
body := strings.NewReader(`{"path":` + jsonQuote(bad) + `}`)
|
|
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete", body))
|
|
if rec.Code == http.StatusOK {
|
|
t.Errorf("suppression de %q acceptée : %s", bad, rec.Body.String())
|
|
}
|
|
}
|
|
if _, err := os.Stat(secret); err != nil {
|
|
t.Fatalf("le fichier hors workspace a été supprimé : %v", err)
|
|
}
|
|
}
|
|
|
|
// La racine elle-même ne se supprime pas : l'agent perdrait le dossier dans
|
|
// lequel il écrit, et « tout effacer » ne doit pas être à un clic de distance.
|
|
func TestFilesRootNotDeletable(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
writeFile(t, filepath.Join(ws, "a.txt"), "a")
|
|
for _, p := range []string{".", "./", "/"} {
|
|
rec := httptest.NewRecorder()
|
|
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
|
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
|
|
if rec.Code == http.StatusOK {
|
|
t.Errorf("path=%q a supprimé la racine", p)
|
|
}
|
|
}
|
|
if _, err := os.Stat(ws); err != nil {
|
|
t.Fatalf("dossier de travail détruit : %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFilesDeleteFileAndDir(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
writeFile(t, filepath.Join(ws, "rapport.md"), "bonjour")
|
|
writeFile(t, filepath.Join(ws, "captures", "conv1", "a.jpg"), "0123456789")
|
|
|
|
del := func(p string) map[string]any {
|
|
rec := httptest.NewRecorder()
|
|
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
|
strings.NewReader(`{"path":`+jsonQuote(p)+`}`)))
|
|
var out map[string]any
|
|
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
|
out["_code"] = rec.Code
|
|
return out
|
|
}
|
|
|
|
r := del("rapport.md")
|
|
if r["ok"] != true || r["freed"].(float64) != 7 {
|
|
t.Errorf("suppression de fichier : %v", r)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ws, "rapport.md")); !os.IsNotExist(err) {
|
|
t.Error("le fichier est toujours là")
|
|
}
|
|
|
|
// Un dossier part avec tout son contenu — c'est ce que l'interface annonce.
|
|
r = del("captures")
|
|
if r["ok"] != true || r["freed"].(float64) != 10 {
|
|
t.Errorf("suppression de dossier : %v", r)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(ws, "captures")); !os.IsNotExist(err) {
|
|
t.Error("le dossier est toujours là")
|
|
}
|
|
// Le dossier de travail, lui, doit avoir survécu.
|
|
if _, err := os.Stat(ws); err != nil {
|
|
t.Fatalf("dossier de travail détruit : %v", err)
|
|
}
|
|
}
|
|
|
|
// Un lien symbolique posé dans le dossier de travail ne doit pas servir de
|
|
// passage vers le reste du disque — ni en lecture, ni en suppression.
|
|
func TestFilesSymlinkEscape(t *testing.T) {
|
|
ws := withWorkspace(t)
|
|
outside := t.TempDir()
|
|
writeFile(t, filepath.Join(outside, "cible.txt"), "secret")
|
|
if err := os.Symlink(outside, filepath.Join(ws, "evasion")); err != nil {
|
|
t.Skipf("liens symboliques indisponibles : %v", err)
|
|
}
|
|
if out := listFiles(t, "evasion"); out["ok"] == true {
|
|
t.Errorf("le lien symbolique a été suivi : %v", out)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
|
strings.NewReader(`{"path":"evasion/cible.txt"}`)))
|
|
if rec.Code == http.StatusOK {
|
|
t.Errorf("suppression à travers le lien acceptée : %s", rec.Body.String())
|
|
}
|
|
if _, err := os.Stat(filepath.Join(outside, "cible.txt")); err != nil {
|
|
t.Fatalf("fichier hors workspace supprimé : %v", err)
|
|
}
|
|
}
|
|
|
|
func jsonQuote(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|