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:
2026-06-22 20:09:43 +01:00
parent 193815dd25
commit a837b25d73
20 changed files with 1535 additions and 10 deletions
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"errors"
"path/filepath"
"strings"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { st.Close() })
for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil {
t.Fatalf("AddAccount %s: %v", a.Name, err)
}
}
buf := &[]byte{}
d := Deps{Store: st, CheckIMAP: imap, CheckSMTP: smtp, Out: bufWriter{buf}}
return d, buf
}
func roAcc(name string) store.Account {
return store.Account{Name: name, Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "u@x.com", Password: "p"}
}
func rwAcc(name string) store.Account {
a := roAcc(name)
a.Mode = "RW"
a.SMTPHost, a.SMTPPort, a.SMTPSecurity = "h", 465, "tls"
return a
}
func TestDoctorAllOK(t *testing.T) {
ok := func(store.Account) error { return nil }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd returned error when all checks pass: %v", err)
}
out := string(*buf)
if !strings.Contains(out, "work") || strings.Contains(strings.ToLower(out), "fail") {
t.Fatalf("unexpected report:\n%s", out)
}
}
func TestDoctorReportsSMTPFailure(t *testing.T) {
ok := func(store.Account) error { return nil }
bad := func(store.Account) error { return errors.New("auth rejected") }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, bad)
err := DoctorCmd(d, "")
if err == nil {
t.Fatal("DoctorCmd must return error when a check fails (non-zero exit)")
}
out := string(*buf)
if !strings.Contains(strings.ToLower(out), "fail") || !strings.Contains(out, "auth rejected") {
t.Fatalf("failure not reported:\n%s", out)
}
}
func TestDoctorSkipsSMTPForRO(t *testing.T) {
ok := func(store.Account) error { return nil }
smtpCalled := false
smtp := func(store.Account) error { smtpCalled = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("ro")}, ok, smtp)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if smtpCalled {
t.Fatal("SMTP check must be skipped for RO accounts")
}
}
func TestDoctorUsesDecryptedCredentials(t *testing.T) {
// roAcc has Password "p". ListAccounts strips secrets, so doctor must
// re-fetch the decrypted account before checking — otherwise the live
// check runs with an empty password.
var gotPassword string
imap := func(a store.Account) error { gotPassword = a.Password; return nil }
ok := func(store.Account) error { return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("work")}, imap, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if gotPassword != "p" {
t.Fatalf("check received password %q, want decrypted \"p\"", gotPassword)
}
}
func TestDoctorFiltersByAccount(t *testing.T) {
ok := func(store.Account) error { return nil }
checked := map[string]bool{}
imap := func(a store.Account) error { checked[a.Name] = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("a"), roAcc("b")}, imap, ok)
if err := DoctorCmd(d, "b"); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if checked["a"] || !checked["b"] {
t.Fatalf("account filter wrong: %v", checked)
}
}