Files
Loki/internal/ajean/web_upload_test.go
T
nathaninline e07051e1e3 v0.8.8 : envoi de fichiers jusqu'a 1 Go, en morceaux
La limite de 24 Mo n'etait pas arbitraire : le fichier partait dans un
seul corps JSON en base64, tenu entier en memoire par le navigateur puis
par le serveur. Relever la constante seule aurait demande ~1,4 Go de RAM
de chaque cote pour un fichier de 1 Go.

Le transfert est donc decoupe en tranches de 8 Mo. Le navigateur lit une
tranche a la fois (Blob.slice), le serveur la decode, l'ecrit dans un
.part et l'oublie ; seul le descripteur reste ouvert. Mesure sur 120 Mo :
la RAM du process n'a pas bouge (95 -> 96 Mo).

uploadMaxBytes (1 Go) borne desormais le DISQUE ; c'est uploadChunkMax
(8 Mo) qui borne la memoire, et lui seul.

Une session interrompue est balayee au bout de 10 min, et les .part
orphelins sont effaces au demarrage du service : les sessions vivent en
memoire, aucun n'est reprenable apres un arret. Un identifiant inconnu
est refuse en 409 plutot que de reprendre un fichier en son milieu.

La vignette affiche l'avancement en pourcentage au-dela d'une tranche.
2026-08-10 20:05:01 +02:00

234 lines
8.0 KiB
Go

