Files
Loki/internal/loki/web_files_test.go
T
Claude 00b9cc2aee Panneau Fichiers : accéder à ce que l'agent écrit
L'agent produit des rapports, des scripts, des exports et des captures dans son
dossier de travail. On ne pouvait en récupérer un que si le modèle avait pensé à
en mettre le lien dans sa réponse : tout ce qu'il écrivait sans le dire restait
invisible, et faire le ménage demandait un shell.

Un panneau de droite, ouvert et fermé par le bouton dossier du pied de carte,
liste ce dossier avec navigation dans les sous-dossiers, téléchargement et
suppression. Il pousse la conversation au lieu de la recouvrir — la largeur de
lecture étant pilotée par --measure, le fil se resserre et se recentre tout
seul. Sur téléphone c'est un tiroir, comme les réglages à gauche.

Le socle existait : /api/chat/file téléchargeait déjà, workspaceRel bornait déjà
les chemins, downloadWorkspaceFile gérait déjà le blob. Manquaient les deux
verbes qui comptent, /api/chat/files pour lister et /api/chat/file/delete pour
supprimer.

Trois choix qui méritent d'être dits :

- Un dossier affiche la taille de TOUT son contenu, pas celle de son inode :
  c'est ce qu'on libère en le supprimant, donc c'est le chiffre qui aide à
  décider. Le parcours est borné à 20 000 entrées pour que la liste reste
  instantanée si l'agent y dézippe quelque chose d'énorme.
- La racine du dossier de travail est refusée à la suppression : l'agent y perd
  le dossier dans lequel il écrit, et « tout effacer » ne doit pas être à un
  clic. La suppression d'un dossier annonce le nombre de fichiers et le poids
  emportés avant de demander confirmation.
- Le panneau ne se remplit qu'à l'ouverture. Un ReadDir récursif à chaque
  chargement de page pour un panneau fermé serait payé par tout le monde.

Ouvrir le panneau rétrécit la conversation SANS déclencher de `resize` : la
gouttière de barre de défilement mesurée dans --sbw resterait celle de l'ancienne
largeur et la carte de saisie serait décalée du fil. On resynchronise donc
explicitement ; la hauteur, elle, est déjà suivie par un ResizeObserver.

Vérifié. Six tests Go sur le bornage, qui est la partie sensible — ces routes
suppriment sur disque à partir d'un chemin venu du navigateur : « .. », chemins
absolus, racine, et un lien symbolique posé dans le dossier de travail sont tous
refusés, et le fichier voisin survit. Puis au navigateur, en clair et en sombre :
listing trié dossiers d'abord, taille récursive, descente et fil d'Ariane,
téléchargement réel (l'événement download porte bien rapport.md), suppression
confirmée puis disparition de la ligne, persistance de l'état ouvert, tiroir
mobile avec voile, et la saisie reste alignée au pixel sur le fil (0 px des deux
côtés) une fois le panneau ouvert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
2026-08-16 12:25:57 +00:00

233 lines
8.0 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. Le
// chemin 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.
func withWorkspace(t *testing.T) string {
t.Helper()
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 dir
}
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)
}