488 lines
16 KiB
Go
488 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/mux"
|
|
)
|
|
|
|
func TestAPISearchFilesFuzzyAcrossAllFiles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
if err := s.storage.Mkdir(user.ID, "/docs"); err != nil {
|
|
t.Fatalf("mkdir failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/project-plan.md", []byte("# project plan")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/notes.txt", []byte("notes")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRec := httptest.NewRecorder()
|
|
s.handleLogin(loginRec, loginReq)
|
|
if loginRec.Code != http.StatusOK {
|
|
t.Fatalf("login status = %d", loginRec.Code)
|
|
}
|
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
|
if access == nil {
|
|
t.Fatal("missing access token")
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q=prjpln&limit=10", nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
var out struct {
|
|
Entries []FileEntry `json:"entries"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode search response failed: %v", err)
|
|
}
|
|
if len(out.Entries) == 0 {
|
|
t.Fatal("expected at least one search result")
|
|
}
|
|
if out.Entries[0].Path != "/docs/project-plan.md" {
|
|
t.Fatalf("top search result = %q, want %q", out.Entries[0].Path, "/docs/project-plan.md")
|
|
}
|
|
}
|
|
|
|
func TestSharePageReturnsPublicURLAndDiscordMeta(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/cover.png", []byte("png-data")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRec := httptest.NewRecorder()
|
|
s.handleLogin(loginRec, loginReq)
|
|
if loginRec.Code != http.StatusOK {
|
|
t.Fatalf("login status = %d", loginRec.Code)
|
|
}
|
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
|
if access == nil {
|
|
t.Fatal("missing access token")
|
|
}
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/files/share", bytes.NewBufferString(`{"path":"/cover.png","expiresMinutes":60}`))
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createReq.AddCookie(access)
|
|
createRec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleCreateShareLink)).ServeHTTP(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create share status = %d, want %d", createRec.Code, http.StatusCreated)
|
|
}
|
|
|
|
var shareResp struct {
|
|
URL string `json:"url"`
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.NewDecoder(createRec.Body).Decode(&shareResp); err != nil {
|
|
t.Fatalf("decode share response failed: %v", err)
|
|
}
|
|
if !strings.Contains(shareResp.URL, "/share/") {
|
|
t.Fatalf("share url = %q, want public /share/ URL", shareResp.URL)
|
|
}
|
|
|
|
router := mux.NewRouter()
|
|
router.HandleFunc("/share/{token}", s.handleSharedPage).Methods(http.MethodGet)
|
|
pageReq := httptest.NewRequest(http.MethodGet, "/share/"+shareResp.Token, nil)
|
|
pageRec := httptest.NewRecorder()
|
|
router.ServeHTTP(pageRec, pageReq)
|
|
|
|
if pageRec.Code != http.StatusOK {
|
|
t.Fatalf("share page status = %d, want %d", pageRec.Code, http.StatusOK)
|
|
}
|
|
body := pageRec.Body.String()
|
|
if !strings.Contains(body, `property="og:title"`) {
|
|
t.Fatal("share page is missing og:title metadata")
|
|
}
|
|
if !strings.Contains(body, "/api/share/"+shareResp.Token+"/preview") {
|
|
t.Fatal("share page is missing preview metadata URL")
|
|
}
|
|
}
|
|
|
|
func TestAPISearchFilesFindsPlainTextContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/meeting-notes.txt", []byte("Budget approval is scheduled for Monday morning.")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
entries := runSearchRequest(t, s, access, "budget approval")
|
|
if len(entries) == 0 {
|
|
t.Fatal("expected at least one search result")
|
|
}
|
|
if entries[0].Path != "/docs/meeting-notes.txt" {
|
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/meeting-notes.txt")
|
|
}
|
|
}
|
|
|
|
func TestAPISearchFilesFindsDocxContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
docPath := filepath.Join(t.TempDir(), "proposal.docx")
|
|
writeDocxFixture(t, docPath, "Quarterly roadmap milestone")
|
|
data, err := os.ReadFile(docPath)
|
|
if err != nil {
|
|
t.Fatalf("read docx failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/proposal.docx", data); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
entries := runSearchRequest(t, s, access, "roadmap milestone")
|
|
if len(entries) == 0 {
|
|
t.Fatal("expected at least one search result")
|
|
}
|
|
if entries[0].Path != "/docs/proposal.docx" {
|
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/proposal.docx")
|
|
}
|
|
}
|
|
|
|
func TestAPISearchFilesFindsImageOCRContent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if _, err := exec.LookPath("tesseract"); err != nil {
|
|
t.Skip("tesseract not installed")
|
|
}
|
|
convertBinary, err := exec.LookPath("convert")
|
|
if err != nil {
|
|
t.Skip("ImageMagick convert not installed")
|
|
}
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
imagePath := filepath.Join(t.TempDir(), "searchable.png")
|
|
cmd := exec.Command(convertBinary,
|
|
"-background", "white",
|
|
"-fill", "black",
|
|
"-font", "DejaVu-Sans-Bold",
|
|
"-pointsize", "96",
|
|
"-size", "1400x280",
|
|
"-gravity", "center",
|
|
"label:SEARCHABLE",
|
|
imagePath,
|
|
)
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("convert failed: %v (%s)", err, strings.TrimSpace(string(out)))
|
|
}
|
|
data, err := os.ReadFile(imagePath)
|
|
if err != nil {
|
|
t.Fatalf("read image failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/images/searchable.png", data); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
entries := runSearchRequest(t, s, access, "searchable")
|
|
if len(entries) == 0 {
|
|
t.Fatal("expected at least one search result")
|
|
}
|
|
found := false
|
|
for _, entry := range entries {
|
|
if entry.Path == "/images/searchable.png" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected OCR result for %q in search results", "/images/searchable.png")
|
|
}
|
|
}
|
|
|
|
func TestAPISearchFilesUsesPersistedOCRCache(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/images/cached.png", []byte("not-a-real-image")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
meta, err := s.storage.Stat(user.ID, "/images/cached.png")
|
|
if err != nil {
|
|
t.Fatalf("stat failed: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`INSERT INTO search_content_cache(user_id, rel_path, extractor, file_size, mod_time_ns, content) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
user.ID,
|
|
"/images/cached.png",
|
|
"ocr",
|
|
meta.Size,
|
|
meta.ModTime.UTC().UnixNano(),
|
|
"vault phrase",
|
|
); err != nil {
|
|
t.Fatalf("insert search cache failed: %v", err)
|
|
}
|
|
s.searchContent = newSearchContentCache(256)
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
entries := runSearchRequest(t, s, access, "vault phrase")
|
|
if len(entries) == 0 {
|
|
t.Fatal("expected persisted OCR cache search result")
|
|
}
|
|
if entries[0].Path != "/images/cached.png" {
|
|
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/images/cached.png")
|
|
}
|
|
}
|
|
|
|
func TestAPIContentPreviewReturnsDocxText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
docPath := filepath.Join(t.TempDir(), "preview.docx")
|
|
writeDocxFixture(t, docPath, "Quarterly preview memo")
|
|
data, err := os.ReadFile(docPath)
|
|
if err != nil {
|
|
t.Fatalf("read docx failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/preview.docx", data); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/content-preview?path="+url.QueryEscape("/docs/preview.docx"), nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleContentPreview)).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("preview status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var out struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode preview response failed: %v", err)
|
|
}
|
|
if !strings.Contains(out.Content, "Quarterly preview memo") {
|
|
t.Fatalf("preview content = %q, want extracted doc text", out.Content)
|
|
}
|
|
}
|
|
|
|
func TestAPIThumbnailReturnsDocxPreviewImage(t *testing.T) {
|
|
if _, err := exec.LookPath("soffice"); err != nil {
|
|
t.Skip("soffice not installed")
|
|
}
|
|
if _, err := exec.LookPath("pdftoppm"); err != nil {
|
|
t.Skip("pdftoppm not installed")
|
|
}
|
|
if _, err := exec.LookPath("convert"); err != nil {
|
|
t.Skip("ImageMagick convert not installed")
|
|
}
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
docPath := filepath.Join(t.TempDir(), "thumbnail.docx")
|
|
writeDocxFixture(t, docPath, "Quarterly thumbnail memo")
|
|
data, err := os.ReadFile(docPath)
|
|
if err != nil {
|
|
t.Fatalf("read docx failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/thumbnail.docx", data); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/thumbnail.docx"), nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if ctype := rec.Header().Get("Content-Type"); !strings.HasPrefix(ctype, "image/png") {
|
|
t.Fatalf("thumbnail content-type = %q, want image/png", ctype)
|
|
}
|
|
if rec.Body.Len() == 0 {
|
|
t.Fatal("expected non-empty thumbnail image")
|
|
}
|
|
}
|
|
|
|
func TestAPIThumbnailUsesPersistedCache(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := makeTestServer(t, nil)
|
|
user, err := s.createUser("alice", "password123", "dracula", "auto")
|
|
if err != nil {
|
|
t.Fatalf("createUser failed: %v", err)
|
|
}
|
|
if err := s.storage.SaveBytes(user.ID, "/docs/cached.docx", []byte("not-a-real-docx")); err != nil {
|
|
t.Fatalf("save bytes failed: %v", err)
|
|
}
|
|
meta, err := s.storage.Stat(user.ID, "/docs/cached.docx")
|
|
if err != nil {
|
|
t.Fatalf("stat failed: %v", err)
|
|
}
|
|
|
|
cachedImage := []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
|
|
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
|
|
0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92,
|
|
0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
|
0x44, 0xae, 0x42, 0x60, 0x82,
|
|
}
|
|
if _, err := s.db.Exec(
|
|
`INSERT INTO preview_thumbnail_cache(user_id, rel_path, renderer, file_size, mod_time_ns, content_type, image) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
user.ID,
|
|
"/docs/cached.docx",
|
|
cacheableThumbnailRenderer(FileEntry{Name: "cached.docx"}),
|
|
meta.Size,
|
|
meta.ModTime.UTC().UnixNano(),
|
|
"image/png",
|
|
cachedImage,
|
|
); err != nil {
|
|
t.Fatalf("insert thumbnail cache failed: %v", err)
|
|
}
|
|
|
|
access := loginAccessToken(t, s, "alice", "password123")
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/cached.docx"), nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
|
|
}
|
|
if !bytes.Equal(rec.Body.Bytes(), cachedImage) {
|
|
t.Fatal("thumbnail response did not use persisted cache bytes")
|
|
}
|
|
}
|
|
|
|
func loginAccessToken(t *testing.T, s *Server, username, password string) *http.Cookie {
|
|
t.Helper()
|
|
|
|
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"`+username+`","password":"`+password+`"}`))
|
|
loginReq.Header.Set("Content-Type", "application/json")
|
|
loginRec := httptest.NewRecorder()
|
|
s.handleLogin(loginRec, loginReq)
|
|
if loginRec.Code != http.StatusOK {
|
|
t.Fatalf("login status = %d", loginRec.Code)
|
|
}
|
|
access := cookieByName(loginRec.Result().Cookies(), "access_token")
|
|
if access == nil {
|
|
t.Fatal("missing access token")
|
|
}
|
|
return access
|
|
}
|
|
|
|
func runSearchRequest(t *testing.T, s *Server, access *http.Cookie, query string) []FileEntry {
|
|
t.Helper()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q="+url.QueryEscape(query)+"&limit=20", nil)
|
|
req.AddCookie(access)
|
|
rec := httptest.NewRecorder()
|
|
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var out struct {
|
|
Entries []FileEntry `json:"entries"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode search response failed: %v", err)
|
|
}
|
|
return out.Entries
|
|
}
|
|
|
|
func writeDocxFixture(t *testing.T, filePath, text string) {
|
|
t.Helper()
|
|
|
|
f, err := os.Create(filePath)
|
|
if err != nil {
|
|
t.Fatalf("create docx failed: %v", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
zw := zip.NewWriter(f)
|
|
writeZipFixtureFile(t, zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
|
<Default Extension="xml" ContentType="application/xml"/>
|
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
|
</Types>`)
|
|
writeZipFixtureFile(t, zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
|
</Relationships>`)
|
|
writeZipFixtureFile(t, zw, "word/document.xml", `<?xml version="1.0" encoding="UTF-8"?>
|
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
|
<w:body>
|
|
<w:p><w:r><w:t>`+text+`</w:t></w:r></w:p>
|
|
</w:body>
|
|
</w:document>`)
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close docx failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeZipFixtureFile(t *testing.T, zw *zip.Writer, name, content string) {
|
|
t.Helper()
|
|
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create zip entry %q failed: %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write zip entry %q failed: %v", name, err)
|
|
}
|
|
}
|