Split Connection Pools: - writeDB: MaxOpenConns=1, serializes all writes (no SQLITE_BUSY) - readDB: MaxOpenConns=8, query_only=ON, for all SELECTs - QueryDB() helper returns read pool when available SQL Query Interface: - New 'query' action via execute MCP tool - Read-only enforcement (PRAGMA query_only=ON + SQL validation) - Curated views: my_messages, my_channels, channel_messages - Per-agent access control via CTE injection - Auto LIMIT 100, 5s timeout, SELECT-only validation - Blocks: INSERT, UPDATE, DELETE, DROP, PRAGMA, etc. - 12 new tests (access control, validation, limits, CTEs) Migration 016: agent query views (v_agent_messages, etc.) Action registry: 30 actions (was 29, added 'query') All 29 test packages pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
)
|
|
|
|
func TestNew(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
dataDir string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "in-memory database",
|
|
dataDir: ":memory:",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty string creates in-memory",
|
|
dataDir: "",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "temp directory",
|
|
dataDir: t.TempDir(),
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
db, err := New(ctx, tt.dataDir)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("New() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer db.Close()
|
|
|
|
// Verify WAL mode (in-memory uses "memory" journal mode)
|
|
if tt.dataDir != "" && tt.dataDir != ":memory:" {
|
|
var journalMode string
|
|
err = db.QueryRow("PRAGMA journal_mode").Scan(&journalMode)
|
|
if err != nil {
|
|
t.Fatalf("failed to query journal_mode: %v", err)
|
|
}
|
|
if journalMode != "wal" {
|
|
t.Errorf("journal_mode = %s, want wal", journalMode)
|
|
}
|
|
}
|
|
|
|
// Verify foreign keys are enabled
|
|
var fk int
|
|
err = db.QueryRow("PRAGMA foreign_keys").Scan(&fk)
|
|
if err != nil {
|
|
t.Fatalf("failed to query foreign_keys: %v", err)
|
|
}
|
|
if fk != 1 {
|
|
t.Errorf("foreign_keys = %d, want 1", fk)
|
|
}
|
|
|
|
// Verify busy_timeout
|
|
var timeout int
|
|
err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
|
|
if err != nil {
|
|
t.Fatalf("failed to query busy_timeout: %v", err)
|
|
}
|
|
if timeout != 15000 {
|
|
t.Errorf("busy_timeout = %d, want 15000", timeout)
|
|
}
|
|
|
|
// Verify database is usable via write pool
|
|
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY)")
|
|
if err != nil {
|
|
t.Fatalf("failed to create test table: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitPools(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
|
|
db, err := New(ctx, dir)
|
|
if err != nil {
|
|
t.Fatalf("New() error: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// Run migrations to create tables
|
|
if err := RunMigrations(ctx, db.DB); err != nil {
|
|
t.Fatalf("migrations: %v", err)
|
|
}
|
|
|
|
// Verify read pool exists for file-based DB
|
|
if db.ReadDB == nil {
|
|
t.Fatal("expected ReadDB to be non-nil for file-based database")
|
|
}
|
|
|
|
// Verify QueryDB returns read pool
|
|
if db.QueryDB() != db.ReadDB {
|
|
t.Error("QueryDB() should return ReadDB when available")
|
|
}
|
|
|
|
// Create user first (FK requirement)
|
|
_, err = db.Exec("INSERT INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test')")
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
|
|
// Verify write pool can write
|
|
_, err = db.Exec("INSERT INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('test-agent', 'Test', 'ai', '{}', 1, 'hash', 'active')")
|
|
if err != nil {
|
|
t.Fatalf("write pool should allow writes: %v", err)
|
|
}
|
|
|
|
// Verify read pool can read
|
|
var name string
|
|
err = db.ReadDB.QueryRow("SELECT name FROM agents WHERE name = 'test-agent'").Scan(&name)
|
|
if err != nil {
|
|
t.Fatalf("read pool should allow reads: %v", err)
|
|
}
|
|
if name != "test-agent" {
|
|
t.Errorf("expected 'test-agent', got %q", name)
|
|
}
|
|
|
|
// Verify read pool rejects writes
|
|
_, err = db.ReadDB.Exec("INSERT INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES ('bad', 'Bad', 'ai', '{}', 1, 'hash', 'active')")
|
|
if err == nil {
|
|
t.Fatal("read pool should reject writes (query_only=ON)")
|
|
}
|
|
}
|
|
|
|
func TestInMemoryNoSplitPool(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
db, err := New(ctx, ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("New() error: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
// In-memory DB should NOT have a separate read pool
|
|
if db.ReadDB != nil {
|
|
t.Error("in-memory DB should not have a separate ReadDB")
|
|
}
|
|
|
|
// QueryDB should fall back to write pool
|
|
if db.QueryDB() != db.DB {
|
|
t.Error("QueryDB() should return write pool for in-memory DB")
|
|
}
|
|
}
|