package ajean
import (
"encoding/base64"
"encoding/json"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// Le nom vient du client : aucun d'eux ne doit permettre d'écrire hors du
// dossier de dépôt.
func TestSafeUploadNameNeverEscapes(t *testing.T) {
for _, in := range []string{
"../../evil.sh", `..\..\evil.sh`, "/etc/passwd", `C:\Windows\system32\x.dll`,
"..", ".", "", " ", "a\x00b", "nul\nname",
} {
got := safeUploadName(in)
if got == "" || got == "." || got == ".." {
t.Fatalf("%q → %q : nom inutilisable", in, got)
}
if strings.ContainsAny(got, `/\`) {
t.Fatalf("%q → %q : contient un séparateur de chemin", in, got)
}
if filepath.Base(got) != got {
t.Fatalf("%q → %q : n'est pas un nom simple", in, got)
}
}
}
func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) {
for in, want := range map[string]string{
"rapport.pdf": "rapport.pdf",
"Compte rendu (final).odt": "Compte rendu (final).odt",
"données-été.csv": "données-été.csv",
`C:\Users\x\notes.md`: "notes.md",
} {
if got := safeUploadName(in); got != want {
t.Fatalf("safeUploadName(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestChatUploadWritesFileAndAttachNote(t *testing.T) {
body, _ := json.Marshal(map[string]any{
"name": "../notes.txt",
"data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")),
})
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
if rec.Code != 200 {
t.Fatalf("code %d : %s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Path string `json:"path"`
Abs string `json:"abs"`
Size int `json:"size"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
defer os.Remove(resp.Abs)
if !resp.OK || resp.Size != 7 {
t.Fatalf("réponse inattendue: %+v", resp)
}
// Le fichier atterrit DANS uploads/, malgré le "../" du nom.
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if filepath.Dir(resp.Abs) != dir {
t.Fatalf("écrit hors du dossier de dépôt : %s", resp.Abs)
}
if b, err := os.ReadFile(resp.Abs); err != nil || string(b) != "bonjour" {
t.Fatalf("contenu = %q, err=%v", b, err)
}
// Le chemin renvoyé est celui que le client rejoue dans /api/chat/send.
got := attachFiles([]string{resp.Path})
if len(got) != 1 || got[0].Path != resp.Path || got[0].Size != 7 {
t.Fatalf("attachFiles = %+v", got)
}
if note := attachNote(got); !strings.Contains(note, resp.Path) {
t.Fatalf("note sans le fichier : %q", note)
}
// Un chemin inventé par le client n'atteint jamais le modèle.
if n := attachFiles([]string{"uploads/jamais-deposé.bin", "../../etc/passwd"}); len(n) != 0 {
t.Fatalf("fichiers absents retenus : %+v", n)
}
if attachNote(nil) != "" {
t.Fatal("note non vide sans pièce jointe")
}
}
// Le téléchargement est le point sensible : il lit des fichiers du serveur à
// partir d'un chemin fourni par le client.
func TestChatFileStaysInsideWorkspace(t *testing.T) {
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "produit.txt")
if err := os.WriteFile(p, []byte("resultat"), 0o644); err != nil {
t.Fatal(err)
}
defer os.Remove(p)
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path=uploads/produit.txt", nil))
if rec.Code != 200 || rec.Body.String() != "resultat" {
t.Fatalf("code %d, corps %q", rec.Code, rec.Body.String())
}
if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") {
t.Fatalf("pas en pièce jointe : %q", cd)
}
// Un .html produit par le modèle ne doit jamais s'exécuter dans l'origine de
// l'UI : il y lirait la clé de pilotage en localStorage.
if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Fatalf("Content-Type = %q", ct)
}
// Le chemin vient du texte du modèle : il peut pointer n'importe où.
for _, bad := range []string{
"../ajean.db", "../../../etc/passwd", `..\..\secret`,
"/etc/passwd", "C:/Windows/win.ini", "", "uploads/absent.txt",
} {
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(bad), nil))
if rec.Code == 200 {
t.Fatalf("chemin %q servi (code 200)", bad)
}
}
}
func TestChatUploadRejectsEmptyAndBadBase64(t *testing.T) {
for _, data := range []string{"", "pas du base64 !!"} {
body, _ := json.Marshal(map[string]any{"name": "x.bin", "data": data})
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body))))
if rec.Code != 400 {
t.Fatalf("data=%q : code %d, attendu 400", data, rec.Code)
}
}
}
// L'export doit garder la trace des pièces jointes : un tour envoyé SANS texte
// donnait sinon une section « Vous » vide, sans dire qu'un fichier était passé.
func TestExportFileNamesBothForms(t *testing.T) {
live := []attachInfo{{Name: "photo.jpg", Path: "uploads/photo.jpg", Size: 10}}
if got := exportFileNames(live); len(got) != 1 || got[0] != "photo.jpg" {
t.Fatalf("forme vivante : %v", got)
}
// Forme relue du journal persisté (JSON → []any de maps).
replayed := []any{map[string]any{"name": "photo.jpg", "path": "uploads/photo.jpg"}}
if got := exportFileNames(replayed); len(got) != 1 || got[0] != "photo.jpg" {
t.Fatalf("forme rejouée : %v", got)
}
if got := exportFileNames(nil); len(got) != 0 {
t.Fatalf("sans pièce jointe : %v", got)
}
}
// upload envoie un morceau et renvoie la réponse décodée.
func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) {
t.Helper()
b, _ := json.Marshal(body)
rec := httptest.NewRecorder()
handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(b))))
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec.Code, out
}
// Un fichier arrive en plusieurs morceaux et doit être reconstitué à l'octet
// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier
// passait dans un seul corps JSON, gardé en mémoire des deux côtés.
func TestChatUploadChunked(t *testing.T) {
part1 := strings.Repeat("A", 5000)
part2 := strings.Repeat("B", 3000)
part3 := "fin"
code, resp := uploadChunk(t, map[string]any{
"name": "gros.bin", "data": base64.StdEncoding.EncodeToString([]byte(part1)), "more": true})
if code != 200 || resp["id"] == nil {
t.Fatalf("1er morceau : code %d, resp %+v", code, resp)
}
id := resp["id"].(string)
if got := resp["received"].(float64); got != 5000 {
t.Fatalf("avancement après 1er morceau = %v", got)
}
if code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part2)), "more": true}); code != 200 {
t.Fatalf("2e morceau : code %d, resp %+v", code, resp)
}
code, resp = uploadChunk(t, map[string]any{
"id": id, "data": base64.StdEncoding.EncodeToString([]byte(part3))})
if code != 200 || resp["ok"] != true {
t.Fatalf("dernier morceau : code %d, resp %+v", code, resp)
}
abs := resp["abs"].(string)
defer os.Remove(abs)
if resp["path"] != "uploads/gros.bin" {
t.Fatalf("chemin = %v", resp["path"])
}
got, err := os.ReadFile(abs)
if err != nil {
t.Fatal(err)
}
if string(got) != part1+part2+part3 {
t.Fatalf("reconstitution : %d octets, attendu %d", len(got), len(part1+part2+part3))
}
// Aucun .part ne doit traîner une fois l'envoi terminé.
dir, _ := uploadsDir()
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".part") {
t.Fatalf("fichier temporaire laissé : %s", e.Name())
}
}
}
// Un identifiant inconnu (envoi expiré, serveur redémarré) ne doit pas produire
// un fichier tronqué qui repart de son milieu.
func TestChatUploadUnknownSessionRefused(t *testing.T) {
code, resp := uploadChunk(t, map[string]any{
"id": "inconnu-1234.part", "data": base64.StdEncoding.EncodeToString([]byte("x"))})
if code != 409 {
t.Fatalf("code %d, attendu 409 — resp %+v", code, resp)
}
}