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, "