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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user