package api_test import ( "bytes" "encoding/json" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "github.com/lgcosta/notas-online/internal/store" ) // auth faz uma chamada autenticada via helper comum. func auth(t *testing.T, srv *httptest.Server, method, path, body string) *http.Response { return do(t, srv, method, path, body, testToken, "") } func uploadFile(t *testing.T, srv *httptest.Server, name string, content []byte, folderID, noteID string) map[string]any { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) part, err := mw.CreateFormFile("file", name) if err != nil { t.Fatal(err) } if _, err := part.Write(content); err != nil { t.Fatal(err) } if folderID != "" { _ = mw.WriteField("folder_id", folderID) } if noteID != "" { _ = mw.WriteField("note_id", noteID) } mw.Close() req, _ := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf) req.Header.Set("Authorization", "Bearer "+testToken) req.Header.Set("Content-Type", mw.FormDataContentType()) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) t.Fatalf("upload %s: %s %s", name, resp.Status, body) } var out map[string]any json.NewDecoder(resp.Body).Decode(&out) return out } func TestFilesAPI(t *testing.T) { srv := newTestServer(t) // upload markdown avulso (raiz) png := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("x", 600)) md := uploadFile(t, srv, "guia.md", []byte("# Guia\n\nconteudo"), "", "") mdID := md["id"].(string) if md["mime"] != "text/markdown; charset=utf-8" { t.Errorf("mime md: %v", md["mime"]) } if md["url"] != "/api/files/static/"+mdID { t.Errorf("url: %v", md["url"]) } // pasta + upload de imagem dentro dela folder := auth(t, srv, "POST", "/api/folders", `{"name":"prints","parent":""}`) var f map[string]any decode(t, folder, &f) fid := f["id"].(string) img := uploadFile(t, srv, "foto.png", png, fid, "") imgID := img["id"].(string) if !strings.HasPrefix(img["mime"].(string), "image/") { t.Errorf("mime img: %v", img["mime"]) } if img["folder"] != fid { t.Errorf("folder: %v", img["folder"]) } // subpasta sub := auth(t, srv, "POST", "/api/folders", `{"name":"2026","parent":"`+fid+`"}`) var subm map[string]any decode(t, sub, &subm) subID := subm["id"].(string) // ciclo: mover prints para dentro de 2026 → 400 cyc := auth(t, srv, "PUT", "/api/folders/"+fid, `{"name":"","parent":"`+subID+`"}`) if cyc.StatusCode != http.StatusBadRequest { t.Fatalf("ciclo deveria 400: %s", cyc.Status) } cyc.Body.Close() // árvore hierárquica treeResp := auth(t, srv, "GET", "/api/files/tree", "") var tree struct { Tree []struct { ID string `json:"id"` Name string `json:"name"` Children []struct { ID string `json:"id"` Files []struct { ID string `json:"id"` } `json:"files"` } `json:"children"` Files []struct { ID string `json:"id"` } `json:"files"` } `json:"tree"` RootFiles []struct { ID string `json:"id"` } `json:"rootFiles"` } decode(t, treeResp, &tree) if len(tree.Tree) != 1 || tree.Tree[0].Name != "prints" { t.Fatalf("tree raiz: %+v", tree.Tree) } if len(tree.Tree[0].Files) != 1 || tree.Tree[0].Files[0].ID != imgID { t.Errorf("files em prints: %+v", tree.Tree[0].Files) } if len(tree.Tree[0].Children) != 1 || tree.Tree[0].Children[0].ID != subID { t.Errorf("children: %+v", tree.Tree[0].Children) } if len(tree.RootFiles) != 1 || tree.RootFiles[0].ID != mdID { t.Errorf("rootFiles: %+v", tree.RootFiles) } // static: imagem inline com headers endurecidos st := auth(t, srv, "GET", "/api/files/static/"+imgID, "") if st.Header.Get("Content-Type") != "image/png" { t.Errorf("content-type img: %q", st.Header.Get("Content-Type")) } if st.Header.Get("Content-Disposition") != "" { t.Errorf("img não deveria ter Content-Disposition: %q", st.Header.Get("Content-Disposition")) } if st.Header.Get("Content-Security-Policy") != "sandbox" { t.Error("CSP sandbox ausente") } body, _ := io.ReadAll(st.Body) st.Body.Close() if !bytes.Equal(body, png) { t.Errorf("conteúdo png difere (%d bytes)", len(body)) } // Range (preview parcial de vídeo) rq, _ := http.NewRequest("GET", srv.URL+"/api/files/static/"+mdID, nil) rq.Header.Set("Authorization", "Bearer "+testToken) rq.Header.Set("Range", "bytes=0-4") rr, err := srv.Client().Do(rq) if err != nil { t.Fatal(err) } if rr.StatusCode != http.StatusPartialContent { t.Errorf("range: %s", rr.Status) } rbody, _ := io.ReadAll(rr.Body) rr.Body.Close() if string(rbody) != "# Gui" { t.Errorf("range body: %q", rbody) } // XSS guard: .html anexado vira attachment (nunca roda same-origin) h := uploadFile(t, srv, "malicioso.html", []byte(""), "", "") hs := auth(t, srv, "GET", "/api/files/static/"+h["id"].(string), "") if !strings.Contains(hs.Header.Get("Content-Disposition"), "attachment") { t.Errorf("html deveria ser attachment: %q", hs.Header.Get("Content-Disposition")) } hs.Body.Close() if h["mime"] != "text/html; charset=utf-8" { t.Errorf("mime html: %v", h["mime"]) } // mover md para 2026 + associar a uma nota + renomear noteResp := auth(t, srv, "POST", "/api/notes", `{"title":"Com anexo","format":"md","categories":[],"content":"oi","author":"","message":""}`) var note map[string]any decode(t, noteResp, ¬e) mv := auth(t, srv, "PUT", "/api/files/"+mdID+"/move", `{"folder":"`+subID+`","note":"`+note["id"].(string)+`","name":"guia-v2.md"}`) if mv.StatusCode != http.StatusOK { t.Fatalf("move: %s", mv.Status) } var mvd map[string]any decode(t, mv, &mvd) if mvd["folder"] != subID || mvd["note"] != note["id"].(string) || mvd["name"] != "guia-v2.md" { t.Errorf("move result: %+v", mvd) } // arrastar-e-soltar depende do id estável: a referência na nota aponta // para /api/files/static/{id} e não pode quebrar ao mover de pasta. if mvd["id"] != mdID { t.Errorf("move deveria preservar o id do arquivo: %+v", mvd) } // nota fantasma na associação → 400 bad := auth(t, srv, "PUT", "/api/files/"+mdID+"/move", `{"note":"naoexiste1"}`) if bad.StatusCode != http.StatusBadRequest { t.Errorf("nota fantasma: %s", bad.Status) } bad.Body.Close() // pasta fantasma no move → 400 badFolder := auth(t, srv, "PUT", "/api/files/"+mdID+"/move", `{"folder":"naoexiste1"}`) if badFolder.StatusCode != http.StatusBadRequest { t.Errorf("pasta fantasma no move: %s", badFolder.Status) } badFolder.Body.Close() // excluir pasta raiz remove subtree e arquivos (png + md movido) del := auth(t, srv, "DELETE", "/api/folders/"+fid, "") if del.StatusCode != http.StatusNoContent { t.Fatalf("delete folder: %s", del.Status) } gone := auth(t, srv, "GET", "/api/files/static/"+imgID, "") if gone.StatusCode != http.StatusNotFound { t.Errorf("arquivo da pasta excluída deveria 404: %s", gone.Status) } gone.Body.Close() // excluir arquivo avulso del2 := auth(t, srv, "DELETE", "/api/files/"+h["id"].(string), "") if del2.StatusCode != http.StatusNoContent { t.Fatalf("delete file: %s", del2.Status) } finalTree := auth(t, srv, "GET", "/api/files/tree", "") var ft map[string]any decode(t, finalTree, &ft) if len(ft["tree"].([]any)) != 0 || len(ft["rootFiles"].([]any)) != 0 { t.Errorf("árvore final deveria estar vazia: %+v", ft) } // static inexistente → 404 (id nunca vira path; traversal impossível) respBad := auth(t, srv, "GET", "/api/files/static/naoexiste1", "") if respBad.StatusCode != http.StatusNotFound { t.Errorf("static inexistente: %s", respBad.Status) } respBad.Body.Close() // upload sem token → 401 noAuth := do(t, srv, "GET", "/api/files/tree", "", "", "") if noAuth.StatusCode != http.StatusUnauthorized { t.Errorf("tree sem auth: %s", noAuth.Status) } noAuth.Body.Close() } func TestUploadRejectsOversizeBody(t *testing.T) { // corpo maior que o cap (64 MiB + 64 KiB): MaxBytesReader deve abortar // antes do multipart ser gravado em temporários big := bytes.Repeat([]byte("A"), store.MaxFileBytes+128<<10) var buf bytes.Buffer w := multipart.NewWriter(&buf) fw, _ := w.CreateFormFile("file", "big.bin") _, _ = fw.Write(big) _ = w.Close() srv := newTestServer(t) req, _ := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf) req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+testToken) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("esperava 413 para corpo acima do cap, obtive %s", resp.Status) } } func TestUploadRejectsFileOver64MiB(t *testing.T) { var buf bytes.Buffer w := multipart.NewWriter(&buf) fw, err := w.CreateFormFile("file", "big.bin") if err != nil { t.Fatal(err) } _, _ = fw.Write(bytes.Repeat([]byte("A"), store.MaxFileBytes+1)) if err := w.Close(); err != nil { t.Fatal(err) } srv := newTestServer(t) req, err := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+testToken) resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("esperava 413 para arquivo >64 MiB, obtive %s", resp.Status) } }