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. // 1228/AC1, 1232 (review round 1 finding 7): several mails over two granted accounts and folders, one mail of a // third account the caller is not granted, and a limit below the granted count — the newest granted mails // answer, in that exact order, never the ungranted account's, even though its mail is the newest of all. func TestMailSearchWithNoFilterAnswersTheNewestGrantedMail(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, `{"accounts": { "mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}, "gmail": {"kind": "imap", "address": "me@gmail.example", "incoming": {"host": "imap.gmail.com", "port": 993, "security": "tls"}}, "secret": {"kind": "imap", "address": "other@example.org", "incoming": {"host": "mail.example.org", "port": 993, "security": "tls"}}}}`) writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"], "gmail": ["triage"], "secret": ["other"]}}`) st := newMemStore() ctx := context.Background() at := func(h int) time.Time { return judgedAt.Add(time.Duration(h) * time.Hour) } for _, m := range []Message{ {Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "", Subject: "m1", Date: at(1), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, {Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "", Subject: "m2", Date: at(2), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, {Account: "mesh", Folder: "Sent", UID: 1, MessageID: "", Subject: "m3", Date: at(3), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, {Account: "gmail", Folder: "INBOX", UID: 1, MessageID: "", Subject: "m4", Date: at(4), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, {Account: "gmail", Folder: "Sent", UID: 1, MessageID: "", Subject: "m5", Date: at(5), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, {Account: "mesh", Folder: "Junk", UID: 1, MessageID: "", Subject: "m6", Date: at(6), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, // The newest of all, but in an account triage is not granted: it must never answer. {Account: "secret", Folder: "INBOX", UID: 1, MessageID: "", Subject: "s1", Date: at(7), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}, } { if err := st.Add(ctx, m, 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()) text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{"limit": 3}) if isErr { t.Fatalf("%v %s", isErr, text) } if strings.Contains(text, "secret") || strings.Contains(text, "s1@secret") { t.Fatalf("the ungranted account's mail answered: %s", text) } i6, i5, i4 := strings.Index(text, "m6@mesh"), strings.Index(text, "m5@gmail"), strings.Index(text, "m4@gmail") if i6 < 0 || i5 < 0 || i4 < 0 || !(i6 < i5 && i5 < i4) { t.Fatalf("not exactly m6, m5, m4 in that (newest-first) order: %s", text) } if strings.Contains(text, "m3@mesh") || strings.Contains(text, "m2@mesh") || strings.Contains(text, "m1@mesh") { t.Fatalf("limit 3 answered more than the 3 newest: %s", 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: "", 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: "", 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) } // 1232/AC17 (review round 1 finding 5): order-by given, order left out, defaults to desc, as the schema's // own description promises — not asc. "from alice" is a prefix of "from alice again", so ascending would put // first; descending puts first. text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"order-by": "subject"}) if isErr { t.Fatalf("%v %s", isErr, text) } if first, second := strings.Index(text, "b@mesh"), strings.Index(text, "a@mesh"); first < 0 || second < 0 || first > second { t.Fatalf("order-by with no order was not desc by default: %s", text) } }