a837b25d73
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>
149 lines
3.9 KiB
Go
149 lines
3.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
|
)
|
|
|
|
func validFields() Fields {
|
|
return Fields{
|
|
Name: "work", Mode: "RW",
|
|
IMAPHost: "imap.x.com", IMAPPort: "993", IMAPSecurity: "tls",
|
|
SMTPHost: "smtp.x.com", SMTPPort: "465", SMTPSecurity: "tls",
|
|
Username: "u@x.com", Password: "pw",
|
|
}
|
|
}
|
|
|
|
func TestFieldsValidateRequired(t *testing.T) {
|
|
f := validFields()
|
|
f.Name = ""
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("missing name must fail validation")
|
|
}
|
|
f = validFields()
|
|
f.Username = ""
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("missing username must fail validation")
|
|
}
|
|
f = validFields()
|
|
f.IMAPHost = ""
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("missing imap host must fail validation")
|
|
}
|
|
}
|
|
|
|
func TestFieldsValidateEnums(t *testing.T) {
|
|
f := validFields()
|
|
f.Mode = "XX"
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("mode must be RO or RW")
|
|
}
|
|
f = validFields()
|
|
f.IMAPSecurity = "ssl"
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("security must be tls or starttls")
|
|
}
|
|
f = validFields()
|
|
f.IMAPPort = "notnum"
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("port must be numeric")
|
|
}
|
|
}
|
|
|
|
func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
|
|
f := validFields()
|
|
f.SMTPHost = ""
|
|
if err := f.Validate(); err == nil {
|
|
t.Fatal("RW account requires an SMTP host")
|
|
}
|
|
// RO without SMTP is fine.
|
|
f = validFields()
|
|
f.Mode = "RO"
|
|
f.SMTPHost, f.SMTPPort, f.SMTPSecurity = "", "", ""
|
|
if err := f.Validate(); err != nil {
|
|
t.Fatalf("RO without SMTP should validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFieldsToAccount(t *testing.T) {
|
|
f := validFields()
|
|
f.WhitelistIn = true
|
|
f.SubjectRegex = "^urgent"
|
|
acc, pwSet := f.ToAccount()
|
|
if !pwSet {
|
|
t.Fatal("password was provided, PasswordSet should be true")
|
|
}
|
|
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
|
|
t.Fatalf("account not assembled: %+v", acc)
|
|
}
|
|
if acc.AuthType != "password" || !acc.WhitelistInEnabled || acc.SubjectRegex != "^urgent" {
|
|
t.Fatalf("account flags wrong: %+v", acc)
|
|
}
|
|
if acc.Password != "pw" {
|
|
t.Fatalf("password not carried: %q", acc.Password)
|
|
}
|
|
}
|
|
|
|
func TestFieldsToAccountBlankPassword(t *testing.T) {
|
|
f := validFields()
|
|
f.Password = ""
|
|
_, pwSet := f.ToAccount()
|
|
if pwSet {
|
|
t.Fatal("blank password should report PasswordSet=false (edit keeps existing)")
|
|
}
|
|
}
|
|
|
|
func TestFieldsFromAccountRoundTrip(t *testing.T) {
|
|
a := store.Account{
|
|
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
|
|
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
|
|
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:",
|
|
}
|
|
f := FieldsFromAccount(a)
|
|
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || !f.WhitelistOut || f.SubjectRegex != "re:" {
|
|
t.Fatalf("FieldsFromAccount wrong: %+v", f)
|
|
}
|
|
// Password is never read back from an account.
|
|
if f.Password != "" {
|
|
t.Fatalf("password must not be prefilled: %q", f.Password)
|
|
}
|
|
}
|
|
|
|
func TestAccountFormSubmitValid(t *testing.T) {
|
|
m := NewAccountForm(validFields(), false)
|
|
// Enter submits; with valid fields the form completes.
|
|
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
m = nm.(AccountForm)
|
|
if !m.Done() || m.Cancelled() {
|
|
t.Fatalf("valid submit should be Done, not cancelled (err=%v)", m.Err())
|
|
}
|
|
if m.Account().Name != "work" {
|
|
t.Fatalf("submitted account wrong: %+v", m.Account())
|
|
}
|
|
}
|
|
|
|
func TestAccountFormSubmitInvalidStays(t *testing.T) {
|
|
f := validFields()
|
|
f.Name = ""
|
|
m := NewAccountForm(f, false)
|
|
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
m = nm.(AccountForm)
|
|
if m.Done() {
|
|
t.Fatal("invalid submit must not complete the form")
|
|
}
|
|
if m.Err() == nil {
|
|
t.Fatal("invalid submit should set an error to show the user")
|
|
}
|
|
}
|
|
|
|
func TestAccountFormCancel(t *testing.T) {
|
|
m := NewAccountForm(validFields(), false)
|
|
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
|
m = nm.(AccountForm)
|
|
if !m.Cancelled() {
|
|
t.Fatal("esc should cancel the form")
|
|
}
|
|
}
|