package store import ( "database/sql" "path/filepath" "testing" ) // openTemp opens a fresh store in a temp dir and initialises keys so that // account tests (which do crypto) work without needing their own setup. func openTemp(t *testing.T) *Store { t.Helper() p := filepath.Join(t.TempDir(), "emcli.db") s, err := Open(p) if err != nil { t.Fatalf("Open: %v", err) } if err := s.InitKeys(k(0xAA), k(0xBB)); err != nil { t.Fatalf("InitKeys: %v", err) } t.Cleanup(func() { s.Close() }) return s } func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) { p := filepath.Join(t.TempDir(), "emcli.db") s, err := Open(p) if err != nil { t.Fatalf("first Open: %v", err) } v, err := s.GetSetting("schema_version") if err != nil || v != "2" { t.Fatalf("schema_version: %q err=%v", v, err) } s.Close() // Re-open: must not error or duplicate. s2, err := Open(p) if err != nil { t.Fatalf("second Open: %v", err) } defer s2.Close() if v, _ := s2.GetSetting("schema_version"); v != "2" { t.Fatalf("schema_version after reopen: %q", v) } } func TestSettingsRoundTrip(t *testing.T) { s := openTemp(t) if err := s.SetSetting("audit_retention_days", "30"); err != nil { t.Fatalf("SetSetting: %v", err) } got, err := s.GetSetting("audit_retention_days") if err != nil || got != "30" { t.Fatalf("got %q err=%v", got, err) } // Upsert overwrites. _ = s.SetSetting("audit_retention_days", "7") if got, _ := s.GetSetting("audit_retention_days"); got != "7" { t.Fatalf("upsert failed: %q", got) } } func TestForeignKeyCascade(t *testing.T) { s := openTemp(t) // Insert an account directly via raw SQL. _, err := s.db.Exec(` INSERT INTO accounts(name, mode, imap_host, imap_port, imap_security, auth_type, username) VALUES('test_account', 'RO', 'imap.example.com', 993, 'tls', 'password', 'user@example.com') `) if err != nil { t.Fatalf("insert account: %v", err) } // Get the inserted account ID. var accountID int64 err = s.db.QueryRow("SELECT id FROM accounts WHERE name = 'test_account'").Scan(&accountID) if err != nil { t.Fatalf("query account id: %v", err) } // Insert a whitelist_in row referencing the account. _, err = s.db.Exec("INSERT INTO whitelist_in(account_id, address) VALUES(?, 'test@example.com')", accountID) if err != nil { t.Fatalf("insert whitelist_in: %v", err) } // Verify the whitelist_in row exists. var count int err = s.db.QueryRow("SELECT COUNT(*) FROM whitelist_in WHERE account_id = ?", accountID).Scan(&count) if err != nil || count != 1 { t.Fatalf("whitelist_in row not found: count=%d err=%v", count, err) } // Delete the account (should cascade and delete whitelist_in row). _, err = s.db.Exec("DELETE FROM accounts WHERE name = 'test_account'") if err != nil { t.Fatalf("delete account: %v", err) } // Verify the whitelist_in row was cascade-deleted. err = s.db.QueryRow("SELECT COUNT(*) FROM whitelist_in WHERE account_id = ?", accountID).Scan(&count) if err != nil || count != 0 { t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err) } } func TestOpenMigratesV1AddsFromAddress(t *testing.T) { p := filepath.Join(t.TempDir(), "emcli.db") // Hand-build a v1 database: accounts table WITHOUT from_address, a settings // table pinned at schema_version=1, and one pre-existing account row. raw, err := sql.Open("sqlite", p) if err != nil { t.Fatalf("sql.Open: %v", err) } const v1Schema = ` CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, mode TEXT NOT NULL, imap_host TEXT NOT NULL, imap_port INTEGER NOT NULL, imap_security TEXT NOT NULL, smtp_host TEXT, smtp_port INTEGER, smtp_security TEXT, auth_type TEXT NOT NULL, username TEXT NOT NULL, enc_password BLOB, enc_oauth_client_id BLOB, enc_oauth_client_secret BLOB, enc_oauth_refresh_token BLOB, whitelist_in_enabled INTEGER NOT NULL DEFAULT 0, whitelist_out_enabled INTEGER NOT NULL DEFAULT 0, subject_regex TEXT, process_backlog INTEGER NOT NULL DEFAULT 0 ); INSERT INTO settings(key,value) VALUES ('schema_version','1'); INSERT INTO accounts(name,mode,imap_host,imap_port,imap_security,auth_type,username) VALUES ('legacy','RO','imap.example.com',993,'tls','password','login@example.com'); ` if _, err := raw.Exec(v1Schema); err != nil { t.Fatalf("seed v1 schema: %v", err) } raw.Close() // Open via the store: the migration must add from_address and bump to v2. s, err := Open(p) if err != nil { t.Fatalf("Open (migrate): %v", err) } defer s.Close() if v, _ := s.GetSetting("schema_version"); v != "2" { t.Fatalf("schema_version after migrate: %q, want 2", v) } // ListAccounts SELECTs from_address; it would error if the column were missing. accs, err := s.ListAccounts() if err != nil { t.Fatalf("ListAccounts after migrate: %v", err) } if len(accs) != 1 || accs[0].FromAddress != "" { t.Fatalf("legacy account wrong after migrate: %+v", accs) } if got := accs[0].SendFrom(); got != "login@example.com" { t.Fatalf("legacy account should send from username, got %q", got) } }