Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/tools_test.go
T

398 lines
15 KiB
Go

package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
// runtime plays the node's runtime against the bundle over its MCP channel, as the bundle is launched: it calls tools
// naming who called in `_meta`, as the runtime does from the subject a call arrived on, and takes what the bundle
// publishes.
type runtime struct {
t *testing.T
in *io.PipeWriter
mu sync.Mutex
next int
answers map[string]chan map[string]any
published []map[string]any
refuse bool
}
func startBundle(t *testing.T, tools []stdio.Tool) *runtime {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
r := &runtime{t: t, in: inW, answers: map[string]chan map[string]any{}}
go func() { _ = stdio.ServeOn(Module, tools, inR, outW) }()
go func() {
sc := bufio.NewScanner(outR)
sc.Buffer(make([]byte, 1<<20), 1<<24)
for sc.Scan() {
var m map[string]any
if json.Unmarshal(sc.Bytes(), &m) != nil {
continue
}
id, _ := json.Marshal(m["id"])
if m["method"] == "mesh/publish" {
r.mu.Lock()
refuse := r.refuse
if !refuse {
r.published = append(r.published, m["params"].(map[string]any))
}
r.mu.Unlock()
reply := map[string]any{"id": m["id"], "result": map[string]any{}}
if refuse {
reply = map[string]any{"id": m["id"], "error": map[string]any{"code": -32000, "message": "the bus is away"}}
}
r.send(reply)
continue
}
r.mu.Lock()
ch := r.answers[string(id)]
r.mu.Unlock()
if ch != nil {
ch <- m
}
}
}()
t.Cleanup(func() { _ = inW.Close(); _ = outW.Close() })
// Answered once the bundle serves: from then on, what it emits goes through this runtime and no other.
r.mu.Lock()
ready := make(chan map[string]any, 1)
r.answers[`"init"`] = ready
r.mu.Unlock()
r.send(map[string]any{"jsonrpc": "2.0", "id": "init", "method": "initialize", "params": map[string]any{}})
select {
case <-ready:
case <-time.After(10 * time.Second):
t.Fatal("the bundle did not start")
}
return r
}
func (r *runtime) send(m map[string]any) {
raw, _ := json.Marshal(m)
r.mu.Lock()
defer r.mu.Unlock()
_, _ = r.in.Write(append(raw, '\n'))
}
// call calls a tool as the caller, answering its text and whether it was an error.
func (r *runtime) call(tool, caller string, args map[string]any) (string, bool) {
r.mu.Lock()
r.next++
id := r.next
ch := make(chan map[string]any, 1)
r.answers[fmt.Sprint(id)] = ch
r.mu.Unlock()
r.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args, "_meta": map[string]any{stdio.MetaCaller: caller}}})
select {
case m := <-ch:
res := m["result"].(map[string]any)
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
isErr, _ := res["isError"].(bool)
return text, isErr
case <-time.After(10 * time.Second):
r.t.Fatalf("%s did not answer", tool)
}
return "", true
}
// aBundle is the bundle over an index holding one message with an attachment, granted to the module triage alone.
func aBundle(t *testing.T) (*Bundle, *memStore, *memObjects) {
t.Helper()
dir := t.TempDir()
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
Status: filepath.Join(dir, "health.json")}
writeFile(t, env.Accounts, stageOne)
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
st, objects := newMemStore(), newMemObjects()
ctx := context.Background()
ref, err := Keep(ctx, objects, []byte(attachmentData), "application/pdf", "invoice.pdf")
if err != nil {
t.Fatal(err)
}
m := Message{Account: "mesh", Folder: "INBOX", UID: 7, UIDValidity: 1, MessageID: "att@example.org", Subject: "the invoice",
Date: judgedAt, From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{ref}, Body: "Please find the invoice attached."}
if err := st.Add(ctx, m, addedEvent(m)); err != nil {
t.Fatal(err)
}
_ = st.SetState(ctx, AccountState{Account: "mesh", State: AccountCurrent, Why: "watching", Heard: judgedAt, Said: judgedAt})
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil },
Objects: func() (Objects, error) { return objects, nil }, Now: func() time.Time { return judgedAt }}
return b, st, objects
}
// The body tools answer a granted caller and refuse every other (ADR 0359 §4, "How it is checked"): the caller is the
// runtime's word, never an argument.
func TestTheBodyToolsAnswerAGrantedCallerAndRefuseAnyOther(t *testing.T) {
b, _, _ := aBundle(t)
rt := startBundle(t, b.Tools())
read := map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7}
text, isErr := rt.call(ReadTool, "novox.triage", read)
if isErr || !strings.Contains(text, "Please find the invoice attached.") || !strings.Contains(text, `"subject":"the invoice"`) {
t.Fatalf("granted: %v %s", isErr, text)
}
for _, caller := range []string{"novox.other", "person.someone", Unproven} {
text, isErr := rt.call(ReadTool, caller, read)
if !isErr || !strings.Contains(text, "refused") || strings.Contains(text, "Please find") {
t.Fatalf("%s: %v %s", caller, isErr, text)
}
text, isErr = rt.call(SearchTool, caller, map[string]any{"query": "invoice"})
if !isErr || !strings.Contains(text, "refused") {
t.Fatalf("%s searched: %v %s", caller, isErr, text)
}
}
// An argument cannot name the caller.
if text, isErr := rt.call(ReadTool, "novox.other", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "caller": "novox.triage"}); !isErr || strings.Contains(text, "Please find") {
t.Fatalf("an argument named the caller: %s", text)
}
text, isErr = rt.call(SearchTool, "shanks.triage", map[string]any{"query": "invoice"})
if isErr || !strings.Contains(text, `"message-id":"att@example.org"`) || strings.Contains(text, "Please find") {
t.Fatalf("search: %v %s", isErr, text)
}
sum := ""
if m, _ := b.Index(context.Background()); m != nil {
got, _, _ := m.Get(context.Background(), "mesh", "INBOX", 7)
sum = got.Attachments[0].Key
}
text, isErr = rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7, "attachment": sum})
var att struct {
Base64 string `json:"base64"`
}
if isErr || json.Unmarshal([]byte(text), &att) != nil {
t.Fatalf("attachment: %v %s", isErr, text)
}
if data, _ := base64.StdEncoding.DecodeString(att.Base64); string(data) != attachmentData {
t.Fatalf("the attachment read is %q", data)
}
}
// mail_accounts says each account's state to anyone, and its address only to a caller granted it.
func TestTheAccountsToolListsOnlyTheAccountsTheCallerIsGranted(t *testing.T) {
b, _, _ := aBundle(t)
rt := startBundle(t, b.Tools())
_ = b.Env // the index holds a folder of the account, so an ungranted answer has something to leak
st, _ := b.Index(context.Background())
_ = st.SetFolder(context.Background(), Folder{Account: "mesh", Name: "Secret-Project", Messages: 41, Synced: judgedAt})
_ = st.SetState(context.Background(), AccountState{Account: "mesh", State: AccountFailing, Why: "the server said NO [AUTHENTICATIONFAILED]", Said: judgedAt})
text, isErr := rt.call(AccountsTool, "novox.other", map[string]any{})
if isErr || !strings.Contains(text, `"accounts":[]`) {
t.Fatalf("ungranted: %s", text)
}
for _, leak := range []string{"mesh\"", "jschoubben@", "Secret-Project", "41", "AUTHENTICATIONFAILED", "failing"} {
if strings.Contains(text, leak) {
t.Fatalf("an ungranted caller learns %q: %s", leak, text)
}
}
text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{})
if !strings.Contains(text, `"address":"jschoubben@novox.be"`) || !strings.Contains(text, "Secret-Project") || !strings.Contains(text, "AUTHENTICATIONFAILED") {
t.Fatalf("granted: %s", text)
}
}
// The events the keeper wrote are said on the bus as the module, through the runtime, with their metadata and never a
// body; each is removed once the bus took it, and kept when it did not.
func TestTheEventsAreSaidOnTheBusThroughTheRuntime(t *testing.T) {
b, st, _ := aBundle(t)
rt := startBundle(t, b.Tools())
p := &Publisher{Index: b.Index, Emit: func(key string, body any) error { return stdio.Emit(key, body) }, Logf: t.Logf}
ctx := context.Background()
rt.mu.Lock()
rt.refuse = true
rt.mu.Unlock()
if n, err := p.Once(ctx); n != 0 || err == nil {
t.Fatalf("said %d with the bus away (%v)", n, err)
}
if left, _ := st.Pending(ctx, 10); len(left) != 1 {
t.Fatalf("an event the bus did not take was dropped: %d left", len(left))
}
rt.mu.Lock()
rt.refuse = false
rt.mu.Unlock()
if n, err := p.Once(ctx); n != 1 || err != nil {
t.Fatalf("said %d (%v)", n, err)
}
if left, _ := st.Pending(ctx, 10); len(left) != 0 {
t.Fatalf("%d event(s) left after the bus took them", len(left))
}
rt.mu.Lock()
defer rt.mu.Unlock()
if len(rt.published) != 1 {
t.Fatalf("published %d", len(rt.published))
}
pub := rt.published[0]
raw, _ := json.Marshal(pub)
if pub["key"] != "mail.mesh.message.added" || !strings.Contains(string(raw), `"subject":"the invoice"`) ||
strings.Contains(string(raw), "Please find") || strings.Contains(string(raw), "dGhlIGludm9pY2Ug") {
t.Fatalf("published %s", raw)
}
}
// A grants setting that cannot be read grants nobody, and every refusal says why (issue 733).
func TestARefusalSaysWhyTheGrantsCouldNotBeRead(t *testing.T) {
b, _, _ := aBundle(t)
writeFile(t, b.Env.Grants, `{"grants": {"mesh": "triage"}}`)
rt := startBundle(t, b.Tools())
text, isErr := rt.call(ReadTool, "novox.triage", map[string]any{"account": "mesh", "folder": "INBOX", "uid": 7})
if !isErr || !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, "not the module's") || strings.Contains(text, "Please find") {
t.Fatalf("read: %v %s", isErr, text)
}
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"query": "invoice"})
if !isErr || !strings.Contains(text, "the setting grants cannot be read") {
t.Fatalf("search: %v %s", isErr, text)
}
text, _ = rt.call(AccountsTool, "novox.triage", map[string]any{})
if !strings.Contains(text, "the setting grants cannot be read") || !strings.Contains(text, `"accounts":[]`) {
t.Fatalf("accounts: %s", text)
}
}
// 1228/AC1, 1232: with no filter at all (not even query), mail_search answers the newest mails of the granted
// accounts, not a refusal demanding a query.
func TestMailSearchWithNoFilterAnswersTheNewestGrantedMail(t *testing.T) {
b, _, _ := aBundle(t)
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{})
if isErr || !strings.Contains(text, `"message-id":"att@example.org"`) {
t.Fatalf("%v %s", isErr, text)
}
}
// 1232/AC16: an unknown order-by, a limit over 50, a date that does not parse, or an account not granted are
// each refused naming the argument, and the index is never asked.
func TestMailSearchValidatesBeforeAskingTheIndex(t *testing.T) {
b, _, _ := aBundle(t)
calls := 0
index := b.Index
b.Index = func(ctx context.Context) (Store, error) { calls++; return index(ctx) }
rt := startBundle(t, b.Tools())
cases := []struct {
name string
args map[string]any
want string
}{
{"order-by", map[string]any{"order-by": "popularity"}, "order-by"},
{"limit", map[string]any{"limit": 51}, "limit"},
{"on-or-after", map[string]any{"on-or-after": "not a date"}, "on-or-after"},
{"account", map[string]any{"account": "no-such-account"}, "no-such-account"},
}
for _, c := range cases {
text, isErr := rt.call(SearchTool, "novox.triage", c.args)
if !isErr || !strings.Contains(text, c.want) {
t.Fatalf("%s: %v %s", c.name, isErr, text)
}
}
if calls != 0 {
t.Fatalf("the index was asked %d time(s) before a refusal", calls)
}
}
// 1232/AC17: the schema describes every filter and order-by's own form (date, sender, subject, size, each
// ascending or descending).
func TestMailSearchSchemaOffersEveryFilterAndOrderBy(t *testing.T) {
b := &Bundle{}
var input map[string]any
for _, tool := range b.Tools() {
if tool.Name == SearchTool {
input = tool.Input
}
}
if input == nil {
t.Fatal("no mail_search tool")
}
props, _ := input["properties"].(map[string]any)
for _, name := range searchArgs {
if _, ok := props[name]; !ok {
t.Fatalf("mail_search's schema does not offer %q", name)
}
}
orderBy, _ := props["order-by"].(map[string]any)
if got, _ := orderBy["enum"].([]string); !sameStrings(got, []string{"date", "sender", "subject", "size"}) {
t.Fatalf("order-by's enum: %v", orderBy["enum"])
}
order, _ := props["order"].(map[string]any)
if got, _ := order["enum"].([]string); !sameStrings(got, []string{"asc", "desc"}) {
t.Fatalf("order's enum: %v", order["enum"])
}
// mail-store.search carries the very same schema (issue 1232: one schema, not two).
var seatInput map[string]any
for _, tool := range b.Tools() {
if tool.Name == SeatSearchTool {
seatInput = tool.Input
}
}
seatProps, _ := seatInput["properties"].(map[string]any)
if len(seatProps) != len(props) {
t.Fatalf("mail-store.search's schema has %d properties, mail_search's has %d", len(seatProps), len(props))
}
}
// 1232 (the end-to-end path of the others): from, unread, order-by/order and group-by-sender, called the way a
// caller calls them, through the MCP tool and its JSON arguments, not Go's Query directly.
func TestMailSearchEndToEndFiltersAndGroupBySender(t *testing.T) {
dir := t.TempDir()
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
Status: filepath.Join(dir, "health.json")}
writeFile(t, env.Accounts, stageOne)
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
st := newMemStore()
ctx := context.Background()
older := judgedAt.Add(-time.Hour)
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<a@mesh>", Subject: "from alice",
Date: older, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "hi"}, nil); err != nil {
t.Fatal(err)
}
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<b@mesh>", Subject: "from alice again",
Date: judgedAt, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "hi again"}, nil); err != nil {
t.Fatal(err)
}
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil }, Now: func() time.Time { return judgedAt }}
rt := startBundle(t, b.Tools())
unread := true
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{"from": []any{"example.org"}, "unread": unread,
"order-by": "date", "order": "asc"})
if isErr || !strings.Contains(text, "a@mesh") || strings.Contains(text, "b@mesh") {
t.Fatalf("%v %s", isErr, text)
}
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"group-by-sender": true})
var grouped struct {
Senders []SenderGroup `json:"senders"`
}
if isErr || json.Unmarshal([]byte(text), &grouped) != nil || len(grouped.Senders) != 1 ||
grouped.Senders[0].Address != "alice@example.org" || grouped.Senders[0].Count != 2 {
t.Fatalf("%v %s", isErr, text)
}
}