mesh/merge-gate head 9f3a8ee39101 merged into trunk 66aa204f6779 — pass: builds mesh-mail, new: modules/mesh-mail-web → novox; no bus step; every machine c…
mesh/repo-check head 9f3a8ee39101 merged into trunk 66aa204f6779 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
938 lines
35 KiB
Go
938 lines
35 KiB
Go
//go:build integration
|
|
|
|
package main
|
|
|
|
// The runtime player (novox/hq ADR 0192, ADR 0198; issue 1285): mesh-mail-web is a bundle under runs-as, and
|
|
// reaches the bus only through the runtime that launches it, over stdin and stdout (initialize, tools/list,
|
|
// tools/call; the bundle's own mesh/ask, mesh/state.<verb>). Nothing in this repository has played that
|
|
// protocol's runtime side against a real, built bundle binary before (the survey at this issue's prepare step
|
|
// found no precedent), so this harness takes the wire shape from the SDK's own source, never from memory:
|
|
// ~/go/pkg/mod/.../mesh-sdk/go@.../stdio.go (the unexported `message`, ServeOn's own switch, c.newID's
|
|
// "bundle-<n>" ids, live()'s "before Serve" refusal) and state.go (KeptState.ask's "mesh/state."+verb,
|
|
// {"state":...,"key":...} params). The stand-in mail-store answers seat:mail-store.<verb> and records every
|
|
// call (AC2); mesh-mail's own account bucket is answered from a real JetStream key-value bucket on a test
|
|
// nats-server, named as the controller names a module's state (Module+"_"+StateName, mesh-mail's own
|
|
// acceptance_integration_test.go:79-107) — here "mesh-mail_accounts", since mesh-mail-web's own `reads` names
|
|
// "mesh-mail.accounts". Compiled by `go vet -tags integration ./...`; never run on this machine (the
|
|
// context file's hard rule) — the lead's try runs it for real.
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats-server/v2/server"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
// ---- the wire, copied from the SDK's own stdio.go (its unexported `message`) --------------------------------
|
|
|
|
// wireMessage mirrors stdio.go's own unexported `message`: newline-framed JSON-RPC, as the runtime and a
|
|
// bundle exchange it over stdin/stdout. Copied, not imported (it is unexported there) — this harness plays
|
|
// the runtime's own side of exactly that shape.
|
|
type wireMessage struct {
|
|
ID json.RawMessage `json:"id,omitempty"`
|
|
Method string `json:"method,omitempty"`
|
|
Params json.RawMessage `json:"params,omitempty"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *wireError `json:"error,omitempty"`
|
|
}
|
|
|
|
type wireError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ---- the stand-in mail-store (issue 1285/AC2, AC4) -------------------------------------------------------
|
|
|
|
// askRecord is one ask the bundle made, as the harness recorded it (issue 1285/AC2): its key
|
|
// ("seat:mail-store.<verb>", stdio.go's own Ask) and its body, whole.
|
|
type askRecord struct {
|
|
Key string
|
|
Body json.RawMessage
|
|
}
|
|
|
|
// mailStoreFunc answers one seat:mail-store.<verb> ask (the key, exactly as stdio.Ask's own caller gives it,
|
|
// and its body's arguments already read into a map); known is false when the stand-in was not given an
|
|
// answer for that key at all — the player then fails the test naming the ask, rather than only shaping a
|
|
// protocol error the bundle's own caller would read as some ordinary failure (issue 1285/AC4).
|
|
type mailStoreFunc func(key string, args map[string]any) (result any, err error, known bool)
|
|
|
|
// standInMailStore is mail-store as the tests in this file and in acceptance_integration_test.go fake it:
|
|
// one function per seat verb, called by key, and every call recorded (issue 1285/AC2).
|
|
type standInMailStore struct {
|
|
mu sync.Mutex
|
|
|
|
Accounts func(args map[string]any) (any, error)
|
|
Search func(args map[string]any) (any, error)
|
|
Read func(args map[string]any) (any, error)
|
|
Send func(args map[string]any) (any, error)
|
|
SendStatus func(args map[string]any) (any, error)
|
|
|
|
calls []askRecord
|
|
}
|
|
|
|
// answer is this stand-in's own mailStoreFunc.
|
|
func (s *standInMailStore) answer(key string, args map[string]any) (any, error, bool) {
|
|
raw, _ := json.Marshal(args)
|
|
s.mu.Lock()
|
|
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
|
|
s.mu.Unlock()
|
|
var f func(args map[string]any) (any, error)
|
|
switch key {
|
|
case "seat:mail-store.accounts":
|
|
f = s.Accounts
|
|
case "seat:mail-store.search":
|
|
f = s.Search
|
|
case "seat:mail-store.read":
|
|
f = s.Read
|
|
case "seat:mail-store.send":
|
|
f = s.Send
|
|
case "seat:mail-store.send-status":
|
|
f = s.SendStatus
|
|
default:
|
|
return nil, nil, false
|
|
}
|
|
if f == nil {
|
|
return nil, nil, false
|
|
}
|
|
out, err := f(args)
|
|
return out, err, true
|
|
}
|
|
|
|
// Calls is every ask this stand-in has answered so far, a copy (issue 1285/AC2, issue 1268's design test 3).
|
|
func (s *standInMailStore) Calls() []askRecord {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return append([]askRecord(nil), s.calls...)
|
|
}
|
|
|
|
// Keys is every distinct key asked so far, in the order first seen (design test 3's own "only these verbs").
|
|
func (s *standInMailStore) Keys() []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, c := range s.calls {
|
|
if !seen[c.Key] {
|
|
seen[c.Key] = true
|
|
out = append(out, c.Key)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the real account bucket, on a real JetStream bus (issue 1285/AC3) ---------------------------------
|
|
|
|
// AccountsBucketName is the real key-value bucket mesh-mail-web's own account bucket read resolves to: the
|
|
// controller's own naming for a module's state, "<module>_<state>" (mesh-mail's
|
|
// acceptance_integration_test.go:79-107, Module+"_"+SendsState) — here mesh-mail's own module name and its
|
|
// accounts state (issue 1255), matching this module's `reads` entry "mesh-mail.accounts".
|
|
const AccountsBucketName = "mesh-mail_accounts"
|
|
|
|
// newTestBus starts a JetStream-enabled nats-server for the test alone, on a random port with its own temp
|
|
// store — copied from mesh-mail's own cmd/mesh-mail/sender_integration_test.go:55-70 (itself copied there
|
|
// from modules/mesh-delivery/cmd/mesh-delivery/bus_test.go, the SDK's own testbus being internal).
|
|
func newTestBus(t *testing.T) string {
|
|
t.Helper()
|
|
opts := &server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(),
|
|
NoLog: true, NoSigs: true}
|
|
s, err := server.NewServer(opts)
|
|
if err != nil {
|
|
t.Fatalf("a bus for this test could not be made: %v", err)
|
|
}
|
|
go s.Start()
|
|
if !s.ReadyForConnections(30 * time.Second) {
|
|
s.Shutdown()
|
|
t.Fatal("a bus for this test did not come up within 30s")
|
|
}
|
|
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
|
|
return s.ClientURL()
|
|
}
|
|
|
|
// newAccountsBucket makes the real key-value bucket mesh-mail's own Mirror would write (issue 1255) on a
|
|
// fresh test bus, and puts one key per account given — each value mesh-mail-web's own accountBucketValue
|
|
// (accounts.go), itself copied from mesh-mail's AccountValue (modules/mesh-mail/cmd/mesh-mail/mirror.go,
|
|
// issue 1255; accounts.go's own comment names the source again). Answers the jetstream.KeyValue the player
|
|
// reads mesh-mail.accounts from, and the *nats.Conn/jetstream.JetStream for a test to write more keys with
|
|
// (issue 1285/AC3).
|
|
func newAccountsBucket(t *testing.T, accounts map[string]accountBucketValue) (jetstream.JetStream, jetstream.KeyValue) {
|
|
t.Helper()
|
|
url := newTestBus(t)
|
|
nc, err := nats.Connect(url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
js, err := jetstream.New(nc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: AccountsBucketName})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for account, v := range accounts {
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := kv.Put(ctx, account, raw); err != nil {
|
|
t.Fatalf("putting the account bucket's key %q: %v", account, err)
|
|
}
|
|
}
|
|
return js, kv
|
|
}
|
|
|
|
// ---- building the bundle's own binary (issue 1285) ---------------------------------------------------------
|
|
|
|
// buildMailWebBinary builds mesh-mail-web's own binary into TMPDIR (the brief's own rule: a short TMPDIR,
|
|
// issue 1244's long-path lesson), once per test.
|
|
func buildMailWebBinary(t *testing.T) string {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "mw-web-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
|
bin := filepath.Join(dir, "mesh-mail-web")
|
|
cmd := exec.Command("go", "build", "-o", bin, ".")
|
|
cmd.Dir, err = os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd.Env = os.Environ()
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("building mesh-mail-web: %v\n%s", err, out)
|
|
}
|
|
return bin
|
|
}
|
|
|
|
// freePort answers a TCP port free at the moment it is asked — released before the bundle (a separate
|
|
// process, so this harness cannot hand it a listener directly) is told to bind it. A small, accepted race,
|
|
// the same shape as picking any port ahead of starting an external process.
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer l.Close()
|
|
return l.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
// ---- the runtime player itself (issue 1285) ------------------------------------------------------------
|
|
|
|
// askFailer is the small part of *testing.T the runtime player reports an unknown ask through (Helper,
|
|
// Errorf) — never the whole of it. Every ordinary test gives its own *testing.T (which has both). Issue
|
|
// 1285/AC4's own test gives a recorder instead: a failed subtest fails its parent (t.Run answers false, but
|
|
// the parent is marked failed regardless, try 2's own finding), which would make "the harness fails the
|
|
// test" itself untestable the direct way.
|
|
type askFailer interface {
|
|
Helper()
|
|
Errorf(format string, args ...any)
|
|
}
|
|
|
|
// runtimePlayer plays the node's runtime against one started bundle, over its real stdin and stdout:
|
|
// initialize, tools/list, tools/call (this harness's own outgoing requests, stdio.go's ServeOn switch), and
|
|
// answers to what the bundle itself asks — mesh/ask, mesh/state.<verb> (the bundle's own outgoing requests,
|
|
// stdio.go's channel.ask / state.go's KeptState.ask).
|
|
type runtimePlayer struct {
|
|
fail askFailer
|
|
cmd *exec.Cmd
|
|
in io.WriteCloser
|
|
|
|
mu sync.Mutex
|
|
nextID int
|
|
waiting map[string]chan wireMessage
|
|
|
|
mailStore mailStoreFunc
|
|
js jetstream.JetStream
|
|
}
|
|
|
|
// startRuntimePlayer starts binary with env, and begins playing the runtime on its stdin/stdout at once: the
|
|
// handshake (initialize, notifications/initialized) is done here, so a caller gets back a player already
|
|
// past it, exactly as the SDK's own live() expects before any Ask (stdio.go:391-398, live_before_serve_test.go).
|
|
// t is used for the process's own setup and cleanup (Fatal, Cleanup) always; failer is where an unknown ask
|
|
// is reported (Errorf) — t itself when nil, a recorder for issue 1285/AC4's own test.
|
|
func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mailStoreFunc, js jetstream.JetStream, failer askFailer) *runtimePlayer {
|
|
t.Helper()
|
|
if failer == nil {
|
|
failer = t
|
|
}
|
|
cmd := exec.Command(binary)
|
|
cmd.Env = env
|
|
stderr := &prefixedLog{t: t, prefix: "mesh-mail-web stderr: "}
|
|
cmd.Stderr = stderr
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := &runtimePlayer{fail: failer, cmd: cmd, in: stdin, waiting: map[string]chan wireMessage{}, mailStore: mailStore, js: js}
|
|
go p.readLoop(stdout)
|
|
t.Cleanup(func() {
|
|
_ = cmd.Process.Kill()
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
if _, err := p.ask("initialize", map[string]any{
|
|
"protocolVersion": "2025-03-26", "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "test", "version": "1"},
|
|
}); err != nil {
|
|
t.Fatalf("initialize: %v", err)
|
|
}
|
|
if err := p.send(map[string]any{"jsonrpc": "2.0", "method": "notifications/initialized", "params": map[string]any{}}); err != nil {
|
|
t.Fatalf("notifications/initialized: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// prefixedLog writes the bundle's own stderr to the test log, a line at a time, so a failure's cause (a
|
|
// panic, a refused build, an unreachable issuer) is in the test's own output rather than lost.
|
|
type prefixedLog struct {
|
|
t *testing.T
|
|
prefix string
|
|
buf []byte
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (p *prefixedLog) Write(b []byte) (int, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.buf = append(p.buf, b...)
|
|
for {
|
|
i := strings.IndexByte(string(p.buf), '\n')
|
|
if i < 0 {
|
|
break
|
|
}
|
|
p.t.Log(p.prefix + string(p.buf[:i]))
|
|
p.buf = p.buf[i+1:]
|
|
}
|
|
return len(b), nil
|
|
}
|
|
|
|
// readLoop reads every line the bundle writes to its own stdout: a line with no "method" and an id is an
|
|
// answer to something this player asked (stdio.go's own channel read loop tells them apart the same way,
|
|
// stdio.go:181-191); a line with a "method" is a request the bundle itself is making, answered by
|
|
// handleBundleRequest.
|
|
func (p *runtimePlayer) readLoop(stdout io.Reader) {
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 1<<20), 1<<24)
|
|
for scanner.Scan() {
|
|
var m wireMessage
|
|
if json.Unmarshal(scanner.Bytes(), &m) != nil {
|
|
continue
|
|
}
|
|
if m.Method == "" && len(m.ID) > 0 {
|
|
var key string
|
|
_ = json.Unmarshal(m.ID, &key)
|
|
p.mu.Lock()
|
|
ch := p.waiting[key]
|
|
delete(p.waiting, key)
|
|
p.mu.Unlock()
|
|
if ch != nil {
|
|
ch <- m
|
|
}
|
|
continue
|
|
}
|
|
if m.Method != "" {
|
|
go p.handleBundleRequest(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
// send writes one JSON-RPC object, newline-framed, to the bundle's stdin — this player's own half of the
|
|
// wire (stdio.go's channel.say).
|
|
func (p *runtimePlayer) send(m map[string]any) error {
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
_, err = p.in.Write(append(raw, '\n'))
|
|
return err
|
|
}
|
|
|
|
// ask sends one request to the bundle (initialize, tools/list, tools/call — this player's own outgoing
|
|
// calls) and waits for its answer, under an id of this player's own choosing (never "bundle-<n>", which the
|
|
// bundle's own outgoing ids use, stdio.go's channel.newID — so the two id spaces never collide on one wire).
|
|
func (p *runtimePlayer) ask(method string, params any) (json.RawMessage, error) {
|
|
p.mu.Lock()
|
|
p.nextID++
|
|
id := "player-" + strconv.Itoa(p.nextID)
|
|
ch := make(chan wireMessage, 1)
|
|
p.waiting[id] = ch
|
|
p.mu.Unlock()
|
|
|
|
if err := p.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case m := <-ch:
|
|
if m.Error != nil {
|
|
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
|
}
|
|
return m.Result, nil
|
|
case <-time.After(30 * time.Second):
|
|
p.mu.Lock()
|
|
delete(p.waiting, id)
|
|
p.mu.Unlock()
|
|
return nil, fmt.Errorf("%s: no answer within 30s", method)
|
|
}
|
|
}
|
|
|
|
// ToolsList is tools/list, for a test to check what the bundle serves.
|
|
func (p *runtimePlayer) ToolsList() ([]string, error) {
|
|
raw, err := p.ask("tools/list", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
} `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
names := make([]string, 0, len(out.Tools))
|
|
for _, t := range out.Tools {
|
|
names = append(names, t.Name)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
// ToolsCall is tools/call, answering the tool's own text content (stdio.go's own "content":[{"type":"text",
|
|
// "text":...}] shape), and whether the runtime itself said isError.
|
|
func (p *runtimePlayer) ToolsCall(name string, args map[string]any) (text string, isError bool, err error) {
|
|
raw, askErr := p.ask("tools/call", map[string]any{"name": name, "arguments": args})
|
|
if askErr != nil {
|
|
return "", false, askErr
|
|
}
|
|
var out struct {
|
|
Content []struct {
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
}
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return "", false, err
|
|
}
|
|
if len(out.Content) == 0 {
|
|
return "", out.IsError, nil
|
|
}
|
|
return out.Content[0].Text, out.IsError, nil
|
|
}
|
|
|
|
// handleBundleRequest answers one request the bundle itself made (mesh/ask, mesh/state.<verb>) — never one
|
|
// this player sent (those are answers, read.Loop already told apart and never reach here).
|
|
func (p *runtimePlayer) handleBundleRequest(m wireMessage) {
|
|
p.fail.Helper()
|
|
reply := func(result any, errMsg string) {
|
|
out := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(m.ID)}
|
|
if errMsg != "" {
|
|
out["error"] = map[string]any{"code": -32000, "message": errMsg}
|
|
} else {
|
|
raw, _ := json.Marshal(result)
|
|
out["result"] = json.RawMessage(raw)
|
|
}
|
|
_ = p.send(out)
|
|
}
|
|
switch {
|
|
case m.Method == "mesh/ask":
|
|
p.handleAsk(m, reply)
|
|
case strings.HasPrefix(m.Method, "mesh/state."):
|
|
p.handleState(m, reply)
|
|
default:
|
|
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", m.Method)
|
|
reply(nil, "the helper does not know "+m.Method)
|
|
}
|
|
}
|
|
|
|
// handleAsk is mesh/ask (stdio.go's Ask, params {"key":...,"body":...}): the stand-in mail-store's own
|
|
// answer, or — a key the stand-in was given nothing for — the test failing, naming the ask (issue 1285/AC4).
|
|
func (p *runtimePlayer) handleAsk(m wireMessage, reply func(result any, errMsg string)) {
|
|
p.fail.Helper()
|
|
var params struct {
|
|
Key string `json:"key"`
|
|
Body map[string]any `json:"body"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, ¶ms)
|
|
if p.mailStore == nil {
|
|
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
|
|
reply(nil, "the helper does not know "+params.Key)
|
|
return
|
|
}
|
|
result, err, known := p.mailStore(params.Key, params.Body)
|
|
if !known {
|
|
p.fail.Errorf("the bundle asked %s, which the helper does not know (issue 1285/AC4)", params.Key)
|
|
reply(nil, "the helper does not know "+params.Key)
|
|
return
|
|
}
|
|
if err != nil {
|
|
reply(nil, err.Error())
|
|
return
|
|
}
|
|
reply(result, "")
|
|
}
|
|
|
|
// handleState is mesh/state.<verb> (state.go's KeptState.ask, params {"state":"<module>.<name>", ...}):
|
|
// get and keys answered from the real JetStream bucket this player was given (issue 1285/AC3); any other
|
|
// verb, or a `state` this player holds no bucket for, fails the test naming the ask (AC4).
|
|
func (p *runtimePlayer) handleState(m wireMessage, reply func(result any, errMsg string)) {
|
|
p.fail.Helper()
|
|
verb := strings.TrimPrefix(m.Method, "mesh/state.")
|
|
var params struct {
|
|
State string `json:"state"`
|
|
Key string `json:"key"`
|
|
}
|
|
_ = json.Unmarshal(m.Params, ¶ms)
|
|
fail := func() {
|
|
p.fail.Errorf("the bundle asked %s for state %s, which the helper does not know (issue 1285/AC4)", m.Method, params.State)
|
|
reply(nil, "the helper does not know mesh/state."+verb+" for "+params.State)
|
|
}
|
|
// Only mesh-mail.accounts is wired to a real bucket (issue 1285's own scope); any other `state` is
|
|
// unknown to this player.
|
|
if params.State != "mesh-mail.accounts" || p.js == nil {
|
|
fail()
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
kv, err := p.js.KeyValue(ctx, AccountsBucketName)
|
|
if err != nil {
|
|
reply(nil, err.Error())
|
|
return
|
|
}
|
|
switch verb {
|
|
case "get":
|
|
entry, err := kv.Get(ctx, params.Key)
|
|
if err != nil {
|
|
if err == jetstream.ErrKeyNotFound {
|
|
reply(nil, "")
|
|
return
|
|
}
|
|
reply(nil, err.Error())
|
|
return
|
|
}
|
|
reply(map[string]any{"key": params.Key, "value": json.RawMessage(entry.Value()), "revision": entry.Revision()}, "")
|
|
case "keys":
|
|
keys, err := kv.Keys(ctx)
|
|
if err != nil && err != jetstream.ErrNoKeysFound {
|
|
reply(nil, err.Error())
|
|
return
|
|
}
|
|
reply(keys, "")
|
|
default:
|
|
fail()
|
|
}
|
|
}
|
|
|
|
// ---- a started mesh-mail-web, ready for a test (issue 1285, reused by acceptance_integration_test.go) ------
|
|
|
|
// testClientID is the oidc-client binding's own client id these tests write (issue 1262's oidc.json), and
|
|
// the audience the stand-in issuer's own ID tokens are minted for (auth_test.go's fi.idToken) — the two must
|
|
// agree, exactly as mesh-mail-web's own real oidc.json and Keycloak's real client would.
|
|
const testClientID = "mesh-mail-web-test"
|
|
|
|
// testNode is MESH_NODE for these tests: InternalHost(testNode) is the Host every request carries, as the
|
|
// operator's own setting would assign it on a real machine (ADR 0440 §2).
|
|
const testNode = "test-node"
|
|
|
|
// startedMailWeb is one running mesh-mail-web, built and started by the helper, with its stand-in mail-store,
|
|
// its real account bucket, its stand-in identity provider, and an HTTP client that reaches its endpoint —
|
|
// everything issue 1285's own tests, and issue 1268's acceptance tests, need.
|
|
type startedMailWeb struct {
|
|
player *runtimePlayer
|
|
store *standInMailStore
|
|
fakeIssuer *fakeIssuer
|
|
client *http.Client
|
|
internalHost string
|
|
js jetstream.JetStream
|
|
}
|
|
|
|
// startMailWeb is startMailWebWithFailer with the test's own *testing.T as the ask failer (every ordinary
|
|
// test).
|
|
func startMailWeb(t *testing.T, accounts map[string]accountBucketValue, operators []string) *startedMailWeb {
|
|
t.Helper()
|
|
return startMailWebWithFailer(t, accounts, operators, nil)
|
|
}
|
|
|
|
// startMailWebWithFailer builds mesh-mail-web's own binary, starts it with a real account bucket (one key
|
|
// per entry in accounts) and the trusted setting operators, and plays the runtime on it until initialize and
|
|
// notifications/initialized are done. The stand-in mail-store (startedMailWeb.store) answers nothing until a
|
|
// test sets its own fields. failer is where an unknown ask is reported (issue 1285/AC4): nil for the test's
|
|
// own *testing.T, or a recorder for a test that must assert on that report without failing itself.
|
|
func startMailWebWithFailer(t *testing.T, accounts map[string]accountBucketValue, operators []string, failer askFailer) *startedMailWeb {
|
|
t.Helper()
|
|
binary := buildMailWebBinary(t)
|
|
port := freePort(t)
|
|
internalHost := InternalHost(testNode)
|
|
|
|
js, _ := newAccountsBucket(t, accounts)
|
|
fi := newFakeIssuer(t)
|
|
|
|
dir := t.TempDir()
|
|
opJSON, err := json.Marshal(map[string]any{"operators": operators})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
opPath := filepath.Join(dir, "operators.json")
|
|
if err := os.WriteFile(opPath, opJSON, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oidcJSON, err := json.Marshal(OIDCConfig{ClientID: testClientID, ClientSecret: "s3cr3t", Issuer: fi.Server.URL})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oidcPath := filepath.Join(dir, "oidc.json")
|
|
if err := os.WriteFile(oidcPath, oidcJSON, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
store := &standInMailStore{}
|
|
env := append(os.Environ(),
|
|
"MESH_MAIL_WEB_LISTEN="+strconv.Itoa(port),
|
|
"MESH_NODE="+testNode,
|
|
"MESH_MAIL_WEB_OPERATORS="+opPath,
|
|
"MESH_MAIL_WEB_OIDC="+oidcPath,
|
|
"MESH_SERVED_MODULE=mesh-mail-web",
|
|
)
|
|
player := startRuntimePlayer(t, binary, env, store.answer, js, failer)
|
|
waitForHTTP(t, port)
|
|
|
|
return &startedMailWeb{
|
|
player: player, store: store, fakeIssuer: fi,
|
|
client: httpClientForPort(port), internalHost: internalHost, js: js,
|
|
}
|
|
}
|
|
|
|
// httpClientForPort is an http.Client that reaches 127.0.0.1:port for any request, whatever Host the
|
|
// request itself carries — so a test sends Host: mail.<node>.internal exactly as the route proxy would,
|
|
// never by editing /etc/hosts (the mesh's own hard rule) or needing a real DNS entry. It never follows a
|
|
// redirect on its own, so a test reads a 302's own Location (the sign-in redirect, issue 1263).
|
|
func httpClientForPort(port int) *http.Client {
|
|
return &http.Client{
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
Transport: &http.Transport{
|
|
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, network, fmt.Sprintf("127.0.0.1:%d", port))
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// waitForHTTP polls 127.0.0.1:port until it accepts a connection: the HTTP endpoint comes up only once
|
|
// sign-in's own issuer discovery has succeeded (main.go's serveHTTP retries buildAuth until it does).
|
|
func waitForHTTP(t *testing.T, port int) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(30 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second)
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
t.Fatal("mesh-mail-web's HTTP endpoint did not come up within 30s")
|
|
}
|
|
|
|
// followSignInBounce is a plain GET to path carrying no session cookie, followed through RequireSession's own
|
|
// one-time bounce (review round 1 finding 1: the first cookieless answer is a 200 page of this origin's own,
|
|
// never a redirect — a browser's own meta refresh is what reaches the real one) exactly the way a browser
|
|
// would: read the bounce's own refresh target and ask it. Answers that second response: the 302 to the
|
|
// issuer, since the cookie is still missing on a request this client never carries one on.
|
|
func followSignInBounce(t *testing.T, mw *startedMailWeb, path string) *http.Response {
|
|
t.Helper()
|
|
resp := doWithCookie(t, mw, http.MethodGet, path, nil, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s with no session: status %d, want 200 (RequireSession's own one-time bounce, review round 1 finding 1)", path, resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return doWithCookie(t, mw, http.MethodGet, extractMetaRefreshURL(t, string(body)), nil, nil)
|
|
}
|
|
|
|
// signIn drives issue 1263's own flow through HTTP alone, as a browser would, never by calling a handler
|
|
// directly: it asks path (any page behind RequireSession) with no session, follows RequireSession's own
|
|
// one-time bounce (review round 1 finding 1), reads the state the resulting redirect to the stand-in issuer
|
|
// carries, and calls back mesh-mail-web's own /signed-in with a fresh ID token the issuer mints for username.
|
|
// Answers the session cookie mesh-mail-web set — the callback's own answer is a 200 refresh page too
|
|
// (finding 1), but Set-Cookie is read from it exactly the same way a 302's would have been.
|
|
func signIn(t *testing.T, mw *startedMailWeb, username, path string) *http.Cookie {
|
|
t.Helper()
|
|
resp := followSignInBounce(t, mw, path)
|
|
if resp.StatusCode != http.StatusFound {
|
|
t.Fatalf("asking %s with no session: status %d, want 302 to sign in", path, resp.StatusCode)
|
|
}
|
|
loc, err := url.Parse(resp.Header.Get("Location"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state := loc.Query().Get("state")
|
|
if state == "" {
|
|
t.Fatalf("the redirect to sign in carries no state: %s", resp.Header.Get("Location"))
|
|
}
|
|
|
|
mw.fakeIssuer.nextIDToken = mw.fakeIssuer.idToken(testClientID, username, time.Now(), nil, mw.fakeIssuer.key, mw.fakeIssuer.kid)
|
|
cbReq, err := http.NewRequest(http.MethodGet,
|
|
"http://"+mw.internalHost+SignedInPath+"?state="+url.QueryEscape(state)+"&code=test-code", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cbReq.Host = mw.internalHost
|
|
cbResp, err := mw.client.Do(cbReq)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer cbResp.Body.Close()
|
|
for _, c := range cbResp.Cookies() {
|
|
if c.Name == SessionCookie {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("sign-in did not set a session cookie for %q (callback status %d)", username, cbResp.StatusCode)
|
|
return nil
|
|
}
|
|
|
|
// checkCSP is review round 1 finding 3's own strengthening: the content security policy (ADR 0440 §5) is
|
|
// Server.ServeHTTP's own first line, set on every answer whatever else happens to it (a page, a 404, a
|
|
// sign-in bounce or redirect alike) — so every request these tests drive through doWithCookie and doForm
|
|
// checks it, rather than the three hand-picked answers the design test asked of it before.
|
|
func checkCSP(t *testing.T, method, path string, resp *http.Response) {
|
|
t.Helper()
|
|
if got := resp.Header.Get("Content-Security-Policy"); got != CSP {
|
|
t.Fatalf("%s %s: Content-Security-Policy %q, want %q", method, path, got, CSP)
|
|
}
|
|
}
|
|
|
|
// doWithCookie is one request to mw's own endpoint, the session cookie attached, Host set to its internal
|
|
// name — the shape every page, attachment and send request in these tests shares. Every answer's own CSP is
|
|
// checked here (review round 1 finding 3), not left to a handful of tests to ask for themselves.
|
|
func doWithCookie(t *testing.T, mw *startedMailWeb, method, path string, body io.Reader, cookie *http.Cookie) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, "http://"+mw.internalHost+path, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Host = mw.internalHost
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
resp, err := mw.client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkCSP(t, method, path, resp)
|
|
return resp
|
|
}
|
|
|
|
// doForm is one form POST to mw's own endpoint, the session cookie attached when given — the shape issue
|
|
// 1268's sign-in, CSRF and send scenarios share. Every answer's own CSP is checked here too (finding 3).
|
|
func doForm(t *testing.T, mw *startedMailWeb, path string, form url.Values, cookie *http.Cookie) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest(http.MethodPost, "http://"+mw.internalHost+path, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Host = mw.internalHost
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
resp, err := mw.client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkCSP(t, http.MethodPost, path, resp)
|
|
return resp
|
|
}
|
|
|
|
// extractMetaRefreshURL reads a page's own meta refresh target out of its rendered HTML (review round 1
|
|
// finding 1, finding 3): RequireSession's own bounce, the callback's own return, and the write page's own
|
|
// send-status poll (write.go's sendStatusTemplate) all carry one, whatever its own delay. html/template's
|
|
// contextual auto-escaping recognises content="...;url=..." inside a meta tag as a URL within an HTML
|
|
// attribute, and escapes it as both (an "&" becomes "&"); html.UnescapeString reverses exactly that, so
|
|
// this never builds the URL by hand — it reads the one the page itself carries, as a browser's own meta
|
|
// refresh would.
|
|
func extractMetaRefreshURL(t *testing.T, body string) string {
|
|
t.Helper()
|
|
marker := "; url="
|
|
i := strings.Index(body, marker)
|
|
if i < 0 {
|
|
t.Fatalf("no meta refresh in the page:\n%s", body)
|
|
}
|
|
rest := body[i+len(marker):]
|
|
j := strings.Index(rest, `"`)
|
|
if j < 0 {
|
|
t.Fatalf("the meta refresh's own url is not closed:\n%s", body)
|
|
}
|
|
return html.UnescapeString(rest[:j])
|
|
}
|
|
|
|
// extractHiddenValue reads a hidden input's own value out of a rendered page's HTML, by its field name —
|
|
// issue 1284's own form token, which is never answered by any tool, only ever rendered into a page
|
|
// (write.go's FormTokenInput).
|
|
func extractHiddenValue(body, field string) string {
|
|
marker := `name="` + field + `" value="`
|
|
i := strings.Index(body, marker)
|
|
if i < 0 {
|
|
return ""
|
|
}
|
|
rest := body[i+len(marker):]
|
|
j := strings.Index(rest, `"`)
|
|
if j < 0 {
|
|
return ""
|
|
}
|
|
return rest[:j]
|
|
}
|
|
|
|
// ---- issue 1285's own acceptance criteria -----------------------------------------------------------------
|
|
|
|
// 1285/AC1: initialize and tools/list complete, and a tools/call of mail-webclient.status answers.
|
|
func TestHarnessInitializeToolsListAndStatusCall(t *testing.T) {
|
|
mw := startMailWeb(t, nil, nil)
|
|
mw.store.Accounts = func(map[string]any) (any, error) {
|
|
return map[string]any{"accounts": []any{}}, nil
|
|
}
|
|
names, err := mw.player.ToolsList()
|
|
if err != nil {
|
|
t.Fatalf("tools/list: %v", err)
|
|
}
|
|
if !containsName(names, StatusTool) {
|
|
t.Fatalf("tools/list does not name %s: %v", StatusTool, names)
|
|
}
|
|
text, isError, err := mw.player.ToolsCall(StatusTool, map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("tools/call %s: %v", StatusTool, err)
|
|
}
|
|
if isError || text == "" {
|
|
t.Fatalf("%s answered isError=%v, text=%q", StatusTool, isError, text)
|
|
}
|
|
}
|
|
|
|
// 1285/AC2: the bundle's ask of seat:mail-store.<verb> is answered by the stand-in, and recorded with its
|
|
// arguments.
|
|
func TestHarnessRecordsASeatAskWithItsArguments(t *testing.T) {
|
|
mw := startMailWeb(t, nil, nil)
|
|
mw.store.Accounts = func(map[string]any) (any, error) {
|
|
return map[string]any{"accounts": []any{map[string]any{"account": "a"}}}, nil
|
|
}
|
|
if _, _, err := mw.player.ToolsCall(StatusTool, map[string]any{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := mw.store.Calls()
|
|
if len(calls) != 1 || calls[0].Key != "seat:mail-store.accounts" {
|
|
t.Fatalf("calls recorded: %+v", calls)
|
|
}
|
|
var args map[string]any
|
|
if err := json.Unmarshal(calls[0].Body, &args); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(args) != 0 {
|
|
t.Fatalf("mail-webclient.status's own accounts ask carries arguments: %v", args)
|
|
}
|
|
}
|
|
|
|
// 1285/AC3: a value put in the real key-value bucket for account a is what the bundle's own state read of
|
|
// mesh-mail.accounts key a answers — driven through the accounts page (accounts.go), the one production
|
|
// path that reads it; there is no tool exposing the bucket read directly.
|
|
func TestHarnessStateReadAnswersTheRealBucketsValue(t *testing.T) {
|
|
mw := startMailWeb(t, map[string]accountBucketValue{
|
|
"a": {State: "current", Folders: []accountBucketFolder{{Name: "INBOX", Mails: 7, Unread: 3}}},
|
|
}, []string{"alice"})
|
|
mw.store.Accounts = func(map[string]any) (any, error) {
|
|
return map[string]any{"accounts": []any{map[string]any{"account": "a", "kind": "imap", "address": "a@example.org", "state": "current"}}}, nil
|
|
}
|
|
cookie := signIn(t, mw, "alice", "/")
|
|
resp := doWithCookie(t, mw, http.MethodGet, "/", nil, cookie)
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status %d: %s", resp.StatusCode, body)
|
|
}
|
|
text := string(body)
|
|
for _, want := range []string{"INBOX", "7 mail(s)", "3 unread"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("the page does not show the real bucket's value (%q missing):\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
// askFailRecorder is a fake askFailer: it keeps every message instead of failing a test (try 2's own
|
|
// finding — a failed subtest fails its parent through t.Run, which would make "the harness fails the test"
|
|
// itself untestable the direct way), so issue 1285/AC4's own test can assert on what was reported without
|
|
// failing itself.
|
|
type askFailRecorder struct {
|
|
mu sync.Mutex
|
|
messages []string
|
|
}
|
|
|
|
func (r *askFailRecorder) Helper() {}
|
|
|
|
func (r *askFailRecorder) Errorf(format string, args ...any) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.messages = append(r.messages, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (r *askFailRecorder) Messages() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.messages...)
|
|
}
|
|
|
|
// 1285/AC4: the bundle asking something the helper does not know is reported through the ask failer, naming
|
|
// the ask, and the bundle's own ask gets an error answer back.
|
|
func TestHarnessFailsTheTestWhenTheBundleAsksSomethingUnknown(t *testing.T) {
|
|
recorder := &askFailRecorder{}
|
|
mw := startMailWebWithFailer(t, nil, nil, recorder)
|
|
// mw.store.Accounts is left nil on purpose: mail-webclient.status's own ask of seat:mail-store.accounts
|
|
// is then unknown to the stand-in.
|
|
text, isError, err := mw.player.ToolsCall(StatusTool, map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("tools/call: %v", err)
|
|
}
|
|
if isError {
|
|
t.Fatalf("mail-webclient.status answered isError; it catches the ask's own error itself: %s", text)
|
|
}
|
|
if !strings.Contains(text, "does not answer") {
|
|
t.Fatalf("mail-webclient.status's own text does not show the ask got an error answer: %s", text)
|
|
}
|
|
messages := recorder.Messages()
|
|
if len(messages) != 1 || !strings.Contains(messages[0], "seat:mail-store.accounts") {
|
|
t.Fatalf("the recorder's messages: %v, want exactly one naming seat:mail-store.accounts", messages)
|
|
}
|
|
}
|