Vellum/internal/search/search_test.go
mithral a2c2f7a7c1 Phase 2b: FTS5 search
- migration 0003: an FTS5 index over media_items as an external-content table,
  so the index stores no copy of the text. Triggers keep it in sync for every
  write path, including the scanner's bulk inserts.
- migrate: splitStatements now tracks BEGIN...END depth, so CREATE TRIGGER
  bodies survive as one statement. Without this, the trigger's internal
  semicolons would tear it apart.
- search: every whitespace term is quoted before it reaches FTS5. Unquoted
  input like a bare quote, a hyphen, or the word OR is valid FTS5 syntax and
  would either error or silently change the query's meaning. Tested against a
  list of hostile inputs, including a DROP TABLE attempt.
- scoped through the same VisibleLibraryIDs chokepoint; an empty library set
  returns nothing, never everything.
- web: search box in the topbar and a results view.

Verified live: finds items across libraries, hostile input returns 200 with no
results, index stays in sync on insert/update/delete.
2026-10-09 21:10:44 -04:00

200 lines
5.3 KiB
Go

package search
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "search.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func addLibrary(t *testing.T, db *sql.DB, name, kind string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO libraries (name, kind, root_path) VALUES (?, ?, ?) RETURNING id`,
name, kind, "/media/"+name).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func addItem(t *testing.T, db *sql.DB, libraryID int64, kind, path, title string) int64 {
t.Helper()
var id int64
err := db.QueryRowContext(context.Background(),
`INSERT INTO media_items (library_id, kind, path, title, sort_title) VALUES (?, ?, ?, ?, ?) RETURNING id`,
libraryID, kind, path, title, title).Scan(&id)
if err != nil {
t.Fatal(err)
}
return id
}
func titles(results []Result) []string {
out := make([]string, len(results))
for i, r := range results {
out[i] = r.Title
}
return out
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}
func TestQueryFindsItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
addItem(t, db, lib, "ebooks", "hobbit.epub", "The Hobbit")
addItem(t, db, lib, "ebooks", "hail.epub", "Project Hail Mary")
res, err := Query(ctx, db, "hobbit", []int64{lib}, 50)
if err != nil {
t.Fatalf("Query: %v", err)
}
if len(res) != 1 || res[0].Title != "The Hobbit" {
t.Fatalf("results = %v", titles(res))
}
// Multi-term is an implicit AND.
res, err = Query(ctx, db, "project mary", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].Title != "Project Hail Mary" {
t.Errorf("multi-term results = %v", titles(res))
}
// A term that matches nothing.
res, err = Query(ctx, db, "nonexistent", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("expected no results, got %v", titles(res))
}
}
func TestQueryIsScopedToLibraries(t *testing.T) {
db := testDB(t)
ctx := context.Background()
allowed := addLibrary(t, db, "allowed", "ebooks")
hidden := addLibrary(t, db, "hidden", "ebooks")
addItem(t, db, allowed, "ebooks", "a.epub", "Shared Word")
addItem(t, db, hidden, "ebooks", "b.epub", "Shared Word Two")
res, err := Query(ctx, db, "shared", []int64{allowed}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 1 || res[0].LibraryID != allowed {
t.Fatalf("scoping failed: %v", titles(res))
}
// No libraries means no results, never everything.
res, err = Query(ctx, db, "shared", nil, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("empty library set returned %v", titles(res))
}
}
func TestQueryHandlesHostileInput(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
addItem(t, db, lib, "ebooks", "a.epub", "The Hobbit")
// None of these may raise an FTS5 syntax error.
for _, q := range []string{
`"`, `"unbalanced`, `OR`, `AND NOT`, `foo*`, `-hobbit`, `title:hobbit`,
`hobbit OR`, `*`, `""`, `NEAR(`, `a"b`, `(); DROP TABLE users;--`,
} {
if _, err := Query(ctx, db, q, []int64{lib}, 50); err != nil {
t.Errorf("Query(%q) errored: %v", q, err)
}
}
// The injection attempt must not have dropped anything.
var n int
if err := db.QueryRowContext(ctx, `SELECT count(*) FROM media_items`).Scan(&n); err != nil {
t.Fatalf("media_items is gone: %v", err)
}
if n != 1 {
t.Errorf("media_items count = %d, want 1", n)
}
}
func TestIndexStaysInSync(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "x.epub", "Original Title")
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 1 {
t.Fatalf("insert did not reach the index")
}
if _, err := db.ExecContext(ctx, `UPDATE media_items SET title = 'Changed Title' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 1 {
t.Error("update did not reach the index")
}
if res, _ := Query(ctx, db, "original", []int64{lib}, 50); len(res) != 0 {
t.Error("stale entry survived the update")
}
if _, err := db.ExecContext(ctx, `DELETE FROM media_items WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
if res, _ := Query(ctx, db, "changed", []int64{lib}, 50); len(res) != 0 {
t.Error("delete did not reach the index")
}
}
func TestMissingItemsAreExcluded(t *testing.T) {
db := testDB(t)
ctx := context.Background()
lib := addLibrary(t, db, "ebooks", "ebooks")
id := addItem(t, db, lib, "ebooks", "gone.epub", "Vanished Book")
if _, err := db.ExecContext(ctx, `UPDATE media_items SET missing = 1 WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
res, err := Query(ctx, db, "vanished", []int64{lib}, 50)
if err != nil {
t.Fatal(err)
}
if len(res) != 0 {
t.Errorf("missing item appeared in search: %v", titles(res))
}
}