package cli import ( "bytes" "path/filepath" "strings" "testing" "time" "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/store" ) // adminEnv points both keys + EMCLI_DB at a fresh, initialized temp DB. func adminEnv(t *testing.T) string { t.Helper() db := filepath.Join(t.TempDir(), "emcli.db") t.Setenv("EMCLI_ADMIN_KEY", b64Key()) t.Setenv("EMCLI_KEY", b64AgentKey()) t.Setenv("EMCLI_DB", db) st, err := store.Open(db) if err != nil { t.Fatalf("Open: %v", err) } adminKey, _ := crypto.AdminKeyFromEnv() agentKey, _ := crypto.AgentKeyFromEnv() if err := st.InitKeys(adminKey, agentKey); err != nil { t.Fatalf("InitKeys: %v", err) } st.Close() return db } func run(t *testing.T, args ...string) (int, string, string) { t.Helper() var out, errOut bytes.Buffer code := Run(args, &out, &errOut) return code, out.String(), errOut.String() } func TestConfigSetGet(t *testing.T) { adminEnv(t) if code, _, e := run(t, "config", "set", "audit_retention_days", "30"); code != 0 { t.Fatalf("config set failed: %s", e) } code, out, _ := run(t, "config", "get", "audit_retention_days") if code != 0 || !strings.Contains(out, "30") { t.Fatalf("config get: code=%d out=%q", code, out) } } func TestConfigSetRejectsBadRetention(t *testing.T) { adminEnv(t) if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 { t.Fatal("negative retention must be rejected") } if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 { t.Fatal("non-integer retention must be rejected") } } func TestAccountRemove(t *testing.T) { adminEnv(t) run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com") if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 { t.Fatalf("remove failed: %s", e) } _, out, _ := run(t, "account", "list") if strings.Contains(out, "gone") { t.Fatalf("account still listed after remove:\n%s", out) } } func TestAccountRemoveMissing(t *testing.T) { adminEnv(t) if code, _, _ := run(t, "account", "remove", "--name", "nope", "--yes"); code == 0 { t.Fatal("removing a missing account must be non-zero") } } func TestAccountEditPartialPreservesOtherFields(t *testing.T) { db := adminEnv(t) run(t, "account", "add", "--name", "ed", "--mode", "RO", "--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig") // Edit only mode + add SMTP; imap-host, username, password must be preserved. if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW", "--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 { t.Fatalf("edit failed: %s", e) } st, err := store.Open(db) if err != nil { t.Fatalf("open: %v", err) } defer st.Close() adminKey, _ := crypto.AdminKeyFromEnv() agentKey, _ := crypto.AgentKeyFromEnv() if err := st.Unlock(store.RoleAdmin, adminKey, agentKey); err != nil { t.Fatalf("Unlock: %v", err) } got, err := st.GetAccount("ed") if err != nil { t.Fatalf("GetAccount: %v", err) } if got.Mode != "RW" || got.SMTPHost != "smtp.x.com" || got.SMTPPort != 587 { t.Fatalf("edit didn't apply: %+v", got) } if got.IMAPHost != "imap.x.com" || got.Username != "u@x.com" || got.Password != "orig" { t.Fatalf("edit clobbered preserved fields: %+v", got) } } func TestAuditListCoreRenders(t *testing.T) { st, err := store.Open(filepath.Join(t.TempDir(), "e.db")) if err != nil { t.Fatalf("open: %v", err) } defer st.Close() if err := st.InitKeys(testKey(), testKey()); err != nil { t.Fatalf("InitKeys: %v", err) } now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"}) _ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"}) var buf bytes.Buffer if err := auditList(st, "", 50, &buf); err != nil { t.Fatalf("auditList: %v", err) } out := buf.String() if !strings.Contains(out, "list") || !strings.Contains(out, "whitelist_out") { t.Fatalf("audit rows not rendered:\n%s", out) } } func TestAccountEditFromValidationRejectsMailformed(t *testing.T) { adminEnv(t) // Seed an account so the failure is from --from validation, not a missing account. run(t, "account", "add", "--name", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com") // A malformed --from value must be rejected with exit code 2 before touching the account. code, _, errStr := run(t, "account", "edit", "--name", "valacc", "--from", "not an address") if code != 2 { t.Fatalf("expected exit code 2 for malformed --from, got %d (stderr: %q)", code, errStr) } if errStr == "" { t.Fatal("expected an error message on stderr for malformed --from, got none") } }