- 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.
148 lines
3.9 KiB
Go
148 lines
3.9 KiB
Go
package core
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mithral/vellum/migrations"
|
|
)
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func openTempDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := OpenDB(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
return db
|
|
}
|
|
|
|
func TestMigrateAppliesAndIsIdempotent(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTempDB(t)
|
|
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("first Migrate: %v", err)
|
|
}
|
|
|
|
// Running again must be a no-op, not an error.
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("second Migrate (should be no-op): %v", err)
|
|
}
|
|
|
|
var version int
|
|
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
|
|
t.Fatalf("read user_version: %v", err)
|
|
}
|
|
if version != 3 {
|
|
t.Errorf("user_version = %d, want 3", version)
|
|
}
|
|
|
|
var tables int
|
|
if err := db.QueryRowContext(ctx,
|
|
"SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
|
|
t.Fatalf("count tables: %v", err)
|
|
}
|
|
if tables < 16 {
|
|
t.Errorf("table count = %d, want >= 16", tables)
|
|
}
|
|
|
|
// WAL must be active: progress writes during a scan depend on it.
|
|
var mode string
|
|
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
|
|
t.Fatalf("read journal_mode: %v", err)
|
|
}
|
|
if !strings.EqualFold(mode, "wal") {
|
|
t.Errorf("journal_mode = %q, want wal", mode)
|
|
}
|
|
}
|
|
|
|
func TestForeignKeyEnforcementIsOn(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := openTempDB(t)
|
|
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
|
|
// library_grants references users and libraries; neither 999 exists.
|
|
_, err := db.ExecContext(ctx,
|
|
"INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)")
|
|
if err == nil {
|
|
t.Fatal("expected foreign key violation, got nil")
|
|
}
|
|
}
|
|
|
|
func TestMigrationNumber(t *testing.T) {
|
|
cases := map[string]int{
|
|
"0001_core.sql": 1,
|
|
"0042_audiobooks.sql": 42,
|
|
"7_shorthand.sql": 7,
|
|
"0010_no_ext": 10,
|
|
}
|
|
for name, want := range cases {
|
|
got, err := migrationNumber(name)
|
|
if err != nil {
|
|
t.Errorf("migrationNumber(%q): %v", name, err)
|
|
continue
|
|
}
|
|
if got != want {
|
|
t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want)
|
|
}
|
|
}
|
|
|
|
if _, err := migrationNumber("core.sql"); err == nil {
|
|
t.Error("expected error for filename without a numeric prefix")
|
|
}
|
|
}
|
|
|
|
func TestSplitStatementsIgnoresComments(t *testing.T) {
|
|
in := `
|
|
-- a comment with a semicolon; which must not split
|
|
CREATE TABLE a (id INTEGER); -- trailing comment;
|
|
/* block; comment; here */
|
|
CREATE TABLE b (id INTEGER);
|
|
`
|
|
got := splitStatements(in)
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d statements, want 2: %#v", len(got), got)
|
|
}
|
|
if !strings.Contains(got[0], "CREATE TABLE a") {
|
|
t.Errorf("statement 0 = %q", got[0])
|
|
}
|
|
if !strings.Contains(got[1], "CREATE TABLE b") {
|
|
t.Errorf("statement 1 = %q", got[1])
|
|
}
|
|
}
|
|
|
|
func TestSplitStatementsKeepsTriggerBodiesWhole(t *testing.T) {
|
|
in := `
|
|
CREATE TABLE t (id INTEGER, name TEXT);
|
|
CREATE TRIGGER t_ai AFTER INSERT ON t BEGIN
|
|
INSERT INTO t(name) VALUES (new.name);
|
|
UPDATE t SET name = 'x' WHERE id = new.id;
|
|
END;
|
|
CREATE TABLE after_trigger (id INTEGER);
|
|
`
|
|
got := splitStatements(in)
|
|
if len(got) != 3 {
|
|
t.Fatalf("got %d statements, want 3:\n%#v", len(got), got)
|
|
}
|
|
if !strings.Contains(got[1], "CREATE TRIGGER") || !strings.Contains(got[1], "END") {
|
|
t.Errorf("trigger was split apart: %q", got[1])
|
|
}
|
|
if n := strings.Count(got[1], ";"); n != 2 {
|
|
t.Errorf("trigger body has %d semicolons, want 2 (the internal ones)", n)
|
|
}
|
|
if !strings.Contains(got[2], "after_trigger") {
|
|
t.Errorf("statement after the trigger = %q", got[2])
|
|
}
|
|
}
|