feat(admin): Phase 4 — doctor, admin completeness, and bubbletea TUI
Adds the admin/diagnostics surface from SPEC §7.2: - doctor [--account]: per-account IMAP + (RW) SMTP connectivity/auth checks via new mail.CheckIMAP/CheckSMTP (connect+auth only, no mail). Exit non-zero on any failure; secrets never printed. - store.UpdateAccount: partial edit, re-encrypts password/secrets only when a non-empty value is supplied (blank keeps existing). RecentAuditFor(account). - config set/get (validates audit_retention_days), audit list [--account][--limit], account edit (flag partial-update) / remove [--yes]. - internal/tui: bubbletea AccountForm with pure, fully-tested Fields (validation + store.Account assembly + edit prefill). init / bare `account add` / `account edit --name X` drop into the TUI; flag forms remain for scripting. Built test-first; full suite green incl -race. Validated live against the mxlogin (password) and Gmail (app-password) accounts. Live validation caught a real bug: doctor authenticated with empty passwords because it iterated ListAccounts (which strips secrets) — fixed to re-fetch via GetAccount, locked in by a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
)
|
||||
|
||||
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
|
||||
func adminEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
db := filepath.Join(t.TempDir(), "emcli.db")
|
||||
t.Setenv("EMCLI_KEY", b64Key())
|
||||
t.Setenv("EMCLI_DB", db)
|
||||
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, mustKey())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
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"), testKey())
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer st.Close()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
|
||||
func mustKey() []byte { return make([]byte, 32) }
|
||||
Reference in New Issue
Block a user