messenger's content rule (ADR 0253 §3) refuses any message carrying a mail address, so mail_send's ask never reached the operator: no send could ever be approved. A recipient that is one of the operator's own mesh-mail accounts is now named by that account's name; any other loses its address and host; the subject and body's first lines have every address and link masked before they reach the ask. The mail itself is sent unchanged. Tests run messenger's real content rule against the asks mail_send builds (content.go copied verbatim as content_test.go, pinned byte-identical by copies_test.go, since messenger's package main cannot be imported from another module).
751 lines
26 KiB
Go
751 lines
26 KiB
Go
package main
|
|
|
|
// mail_send (novox/hq ADR 0361 §2): an ungranted caller is refused before anything is asked; a granted caller's ask
|
|
// holds the account, the recipients, the subject and the first lines; the operator's Approve sends through an
|
|
// in-process fake SMTP server exactly once, even delivered twice; a Decline sends nothing.
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/smtp"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// ---- a fake `sends` state and a fake operator channel, as a test keeps them --------------------------------------
|
|
|
|
type memSends struct {
|
|
mu sync.Mutex
|
|
vals map[string]json.RawMessage
|
|
revs map[string]uint64
|
|
n uint64
|
|
|
|
// failKey/failErr: when failKey matches, Create and Update report failErr instead of writing, for a test to
|
|
// simulate a store whose index write fails (novox/hq issue 811, review round 3).
|
|
failKey string
|
|
failErr error
|
|
}
|
|
|
|
func newMemSends() *memSends {
|
|
return &memSends{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}}
|
|
}
|
|
|
|
func (m *memSends) Get(key string) (json.RawMessage, uint64, bool, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
v, ok := m.vals[key]
|
|
if !ok {
|
|
return nil, 0, false, nil
|
|
}
|
|
return v, m.revs[key], true, nil
|
|
}
|
|
func (m *memSends) Create(key string, value any) (uint64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.failErr != nil && key == m.failKey {
|
|
return 0, m.failErr
|
|
}
|
|
if _, ok := m.vals[key]; ok {
|
|
return 0, stdio.ErrChanged
|
|
}
|
|
return m.put(key, value)
|
|
}
|
|
func (m *memSends) Update(key string, value any, revision uint64) (uint64, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if m.failErr != nil && key == m.failKey {
|
|
return 0, m.failErr
|
|
}
|
|
if revision == 0 || m.revs[key] != revision {
|
|
return 0, stdio.ErrChanged
|
|
}
|
|
return m.put(key, value)
|
|
}
|
|
func (m *memSends) put(key string, value any) (uint64, error) {
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
m.n++
|
|
m.vals[key], m.revs[key] = raw, m.n
|
|
return m.n, nil
|
|
}
|
|
func (m *memSends) Keys() ([]string, error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
out := make([]string, 0, len(m.vals))
|
|
for k := range m.vals {
|
|
out = append(out, k)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// sendRouter is the operator channel's holder as this module reaches it, kept by a test: what was asked, and the
|
|
// router's own record of each ask.
|
|
type sendRouter struct {
|
|
mu sync.Mutex
|
|
published map[string]asks.Ask
|
|
records map[string]json.RawMessage
|
|
publishErr error
|
|
recordCalls map[string]int
|
|
}
|
|
|
|
func newSendRouter() *sendRouter {
|
|
return &sendRouter{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}, recordCalls: map[string]int{}}
|
|
}
|
|
|
|
func (r *sendRouter) Publish(subject string, body any, id string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.publishErr != nil {
|
|
return r.publishErr
|
|
}
|
|
raw, _ := json.Marshal(body)
|
|
var a asks.Ask
|
|
_ = json.Unmarshal(raw, &a)
|
|
r.published[a.ID] = a
|
|
r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"})
|
|
return nil
|
|
}
|
|
|
|
// recordCallsFor is how many times Record was asked about id since the last resetRecordCalls, for a test to check
|
|
// the sweep read only the sends still undecided (novox/hq issue 811).
|
|
func (r *sendRouter) recordCallsFor(id string) int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.recordCalls[id]
|
|
}
|
|
|
|
func (r *sendRouter) resetRecordCalls() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.recordCalls = map[string]int{}
|
|
}
|
|
|
|
func (r *sendRouter) Record(id string) (*stdio.StateEntry, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.recordCalls[id]++
|
|
raw, ok := r.records[id]
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return &stdio.StateEntry{Key: id, Value: raw}, nil
|
|
}
|
|
|
|
// close ends an ask with a warrant, as the router records it.
|
|
func (r *sendRouter) close(w asks.Warrant) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.records[w.Ask], _ = json.Marshal(map[string]any{"state": string(w.Outcome), "warrant": w})
|
|
}
|
|
|
|
// chose is the warrant for a person's choice of an option of the ask as the router took it.
|
|
func (r *sendRouter) chose(id, option string, at time.Time) asks.Warrant {
|
|
r.mu.Lock()
|
|
a := r.published[id]
|
|
r.mu.Unlock()
|
|
o, _ := a.Option(option)
|
|
return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level,
|
|
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
|
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()}
|
|
}
|
|
|
|
func (r *sendRouter) published1() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.published)
|
|
}
|
|
|
|
// ---- an in-process fake SMTP server, speaking the protocol over a real socket -------------------------------------
|
|
|
|
type receivedMail struct {
|
|
from string
|
|
to []string
|
|
data string
|
|
}
|
|
|
|
type fakeSMTPServer struct {
|
|
ln net.Listener
|
|
|
|
mu sync.Mutex
|
|
received []receivedMail
|
|
refuse bool // when true, DATA is refused: a transient server error
|
|
}
|
|
|
|
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f := &fakeSMTPServer{ln: ln}
|
|
go f.serve()
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
return f
|
|
}
|
|
|
|
func (f *fakeSMTPServer) addr() string { return f.ln.Addr().String() }
|
|
|
|
// dialer reaches the fake server directly, plain: only a test skips the TLS the mesh's own dialer always uses.
|
|
func (f *fakeSMTPServer) dialer() SMTPDialer {
|
|
addr := f.addr()
|
|
return func(Server) (*smtp.Client, error) { return smtp.Dial(addr) }
|
|
}
|
|
|
|
func (f *fakeSMTPServer) count() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.received)
|
|
}
|
|
|
|
func (f *fakeSMTPServer) last() receivedMail {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if len(f.received) == 0 {
|
|
return receivedMail{}
|
|
}
|
|
return f.received[len(f.received)-1]
|
|
}
|
|
|
|
func (f *fakeSMTPServer) serve() {
|
|
for {
|
|
conn, err := f.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go f.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (f *fakeSMTPServer) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
r := bufio.NewReader(conn)
|
|
reply := func(code int, msg string) { fmt.Fprintf(conn, "%d %s\r\n", code, msg) }
|
|
reply(220, "fake.local ESMTP")
|
|
var from string
|
|
var to []string
|
|
for {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
upper := strings.ToUpper(line)
|
|
switch {
|
|
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
|
reply(250, "fake.local")
|
|
case strings.HasPrefix(upper, "AUTH"):
|
|
reply(235, "authenticated")
|
|
case strings.HasPrefix(upper, "MAIL FROM:"):
|
|
from = addrIn(line)
|
|
reply(250, "OK")
|
|
case strings.HasPrefix(upper, "RCPT TO:"):
|
|
to = append(to, addrIn(line))
|
|
reply(250, "OK")
|
|
case upper == "DATA":
|
|
f.mu.Lock()
|
|
refuse := f.refuse
|
|
f.mu.Unlock()
|
|
if refuse {
|
|
reply(451, "temporary failure")
|
|
continue
|
|
}
|
|
reply(354, "End data with <CR><LF>.<CR><LF>")
|
|
var b strings.Builder
|
|
for {
|
|
dl, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
if dl == ".\r\n" || dl == ".\n" {
|
|
break
|
|
}
|
|
if strings.HasPrefix(dl, "..") {
|
|
dl = dl[1:]
|
|
}
|
|
b.WriteString(dl)
|
|
}
|
|
f.mu.Lock()
|
|
f.received = append(f.received, receivedMail{from: from, to: append([]string{}, to...), data: b.String()})
|
|
f.mu.Unlock()
|
|
reply(250, "OK: queued")
|
|
from, to = "", nil
|
|
case upper == "RSET":
|
|
reply(250, "OK")
|
|
from, to = "", nil
|
|
case upper == "QUIT":
|
|
reply(221, "bye")
|
|
return
|
|
default:
|
|
reply(500, "unrecognized command")
|
|
}
|
|
}
|
|
}
|
|
|
|
func addrIn(line string) string {
|
|
start, end := strings.Index(line, "<"), strings.Index(line, ">")
|
|
if start >= 0 && end > start {
|
|
return line[start+1 : end]
|
|
}
|
|
return line
|
|
}
|
|
|
|
// ---- a bundle over an account with an outgoing server ---------------------------------------------------------
|
|
|
|
// sendTestBundle is a bundle over one account, "mesh", granted to "triage" alone, with an outgoing server of the
|
|
// caller's choosing (a fake SMTP server's, in most tests) and its password already given.
|
|
func sendTestBundle(t *testing.T, outgoing Server) (*Bundle, *sendRouter, *memSends) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir}
|
|
writeFile(t, env.Accounts, fmt.Sprintf(`{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be",
|
|
"incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"},
|
|
"outgoing": {"host": %q, "port": %d, "security": %q}}}}`, outgoing.Host, outgoing.Port, outgoing.Security))
|
|
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
|
giveSecret(t, dir, Account{Name: "mesh"}, "the-password")
|
|
router, sends := newSendRouter(), newMemSends()
|
|
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends}
|
|
return b, router, sends
|
|
}
|
|
|
|
var sendArgs = map[string]any{"account": "mesh", "to": []any{"ops@example.org", "lead@example.org"},
|
|
"subject": "The nightly gate is red", "body": "The merge gate failed on the resolver change.\nSee the job log."}
|
|
|
|
// (1) An ungranted caller is refused, and no ask is raised.
|
|
func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
text, isErr := rt.call(SendTool, "novox.other", sendArgs)
|
|
if !isErr || !strings.Contains(text, "refused") || !strings.Contains(text, "mesh") {
|
|
t.Fatalf("%v %s", isErr, text)
|
|
}
|
|
if n := router.published1(); n != 0 {
|
|
t.Fatalf("an ask was raised for an ungranted caller: %d", n)
|
|
}
|
|
}
|
|
|
|
// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first
|
|
// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3)
|
|
// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one).
|
|
func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
if json.Unmarshal([]byte(text), &out) != nil {
|
|
t.Fatalf("not JSON: %s", text)
|
|
}
|
|
id, _ := out["ask"].(string)
|
|
if id == "" || out["status"] != "waiting" {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
if n := router.published1(); n != 1 {
|
|
t.Fatalf("published %d asks, not 1", n)
|
|
}
|
|
a := router.published[id]
|
|
if err := a.Check(judgedAt); err != nil {
|
|
t.Errorf("the ask does not pass the asks contract: %v", err)
|
|
}
|
|
whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
|
for _, want := range []string{"mesh", "o… at example org", "l… at example org", "The nightly gate is red", "The merge gate failed on the resolver change"} {
|
|
if !strings.Contains(whole, want) {
|
|
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
|
}
|
|
}
|
|
if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") {
|
|
t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", whole)
|
|
}
|
|
for _, o := range a.Options {
|
|
if o.Level != asks.Approve || o.Binds == "" {
|
|
t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds)
|
|
}
|
|
}
|
|
}
|
|
|
|
// (3) On Approve, the fake SMTP server receives exactly one message, even if the warrant is delivered twice.
|
|
func TestOnApproveTheFakeSMTPServerReceivesExactlyOneMessageEvenDeliveredTwice(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
|
|
lookup := b.lookupAccount
|
|
actor := b.sendActor()
|
|
if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("deciding: %v", err)
|
|
}
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("the fake server received %d message(s) after one Approve", n)
|
|
}
|
|
// The warrant delivered a second time: still exactly one message.
|
|
if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(3*time.Minute)); err != nil {
|
|
t.Fatalf("deciding again: %v", err)
|
|
}
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("the fake server received %d message(s) after the warrant was delivered twice", n)
|
|
}
|
|
got := fake.last()
|
|
if got.from != "jschoubben@novox.be" || len(got.to) != 2 || got.to[0] != "ops@example.org" || got.to[1] != "lead@example.org" {
|
|
t.Fatalf("received %+v", got)
|
|
}
|
|
if !strings.Contains(got.data, "The nightly gate is red") || !strings.Contains(got.data, "The merge gate failed on the resolver change") {
|
|
t.Fatalf("the message sent: %q", got.data)
|
|
}
|
|
|
|
// mail_send_status reports it sent, and reports the same thing again without sending a third time.
|
|
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr || !strings.Contains(text, `"status":"sent"`) {
|
|
t.Fatalf("%v %s", isErr, text)
|
|
}
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("mail_send_status sent again: %d", n)
|
|
}
|
|
}
|
|
|
|
// (4) On Decline, nothing is sent, and the status says declined.
|
|
func TestOnDeclineNothingIsSentAndTheStatusSaysDeclined(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionDecline, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("deciding: %v", err)
|
|
}
|
|
if n := fake.count(); n != 0 {
|
|
t.Fatalf("the fake server received %d message(s) on Decline", n)
|
|
}
|
|
|
|
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr || !strings.Contains(text, `"status":"declined"`) {
|
|
t.Fatalf("%v %s", isErr, text)
|
|
}
|
|
}
|
|
|
|
// An account with no outgoing server is refused before anything is asked.
|
|
func TestMailSendRefusesAnAccountWithNoOutgoingServer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir}
|
|
writeFile(t, env.Accounts, stageOne) // no outgoing server
|
|
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
|
router := newSendRouter()
|
|
b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: newMemSends()}
|
|
rt := startBundle(t, b.Tools())
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if !isErr || !strings.Contains(text, "no outgoing server") {
|
|
t.Fatalf("%v %s", isErr, text)
|
|
}
|
|
if n := router.published1(); n != 0 {
|
|
t.Fatalf("an ask was raised for an account with no outgoing server: %d", n)
|
|
}
|
|
}
|
|
|
|
// (5) An approved send goes out once the warrant reaches the module through the hear handler alone — never
|
|
// through mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790).
|
|
func TestAnApprovedSendGoesOutOnceWithNoStatusCall(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
|
|
b.Heard(w) // the warrant delivered through the hear handler only
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("the fake server received %d message(s) after the warrant was heard once", n)
|
|
}
|
|
b.Heard(w) // the same warrant delivered a second time
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("the fake server received %d message(s) after the warrant was heard twice", n)
|
|
}
|
|
}
|
|
|
|
// (6) The sweep acts on a send the operator approved while this module was never told directly: it reads the
|
|
// router's own record, the same path mail_send_status takes (novox/hq issue 790, review round 1).
|
|
func TestSweepActsOnAnApprovedSendItWasNotToldAboutDirectly(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
|
router.close(w) // only the router's record holds the answer; mesh-mail is never told directly
|
|
|
|
b.Sweep(judgedAt.Add(2 * time.Minute))
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("the sweep did not act on the approved send: %d message(s)", n)
|
|
}
|
|
b.Sweep(judgedAt.Add(3 * time.Minute))
|
|
if n := fake.count(); n != 1 {
|
|
t.Fatalf("a second sweep sent again: %d message(s)", n)
|
|
}
|
|
}
|
|
|
|
// (7) A send claimed "sending" longer than StaleSendAfter with no final write reads as "not proven", never
|
|
// "waiting" forever (novox/hq issue 790, review round 1).
|
|
func TestAStaleSendingClaimIsNotProvenNotWaiting(t *testing.T) {
|
|
b, _, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
req, rev, ok, err := getSend(sends, id)
|
|
if err != nil || !ok {
|
|
t.Fatalf("%v %v", err, ok)
|
|
}
|
|
claim := SendDecision{At: judgedAt.Add(-11 * time.Minute), Outcome: "sending", Warrant: "W1"}
|
|
req.Decided = &claim
|
|
if _, err := sends.Update(req.ID, req, rev); err != nil {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
|
|
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
if strings.Contains(text, `"status":"waiting"`) {
|
|
t.Fatalf("still reports waiting past the stale bound: %s", text)
|
|
}
|
|
if !strings.Contains(text, `"status":"not proven"`) {
|
|
t.Fatalf("does not report not proven once the claim is stale: %s", text)
|
|
}
|
|
}
|
|
|
|
// (8) The subject and every address are refused when they hold a control character: no header injection
|
|
// (novox/hq issue 790, review round 1).
|
|
func TestMailSendRefusesAControlCharacterInTheSubjectOrAnAddress(t *testing.T) {
|
|
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
bySubject := map[string]any{"account": "mesh", "to": []any{"ops@example.org"},
|
|
"subject": "Report\r\nBcc: attacker@example.org", "body": "body"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", bySubject)
|
|
if !isErr || !strings.Contains(text, "control character") {
|
|
t.Fatalf("a subject with a control character was not refused: %v %s", isErr, text)
|
|
}
|
|
|
|
byAddress := map[string]any{"account": "mesh", "to": []any{"ops@example.org\r\nBcc: attacker@example.org"},
|
|
"subject": "Report", "body": "body"}
|
|
text, isErr = rt.call(SendTool, "novox.triage", byAddress)
|
|
if !isErr || !strings.Contains(text, "control character") {
|
|
t.Fatalf("an address with a control character was not refused: %v %s", isErr, text)
|
|
}
|
|
}
|
|
|
|
// (9) Rcpt takes the parsed address alone; the To header takes the formatted address (novox/hq issue 790, review
|
|
// round 1).
|
|
func TestSendUsesTheParsedAddressForRcptAndTheFormattedAddressForTheToHeader(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"Ops Team <ops@example.org>"},
|
|
"subject": "Status", "body": "body"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
|
|
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
b.Heard(w)
|
|
|
|
got := fake.last()
|
|
if len(got.to) != 1 || got.to[0] != "ops@example.org" {
|
|
t.Fatalf("RCPT TO got %v, want the parsed address alone", got.to)
|
|
}
|
|
if !strings.Contains(got.data, `To: "Ops Team" <ops@example.org>`) {
|
|
t.Fatalf("the To header is not the formatted address:\n%s", got.data)
|
|
}
|
|
}
|
|
|
|
// (10) A send whose ask was never published (so the router never recorded it) is settled as ended, with its own
|
|
// words, once its asked time plus SendAskLasts has passed — never left "waiting" forever (novox/hq issue 811).
|
|
func TestASendWhoseAskNeverPublishedEndsAfterTheAskWouldHaveLasted(t *testing.T) {
|
|
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
router.publishErr = errors.New("the bus is down")
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
_, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if !isErr {
|
|
t.Fatalf("mail_send did not report the publish failure")
|
|
}
|
|
|
|
// The send was kept despite the publish failure (so a later sweep can still find it).
|
|
keys, err := sends.Keys()
|
|
if err != nil {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
var id string
|
|
for _, k := range keys {
|
|
if strings.HasPrefix(k, "send-") {
|
|
id = k
|
|
}
|
|
}
|
|
if id == "" {
|
|
t.Fatalf("no send was kept despite the publish failure: %v", keys)
|
|
}
|
|
|
|
// Before the ask would have lasted, it still reads waiting: the router may yet come up and record it.
|
|
text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr || !strings.Contains(text, `"status":"waiting"`) {
|
|
t.Fatalf("before expiry: %v %s", isErr, text)
|
|
}
|
|
|
|
// Once a sweep runs after the ask would have lasted, it settles as ended, and nothing was sent.
|
|
b.Sweep(judgedAt.Add(SendAskLasts + time.Minute))
|
|
|
|
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
if !strings.Contains(text, `"status":"ended"`) {
|
|
t.Fatalf("does not read ended once the ask would have lasted: %s", text)
|
|
}
|
|
if !strings.Contains(text, "its ask to the operator was never heard, so it was not sent") {
|
|
t.Fatalf("does not say why: %s", text)
|
|
}
|
|
}
|
|
|
|
// (11) The sweep reads only the sends still undecided, by the undecided index, not every send ever kept: a send
|
|
// already decided is never asked about again (novox/hq issue 811).
|
|
func TestSweepReadsOnlyUndecidedSends(t *testing.T) {
|
|
fake := newFakeSMTPServer(t)
|
|
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
b.Dial = fake.dialer()
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
// One send, declined right away: decided, so it leaves the undecided index.
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
declinedID := out["ask"].(string)
|
|
w := router.chose(declinedID, OptionDecline, judgedAt.Add(time.Minute))
|
|
router.close(w)
|
|
if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil {
|
|
t.Fatalf("deciding: %v", err)
|
|
}
|
|
|
|
// A second send, left undecided: still waiting on the operator.
|
|
text, isErr = rt.call(SendTool, "novox.triage", sendArgs)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
waitingID := out["ask"].(string)
|
|
|
|
router.resetRecordCalls()
|
|
b.Sweep(judgedAt.Add(3 * time.Minute))
|
|
|
|
if n := router.recordCallsFor(declinedID); n != 0 {
|
|
t.Fatalf("the sweep read the decided send's router record %d time(s): it already left the undecided index", n)
|
|
}
|
|
if n := router.recordCallsFor(waitingID); n == 0 {
|
|
t.Fatalf("the sweep never read the undecided send's router record")
|
|
}
|
|
}
|
|
|
|
// (12) When the undecided index cannot be written, mail_send refuses the send and publishes no ask: nothing was
|
|
// published yet, so refusing costs nothing, and an ask the sweep could never see is never sent (novox/hq issue
|
|
// 811, review round 3).
|
|
func TestMailSendRefusesWhenTheUndecidedIndexCannotBeWritten(t *testing.T) {
|
|
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
sends.failKey, sends.failErr = undecidedIndexKey, errors.New("the store is down")
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
|
|
if !isErr {
|
|
t.Fatalf("mail_send did not report the undecided index failing to write: %s", text)
|
|
}
|
|
if n := router.published1(); n != 0 {
|
|
t.Fatalf("an ask was published despite the undecided index failing to write: %d", n)
|
|
}
|
|
}
|
|
|
|
// (13) An undecided index that cannot be parsed is never overwritten: addUndecided and removeUndecided fail,
|
|
// naming the key, and leave the stored bytes as they were, instead of resetting the index to empty (novox/hq
|
|
// issue 811, review round 3).
|
|
func TestAddAndRemoveUndecidedFailOnACorruptIndexRatherThanResettingIt(t *testing.T) {
|
|
sends := newMemSends()
|
|
sends.vals[undecidedIndexKey] = json.RawMessage(`not json`)
|
|
sends.revs[undecidedIndexKey] = 1
|
|
|
|
if err := addUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) {
|
|
t.Fatalf("addUndecided did not fail naming the key on a corrupt index: %v", err)
|
|
}
|
|
if string(sends.vals[undecidedIndexKey]) != "not json" {
|
|
t.Fatalf("addUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey])
|
|
}
|
|
|
|
if err := removeUndecided(sends, "send-x"); err == nil || !strings.Contains(err.Error(), undecidedIndexKey) {
|
|
t.Fatalf("removeUndecided did not fail naming the key on a corrupt index: %v", err)
|
|
}
|
|
if string(sends.vals[undecidedIndexKey]) != "not json" {
|
|
t.Fatalf("removeUndecided overwrote the corrupt index instead of leaving it: %s", sends.vals[undecidedIndexKey])
|
|
}
|
|
}
|