The group's module paths are placeholders the re-pin rewrites. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
219 lines
9.4 KiB
Go
219 lines
9.4 KiB
Go
package main
|
|
|
|
// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any
|
|
// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the
|
|
// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in
|
|
// a form the content rule lets through (novox/hq issue 868, "Decided" note and review round 1). These tests
|
|
// build real asks through mail_send and judge them with the real rule, copied verbatim as content_rule.go.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
|
)
|
|
|
|
// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details
|
|
// together. Useful for a readability check (nothing unmasked is visible anywhere), but never for judging
|
|
// whether messenger would refuse the ask as a whole — judgedWords is that (novox/hq issue 868, review
|
|
// round 1): Details is filtered line by line, not judged with the rest (holder.go's sayOn).
|
|
func wholeAsk(r *sendRouter, id string) string {
|
|
a := r.published[id]
|
|
return a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
|
}
|
|
|
|
// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's
|
|
// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect.
|
|
// Details is never part of it.
|
|
func judgedWords(a oc.Ask) string {
|
|
words := []string{a.Headline, a.Explanation, a.OnExpiry}
|
|
for _, o := range a.Options {
|
|
words = append(words, o.Label, o.Does)
|
|
}
|
|
return strings.Join(words, "\n")
|
|
}
|
|
|
|
// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger
|
|
// itself judges an ask as a whole, on judgedWords alone.
|
|
func mustNotBeRefused(t *testing.T, a oc.Ask) {
|
|
t.Helper()
|
|
if refusal, ok := Check(judgedWords(a)); !ok {
|
|
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a))
|
|
}
|
|
}
|
|
|
|
// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address
|
|
// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and
|
|
// passes the real content rule. This is the first test issue 868's decision asked for; it fails today
|
|
// because AskMailSend puts the raw address into the ask.
|
|
func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"},
|
|
"subject": "Testing issue 868", "body": "Does the ask reach the operator now?"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
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 == "" {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") {
|
|
t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "your account mesh") {
|
|
t.Errorf("the ask does not name the own account:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (2) A recipient outside the operator's own accounts is said without its address or host: its first
|
|
// letter, an ellipsis and the domain's labels as words.
|
|
func TestMailSendAskForOutsideRecipientCarriesNoAddress(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) // to: ops@example.org, lead@example.org
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") {
|
|
t.Errorf("the ask carries the recipients' address or host:\n%s", whole)
|
|
}
|
|
for _, want := range []string{"o… at example org", "l… at example org"} {
|
|
if !strings.Contains(whole, want) {
|
|
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
|
}
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (3) More than three recipients are counted rather than said one by one.
|
|
func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh",
|
|
"to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"},
|
|
"subject": "Many recipients", "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)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") {
|
|
t.Errorf("the ask carries a mail address among many recipients:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "and 2 more") {
|
|
t.Errorf("the ask does not count the recipients past the first three:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (4) A subject holding a mail address and a link has both masked before the ask carries it.
|
|
func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"},
|
|
"subject": "Contact ops@example.org or see https://example.com/issue/42",
|
|
"body": "See https://example.com/issue/42 and write to ops@example.org for details."}
|
|
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)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") {
|
|
t.Errorf("the ask carries an unmasked address or link:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") {
|
|
t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (5) review round 1: a subject the masking does not catch (an IP, a two-slash path-shaped reference, a
|
|
// 40-character hex hash) does not refuse the whole ask: the real content rule (content_rule.go) still judges
|
|
// the masked explanation as refused, so the subject and first lines are left out of it and pointed at Details
|
|
// instead — the ask still names the account and recipients, and still reaches the operator.
|
|
func TestMailSendAskFallsBackToDetailsWhenTheSubjectStillRefuses(t *testing.T) {
|
|
for _, subject := range []string{
|
|
"Invoice 2026/10/15",
|
|
"server 10.0.0.1",
|
|
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 40 hex characters: reHex's shape
|
|
} {
|
|
t.Run(subject, func(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, "subject": subject, "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)
|
|
a := router.published[id]
|
|
mustNotBeRefused(t, a)
|
|
if !strings.Contains(a.Explanation, "mesh") {
|
|
t.Errorf("the explanation does not name the account:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Explanation, "o… at example org") {
|
|
t.Errorf("the explanation does not name the recipient:\n%s", a.Explanation)
|
|
}
|
|
if strings.Contains(a.Explanation, subject) {
|
|
t.Errorf("the explanation still carries the subject that refused the ask:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Details, "Details") && !strings.Contains(a.Explanation, "Details") {
|
|
t.Errorf("the explanation does not point at Details:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Details, subject) {
|
|
t.Errorf("Details does not carry the subject (filtered line by line by messenger, as the subject's own line): %s", a.Details)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// (6) review round 1: a recipient written with a display name ("Name <addr>") is matched against the
|
|
// operator's own accounts, and masked, by its address alone.
|
|
func TestMailSendAskMatchesADisplayNameRecipientByItsAddress(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"Jochen Schoubben <jschoubben@novox.be>"},
|
|
"subject": "Testing a display name", "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)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "Jochen Schoubben") {
|
|
t.Errorf("the ask carries the display-name recipient's address or name:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "your account mesh") {
|
|
t.Errorf("the ask does not match the display-name recipient to its own account:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|