mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 16:21:23 +00:00
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
380 lines
14 KiB
Go
380 lines
14 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestEnsureSchema_FreshDB verifies schema.sql + all migrations apply cleanly on a fresh DB.
|
|
func TestEnsureSchema_FreshDB(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema (fresh) failed: %v", err)
|
|
}
|
|
|
|
// Verify schema version matches current
|
|
var version int
|
|
if err := db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(&version); err != nil {
|
|
t.Fatalf("read schema_version: %v", err)
|
|
}
|
|
if version != SchemaVersion {
|
|
t.Errorf("schema version = %d, want %d", version, SchemaVersion)
|
|
}
|
|
|
|
// Verify vault_documents table has expected columns (team_id, custom_scope, summary)
|
|
rows, err := db.Query("PRAGMA table_info(vault_documents)")
|
|
if err != nil {
|
|
t.Fatalf("PRAGMA table_info: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols := map[string]bool{}
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, typ string
|
|
var notnull int
|
|
var dflt *string
|
|
var pk int
|
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
|
t.Fatalf("scan column: %v", err)
|
|
}
|
|
cols[name] = true
|
|
}
|
|
for _, want := range []string{"team_id", "custom_scope", "summary"} {
|
|
if !cols[want] {
|
|
t.Errorf("vault_documents missing column %q", want)
|
|
}
|
|
}
|
|
|
|
for _, table := range []string{"hooks", "hook_agents"} {
|
|
var count int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
|
|
t.Fatalf("lookup %s table: %v", table, err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("fresh schema missing %q table", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnsureSchema_PreHooksUpgradeCreatesHookTables(t *testing.T) {
|
|
db := openTestDBAtVersion(t, 19)
|
|
for _, table := range []string{"tenant_hook_budget", "hook_executions", "hook_agents", "hooks"} {
|
|
if _, err := db.Exec(`DROP TABLE IF EXISTS ` + table); err != nil {
|
|
t.Fatalf("drop %s: %v", table, err)
|
|
}
|
|
}
|
|
if _, err := db.Exec(`UPDATE schema_version SET version = 19`); err != nil {
|
|
t.Fatalf("set pre-hooks schema version: %v", err)
|
|
}
|
|
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema (pre-hooks to current) failed: %v", err)
|
|
}
|
|
|
|
for _, table := range []string{"hooks", "hook_agents"} {
|
|
var count int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count); err != nil {
|
|
t.Fatalf("lookup %s table: %v", table, err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("upgrade schema missing %q table", table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEnsureSchema_MigrationV11Only verifies migrations from v11 onward
|
|
// apply correctly on a DB built at version 11.
|
|
func TestEnsureSchema_MigrationV11Only(t *testing.T) {
|
|
db := openTestDBAtVersion(t, 11)
|
|
|
|
// Re-apply — should run migrations 11→SchemaVersion
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema (v11→current) failed: %v", err)
|
|
}
|
|
|
|
var version int
|
|
db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(&version)
|
|
if version != SchemaVersion {
|
|
t.Errorf("schema version = %d, want %d", version, SchemaVersion)
|
|
}
|
|
}
|
|
|
|
// TestEnsureSchema_IdempotentRerun verifies EnsureSchema can be called twice without error.
|
|
func TestEnsureSchema_IdempotentRerun(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("first EnsureSchema: %v", err)
|
|
}
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("second EnsureSchema (idempotent) failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEnsureSchema_MigrationV11_SeedsAgentFiles verifies migration 11→12 seeds
|
|
// AGENTS_CORE.md and AGENTS_TASK.md and removes AGENTS_MINIMAL.md.
|
|
func TestEnsureSchema_MigrationV11_SeedsAgentFiles(t *testing.T) {
|
|
db := openTestDBAtVersion(t, 11)
|
|
|
|
// Use master tenant (seeded by seedMasterTenant)
|
|
tenantID := "0193a5b0-7000-7000-8000-000000000001"
|
|
agentID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
|
_, err := db.Exec(`INSERT INTO agents (id, tenant_id, agent_key, display_name, provider, model, agent_type, owner_id)
|
|
VALUES (?, ?, 'test-agent', 'Test', 'test', 'test', 'predefined', 'owner-1')`,
|
|
agentID, tenantID)
|
|
if err != nil {
|
|
t.Fatalf("insert agent: %v", err)
|
|
}
|
|
|
|
// Insert an AGENTS_MINIMAL.md that should be cleaned up
|
|
db.Exec(`INSERT INTO agent_context_files (id, agent_id, file_name, content, tenant_id, created_at, updated_at)
|
|
VALUES ('min-id', ?, 'AGENTS_MINIMAL.md', 'old minimal', ?, datetime('now'), datetime('now'))`,
|
|
agentID, tenantID)
|
|
|
|
// Re-apply — runs migrations 11→SchemaVersion (includes v11→12 seed)
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema (re-apply from v11): %v", err)
|
|
}
|
|
|
|
// Verify AGENTS_CORE.md seeded
|
|
var coreCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM agent_context_files WHERE agent_id = ? AND file_name = 'AGENTS_CORE.md'", agentID).Scan(&coreCount)
|
|
if coreCount != 1 {
|
|
t.Errorf("AGENTS_CORE.md count = %d, want 1", coreCount)
|
|
}
|
|
|
|
// Verify AGENTS_TASK.md seeded
|
|
var taskCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM agent_context_files WHERE agent_id = ? AND file_name = 'AGENTS_TASK.md'", agentID).Scan(&taskCount)
|
|
if taskCount != 1 {
|
|
t.Errorf("AGENTS_TASK.md count = %d, want 1", taskCount)
|
|
}
|
|
|
|
// Verify AGENTS_MINIMAL.md removed
|
|
var minCount int
|
|
db.QueryRow("SELECT COUNT(*) FROM agent_context_files WHERE file_name = 'AGENTS_MINIMAL.md'").Scan(&minCount)
|
|
if minCount != 0 {
|
|
t.Errorf("AGENTS_MINIMAL.md count = %d, want 0 (should be deleted)", minCount)
|
|
}
|
|
}
|
|
|
|
// TestSQLiteSchemaUpgrade_23_to_24 verifies the v23→24 migration creates both
|
|
// scope-consistency triggers on an existing DB.
|
|
func TestSQLiteSchemaUpgrade_23_to_24(t *testing.T) {
|
|
db := openTestDBAtVersion(t, 23)
|
|
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema (v23→24) failed: %v", err)
|
|
}
|
|
|
|
var version int
|
|
db.QueryRow("SELECT version FROM schema_version LIMIT 1").Scan(&version)
|
|
if version != SchemaVersion {
|
|
t.Errorf("schema version = %d, want %d", version, SchemaVersion)
|
|
}
|
|
|
|
// Verify both triggers exist in sqlite_master.
|
|
for _, trigName := range []string{
|
|
"trg_vault_docs_scope_consistency_ins",
|
|
"trg_vault_docs_scope_consistency_upd",
|
|
} {
|
|
var count int
|
|
db.QueryRow(
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name=?`, trigName,
|
|
).Scan(&count)
|
|
if count != 1 {
|
|
t.Errorf("trigger %q not found after migration", trigName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSQLiteVaultStore_UpsertTriggerEnforcesCheck verifies the v24 triggers
|
|
// fire on both the INSERT path and the UPDATE path (UPSERT ON CONFLICT).
|
|
func TestSQLiteVaultStore_UpsertTriggerEnforcesCheck(t *testing.T) {
|
|
db := openTestDB(t)
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema: %v", err)
|
|
}
|
|
|
|
// Seed required FK rows: tenant + agent.
|
|
tenantID := "00000000-0000-0000-0000-000000000001"
|
|
agentID := "00000000-0000-0000-0000-000000000002"
|
|
db.Exec(`INSERT INTO tenants (id, name, slug, status) VALUES (?, 'T', 't', 'active')`, tenantID)
|
|
db.Exec(`INSERT INTO agents (id, agent_key, display_name, status, tenant_id, owner_id, model, provider)
|
|
VALUES (?, 'agt', 'A', 'active', ?, 'owner', 'gpt-4o', 'openai')`, agentID, tenantID)
|
|
|
|
// 1. Valid INSERT (personal + agent_id set) must succeed.
|
|
_, err := db.Exec(
|
|
`INSERT INTO vault_documents (id, tenant_id, agent_id, team_id, scope, path, path_basename, title, doc_type, content_hash)
|
|
VALUES ('doc-1', ?, ?, NULL, 'personal', '/a/b.md', 'b.md', 'T', 'note', 'h1')`,
|
|
tenantID, agentID)
|
|
if err != nil {
|
|
t.Fatalf("valid INSERT failed: %v", err)
|
|
}
|
|
|
|
// 2. Invalid fresh INSERT (personal + agent_id NULL) must abort.
|
|
_, err = db.Exec(
|
|
`INSERT INTO vault_documents (id, tenant_id, agent_id, team_id, scope, path, path_basename, title, doc_type, content_hash)
|
|
VALUES ('doc-2', ?, NULL, NULL, 'personal', '/a/c.md', 'c.md', 'T2', 'note', 'h2')`,
|
|
tenantID)
|
|
if err == nil {
|
|
t.Fatal("expected INSERT to fail scope_consistency check, but it succeeded")
|
|
}
|
|
|
|
// 3. UPSERT that would make scope inconsistent must abort on UPDATE path.
|
|
_, err = db.Exec(
|
|
`INSERT INTO vault_documents (id, tenant_id, agent_id, team_id, scope, path, path_basename, title, doc_type, content_hash)
|
|
VALUES ('doc-1', ?, NULL, NULL, 'personal', '/a/b.md', 'b.md', 'T-upd', 'note', 'h1')
|
|
ON CONFLICT(id) DO UPDATE SET agent_id = NULL, scope = 'personal'`,
|
|
tenantID)
|
|
if err == nil {
|
|
t.Fatal("expected UPSERT to fail scope_consistency check on UPDATE path, but it succeeded")
|
|
}
|
|
}
|
|
|
|
func openTestDB(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
|
|
}
|
|
|
|
// openTestDBAtVersion creates a fresh DB, applies full schema, then
|
|
// drops columns added by migrations > targetVersion so re-running
|
|
// EnsureSchema from that version exercises the real migration path.
|
|
//
|
|
// We accomplish this by applying schema at targetVersion: apply full
|
|
// schema.sql then set version = targetVersion. Migrations will ALTER
|
|
// TABLE ADD COLUMN — which only fails if the column already exists.
|
|
// To avoid that, we drop the columns that post-targetVersion migrations add.
|
|
func openTestDBAtVersion(t *testing.T, targetVersion int) *sql.DB {
|
|
t.Helper()
|
|
db := openTestDB(t)
|
|
|
|
// Apply full schema first (creates all tables with all columns).
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema: %v", err)
|
|
}
|
|
|
|
// Undo columns added by migrations after targetVersion.
|
|
// SQLite DROP COLUMN support varies, so recreate affected tables.
|
|
|
|
// Phase 03 (v15 → v16) adds:
|
|
// - team_task_attachments.base_name
|
|
// - vault_documents.path_basename
|
|
// - vault_links.metadata
|
|
// Strip these when the test targets any version < 16 so the v13 migration
|
|
// (which recreates vault_documents via SELECT *) doesn't hit a column
|
|
// count mismatch.
|
|
if targetVersion < 16 {
|
|
// Recreate vault_documents without path_basename.
|
|
db.Exec(`CREATE TABLE vault_documents_v15 AS SELECT
|
|
id, tenant_id, agent_id, team_id, scope, custom_scope, path,
|
|
title, doc_type, content_hash, summary, metadata,
|
|
created_at, updated_at
|
|
FROM vault_documents`)
|
|
db.Exec(`DROP TABLE vault_documents`)
|
|
db.Exec(`CREATE TABLE vault_documents (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
|
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
|
team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL,
|
|
scope TEXT NOT NULL DEFAULT 'personal',
|
|
custom_scope TEXT,
|
|
path TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
doc_type TEXT NOT NULL DEFAULT 'note',
|
|
content_hash TEXT NOT NULL DEFAULT '',
|
|
summary TEXT NOT NULL DEFAULT '',
|
|
metadata TEXT DEFAULT '{}',
|
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
updated_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
|
)`)
|
|
db.Exec(`INSERT INTO vault_documents SELECT * FROM vault_documents_v15`)
|
|
db.Exec(`DROP TABLE vault_documents_v15`)
|
|
|
|
// Recreate team_task_attachments without base_name.
|
|
db.Exec(`CREATE TABLE team_task_attachments_v15 AS SELECT
|
|
id, task_id, team_id, chat_id, path, file_size, mime_type,
|
|
created_by_agent_id, created_by_sender_id, metadata, custom_scope,
|
|
tenant_id, created_at
|
|
FROM team_task_attachments`)
|
|
db.Exec(`DROP TABLE team_task_attachments`)
|
|
db.Exec(`CREATE TABLE team_task_attachments (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
task_id TEXT NOT NULL REFERENCES team_tasks(id) ON DELETE CASCADE,
|
|
team_id TEXT NOT NULL REFERENCES agent_teams(id) ON DELETE CASCADE,
|
|
chat_id VARCHAR(255) NOT NULL DEFAULT '',
|
|
path TEXT NOT NULL,
|
|
file_size BIGINT NOT NULL DEFAULT 0,
|
|
mime_type VARCHAR(100) DEFAULT '',
|
|
created_by_agent_id TEXT REFERENCES agents(id),
|
|
created_by_sender_id VARCHAR(255) DEFAULT '',
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
custom_scope TEXT,
|
|
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
UNIQUE(task_id, path)
|
|
)`)
|
|
db.Exec(`INSERT INTO team_task_attachments SELECT * FROM team_task_attachments_v15`)
|
|
db.Exec(`DROP TABLE team_task_attachments_v15`)
|
|
|
|
// Recreate vault_links without metadata column.
|
|
db.Exec(`CREATE TABLE vault_links_v15 AS SELECT
|
|
id, from_doc_id, to_doc_id, link_type, context, custom_scope, created_at
|
|
FROM vault_links`)
|
|
db.Exec(`DROP TABLE vault_links`)
|
|
db.Exec(`CREATE TABLE vault_links (
|
|
id TEXT NOT NULL PRIMARY KEY,
|
|
from_doc_id TEXT NOT NULL REFERENCES vault_documents(id) ON DELETE CASCADE,
|
|
to_doc_id TEXT NOT NULL REFERENCES vault_documents(id) ON DELETE CASCADE,
|
|
link_type TEXT NOT NULL DEFAULT 'wikilink',
|
|
context TEXT NOT NULL DEFAULT '',
|
|
custom_scope TEXT,
|
|
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
UNIQUE(from_doc_id, to_doc_id, link_type)
|
|
)`)
|
|
db.Exec(`INSERT INTO vault_links SELECT * FROM vault_links_v15`)
|
|
db.Exec(`DROP TABLE vault_links_v15`)
|
|
}
|
|
|
|
if targetVersion <= 11 {
|
|
// Migration 12 adds recall_count, recall_score, last_recalled_at.
|
|
// Recreate episodic_summaries without those columns.
|
|
db.Exec(`CREATE TABLE episodic_summaries_old AS SELECT
|
|
id, tenant_id, agent_id, user_id, session_key, summary, l0_abstract,
|
|
key_topics, source_type, source_id, turn_count, token_count,
|
|
created_at, expires_at, promoted_at
|
|
FROM episodic_summaries`)
|
|
db.Exec(`DROP TABLE episodic_summaries`)
|
|
db.Exec(`CREATE TABLE episodic_summaries (
|
|
id TEXT NOT NULL PRIMARY KEY, tenant_id TEXT NOT NULL, agent_id TEXT NOT NULL,
|
|
user_id VARCHAR(255) NOT NULL DEFAULT '', session_key TEXT NOT NULL,
|
|
summary TEXT NOT NULL, l0_abstract TEXT NOT NULL DEFAULT '',
|
|
key_topics TEXT NOT NULL DEFAULT '[]', source_type TEXT NOT NULL DEFAULT 'session',
|
|
source_id TEXT, turn_count INTEGER NOT NULL DEFAULT 0,
|
|
token_count INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
expires_at TEXT, promoted_at TEXT)`)
|
|
db.Exec(`INSERT INTO episodic_summaries SELECT * FROM episodic_summaries_old`)
|
|
db.Exec(`DROP TABLE episodic_summaries_old`)
|
|
}
|
|
|
|
if targetVersion < 25 {
|
|
// Migration 24→25 adds vault_documents.chat_id + idx_vault_docs_team_chat.
|
|
// Drop both so the migration's ALTER TABLE / CREATE INDEX succeed.
|
|
db.Exec(`DROP INDEX IF EXISTS idx_vault_docs_team_chat`)
|
|
db.Exec(`ALTER TABLE vault_documents DROP COLUMN chat_id`)
|
|
}
|
|
|
|
// Set version back to target.
|
|
db.Exec("UPDATE schema_version SET version = ?", targetVersion)
|
|
return db
|
|
}
|