mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-19 05:57:39 +02:00
237 lines
9.2 KiB
Go
237 lines
9.2 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func rawSchemaDB(t *testing.T) *DB {
|
|
t.Helper()
|
|
conn, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { conn.Close() })
|
|
return &DB{DB: conn, logger: zap.NewNop()}
|
|
}
|
|
func schemaExec(t *testing.T, db *DB, statement string) {
|
|
t.Helper()
|
|
if _, err := db.Exec(statement); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestReconcileSchemaUsesDefinitionsWithoutMigrationList(t *testing.T) {
|
|
db := rawSchemaDB(t)
|
|
schemaExec(t, db, `CREATE TABLE "future's (table)" (id INTEGER PRIMARY KEY, payload TEXT, extra TEXT);
|
|
INSERT INTO "future's (table)" VALUES (7, 'old', 'keep');
|
|
CREATE TABLE unrelated (value TEXT); INSERT INTO unrelated VALUES ('untouched');`)
|
|
latest := `CREATE TABLE "future's (table)" (
|
|
id INTEGER PRIMARY KEY, payload TEXT,
|
|
/* punctuation , ( ) */ new_value TEXT NOT NULL DEFAULT 'a,b''c',
|
|
quota DECIMAL(10,2) DEFAULT 1.25 CHECK (quota IN (1.25, 2)),
|
|
"UNIQUE" TEXT DEFAULT 'quoted',
|
|
derived TEXT GENERATED ALWAYS AS (payload || ',x') VIRTUAL
|
|
);
|
|
CREATE TABLE missing_table (id INTEGER PRIMARY KEY, source INTEGER REFERENCES "future's (table)"(id));
|
|
CREATE INDEX new_index ON "future's (table)"(new_value);`
|
|
for i := 0; i < 2; i++ {
|
|
if err := db.reconcileSchema(latest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
var payload, extra, value, derived, quoted string
|
|
var quota float64
|
|
if err := db.QueryRow(`SELECT payload,extra,new_value,quota,derived,"UNIQUE" FROM "future's (table)" WHERE id=7`).Scan(&payload, &extra, &value, "a, &derived, "ed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload != "old" || extra != "keep" || value != "a,b'c" || quota != 1.25 || derived != "old,x" || quoted != "quoted" {
|
|
t.Fatalf("data/defaults: %q %q %q %v %q %q", payload, extra, value, quota, derived, quoted)
|
|
}
|
|
if err := db.QueryRow(`SELECT value FROM unrelated`).Scan(&value); err != nil || value != "untouched" {
|
|
t.Fatalf("unrelated data: %q %v", value, err)
|
|
}
|
|
schemaExec(t, db, `INSERT INTO missing_table VALUES(1,7)`)
|
|
if _, err := db.Exec(`INSERT INTO missing_table VALUES(2,999)`); err == nil {
|
|
t.Fatal("foreign key not enforced")
|
|
}
|
|
}
|
|
|
|
func TestReconcileSchemaFailureRollsBackAllRepairs(t *testing.T) {
|
|
for _, addition := range []string{"required TEXT NOT NULL", "unique_value TEXT UNIQUE", "stamp TEXT DEFAULT CURRENT_TIMESTAMP", "stored TEXT GENERATED ALWAYS AS (payload) STORED"} {
|
|
t.Run(addition, func(t *testing.T) {
|
|
db := rawSchemaDB(t)
|
|
schemaExec(t, db, `CREATE TABLE z_existing (id INTEGER PRIMARY KEY,payload TEXT); INSERT INTO z_existing VALUES(1,'keep')`)
|
|
err := db.reconcileSchema(`CREATE TABLE a_missing (id INTEGER); CREATE TABLE z_existing (id INTEGER PRIMARY KEY,payload TEXT, safe TEXT,` + addition + `);`)
|
|
if err == nil || !strings.Contains(err.Error(), "z_existing.") {
|
|
t.Fatalf("missing contextual failure: %v", err)
|
|
}
|
|
var count int
|
|
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE name='a_missing'`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("table repair not rolled back: %d %v", count, err)
|
|
}
|
|
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('z_existing') WHERE name='safe'`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("column repair not rolled back: %d %v", count, err)
|
|
}
|
|
var value string
|
|
if err := db.QueryRow(`SELECT payload FROM z_existing WHERE id=1`).Scan(&value); err != nil || value != "keep" {
|
|
t.Fatalf("original data: %q %v", value, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReconcileSchemaIndexFailureRollsBack(t *testing.T) {
|
|
db := rawSchemaDB(t)
|
|
schemaExec(t, db, `CREATE TABLE existing (value TEXT); INSERT INTO existing VALUES ('duplicate'),('duplicate')`)
|
|
err := db.reconcileSchema(`CREATE TABLE existing (value TEXT,added TEXT); CREATE UNIQUE INDEX unique_values ON existing(value)`)
|
|
if err == nil || !strings.Contains(err.Error(), "unique_values") {
|
|
t.Fatalf("index failure: %v", err)
|
|
}
|
|
var count int
|
|
if err := db.QueryRow(`SELECT count(*) FROM pragma_table_info('existing') WHERE name='added'`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("repair rollback: %d %v", count, err)
|
|
}
|
|
}
|
|
|
|
func TestStartupRepairsTablesAndUnlistedColumnsPreservesData(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "startup.db")
|
|
db, err := NewDB(path, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conv, err := db.CreateConversation("retained", ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
schemaExec(t, db, `ALTER TABLE conversations ADD COLUMN extension TEXT DEFAULT 'keep'; CREATE INDEX extension_index ON conversations(extension);
|
|
CREATE TABLE unrelated(value TEXT); INSERT INTO unrelated VALUES ('untouched');
|
|
DROP TABLE c2_payload_artifacts; DROP TABLE rbac_roles; DROP TABLE notification_reads_by_user;
|
|
ALTER TABLE hitl_interrupts DROP COLUMN payload;
|
|
ALTER TABLE hitl_conversation_configs DROP COLUMN reviewer;
|
|
ALTER TABLE model_token_usage DROP COLUMN cached_tokens;
|
|
ALTER TABLE rbac_users DROP COLUMN display_name;
|
|
ALTER TABLE messages DROP COLUMN reasoning_content;`)
|
|
db.Close()
|
|
for i := 0; i < 2; i++ {
|
|
db, err = NewDB(path, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reference, err := openSchemaReference(applicationSchema)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tables, err := schemaObjects(reference, "table")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, table := range tables {
|
|
expected, err := schemaColumns(reference, table.name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actual, err := schemaColumns(db, table.name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
present := map[string]bool{}
|
|
for _, c := range actual {
|
|
present[c.name] = true
|
|
}
|
|
for _, c := range expected {
|
|
if !present[c.name] {
|
|
t.Errorf("missing %s.%s", table.name, c.name)
|
|
}
|
|
}
|
|
}
|
|
reference.Close()
|
|
var title, extra string
|
|
if err := db.QueryRow(`SELECT title,extension FROM conversations WHERE id=?`, conv.ID).Scan(&title, &extra); err != nil || title != "retained" || extra != "keep" {
|
|
t.Fatalf("data: %q %q %v", title, extra, err)
|
|
}
|
|
var value string
|
|
if err := db.QueryRow(`SELECT value FROM unrelated`).Scan(&value); err != nil || value != "untouched" {
|
|
t.Fatalf("extra table: %q %v", value, err)
|
|
}
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
func TestOtherSQLiteFileStartupRepairsSchema(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "other.db")
|
|
db, err := NewKnowledgeDB(path, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
schemaExec(t, db, `INSERT INTO knowledge_base_items (id,category,title,file_path,created_at,updated_at) VALUES('keep','cat','title','path','2026-10-10','2026-10-10');
|
|
ALTER TABLE knowledge_base_items DROP COLUMN content;
|
|
ALTER TABLE knowledge_embeddings DROP COLUMN embedding_model;
|
|
DROP TABLE knowledge_retrieval_logs;`)
|
|
db.Close()
|
|
for i := 0; i < 2; i++ {
|
|
db, err = NewKnowledgeDB(path, zap.NewNop())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var title string
|
|
if err := db.QueryRow(`SELECT title FROM knowledge_base_items WHERE id='keep'`).Scan(&title); err != nil || title != "title" {
|
|
t.Fatalf("data: %q %v", title, err)
|
|
}
|
|
schemaExec(t, db, `SELECT content FROM knowledge_base_items; SELECT embedding_model FROM knowledge_embeddings; SELECT * FROM knowledge_retrieval_logs`)
|
|
db.Close()
|
|
}
|
|
}
|
|
|
|
func TestHistoricalFKMigrationPreservesExtraAndDependentData(t *testing.T) {
|
|
db := rawSchemaDB(t)
|
|
legacy := strings.Replace(applicationSchema, `FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL`, `FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE`, 1)
|
|
schemaExec(t, db, legacy)
|
|
conv, err := db.CreateConversation("source", ConversationCreateMeta{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vuln, err := db.CreateVulnerability(&Vulnerability{ConversationID: conv.ID, Title: "keep", Severity: "high", Status: "open"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
schemaExec(t, db, `ALTER TABLE vulnerabilities ADD COLUMN extension TEXT DEFAULT 'keep'; CREATE INDEX vuln_extra_index ON vulnerabilities(extension);
|
|
CREATE TABLE dependent(id TEXT REFERENCES vulnerabilities(id) ON DELETE CASCADE);
|
|
CREATE TABLE trigger_log(value TEXT);
|
|
CREATE TRIGGER vuln_custom AFTER UPDATE ON vulnerabilities BEGIN INSERT INTO trigger_log VALUES (NEW.extension); END;`)
|
|
if _, err := db.Exec(`INSERT INTO dependent VALUES(?)`, vuln.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.initTables(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var value string
|
|
if err := db.QueryRow(`SELECT extension FROM vulnerabilities WHERE id=?`, vuln.ID).Scan(&value); err != nil || value != "keep" {
|
|
t.Fatalf("extra data: %q %v", value, err)
|
|
}
|
|
var count int
|
|
if err := db.QueryRow(`SELECT count(*) FROM dependent`).Scan(&count); err != nil || count != 1 {
|
|
t.Fatalf("dependent rows: %d %v", count, err)
|
|
}
|
|
if _, err := db.Exec(`UPDATE vulnerabilities SET extension='triggered' WHERE id=?`, vuln.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.QueryRow(`SELECT value FROM trigger_log`).Scan(&value); err != nil || value != "triggered" {
|
|
t.Fatalf("trigger: %q %v", value, err)
|
|
}
|
|
if err := db.QueryRow(`PRAGMA foreign_keys`).Scan(&count); err != nil || count != 1 {
|
|
t.Fatalf("foreign keys: %d %v", count, err)
|
|
}
|
|
if err := db.DeleteConversation(conv.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.GetVulnerability(vuln.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|