mesh/merge-gate out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 2130f9961291, the trunk is now 5f6a41146c3a; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The review of #300 found a batch of 25 whole messages could hold a gigabyte, mail_accounts told ungranted callers folder names, counts and server errors, an unreadable grants setting was refused without its reason, and the watch read the password only to learn it was there.
276 lines
10 KiB
Go
276 lines
10 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)
|
|
}
|
|
}
|