package api_test
import (
"bufio"
"bytes"
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/lgcosta/notas-online/internal/api"
"github.com/lgcosta/notas-online/internal/store"
)
const testToken = "s3cr3t-token"
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
st, err := store.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
webFS := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("MOUSEPAD-SHELL")}})
srv := httptest.NewServer(api.New(st, testToken, webFS).Handler())
t.Cleanup(srv.Close)
return srv
}
func do(t *testing.T, srv *httptest.Server, method, path, body, bearer, cookie string) *http.Response {
t.Helper()
var rdr io.Reader
if body != "" {
rdr = strings.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal(err)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
if cookie != "" {
req.Header.Add("Cookie", cookie)
}
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
func decode(t *testing.T, resp *http.Response, v any) {
t.Helper()
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
t.Fatalf("decodificando resposta %s %s: %v", resp.Status, resp.Request.URL.Path, err)
}
}
type noteView struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Categories []string `json:"categories"`
Tags []string `json:"tags"`
Hash string `json:"hash"`
Content string `json:"content"`
}
func TestAuthRequiredAndLoginFlow(t *testing.T) {
srv := newTestServer(t)
// Sem token: API bloqueia.
if resp := do(t, srv, "GET", "/api/notes", "", "", ""); resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("esperava 401 sem token, obtive %s", resp.Status)
}
resp := do(t, srv, "GET", "/api/notes", "", "token-errado", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bearer errado deveria dar 401, obtive %s", resp.Status)
}
// Login errado.
resp = do(t, srv, "POST", "/api/login", `{"token":"nope"}`, "", "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("login errado: %s", resp.Status)
}
// Login correto: cookie HttpOnly com ID DE SESSÃO opaco (nunca o token mestre).
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %s", resp.Status)
}
sc := resp.Header.Get("Set-Cookie")
if !strings.HasPrefix(sc, "notas_token=") || !strings.Contains(sc, "HttpOnly") ||
strings.Contains(sc, "Max-Age=0") {
t.Errorf("Set-Cookie inadequado: %q", sc)
}
cookieValue := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
if cookieValue == testToken || strings.Contains(sc, testToken) {
t.Errorf("cookie não deveria carregar o token mestre: %q", sc)
}
if len(cookieValue) != 64 {
t.Errorf("id de sessão deveria ter 64 hex chars (32 bytes), tem %d", len(cookieValue))
}
// /api/me com o cookie.
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+cookieValue)
if resp.StatusCode != http.StatusOK {
t.Fatalf("/api/me com cookie: %s", resp.Status)
}
// Logout limpa o cookie.
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+cookieValue)
resp.Body.Close()
sc = resp.Header.Get("Set-Cookie")
if !strings.Contains(sc, "Max-Age=0") && !strings.Contains(sc, "Expires=Thu, 01 Jan 1970") {
t.Errorf("logout deveria expirar o cookie: %q", sc)
}
// Index é público e servido do FS embutido.
resp = do(t, srv, "GET", "/", "", "", "")
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
resp.Body.Close()
if !strings.Contains(buf.String(), "MOUSEPAD-SHELL") {
t.Errorf("index não servido: %q", buf.String())
}
}
func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
srv := newTestServer(t)
call := func(method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
// Criar uma categoria raiz e uma filha para vincular à nota.
resp := call("POST", "/api/categories", `{"name":"devops","parent":""}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create category: %s", resp.Status)
}
var cat struct {
ID string `json:"id"`
Name string `json:"name"`
}
decode(t, resp, &cat)
// Criar nota em múltiplas categorias.
resp = call("POST", "/api/notes",
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %s", resp.Status)
}
var created noteView
decode(t, resp, &created)
if created.ID == "" || len(created.Tags) != 2 || len(created.Categories) != 1 {
t.Fatalf("payload criado: %+v", created)
}
id := created.ID
// Listar com filtro de tag.
resp = call("GET", "/api/notes?tag=linux", "")
var list []noteView
decode(t, resp, &list)
if len(list) != 1 || list[0].Content != "" { // listagem não carrega conteúdo
t.Fatalf("list: %+v", list)
}
// Ler completo.
resp = call("GET", "/api/notes/"+id, "")
var got noteView
decode(t, resp, &got)
if !strings.Contains(got.Content, "#systemd") {
t.Fatalf("get: %+v", got)
}
// Atualizar: nova revisão.
resp = call("PUT", "/api/notes/"+id,
`{"title":"Servidor","format":"md","categories":["`+cat.ID+`"],"content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
var updated noteView
decode(t, resp, &updated)
if updated.Hash == created.Hash {
t.Fatal("hash não mudou após edição de conteúdo")
}
// Histórico: mais recente primeiro, com parent e mensagem.
resp = call("GET", "/api/notes/"+id+"/revisions", "")
var revs []store.Revision
decode(t, resp, &revs)
if len(revs) != 2 || revs[0].Message != "removi systemd" || revs[0].Parent != created.Hash {
t.Fatalf("revisões: %+v", revs)
}
// Diff da revisão atual.
resp = call("GET", "/api/notes/"+id+"/revisions/"+revs[0].Hash+"/diff", "")
var dv struct {
Diff string `json:"diff"`
Added int `json:"added"`
Removed int `json:"removed"`
}
decode(t, resp, &dv)
if !strings.Contains(dv.Diff, "-#linux #systemd") || !strings.Contains(dv.Diff, "+#linux") {
t.Fatalf("diff: %q", dv.Diff)
}
if dv.Added != 1 || dv.Removed != 1 {
t.Fatalf("stats do diff: +%d -%d", dv.Added, dv.Removed)
}
// GET de revisão específica (conteúdo + metadados).
resp = call("GET", "/api/notes/"+id+"/revisions/"+created.Hash, "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("get revision: %s", resp.Status)
}
var rc struct {
Hash string `json:"hash"`
Content string `json:"content"`
}
decode(t, resp, &rc)
if rc.Hash != created.Hash || !strings.Contains(rc.Content, "#systemd") {
t.Fatalf("revisão: %+v", rc)
}
// Hash malformado → 404.
resp = call("GET", "/api/notes/"+id+"/revisions/xyz", "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("hash inválido: esperava 404, obtive %s", resp.Status)
}
resp.Body.Close()
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("restore: %s", resp.Status)
}
var restored noteView
decode(t, resp, &restored)
if !strings.Contains(restored.Content, "#systemd") {
t.Fatalf("restore não trouxe conteúdo antigo: %+v", restored)
}
// Restaurar de novo → 409.
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("esperava 409, obtive %s", resp.Status)
}
resp.Body.Close()
// Download bruto com Content-Disposition.
resp = call("GET", "/api/notes/"+id+"/raw", "")
if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="Servidor.md"`) {
t.Errorf("Content-Disposition: %q", cd)
}
resp.Body.Close()
// Deletar → 204, e GET passa a 404.
resp = call("DELETE", "/api/notes/"+id, "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("delete: %s", resp.Status)
}
resp = call("GET", "/api/notes/"+id, "")
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("get pós-delete: %s", resp.Status)
}
}
func TestRenderMarkdownIsSafe(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/render",
`{"content":"**negrito** \nlinha1\nlinha2","format":"md"}`, testToken, "")
var out struct {
HTML string `json:"html"`
}
decode(t, resp, &out)
if !strings.Contains(out.HTML, "negrito") {
t.Errorf("markdown não renderizado: %q", out.HTML)
}
if strings.Contains(out.HTML, "