Answer a send's state only to its caller (issue 1261)
This commit is contained in:
@@ -94,7 +94,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
}
|
||||
wantTools := []string{
|
||||
"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status",
|
||||
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send",
|
||||
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", "mail-store.send-status",
|
||||
}
|
||||
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) {
|
||||
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
|
||||
@@ -140,7 +140,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
// mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier
|
||||
// there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3).
|
||||
wantSeats := []seat{
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
|
||||
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
|
||||
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
|
||||
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
|
||||
}
|
||||
wantClaims := []seat{
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
|
||||
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, wantClaims) {
|
||||
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
|
||||
|
||||
@@ -1312,19 +1312,30 @@ func tellWaits(store SendStore, tell TellOperator, id string, now time.Time) err
|
||||
|
||||
// ---- reading the status -----------------------------------------------------------------------------------------
|
||||
|
||||
// ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent,
|
||||
// declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own
|
||||
// record; when the router's word is a close one that was not decided yet, this is also where a send is first
|
||||
// decided (and so sent), exactly once (Decide).
|
||||
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string, now time.Time) (map[string]any, error) {
|
||||
// ReadSendStatus is where a send asked with mail_send stands, answered only to the caller that asked for it
|
||||
// (novox/hq ADR 0440 §7, issue 1261): an id no send is kept under, or a send whose own caller differs from the
|
||||
// one asking, answers status "unknown" and nothing else of it — never an error, and never the account,
|
||||
// recipients or subject of a send that is not the caller's own. The sweep, which settles a send regardless of
|
||||
// who asked for it, calls readSendStatus directly instead, never through this caller check.
|
||||
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id, caller string, now time.Time) (map[string]any, error) {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
req, rev, ok, err := getSend(store, trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed)
|
||||
if !ok || req.Caller != caller {
|
||||
return map[string]any{"status": "unknown"}, nil
|
||||
}
|
||||
return readSendStatus(store, bus, publish, req, rev, now)
|
||||
}
|
||||
|
||||
// readSendStatus is ReadSendStatus's own reading of one send already fetched (req, rev): waiting for the
|
||||
// operator, sent, declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the
|
||||
// router's own record; when the router's word is a close one that was not decided yet, this is also where a
|
||||
// send is first decided (and so sent), exactly once (Decide). It never checks who is asking: ReadSendStatus
|
||||
// does that before calling it, and the sweep (which settles a send no matter who asked for it) calls it
|
||||
// directly.
|
||||
func readSendStatus(store SendStore, bus AskBus, publish QueuePublish, req SendRequest, rev uint64, now time.Time) (map[string]any, error) {
|
||||
out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject,
|
||||
"asked": req.At.Format(time.RFC3339)}
|
||||
if req.Decided != nil {
|
||||
@@ -1342,7 +1353,7 @@ func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string
|
||||
if now.Sub(req.At) > SendAskLasts {
|
||||
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err)
|
||||
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", req.ID, err)
|
||||
}
|
||||
reportDecision(out, d, now)
|
||||
return out, nil
|
||||
@@ -1448,11 +1459,13 @@ func (b *Bundle) Sweep(now time.Time) {
|
||||
}
|
||||
} else {
|
||||
for _, id := range undecided {
|
||||
req, _, ok, err := getSend(store, id)
|
||||
req, rev, ok, err := getSend(store, id)
|
||||
if err != nil || !ok || req.Decided != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := ReadSendStatus(store, b.askBus(), b.queuePublish(), id, now); err != nil {
|
||||
// 1261/AC6: the sweep settles a send regardless of who asked for it — readSendStatus directly,
|
||||
// never ReadSendStatus's caller check.
|
||||
if _, err := readSendStatus(store, b.askBus(), b.queuePublish(), req, rev, now); err != nil {
|
||||
if b.Logf != nil {
|
||||
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
|
||||
}
|
||||
|
||||
@@ -861,3 +861,163 @@ func TestADigestMismatchIsRefusedAndNothingIsQueued(t *testing.T) {
|
||||
t.Fatalf("the queue received %d message(s) despite the digest mismatch", n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- mail_send_status answers only its caller (novox/hq ADR 0440 §7, issue 1261) ---------------------------------
|
||||
|
||||
// 1261/AC1: the caller that made a send reads its own status in full, with its account, recipients and subject
|
||||
// as before.
|
||||
func TestSendStatusAnswersTheCallersOwnSendInFull(t *testing.T) {
|
||||
b, _, _ := 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)
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if isErr {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status["status"] != "waiting" || status["account"] != "mesh" || status["subject"] != sendArgs["subject"] {
|
||||
t.Fatalf("the caller's own send: %v", status)
|
||||
}
|
||||
to, _ := status["to"].([]any)
|
||||
if len(to) != 2 {
|
||||
t.Fatalf("to: %v", status["to"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC2: a caller other than the one that made the send reads status unknown and nothing else of it — no
|
||||
// account, recipients or subject, and no error either.
|
||||
func TestSendStatusAnswersUnknownAndNothingElseToAnotherCaller(t *testing.T) {
|
||||
b, _, _ := 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)
|
||||
|
||||
text, isErr = rt.call(SendStatusTool, "novox.someone-else", map[string]any{"id": id})
|
||||
if isErr {
|
||||
t.Fatalf("another caller's read of the send was an error: %s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != 1 || status["status"] != "unknown" {
|
||||
t.Fatalf("another caller's read of the send: %v, want only status unknown", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC3: an id no send is kept under answers status unknown and no error, for any caller.
|
||||
func TestSendStatusAnswersUnknownForAnUnknownIDWithoutAnError(t *testing.T) {
|
||||
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": "send-0000000000000000"})
|
||||
if isErr {
|
||||
t.Fatalf("an unknown id was an error: %s", text)
|
||||
}
|
||||
var status map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != 1 || status["status"] != "unknown" {
|
||||
t.Fatalf("%v", status)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC4: every kept decision's status word is one of the fixed vocabulary (waiting, sent, failed, refused,
|
||||
// not proven, declined, expired), answered to the send's own caller.
|
||||
func TestSendStatusWordsAreTheFixedVocabularyForTheCallersOwnSend(t *testing.T) {
|
||||
sends := newMemSends()
|
||||
const caller = "novox.triage"
|
||||
cases := []struct {
|
||||
name, outcome, want string
|
||||
}{
|
||||
{"queued", "queued", "waiting"},
|
||||
{"sending", "sending", "waiting"},
|
||||
{"retrying", "retrying", "waiting"},
|
||||
{"sent", "sent", "sent"},
|
||||
{"declined", "declined", "declined"},
|
||||
{"not-proven", "not proven", "not proven"},
|
||||
{"failed", "failed: boom", "failed"},
|
||||
{"refused", "refused: nope", "refused"},
|
||||
{"ended-never-heard", "ended: never heard", "expired"},
|
||||
{"ended-other", "ended: cancelled", "expired"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
id := "send-" + c.name
|
||||
req := SendRequest{ID: id, Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Caller: caller,
|
||||
At: judgedAt, Decided: &SendDecision{At: judgedAt, Outcome: c.outcome}}
|
||||
if _, err := sends.Create(id, req); err != nil {
|
||||
t.Fatalf("%s: keeping: %v", c.name, err)
|
||||
}
|
||||
out, err := ReadSendStatus(sends, nil, nil, id, caller, judgedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", c.name, err)
|
||||
}
|
||||
if out["status"] != c.want {
|
||||
t.Fatalf("%s: status %v, want %v", c.name, out["status"], c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC5: the seat verb mail-store.send-status answers exactly as mail_send_status, for the same caller.
|
||||
func TestSeatSendStatusAnswersExactlyAsMailSendStatus(t *testing.T) {
|
||||
b, _, _ := 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)
|
||||
|
||||
toolText, toolErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
seatText, seatErr := rt.call(SeatSendStatusTool, "novox.triage", map[string]any{"id": id})
|
||||
if toolErr != seatErr || toolText != seatText {
|
||||
t.Fatalf("mail_send_status: %v %s; mail-store.send-status: %v %s", toolErr, toolText, seatErr, seatText)
|
||||
}
|
||||
}
|
||||
|
||||
// 1261/AC6: the sweep settles a send left undecided no matter who asked for it — never refused by the caller
|
||||
// check mail_send_status itself applies.
|
||||
func TestSweepSettlesASendRegardlessOfCaller(t *testing.T) {
|
||||
b, router, _ := 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) // only the router's record holds the answer
|
||||
|
||||
// The sweep itself names no caller at all; it must still settle "novox.triage"'s send.
|
||||
b.Sweep(judgedAt.Add(2 * time.Minute))
|
||||
if n := queue.count(); n != 1 {
|
||||
t.Fatalf("the sweep did not settle the send regardless of caller: %d message(s)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,11 @@ const (
|
||||
// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants
|
||||
// reach the same account's mail through, whichever name it calls.
|
||||
const (
|
||||
SeatAccountsTool = "mail-store.accounts"
|
||||
SeatSearchTool = "mail-store.search"
|
||||
SeatReadTool = "mail-store.read"
|
||||
SeatSendTool = "mail-store.send"
|
||||
SeatAccountsTool = "mail-store.accounts"
|
||||
SeatSearchTool = "mail-store.search"
|
||||
SeatReadTool = "mail-store.read"
|
||||
SeatSendTool = "mail-store.send"
|
||||
SeatSendStatusTool = "mail-store.send-status"
|
||||
)
|
||||
|
||||
// Bounds of what a tool answers.
|
||||
@@ -212,7 +213,9 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
{Name: SendStatusTool,
|
||||
Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " +
|
||||
"ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " +
|
||||
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).",
|
||||
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here). " +
|
||||
"Answered only to the caller that asked for the send: an id this caller did not ask for, known or not, " +
|
||||
"answers status unknown and nothing else of it (novox/hq ADR 0440 §7, issue 1261).",
|
||||
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
|
||||
Run: b.sendStatusTool},
|
||||
{Name: SeatAccountsTool,
|
||||
@@ -242,6 +245,10 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
"body": text("the message's plain-text body"),
|
||||
}),
|
||||
Run: b.sendTool},
|
||||
{Name: SeatSendStatusTool,
|
||||
Description: "The mail-store seat's send-status verb (novox/hq ADR 0378 §2, issue 1261): the same as mail_send_status.",
|
||||
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
|
||||
Run: b.sendStatusTool},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,7 +619,8 @@ func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool)
|
||||
}
|
||||
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, b.now())
|
||||
caller := b.caller(args)
|
||||
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, caller, b.now())
|
||||
}
|
||||
|
||||
// ---- arguments ----------------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user