Files
notas-online/internal/api/files_test.go
T

300 lines
9.3 KiB
Go

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("<script>alert(1)</script>"), "", "")
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, &note)
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)
}
}