9d946b1b03
openStore(role) selects the DEK wrap slot; admin commands require EMCLI_ADMIN_KEY (admin slot only, no agent fallback); init writes both slots from both keys. Test helpers seed the wrap slots. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
5.1 KiB
Go
166 lines
5.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.dcglab.co.uk/steve/emcli/internal/mail"
|
|
"git.dcglab.co.uk/steve/emcli/internal/store"
|
|
)
|
|
|
|
// sendDeps builds Deps wired to a recording fake sender and an optional fake
|
|
// mailer (for reply-to). The named account is created per the supplied template.
|
|
func sendDeps(t *testing.T, acc store.Account, fm *fakeMailer) (Deps, *[]mail.OutgoingMessage, *[]byte) {
|
|
t.Helper()
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"))
|
|
if err != nil {
|
|
t.Fatalf("store: %v", err)
|
|
}
|
|
if err := st.InitKeys(testKey(), testKey()); err != nil {
|
|
t.Fatalf("InitKeys: %v", err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
if _, err := st.AddAccount(acc); err != nil {
|
|
t.Fatalf("AddAccount: %v", err)
|
|
}
|
|
var sent []mail.OutgoingMessage
|
|
buf := &[]byte{}
|
|
d := Deps{
|
|
Store: st,
|
|
Dial: func(store.Account) (Mailer, error) { return fm, nil },
|
|
Now: func() time.Time { return time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC) },
|
|
Out: bufWriter{buf},
|
|
Send: func(_ store.Account, m mail.OutgoingMessage) error {
|
|
sent = append(sent, m)
|
|
return nil
|
|
},
|
|
}
|
|
return d, &sent, buf
|
|
}
|
|
|
|
type bufWriter struct{ b *[]byte }
|
|
|
|
func (w bufWriter) Write(p []byte) (int, error) { *w.b = append(*w.b, p...); return len(p), nil }
|
|
|
|
func rwAccount() store.Account {
|
|
return store.Account{
|
|
Name: "send", Mode: "RW", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
|
|
SMTPHost: "h", SMTPPort: 465, SMTPSecurity: "tls",
|
|
AuthType: "password", Username: "emcli@stevecliff.com", Password: "pw",
|
|
}
|
|
}
|
|
|
|
func TestSendHappyPath(t *testing.T) {
|
|
d, sent, buf := sendDeps(t, rwAccount(), nil)
|
|
err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
|
|
if err != nil {
|
|
t.Fatalf("SendCmd: %v", err)
|
|
}
|
|
res := decode(t, *buf)
|
|
if res["error"] != false {
|
|
t.Fatalf("unexpected error: %v", res)
|
|
}
|
|
if len(*sent) != 1 {
|
|
t.Fatalf("want 1 send, got %d", len(*sent))
|
|
}
|
|
m := (*sent)[0]
|
|
if len(m.To) != 1 || m.To[0] != "me@stevecliff.com" || m.Subject != "hi" {
|
|
t.Fatalf("wrong outgoing message: %+v", m)
|
|
}
|
|
}
|
|
|
|
func TestSendROBlocked(t *testing.T) {
|
|
acc := rwAccount()
|
|
acc.Mode = "RO"
|
|
d, sent, buf := sendDeps(t, acc, nil)
|
|
_ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
|
|
res := decode(t, *buf)
|
|
if res["error"] != true {
|
|
t.Fatal("RO send must be blocked")
|
|
}
|
|
if len(*sent) != 0 {
|
|
t.Fatal("sender must not be called when blocked")
|
|
}
|
|
}
|
|
|
|
func TestSendWhitelistOutBlocked(t *testing.T) {
|
|
acc := rwAccount()
|
|
acc.WhitelistOutEnabled = true
|
|
d, sent, buf := sendDeps(t, acc, nil)
|
|
_ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com")
|
|
// recipient not on the whitelist -> blocked
|
|
_ = SendCmd(d, "send", []string{"stranger@elsewhere.com"}, nil, nil, "hi", "body", nil, 0, "INBOX")
|
|
res := decode(t, *buf)
|
|
if res["error"] != true {
|
|
t.Fatal("non-whitelisted recipient must be blocked")
|
|
}
|
|
if len(*sent) != 0 {
|
|
t.Fatal("sender must not be called when blocked")
|
|
}
|
|
}
|
|
|
|
func TestSendWhitelistOutAllowed(t *testing.T) {
|
|
acc := rwAccount()
|
|
acc.WhitelistOutEnabled = true
|
|
d, sent, _ := sendDeps(t, acc, nil)
|
|
_ = d.Store.AddWhitelist("send", store.DirOut, "@stevecliff.com")
|
|
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "hi", "body", nil, 0, "INBOX"); err != nil {
|
|
t.Fatalf("SendCmd: %v", err)
|
|
}
|
|
if len(*sent) != 1 {
|
|
t.Fatalf("whitelisted recipient should be sent, got %d", len(*sent))
|
|
}
|
|
}
|
|
|
|
func TestSendReplyToThreadsHeaders(t *testing.T) {
|
|
acc := rwAccount()
|
|
fm := &fakeMailer{
|
|
uidValidity: 1, maxUID: 100,
|
|
headers: []mail.Header{
|
|
{UID: 42, From: "me@stevecliff.com", Subject: "orig",
|
|
MessageID: "orig@stevecliff.com", References: []string{"root@stevecliff.com"}},
|
|
},
|
|
}
|
|
d, sent, _ := sendDeps(t, acc, fm)
|
|
if err := SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re: orig", "body", nil, 42, "INBOX"); err != nil {
|
|
t.Fatalf("SendCmd: %v", err)
|
|
}
|
|
if len(*sent) != 1 {
|
|
t.Fatalf("want 1 send, got %d", len(*sent))
|
|
}
|
|
m := (*sent)[0]
|
|
if m.InReplyTo != "orig@stevecliff.com" {
|
|
t.Fatalf("InReplyTo=%q", m.InReplyTo)
|
|
}
|
|
// References should chain the source's references then the source itself.
|
|
if len(m.References) != 2 || m.References[0] != "root@stevecliff.com" || m.References[1] != "orig@stevecliff.com" {
|
|
t.Fatalf("References=%v", m.References)
|
|
}
|
|
}
|
|
|
|
func TestSendReplyToFilteredSourceNotFound(t *testing.T) {
|
|
acc := rwAccount()
|
|
acc.WhitelistInEnabled = true // inbound filter active
|
|
fm := &fakeMailer{
|
|
uidValidity: 1, maxUID: 100,
|
|
headers: []mail.Header{
|
|
{UID: 42, From: "eve@evil.com", Subject: "spam", MessageID: "spam@evil.com"},
|
|
},
|
|
}
|
|
d, sent, buf := sendDeps(t, acc, fm)
|
|
_ = d.Store.AddWhitelist("send", store.DirIn, "@stevecliff.com") // 42 is from evil.com => filtered
|
|
_ = SendCmd(d, "send", []string{"me@stevecliff.com"}, nil, nil, "re", "body", nil, 42, "INBOX")
|
|
res := decode(t, *buf)
|
|
if res["error"] != true {
|
|
t.Fatal("reply to a filtered source must fail")
|
|
}
|
|
ed := res["error_detail"].(map[string]any)
|
|
if ed["code"] != "not_found" {
|
|
t.Fatalf("want not_found, got %v", ed["code"])
|
|
}
|
|
if len(*sent) != 0 {
|
|
t.Fatal("must not send when reply source is filtered")
|
|
}
|
|
}
|