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 != 2 { t.Fatal("negative retention must be a usage error") } if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code != 2 { t.Fatal("non-integer retention must be a usage error") } } func TestConfigRejectsUnknownKey(t *testing.T) { adminEnv(t) if code, _, e := run(t, "config", "set", "bogus", "1"); code != 2 || !strings.Contains(e, "unknown setting") { t.Fatalf("set unknown key: code=%d err=%q", code, e) } if code, _, e := run(t, "config", "get", "bogus"); code != 2 || !strings.Contains(e, "unknown setting") { t.Fatalf("get unknown key: code=%d err=%q", code, e) } } func TestConfigList(t *testing.T) { adminEnv(t) run(t, "config", "set", "audit_retention_days", "42") code, out, _ := run(t, "config", "ls") // alias if code != 0 { t.Fatalf("config ls exit=%d", code) } if !strings.Contains(out, "audit_retention_days") || !strings.Contains(out, "42") || !strings.Contains(out, "KEY") { t.Fatalf("config list output wrong:\n%s", out) } } func TestAccountRemove(t *testing.T) { adminEnv(t) run(t, "account", "add", "gone", "--imap-host", "h", "--username", "u@x.com") if code, _, e := run(t, "account", "remove", "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", "nope", "--yes"); code == 0 { t.Fatal("removing a missing account must be non-zero") } } func TestAccountRemoveNoTTYNeedsYes(t *testing.T) { adminEnv(t) run(t, "account", "add", "keep", "--imap-host", "h", "--username", "u@x.com") // Under `go test`, stdin is not a TTY, so without --yes this must refuse. code, _, errOut := run(t, "account", "remove", "keep") if code != 2 || !strings.Contains(errOut, "--yes") { t.Fatalf("non-TTY remove without --yes must refuse: code=%d err=%q", code, errOut) } } func TestAccountEditPartialPreservesOtherFields(t *testing.T) { db := adminEnv(t) run(t, "account", "add", "ed", "--mode", "RO", "--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig") if code, _, e := run(t, "account", "edit", "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 TestWhitelistRequiresDirection(t *testing.T) { adminEnv(t) run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com") code, _, errOut := run(t, "whitelist", "add", "bobby", "a@x.com") if code != 2 || !strings.Contains(errOut, "--in") || !strings.Contains(errOut, "--out") { t.Fatalf("missing direction must name --in/--out: code=%d err=%q", code, errOut) } } func TestWhitelistAddListRemove(t *testing.T) { adminEnv(t) run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com") if code, _, e := run(t, "whitelist", "add", "bobby", "a@x.com", "@y.com", "--out"); code != 0 { t.Fatalf("add failed: %s", e) } code, out, _ := run(t, "whitelist", "list", "bobby", "--out") if code != 0 || !strings.Contains(out, "a@x.com") || !strings.Contains(out, "@y.com") || !strings.Contains(out, "DISABLED") { t.Fatalf("list wrong: code=%d out=%q", code, out) } if code, _, e := run(t, "whitelist", "rm", "bobby", "a@x.com", "--out"); code != 0 { // alias t.Fatalf("rm failed: %s", e) } _, out, _ = run(t, "whitelist", "ls", "bobby", "--out") // alias if strings.Contains(out, "a@x.com") { t.Fatalf("address not removed:\n%s", out) } } func TestWhitelistRejectsBadAddress(t *testing.T) { adminEnv(t) run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com") if code, _, e := run(t, "whitelist", "add", "bobby", "notanaddress", "--in"); code != 2 || !strings.Contains(e, "invalid address") { t.Fatalf("bad address must be rejected: code=%d err=%q", code, e) } // The original bug: a missing address must not silently insert a blank row. if code, _, _ := run(t, "whitelist", "add", "bobby", "--in"); code != 2 { t.Fatal("add with no address must be a usage error") } } func TestWhitelistEnableDisable(t *testing.T) { adminEnv(t) run(t, "account", "add", "bobby", "--imap-host", "h", "--username", "u@x.com") // Enabling an empty whitelist warns but succeeds. code, _, errOut := run(t, "whitelist", "enable", "bobby", "--in") if code != 0 || !strings.Contains(errOut, "empty") { t.Fatalf("enable empty: code=%d err=%q", code, errOut) } _, out, _ := run(t, "whitelist", "list", "bobby", "--in") if !strings.Contains(out, "ENABLED") { t.Fatalf("expected ENABLED:\n%s", out) } if code, _, e := run(t, "whitelist", "disable", "bobby", "--in"); code != 0 { t.Fatalf("disable failed: %s", e) } _, out, _ = run(t, "whitelist", "list", "bobby", "--in") if !strings.Contains(out, "DISABLED") { t.Fatalf("expected DISABLED:\n%s", out) } } 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 TestAuditListPositionalAccount(t *testing.T) { adminEnv(t) // Positional account + `ls` alias must be accepted (empty log → exit 0). if code, _, e := run(t, "audit", "ls", "someacct"); code != 0 { t.Fatalf("audit ls should succeed: code=%d err=%q", code, e) } // Extra positional is a usage error. if code, _, _ := run(t, "audit", "list", "a", "b"); code != 2 { t.Fatal("extra positional must be a usage error") } // The removed --account flag is now a usage error. if code, _, _ := run(t, "audit", "list", "--account", "x"); code != 2 { t.Fatal("removed --account flag should now be a usage error") } } func TestAccountEditFromValidationRejectsMalformed(t *testing.T) { adminEnv(t) run(t, "account", "add", "valacc", "--imap-host", "imap.x.com", "--username", "u@x.com") code, _, errStr := run(t, "account", "edit", "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") } }