3bea73f857
A literal "~/..." in EMCLI_DB has no shell to expand it, so SQLite opened it relative to the cwd and silently created a stray "~" directory tree. Expand a leading "~" or "~/" to the user's home dir; "~user", mid-path tildes, and absolute/relative paths are left untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
6.4 KiB
Go
214 lines
6.4 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A leading "~" in EMCLI_DB must be expanded to the home dir, so a literal
|
|
// tilde (no shell to expand it) can't be opened relative to the cwd and
|
|
// silently create a stray "~" directory.
|
|
func TestDefaultDBPathExpandsLeadingTilde(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
t.Skipf("no home dir: %v", err)
|
|
}
|
|
cases := map[string]string{
|
|
"~/.config/emcli/emcli.db": filepath.Join(home, ".config", "emcli", "emcli.db"),
|
|
"~": home,
|
|
}
|
|
for in, want := range cases {
|
|
t.Setenv("EMCLI_DB", in)
|
|
got, err := DefaultDBPath()
|
|
if err != nil {
|
|
t.Fatalf("DefaultDBPath(%q): %v", in, err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("EMCLI_DB=%q -> %q, want %q", in, got, want)
|
|
}
|
|
if strings.Contains(got, "~") {
|
|
t.Fatalf("EMCLI_DB=%q left a literal tilde: %q", in, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A non-leading tilde or "~user" is NOT a path we should rewrite — leave it be.
|
|
func TestDefaultDBPathLeavesOtherPathsUntouched(t *testing.T) {
|
|
for _, p := range []string{"/var/lib/emcli.db", "./rel/emcli.db", "~user/db"} {
|
|
t.Setenv("EMCLI_DB", p)
|
|
got, err := DefaultDBPath()
|
|
if err != nil {
|
|
t.Fatalf("DefaultDBPath(%q): %v", p, err)
|
|
}
|
|
if got != p {
|
|
t.Fatalf("EMCLI_DB=%q was rewritten to %q", p, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
t.Fatalf("want 1 account after migrate, got %d", len(accs))
|
|
}
|
|
if accs[0].FromAddress != "" {
|
|
t.Fatalf("legacy account FromAddress should be empty, got %q", accs[0].FromAddress)
|
|
}
|
|
if got := accs[0].SendFrom(); got != "login@example.com" {
|
|
t.Fatalf("legacy account should send from username, got %q", got)
|
|
}
|
|
}
|