ci / check (push) Successful in 1m7s
Document Bearer-authenticated multipart uploads with curl and Wget2. Return 413 for oversized multipart bodies and report attachment size limits accurately.
288 lines
8.8 KiB
Go
288 lines
8.8 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, ¬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)
|
|
}
|
|
|
|
// 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()
|
|
|
|
// 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)
|
|
}
|
|
}
|