Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/claim_test.go
T
jochen a2c0c58677
mesh/merge-gate head a2c0c586772b merged into trunk c441d83ba753 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head a2c0c586772b merged into trunk c441d83ba753 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh-mail: a redelivery of a message whose send already finished (sent, failed, refused or not proven) is a repeat, terminated without recording, emitting or telling anything again (operator-approved change to ADR 0436 clause 10; issue 1196)
2026-10-19 13:29:31 +02:00

452 lines
20 KiB
Go

package main
// The sender's own claim (novox/hq ADR 0436 clauses 9, 10, issue 1196): it never trusts the queue's message,
// verifying the operator's warrant again from the router's own record before it claims the send by
// compare-and-set — exactly mirroring the tools' own check, so the boundary clause 9 describes (the tool
// runner's account may publish on the queue and write `sends`, but only a warrant that really closed the
// ask moves a send to "sending").
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// claimTestSend asks and approves one send (through the tools, as issue 1194 built it), leaving it queued,
// and returns its id and the router that holds its warrant.
func claimTestSend(t *testing.T) (id string, router *sendRouter, sends *memSends) {
t.Helper()
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
queue := newMemQueue()
b.Queue = queue.publish
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)
if _, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute)); err != nil {
t.Fatalf("approving: %v", err)
}
req, _, ok, err := getSend(sends, id)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" {
t.Fatalf("not left queued: %v %v %+v", err, ok, req.Decided)
}
return id, router, sends
}
// rawQueuedSend creates a send directly in the queued state, without going through the tools or the router
// at all, so an AC2 test controls the router's record independently of how the send reached queued: the
// defence in depth clause 9 describes, since the tool runner's account could write `sends` and the queue
// without ever having closed a real warrant.
func rawQueuedSend(t *testing.T, sends *memSends) SendRequest {
t.Helper()
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "Status",
Body: "the body", Caller: "person.op", At: judgedAt, AskTo: "o… at example org"}
req.Decided = &SendDecision{At: judgedAt.Add(time.Minute), Outcome: "queued"}
if _, err := sends.Create(req.ID, req); err != nil {
t.Fatal(err)
}
return req
}
// warrantFor is a well-formed warrant answering req's ask with Send, at, and the ask's own digest — the
// same shape sendRouter.chose builds, for a test that never published the ask to a router at all.
func warrantFor(req SendRequest, at time.Time) asks.Warrant {
ask := req.Ask()
opt, _ := ask.Option(OptionSend)
return asks.Warrant{Ask: req.ID, Asker: Module, Outcome: asks.OutcomeChosen, Option: opt.ID, Label: opt.Label,
Level: opt.Level,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: ask.Digest()}
}
// 1196/AC1: given a queued send whose router record is closed by the operator's Approve at level approve
// within its expiry and whose recomputed act digest is the option's binds, when the sender takes the
// message, then it claims the send by compare-and-set from queued to sending with the attempt and time
// before any connection (nothing here connects to anything: claimQueuedSend never dials).
func TestClaimQueuedSendClaimsSendingWithTheAttemptBeforeAnyConnection(t *testing.T) {
id, router, sends := claimTestSend(t)
emit := &memEmit{}
req, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
if err != nil {
t.Fatalf("claiming: %v", err)
}
if outcome != claimToSend {
t.Fatalf("outcome: %v, want claimToSend", outcome)
}
if d.Outcome != "sending" || d.Attempt != 1 || !d.At.Equal(judgedAt.Add(3*time.Minute)) {
t.Fatalf("claimed: %+v", d)
}
if req.ID != id {
t.Fatalf("req: %+v", req)
}
kept, _, ok, err := getSend(sends, id)
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "sending" {
t.Fatalf("not kept as sending: %v %v %+v", err, ok, kept.Decided)
}
if len(emit.calls) != 0 {
t.Fatalf("claiming to send emitted something: %+v", emit.calls)
}
}
// 1196/AC2: given a router record that is not mesh-mail's, not closed by Approve, approved after its
// expiry, or whose digest differs from the request in sends, when taken, then the sender records refused
// with the reason, terminates the message and connects to no server (claimQueuedSend never dials, so
// "connects to no server" holds for every path by construction; this proves each is refused and the claim
// never advances to sending).
func TestClaimQueuedSendRefusesAMismatchedOrUnclosedWarrant(t *testing.T) {
t.Run("not mesh-mail's ask", func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
w := warrantFor(req, judgedAt.Add(time.Minute))
w.Asker = "another-module"
router.close(w)
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
})
t.Run("not closed by approve", func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
// The router keeps no record of this ask at all: never published, or never answered.
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
})
t.Run("approved after the ask's expiry", func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
w := warrantFor(req, req.Ask().Expires.Add(time.Hour))
router.close(w)
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
})
t.Run("a digest the published ask never bound", func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
w := warrantFor(req, judgedAt.Add(time.Minute))
w.AskDigest = "sha256:" + strings.Repeat("0", 64)
router.close(w)
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil || outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
t.Fatalf("outcome %v, decision %+v, err %v", outcome, d, err)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
})
}
// assertEmittedFailed checks that exactly one mail.send.failed was emitted, naming the send id and the
// decision's own outcome in words (novox/hq review round 1 finding 4).
func assertEmittedFailed(t *testing.T, emit *memEmit, id, outcome string) {
t.Helper()
if len(emit.calls) != 1 {
t.Fatalf("emitted %d time(s), want exactly one mail.send.failed: %+v", len(emit.calls), emit.calls)
}
c := emit.calls[0]
if c.eventType != MailSendFailed || c.id != id || c.body.Send != id || c.body.Outcome != outcome {
t.Fatalf("emitted %+v, want mail.send.failed naming %s and outcome %q", c, id, outcome)
}
}
// 1196/AC3: given a send already sending (the sender stopped mid-send), when its message is delivered
// again, then the sender sends nothing, records not proven and terminates.
func TestClaimQueuedSendOnARedeliveryOfAnAlreadySendingClaimRecordsNotProven(t *testing.T) {
id, router, sends := claimTestSend(t)
emit := &memEmit{}
req, _, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
if err != nil || outcome != claimToSend {
t.Fatalf("first claim: %v %v", outcome, err)
}
_ = req
// Redelivered: the first claim's handler may have stopped mid-send.
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, id, judgedAt.Add(4*time.Minute))
if err != nil {
t.Fatalf("redelivered claim: %v", err)
}
if outcome != claimNotProven || d.Outcome != "not proven" {
t.Fatalf("outcome %v, decision %+v", outcome, d)
}
assertEmittedFailed(t, emit, id, d.Outcome)
}
// 1196/AC4: given a lost compare-and-set (another claim), when taken, then it sends nothing and leaves the
// message to the bus (no decision of this call's own making is kept; it reads back whatever the other
// writer decided).
func TestClaimQueuedSendOnALostRaceSendsNothingAndLeavesTheMessageToTheBus(t *testing.T) {
id, router, sends := claimTestSend(t)
racy := &raceOnFirstUpdate{SendStore: sends, race: func() {
r, rv, ok, err := getSend(sends, id)
if err != nil || !ok {
t.Fatalf("racing claim could not read the send: %v %v", err, ok)
}
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt.Add(3 * time.Minute), Outcome: "sending", Attempt: 1}); err != nil {
t.Fatalf("racing claim: %v", err)
}
}}
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(racy, router.Record, emit.emit, id, judgedAt.Add(3*time.Minute))
if err != nil {
t.Fatalf("claiming: %v", err)
}
if outcome != claimLostRace {
t.Fatalf("outcome: %v, want claimLostRace", outcome)
}
if d.Outcome != "sending" || d.Attempt != 1 {
t.Fatalf("the decision read back is not the racing claim's own: %+v", d)
}
if len(emit.calls) != 0 {
t.Fatalf("a lost race emitted something: %+v", emit.calls)
}
}
// review round 1 finding 3: a failed write of the tells-pending index does not stop the refusal itself
// from being recorded — the record write and the message's own fate are never undone by an index that
// could not be kept — but the index failure is still reported, never silently dropped.
func TestClaimQueuedSendStillRefusesWhenTheTellsPendingIndexCannotBeWritten(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
sends.failKey, sends.failErr = tellsPendingKey, errors.New("the store is down")
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if outcome != claimRefused || !strings.HasPrefix(d.Outcome, "refused: ") {
t.Fatalf("outcome %v, decision %+v", outcome, d)
}
if err == nil {
t.Fatalf("the index failure was not reported")
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != d.Outcome {
t.Fatalf("the refusal itself was not kept despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
}
// Same finding, for the redelivery-finds-sending path (not proven).
func TestClaimQueuedSendStillRecordsNotProvenWhenTheTellsPendingIndexCannotBeWritten(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
current, rev, ok, err := getSend(sends, req.ID)
if err != nil || !ok {
t.Fatalf("reading it back: %v %v", err, ok)
}
current.Decided.Outcome = "sending"
if _, err := sends.Update(req.ID, current, rev); err != nil {
t.Fatalf("leaving it sending: %v", err)
}
sends.failKey, sends.failErr = tellsPendingKey, errors.New("the store is down")
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(4*time.Minute))
if outcome != claimNotProven || d.Outcome != "not proven" {
t.Fatalf("outcome %v, decision %+v", outcome, d)
}
if err == nil {
t.Fatalf("the index failure was not reported")
}
kept, _, ok2, kerr := getSend(sends, req.ID)
if kerr != nil || !ok2 || kept.Decided == nil || kept.Decided.Outcome != "not proven" {
t.Fatalf("not proven was not kept despite the index failing: %v %v %+v", kerr, ok2, kept.Decided)
}
assertEmittedFailed(t, emit, req.ID, d.Outcome)
}
// Same finding, for decideSend: a failed sender-pending index write does not stop "queued" from being
// recorded, nor the publish from being attempted — there is no separate "approved" state any more (novox/hq
// ADR 0436 clause 8, as decided to replace the two-state design, review round 1).
func TestDecideSendStillRecordsQueuedAndPublishesWhenTheSenderPendingIndexCannotBeWritten(t *testing.T) {
sends := newMemSends()
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
rev, err := sends.Create(req.ID, req)
if err != nil {
t.Fatal(err)
}
sends.failKey, sends.failErr = senderPendingKey, errors.New("the store is down")
queue := newMemQueue()
w := warrantFor(req, judgedAt.Add(time.Minute))
d, err := decideSend(sends, queue.publish, req, rev, w, judgedAt.Add(time.Minute))
if d.Outcome != "queued" {
t.Fatalf("decision: %+v, want queued despite the index failing", d)
}
if err == nil {
t.Fatalf("the index failure was not reported")
}
if n := queue.count(); n != 1 {
t.Fatalf("published %d time(s)", n)
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
t.Fatalf("not kept as queued despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
}
}
// 1194/AC1 (as decided to replace the two-state design, review round 1): on a verified warrant the record
// moves straight from undecided to queued by one compare-and-set, with its time, and mail.send.approved is
// published with the send id as its message id — no "approved" state in between.
func TestDecideSendRecordsQueuedDirectlyAndPublishes(t *testing.T) {
sends := newMemSends()
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
rev, err := sends.Create(req.ID, req)
if err != nil {
t.Fatal(err)
}
queue := newMemQueue()
w := warrantFor(req, judgedAt.Add(time.Minute))
now := judgedAt.Add(time.Minute)
d, err := decideSend(sends, queue.publish, req, rev, w, now)
if err != nil {
t.Fatalf("deciding: %v", err)
}
if d.Outcome != "queued" || !d.At.Equal(now) {
t.Fatalf("decision: %+v, want queued at %v", d, now)
}
if n := queue.count(); n != 1 {
t.Fatalf("published %d time(s), want exactly 1", n)
}
got := queue.last()
if got.id != req.ID || got.body.Send != req.ID || got.body.Account != "mesh" || got.body.Ask != req.ID {
t.Fatalf("published %+v, want the send id as the message id and body", got)
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
t.Fatalf("not kept as queued: %v %v %+v", kerr, ok, kept.Decided)
}
ids, err := senderPendingIDs(sends)
if err != nil || len(ids) != 1 || ids[0] != req.ID {
t.Fatalf("not indexed as sender-pending: %v %v", err, ids)
}
}
// 1196/AC2 (review round 2): a store that changes the revision between the claim and the refusal's own
// write — another writer's decision landing in between — never emits mail.send.failed for a write that did
// not actually land (refuse() does not retry: that is recordOutcome's own job, send.go, review round 2).
func TestClaimQueuedSendRefuseEmitsNothingWhenTheWriteDidNotLand(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
w := warrantFor(req, judgedAt.Add(time.Minute))
w.AskDigest = "sha256:" + strings.Repeat("0", 64) // mismatched: claimQueuedSend refuses it
router.close(w)
racy := &raceOnFirstUpdate{SendStore: sends, race: func() {
r, rv, ok, err := getSend(sends, req.ID)
if err != nil || !ok {
t.Fatalf("racing write could not read the send: %v %v", err, ok)
}
if _, _, err := settleSend(sends, r, rv, SendDecision{At: judgedAt, Outcome: "not proven", Attempt: 1}); err != nil {
t.Fatalf("racing write: %v", err)
}
}}
emit := &memEmit{}
_, d, _, outcome, _ := claimQueuedSend(racy, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if outcome != claimRefused {
t.Fatalf("outcome: %v, want claimRefused", outcome)
}
if len(emit.calls) != 0 {
t.Fatalf("emitted mail.send.failed despite the refusal's own write not landing: %+v", emit.calls)
}
// The kept record is the racing write's own decision, not this call's: refuse() never retries.
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "not proven" {
t.Fatalf("kept: %v %v %+v (returned %+v)", kerr, ok, kept.Decided, d)
}
}
// 1196/AC5 (operator-approved change to clause 10): given a send already sent (and one already failed,
// refused, or not proven), when its message is delivered again, then the record is unchanged, nothing is
// emitted or told, and the message is terminated (claimAlreadyFinished; sender.go's handler maps it to
// Term alone, tested here the same way every other claimOutcome is in this file — by the outcome value).
func TestClaimQueuedSendOnARedeliveryOfAnAlreadyFinishedSendTerminatesWithoutTouchingAnything(t *testing.T) {
for name, outcome := range map[string]string{
"sent": "sent",
"failed": "failed: 550 no such mailbox",
"refused": "refused: the warrant does not bind the request",
"not proven": "not proven",
} {
t.Run(name, func(t *testing.T) {
sends, router := newMemSends(), newSendRouter()
req := rawQueuedSend(t, sends)
current, rev, ok, err := getSend(sends, req.ID)
if err != nil || !ok {
t.Fatalf("reading it back: %v %v", err, ok)
}
current.Decided.Outcome = outcome
current.Decided.Warrant = "the operator, via telegram (user id verified), chose Send"
current.Decided.Attempt = 1
if _, err := sends.Update(req.ID, current, rev); err != nil {
t.Fatalf("leaving it %s: %v", outcome, err)
}
before := *current.Decided
emit := &memEmit{}
_, d, _, got, err := claimQueuedSend(sends, router.Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil {
t.Fatalf("claiming: %v", err)
}
if got != claimAlreadyFinished {
t.Fatalf("outcome: %v, want claimAlreadyFinished", got)
}
if d.Outcome != before.Outcome || d.Warrant != before.Warrant || d.Attempt != before.Attempt || !d.At.Equal(before.At) {
t.Fatalf("decision returned: %+v, want unchanged %+v", d, before)
}
if len(emit.calls) != 0 {
t.Fatalf("emitted something for an already-finished send: %+v", emit.calls)
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil {
t.Fatalf("the send could not be read back: %v %v", kerr, ok)
}
if kept.Decided.Outcome != before.Outcome || kept.Decided.Warrant != before.Warrant || kept.Decided.Attempt != before.Attempt {
t.Fatalf("the record changed: %+v, want unchanged %+v", kept.Decided, before)
}
ids, terr := tellPendingIDs(sends)
if terr != nil || len(ids) != 0 {
t.Fatalf("a tell was queued for an already-finished send: %v %v", terr, ids)
}
})
}
}
// raceOnFirstUpdate performs one competing write the first time Update is called — simulating another
// delivery's claim landing first — so the caller's own compare-and-set loses with stdio.ErrChanged
// (novox/hq issue 1196, AC4).
type raceOnFirstUpdate struct {
SendStore
raced bool
race func()
}
func (r *raceOnFirstUpdate) Update(key string, value any, revision uint64) (uint64, error) {
if !r.raced {
r.raced = true
r.race()
}
return r.SendStore.Update(key, value, revision)
}