- 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.
200 lines
5.3 KiB
Go
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))
|
|
}
|
|
}
|