398 lines
15 KiB
Go
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)
|
|
}
|
|
}
|