Files
steve 9d946b1b03 feat(cli): two-key role routing + init bootstrap
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>
2026-06-22 22:59:16 +01:00

222 lines
6.5 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/mail"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
type fakeMailer struct {
uidValidity uint32
maxUID uint32
headers []mail.Header
full map[uint32]mail.Message
}
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) {
return f.uidValidity, f.maxUID, nil
}
func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) {
if len(uids) == 0 {
return f.headers, nil
}
want := map[uint32]bool{}
for _, u := range uids {
want[u] = true
}
var out []mail.Header
for _, h := range f.headers {
if want[h.UID] {
out = append(out, h)
}
}
return out, nil
}
func (f *fakeMailer) FetchHeadersRange(string, uint32, uint32, int) ([]mail.Header, error) {
return f.headers, nil
}
func (f *fakeMailer) FetchFull(_ string, uid uint32) (mail.Message, error) {
return f.full[uid], nil
}
func (f *fakeMailer) Search(string, mail.SearchCriteria, int) ([]mail.Header, error) {
return f.headers, nil
}
func (f *fakeMailer) Logout() error { return nil }
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
func newDeps(t *testing.T, fm *fakeMailer) (Deps, *bytes.Buffer) {
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() })
_, err = st.AddAccount(store.Account{
Name: "work", Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "me@example.com", Password: "pw",
WhitelistInEnabled: true,
})
if err != nil {
t.Fatalf("AddAccount: %v", err)
}
_ = st.AddWhitelist("work", store.DirIn, "@trusted.com")
var buf bytes.Buffer
d := Deps{
Store: st,
Dial: func(store.Account) (Mailer, error) { return fm, nil },
Now: func() time.Time { return time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC) },
Out: &buf,
}
return d, &buf
}
func decode(t *testing.T, b []byte) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal %q: %v", b, err)
}
return m
}
func TestListNewFiltersBySenderAndState(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 101, From: "bob@trusted.com", Subject: "hi"},
{UID: 102, From: "eve@evil.com", Subject: "spam"}, // filtered out by whitelist
{UID: 103, From: "ann@trusted.com", Subject: "yo"},
},
}
d, buf := newDeps(t, fm)
if err := ListCmd(d, "work", "INBOX", true, 0, 0, 50); err != nil {
t.Fatalf("ListCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error: %v", res)
}
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 2 { // 101 and 103; 102 filtered
t.Fatalf("want 2 messages, got %d: %v", len(msgs), msgs)
}
}
func TestGetFilteredReturnsNotFound(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}},
full: map[uint32]mail.Message{
102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"},
},
}
d, buf := newDeps(t, fm)
_ = GetCmd(d, "work", "INBOX", 102)
res := decode(t, buf.Bytes())
if res["error"] != true {
t.Fatal("filtered get must return error envelope")
}
ed := res["error_detail"].(map[string]any)
if ed["code"] != "not_found" {
t.Fatalf("want not_found, got %v", ed["code"])
}
}
func TestGetFilteredReturnsErrorForExit(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{{UID: 102, From: "eve@evil.com", Subject: "spam"}},
full: map[uint32]mail.Message{
102: {Header: mail.Header{UID: 102, From: "eve@evil.com", Subject: "spam"}, BodyText: "secret"},
},
}
d, buf := newDeps(t, fm)
err := GetCmd(d, "work", "INBOX", 102)
if err == nil {
t.Fatal("GetCmd on filtered uid must return non-nil error so caller can exit non-zero")
}
res := decode(t, buf.Bytes())
if res["error"] != true {
t.Fatalf("envelope must report error=true, got %v", res["error"])
}
}
func TestSearchLimitCountsVisibleOnly(t *testing.T) {
// fakeMailer.Search returns all headers regardless of the limit passed in.
// Headers: UIDs 1,3,5 are visible (@trusted.com); UIDs 2,4 are filtered.
// With limit=2, SearchCmd must return 2 visible messages — not fewer, which
// would happen if the mail layer truncated to limit=2 before filtering.
fm := &fakeMailer{
uidValidity: 1, maxUID: 5,
headers: []mail.Header{
{UID: 1, From: "a@trusted.com", Subject: "one"},
{UID: 2, From: "x@evil.com", Subject: "spam1"}, // filtered
{UID: 3, From: "b@trusted.com", Subject: "two"},
{UID: 4, From: "y@evil.com", Subject: "spam2"}, // filtered
{UID: 5, From: "c@trusted.com", Subject: "three"},
},
}
d, buf := newDeps(t, fm)
if err := SearchCmd(d, "work", "INBOX", mail.SearchCriteria{}, 2); err != nil {
t.Fatalf("SearchCmd: %v", err)
}
res := decode(t, buf.Bytes())
if res["error"] != false {
t.Fatalf("unexpected error envelope: %v", res)
}
data := res["data"].(map[string]any)
msgs := data["messages"].([]any)
// With pre-filter cap (old bug): limit=2 would have fetched UIDs 1,2 then
// filtered, yielding only 1 visible. Correct behaviour: 2 visible (1,3).
if len(msgs) != 2 {
t.Fatalf("want 2 visible messages, got %d: %v", len(msgs), msgs)
}
}
func TestAckAdvancesStateAndFiltered(t *testing.T) {
fm := &fakeMailer{
uidValidity: 1, maxUID: 100,
headers: []mail.Header{
{UID: 101, From: "bob@trusted.com", Subject: "hi"},
{UID: 102, From: "eve@evil.com", Subject: "spam"},
},
}
d, buf := newDeps(t, fm)
// Acking a filtered uid (102) must be rejected as not-found.
_ = AckCmd(d, "work", "INBOX", []uint32{102})
if decode(t, buf.Bytes())["error"] != true {
t.Fatal("acking filtered uid must fail")
}
// Acking a visible uid (101) succeeds and removes it from list --new.
buf.Reset()
if err := AckCmd(d, "work", "INBOX", []uint32{101}); err != nil {
t.Fatalf("AckCmd 101: %v", err)
}
if decode(t, buf.Bytes())["error"] != false {
t.Fatal("ack of visible uid should succeed")
}
buf.Reset()
_ = ListCmd(d, "work", "INBOX", true, 0, 0, 50)
data := decode(t, buf.Bytes())["data"].(map[string]any)
msgs := data["messages"].([]any)
if len(msgs) != 0 { // 101 acked, 102 filtered
t.Fatalf("want 0 new messages, got %d", len(msgs))
}
}