Some checks failed
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-frontend (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Conversation search (db.query search_like, chat-core /search endpoint, sidebar search UI), message pagination polish (scroll preservation, loading spinner), workflow-chat integration package, and multi-user E2E test infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
433 lines
14 KiB
Go
433 lines
14 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.starlark.net/starlark"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// newTestDB creates an in-memory SQLite database with a test extension table.
|
|
func newTestDB(t *testing.T) (*sql.DB, DBModuleConfig) {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Create the extension table the tests will use.
|
|
// physicalTable("logs") for package "test-ext" → ext_test_ext_logs
|
|
_, err = db.Exec(`CREATE TABLE ext_test_ext_logs (
|
|
id TEXT PRIMARY KEY,
|
|
message TEXT,
|
|
user_id TEXT,
|
|
count INTEGER,
|
|
created_at TEXT DEFAULT (datetime('now'))
|
|
)`)
|
|
if err != nil {
|
|
t.Fatalf("create test table: %v", err)
|
|
}
|
|
|
|
cfg := DBModuleConfig{
|
|
PackageID: "test-ext",
|
|
CanWrite: true,
|
|
DB: db,
|
|
IsPostgres: false,
|
|
}
|
|
return db, cfg
|
|
}
|
|
|
|
// execScript runs a Starlark snippet with the db module injected.
|
|
func execScript(t *testing.T, cfg DBModuleConfig, script string) (*Result, error) {
|
|
t.Helper()
|
|
sb := New(DefaultConfig())
|
|
ctx := context.Background()
|
|
modules := map[string]starlark.Value{
|
|
"db": BuildDBModule(ctx, cfg),
|
|
}
|
|
return sb.Exec(ctx, "test.star", script, modules)
|
|
}
|
|
|
|
// ── physicalTable ───────────────────────────
|
|
|
|
func TestPhysicalTable_ValidName(t *testing.T) {
|
|
cfg := DBModuleConfig{PackageID: "my-ext"}
|
|
got, err := cfg.physicalTable("logs")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "ext_my_ext_logs" {
|
|
t.Errorf("got %q, want ext_my_ext_logs", got)
|
|
}
|
|
}
|
|
|
|
func TestPhysicalTable_EmptyName(t *testing.T) {
|
|
cfg := DBModuleConfig{PackageID: "my-ext"}
|
|
_, err := cfg.physicalTable("")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty table name")
|
|
}
|
|
}
|
|
|
|
func TestPhysicalTable_InvalidChars(t *testing.T) {
|
|
cfg := DBModuleConfig{PackageID: "my-ext"}
|
|
_, err := cfg.physicalTable("bad name")
|
|
if err == nil {
|
|
t.Fatal("expected error for table name with space")
|
|
}
|
|
_, err = cfg.physicalTable("drop;table")
|
|
if err == nil {
|
|
t.Fatal("expected error for table name with semicolon")
|
|
}
|
|
}
|
|
|
|
// ── db.insert / db.query ────────────────────
|
|
|
|
func TestDBInsertAndQuery(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
|
|
result, err := execScript(t, cfg, `
|
|
row = db.insert("logs", {"message": "hello", "user_id": "u1"})
|
|
rows = db.query("logs")
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
|
|
rowsVal, ok := result.Globals["rows"]
|
|
if !ok {
|
|
t.Fatal("rows not in globals")
|
|
}
|
|
if _, ok := rowsVal.(*starlark.List); !ok {
|
|
t.Fatalf("rows is %T, want *starlark.List", rowsVal)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryFilters(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
|
|
// Pre-populate with two rows
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'one', 'u1')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'two', 'u2')`)
|
|
|
|
result, err := execScript(t, cfg, `
|
|
rows = db.query("logs", filters={"user_id": "u1"})
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "one") {
|
|
t.Errorf("expected row with message=one, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "two") {
|
|
t.Errorf("unexpected row with message=two: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryLimit(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
for i := 0; i < 5; i++ {
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES (?, ?)`,
|
|
generateID(), "msg")
|
|
}
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", limit=2)`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
// String representation of a 2-element list has exactly 2 entries
|
|
rowsStr := result.Globals["rows"].String()
|
|
// Count occurrences of "id" key — each row has one
|
|
count := strings.Count(rowsStr, `"id"`)
|
|
if count != 2 {
|
|
t.Errorf("expected 2 rows with limit=2, got repr: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
// ── db.update ───────────────────────────────
|
|
|
|
func TestDBUpdate(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES ('r1', 'original')`)
|
|
|
|
_, err := execScript(t, cfg, `db.update("logs", "r1", {"message": "updated"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
|
|
var msg string
|
|
db.QueryRow(`SELECT message FROM ext_test_ext_logs WHERE id = 'r1'`).Scan(&msg)
|
|
if msg != "updated" {
|
|
t.Errorf("expected message=updated, got %q", msg)
|
|
}
|
|
}
|
|
|
|
// ── db.delete ───────────────────────────────
|
|
|
|
func TestDBDelete(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message) VALUES ('r1', 'to-delete')`)
|
|
|
|
_, err := execScript(t, cfg, `db.delete("logs", "r1")`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
|
|
var count int
|
|
db.QueryRow(`SELECT COUNT(*) FROM ext_test_ext_logs WHERE id = 'r1'`).Scan(&count)
|
|
if count != 0 {
|
|
t.Errorf("expected row deleted, count = %d", count)
|
|
}
|
|
}
|
|
|
|
// ── write guard ─────────────────────────────
|
|
|
|
func TestDBReadOnlyModuleNoWriteFunctions(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
cfg.CanWrite = false
|
|
|
|
mod := BuildDBModule(context.Background(), cfg)
|
|
modStr := mod.String()
|
|
|
|
// insert/update/delete should not be present in a read-only module
|
|
for _, fn := range []string{"insert", "update", "delete"} {
|
|
if strings.Contains(modStr, fn) {
|
|
t.Errorf("read-only module exposes write function %q", fn)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDBWriteFunctionBlockedByReadOnly(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
cfg.CanWrite = false
|
|
|
|
_, err := execScript(t, cfg, `db.insert("logs", {"message": "x"})`)
|
|
if err == nil {
|
|
t.Fatal("expected error calling insert on read-only module")
|
|
}
|
|
}
|
|
|
|
// ── view allowlist ───────────────────────────
|
|
|
|
func TestDBViewRejectsUnknownView(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
_, err := execScript(t, cfg, `db.view("secrets")`)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown view")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown view") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── before / after range parameters ──────────
|
|
|
|
func TestDBQueryBefore(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", before={"created_at": "2026-02-15T00:00:00Z"}, order="created_at")`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "old") || !strings.Contains(rowsStr, "mid") {
|
|
t.Errorf("expected old and mid rows, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "new") {
|
|
t.Errorf("unexpected 'new' row in before results: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryAfter(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", after={"created_at": "2026-02-01T00:00:00Z"}, order="created_at")`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "new") {
|
|
t.Errorf("expected 'new' row, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "old") || strings.Contains(rowsStr, "mid") {
|
|
t.Errorf("unexpected old/mid rows in after results: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryBeforeWithFilters(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('a', 'u1-old', 'u1', '2026-01-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('b', 'u1-new', 'u1', '2026-03-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id, created_at) VALUES ('c', 'u2-old', 'u2', '2026-01-01T00:00:00Z')`)
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", filters={"user_id": "u1"}, before={"created_at": "2026-02-01T00:00:00Z"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "u1-old") {
|
|
t.Errorf("expected u1-old, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "u1-new") || strings.Contains(rowsStr, "u2-old") {
|
|
t.Errorf("unexpected rows: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryBeforeAfterCombined(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('a', 'old', '2026-01-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('b', 'mid', '2026-02-01T00:00:00Z')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, created_at) VALUES ('c', 'new', '2026-03-01T00:00:00Z')`)
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", after={"created_at": "2026-01-15T00:00:00Z"}, before={"created_at": "2026-02-15T00:00:00Z"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "mid") {
|
|
t.Errorf("expected mid row, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "old") || strings.Contains(rowsStr, "new") {
|
|
t.Errorf("unexpected old/new rows: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryBeforeEmptyDict(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
// Empty before dict should be a no-op (same as no before)
|
|
_, err := execScript(t, cfg, `rows = db.query("logs", before={})`)
|
|
if err != nil {
|
|
t.Fatalf("empty before dict should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDBQueryBeforeInvalidColumn(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
_, err := execScript(t, cfg, `rows = db.query("logs", before={"bad name": "x"})`)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid column name in before")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid column name") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ── namespace isolation ──────────────────────
|
|
|
|
func TestDBQueryRejectsOtherExtensionTable(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
// Attempt to query a table that belongs to a different extension
|
|
_, err := execScript(t, cfg, `db.query("../../etc/passwd")`)
|
|
if err == nil {
|
|
t.Fatal("expected error for path traversal attempt")
|
|
}
|
|
}
|
|
|
|
// ── list_tables ─────────────────────────────
|
|
|
|
func TestDBListTables(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
result, err := execScript(t, cfg, `names = db.list_tables()`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
namesStr := result.Globals["names"].String()
|
|
// The test table ext_test_ext_logs should appear as "logs"
|
|
if !strings.Contains(namesStr, "logs") {
|
|
t.Errorf("expected 'logs' in list_tables output, got: %s", namesStr)
|
|
}
|
|
}
|
|
|
|
// ── search_like ─────────────────────────────
|
|
|
|
func TestDBQuerySearchLike(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'hello world', 'u1')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'goodbye world', 'u2')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('c', 'nothing here', 'u3')`)
|
|
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", search_like={"message": "%hello%"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "hello world") {
|
|
t.Errorf("expected hello world row, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "goodbye") || strings.Contains(rowsStr, "nothing") {
|
|
t.Errorf("unexpected rows in search_like results: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQuerySearchLikeMultiColumn(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'alpha', 'u1')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'beta', 'u-alpha')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('c', 'gamma', 'u3')`)
|
|
|
|
// Search across message OR user_id — should match both 'a' (message=alpha) and 'b' (user_id=u-alpha)
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", search_like={"message": "%alpha%", "user_id": "%alpha%"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "alpha") {
|
|
t.Errorf("expected alpha match, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "gamma") {
|
|
t.Errorf("unexpected gamma row in multi-column search: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQuerySearchLikeWithFilters(t *testing.T) {
|
|
db, cfg := newTestDB(t)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('a', 'hello from u1', 'u1')`)
|
|
db.Exec(`INSERT INTO ext_test_ext_logs (id, message, user_id) VALUES ('b', 'hello from u2', 'u2')`)
|
|
|
|
// Combine search_like with filters — should only match u1's hello
|
|
result, err := execScript(t, cfg, `rows = db.query("logs", filters={"user_id": "u1"}, search_like={"message": "%hello%"})`)
|
|
if err != nil {
|
|
t.Fatalf("script error: %v", err)
|
|
}
|
|
rowsStr := result.Globals["rows"].String()
|
|
if !strings.Contains(rowsStr, "hello from u1") {
|
|
t.Errorf("expected u1 hello row, got: %s", rowsStr)
|
|
}
|
|
if strings.Contains(rowsStr, "hello from u2") {
|
|
t.Errorf("unexpected u2 row when filter + search_like combined: %s", rowsStr)
|
|
}
|
|
}
|
|
|
|
func TestDBQuerySearchLikeEmptyDict(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
// Empty search_like dict should be a no-op
|
|
_, err := execScript(t, cfg, `rows = db.query("logs", search_like={})`)
|
|
if err != nil {
|
|
t.Fatalf("empty search_like dict should not error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDBQuerySearchLikeInvalidColumn(t *testing.T) {
|
|
_, cfg := newTestDB(t)
|
|
_, err := execScript(t, cfg, `rows = db.query("logs", search_like={"bad name": "%x%"})`)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid column name in search_like")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid column name") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|