1. /api/render: cap de 512 KiB na entrada (amplificação ~2× medida); decodeJSON agora amarra http.MaxBytesReader ao ResponseWriter (413 real ao cliente em todos os endpoints JSON) 2. Login com rate-limit por IP em memória (5 falhas/15 min → 429, acerto reseta, RemoteAddr puro — X-F-F não é confiável) + log de falha com IP 3. Upload: MaxBytesReader no corpo inteiro (64 MiB + 64 KiB) antes do parse do multipart; quota global de 2 GiB (MaxFilesTotalBytes) em AddFile 4. sanitizeRich neutraliza href/src com javascript:/vbscript:/data: (case/whitespace-insensível); link legítimo preservado 5. Cookie Secure passa a ser configuração explícita (NOTES_SECURE_COOKIE) — X-Forwarded-Proto é spoofável; documentado em .env.example e compose 6. Cookie agora carrega id de sessão opaco (crypto/rand 32B, TTL 30d, revogação no logout e no restart); token mestre só vale via Bearer — furo pego pelo próprio teste novo (token no cookie era aceito) Testes: rate-limit, revogação, canais separados, render 413, upload cap sw v16
550 lines
18 KiB
Go
550 lines
18 KiB
Go
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("<html>MOUSEPAD-SHELL</html>")}})
|
|
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)
|
|
}
|
|
|
|
// Restaurar a primeira revisão.
|
|
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** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
|
|
var out struct {
|
|
HTML string `json:"html"`
|
|
}
|
|
decode(t, resp, &out)
|
|
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
|
|
t.Errorf("markdown não renderizado: %q", out.HTML)
|
|
}
|
|
if strings.Contains(out.HTML, "<script>") {
|
|
t.Errorf("HTML cru deveria ser escapado: %q", out.HTML)
|
|
}
|
|
if !strings.Contains(out.HTML, "<br") {
|
|
t.Errorf("quebra de linha simples deveria virar <br>: %q", out.HTML)
|
|
}
|
|
|
|
// html não é renderizado no servidor.
|
|
resp = do(t, srv, "POST", "/api/render", `{"content":"<b>x</b>","format":"html"}`, testToken, "")
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("formato html deveria retornar 400, obtive %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestCategoryCRUDViaAPI(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
call := func(method, path, body string) *http.Response {
|
|
return do(t, srv, method, path, body, testToken, "")
|
|
}
|
|
|
|
resp := call("POST", "/api/categories", `{"name":"infra","parent":""}`)
|
|
var infra struct{ ID string }
|
|
decode(t, resp, &infra)
|
|
resp = call("POST", "/api/categories", `{"name":"linux","parent":"`+infra.ID+`"}`)
|
|
var linux struct{ ID string }
|
|
decode(t, resp, &linux)
|
|
|
|
// duplicata entre irmãos → 400
|
|
resp = call("POST", "/api/categories", `{"name":"Infra","parent":""}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("duplicata: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// nota na subcategoria
|
|
resp = call("POST", "/api/notes", `{"title":"Kernel","format":"txt","categories":["`+linux.ID+`"],"content":"x","author":"","message":""}`)
|
|
var note noteView
|
|
decode(t, resp, ¬e)
|
|
|
|
// filtro recursivo pela raiz (padrão) → 1 nota; não-recursivo → 0
|
|
resp = call("GET", "/api/notes?category="+infra.ID, "")
|
|
var rec []noteView
|
|
decode(t, resp, &rec)
|
|
if len(rec) != 1 {
|
|
t.Fatalf("recursivo: %+v", rec)
|
|
}
|
|
resp = call("GET", "/api/notes?category="+infra.ID+"&recursive=0", "")
|
|
var nonrec []noteView
|
|
decode(t, resp, &nonrec)
|
|
if len(nonrec) != 0 {
|
|
t.Fatalf("não-recursivo: %+v", nonrec)
|
|
}
|
|
|
|
// ciclo: mover infra para dentro de linux → 400
|
|
resp = call("PUT", "/api/categories/"+infra.ID, `{"name":"","parent":"`+linux.ID+`"}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("ciclo deveria dar 400, obtive %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// excluir raiz remove subtree e limpa a nota
|
|
resp = call("DELETE", "/api/categories/"+infra.ID, "")
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete: %s", resp.Status)
|
|
}
|
|
resp = call("GET", "/api/notes/"+note.ID, "")
|
|
decode(t, resp, ¬e)
|
|
if len(note.Categories) != 0 {
|
|
t.Errorf("nota deveria ficar órfã: %+v", note.Categories)
|
|
}
|
|
resp = call("GET", "/api/categories", "")
|
|
var cats []map[string]any
|
|
decode(t, resp, &cats)
|
|
if len(cats) != 0 {
|
|
t.Errorf("árvore deveria estar vazia: %+v", cats)
|
|
}
|
|
|
|
// categoria inexistente em nota → 400
|
|
resp = call("POST", "/api/notes", `{"title":"x","format":"txt","categories":["zzzzzzzzzzzz"],"content":"", "author":"","message":""}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("categoria fantasma: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestSaveConflictViaAPI(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
call := func(method, path, body string) *http.Response {
|
|
return do(t, srv, method, path, body, testToken, "")
|
|
}
|
|
resp := call("POST", "/api/notes", `{"title":"Sync","format":"txt","categories":[],"content":"v1","author":"","message":""}`)
|
|
var created noteView
|
|
decode(t, resp, &created)
|
|
|
|
// dispositivo B salva primeiro (sem base_hash = força)
|
|
resp = call("PUT", "/api/notes/"+created.ID,
|
|
`{"title":"Sync","format":"txt","categories":[],"content":"v2-B","author":"B","message":"B ganhou"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("save B: %s", resp.Status)
|
|
}
|
|
var afterB noteView
|
|
decode(t, resp, &afterB)
|
|
|
|
// dispositivo A salva com base antiga → 409 + server_hash
|
|
resp = call("PUT", "/api/notes/"+created.ID,
|
|
`{"title":"Sync","format":"txt","categories":[],"content":"v2-A","author":"A","message":"A atrasado","base_hash":"`+created.Hash+`"}`)
|
|
if resp.StatusCode != http.StatusConflict {
|
|
t.Fatalf("esperava 409, obtive %s", resp.Status)
|
|
}
|
|
var conflict struct {
|
|
Error string `json:"error"`
|
|
ServerHash string `json:"server_hash"`
|
|
}
|
|
decode(t, resp, &conflict)
|
|
if conflict.ServerHash != afterB.Hash {
|
|
t.Errorf("409 deveria trazer o hash atual: %q != %q", conflict.ServerHash, afterB.Hash)
|
|
}
|
|
|
|
// A resolve: força sem base_hash
|
|
resp = call("PUT", "/api/notes/"+created.ID,
|
|
`{"title":"Sync","format":"txt","categories":[],"content":"v3-A forçado","author":"A","message":"force"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("force: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestPresenceAndSSEEvents(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
call := func(method, path, body string) *http.Response {
|
|
return do(t, srv, method, path, body, testToken, "")
|
|
}
|
|
|
|
// abre o stream SSE
|
|
req, err := http.NewRequest("GET", srv.URL+"/api/events?session=s1", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+testToken)
|
|
streamResp, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer streamResp.Body.Close()
|
|
if ct := streamResp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Fatalf("content-type SSE: %q", ct)
|
|
}
|
|
sc := bufio.NewScanner(streamResp.Body)
|
|
|
|
readEvent := func(contains string) string {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for sc.Scan() && time.Now().Before(deadline) {
|
|
line := sc.Text()
|
|
if strings.HasPrefix(line, "data: ") && strings.Contains(line, contains) {
|
|
return line
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
t.Fatalf("scanner SSE: %v", err)
|
|
}
|
|
t.Fatalf("evento contendo %q não chegou", contains)
|
|
return ""
|
|
}
|
|
|
|
// hello inicial com snapshot de presença
|
|
readEvent(`"type":"hello"`)
|
|
|
|
// heartbeat de presença → broadcast presence com s2
|
|
resp := call("POST", "/api/presence", `{"session":"s2","notes":["nota1"]}`)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("presence: %s", resp.Status)
|
|
}
|
|
readEvent(`"type":"presence"`)
|
|
|
|
// presença sem session → 400
|
|
resp = call("POST", "/api/presence", `{"notes":[]}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("presence sem session: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// criar nota com session → evento saved para os ouvintes
|
|
resp = call("POST", "/api/notes", `{"title":"E","format":"txt","categories":[],"content":"x","author":"","message":"","session":"s3"}`)
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create: %s", resp.Status)
|
|
}
|
|
var created noteView
|
|
decode(t, resp, &created)
|
|
ev := readEvent(`"type":"saved"`)
|
|
if !strings.Contains(ev, `"session":"s3"`) || !strings.Contains(ev, created.ID) {
|
|
t.Errorf("evento saved incompleto: %s", ev)
|
|
}
|
|
|
|
// SSE sem token → 401
|
|
resp = do(t, srv, "GET", "/api/events", "", "", "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("SSE sem auth: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestInvalidPayloads(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("formato inválido: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
resp = do(t, srv, "POST", "/api/notes", `{"token":"extra"}`, testToken, "")
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("campo desconhecido deveria falhar (DisallowUnknownFields): %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
resp = do(t, srv, "GET", "/api/notes/z0zzzzzzzzzz/revisions", "", testToken, "")
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("nota inexistente: %s", resp.Status)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestLoginRateLimitPerIP(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
for i := 0; i < 5; i++ {
|
|
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("tentativa %d: esperava 401, obtive %s", i+1, resp.Status)
|
|
}
|
|
}
|
|
// 6ª tentativa errada: bloqueada por IP
|
|
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("esperava 429 após 5 falhas, obtive %s", resp.Status)
|
|
}
|
|
// bloqueio vale até para o token CORRETO (o IP está penalizado)
|
|
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusTooManyRequests {
|
|
t.Fatalf("esperava 429 mesmo com token correto, obtive %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestSessionRevokedOnLogoutAndBadSessionRejected(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
resp := do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
|
sc := resp.Header.Get("Set-Cookie")
|
|
resp.Body.Close()
|
|
sid := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
|
|
|
|
// sessão válida autentica
|
|
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("sessão válida: %s", resp.Status)
|
|
}
|
|
// token mestre cru NO cookie não vale mais (cookie = só id de sessão)
|
|
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+testToken)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("token mestre no cookie deveria ser rejeitado, obtive %s", resp.Status)
|
|
}
|
|
// token mestre via Bearer continua valendo (scripts/CLI)
|
|
resp = do(t, srv, "GET", "/api/me", "", testToken, "")
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("bearer com token mestre: %s", resp.Status)
|
|
}
|
|
// logout revoga a sessão
|
|
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+sid)
|
|
resp.Body.Close()
|
|
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("sessão após logout deveria estar revogada, obtive %s", resp.Status)
|
|
}
|
|
}
|
|
|
|
func TestRenderRejectsOversizeInput(t *testing.T) {
|
|
srv := newTestServer(t)
|
|
big := strings.Repeat("x", 512<<10+1)
|
|
body := `{"content":"` + big + `","format":"md"}`
|
|
resp := do(t, srv, "POST", "/api/render", body, testToken, "")
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("esperava 413 em render >512KiB, obtive %s", resp.Status)
|
|
}
|
|
}
|