Files
mesh-catalog/modules/mesh-mail-web/internal/web/runtime_integration_test.go
T
jochen ad4f312263 issue 1859: open the mail page on the operator's list (1859.1)
ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then
shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam
warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
2026-10-22 20:03:21 +02:00

1034 lines
39 KiB
Go

//go:build integration
package web
// 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. The key is matched BEFORE anything is recorded (review
// round 1 finding 1, issue 1430/1523): a key that is not one of mail-store's own five verbs — such as
// seat:mail-reading.<verb>, which handleAsk tries here first and falls through on — is never added to
// Calls/Keys at all, so a test's own "only these verbs were asked" check (acceptance_integration_test.go's
// wantKeys) is never tripped by an ask this stand-in was never for.
func (s *standInMailStore) answer(key string, args map[string]any) (any, error, bool) {
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
}
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
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 stand-in mail-reading (issue 1523/AC4) ------------------------------------------------------------
// mailReadingFunc is mailStoreFunc's own shape, for seat:mail-reading.<verb> (issue 1523/AC4).
type mailReadingFunc func(key string, args map[string]any) (result any, err error, known bool)
// standInMailReading is mail-reading's own six seat verbs, faked the same shape as standInMailStore above:
// one function per verb, called by key, every call recorded. Extends the harness that issue 1285 built (the
// pattern it already proves): a correction posted on the page reaches this stand-in's Correct, an undo
// reaches Undo, and nothing of mail is read but through mail-store (1523/AC4) — this stand-in never touches
// a mailbox itself.
type standInMailReading struct {
mu sync.Mutex
Entries func(args map[string]any) (any, error)
Groups func(args map[string]any) (any, error)
Status func(args map[string]any) (any, error)
Correct func(args map[string]any) (any, error)
Lessons func(args map[string]any) (any, error)
Undo func(args map[string]any) (any, error)
// The list page's verbs (issue 1859): left unset, each answers an error, as the six above do.
List func(args map[string]any) (any, error)
Handle func(args map[string]any) (any, error)
Unhandle func(args map[string]any) (any, error)
Remind func(args map[string]any) (any, error)
Record func(args map[string]any) (any, error)
calls []askRecord
}
// answer is this stand-in's own mailReadingFunc. A key matched below but left unset by the test answers an
// error, never "unknown" (review round 1 finding 1, issue 1430/1523): accounts.go's mailReadingSection asks
// mail-reading.status on every page load now (issue 1430), so every test from before mail-reading existed
// that loads "/" and never configured this stand-in at all would otherwise fail as an unknown ask; an error
// here is exactly what mailReadingSection itself reads as "mail-reading does not answer" and takes its own
// "unavailable" path, same as the real seat refusing or timing out would. A key that is not one of
// mail-reading's own six verbs is still unknown, as before (never recorded, below).
func (s *standInMailReading) answer(key string, args map[string]any) (any, error, bool) {
var f func(args map[string]any) (any, error)
switch key {
case "seat:mail-reading.entries":
f = s.Entries
case "seat:mail-reading.groups":
f = s.Groups
case "seat:mail-reading.status":
f = s.Status
case "seat:mail-reading.correct":
f = s.Correct
case "seat:mail-reading.lessons":
f = s.Lessons
case "seat:mail-reading.undo":
f = s.Undo
case "seat:mail-reading.list":
f = s.List
case "seat:mail-reading.handle":
f = s.Handle
case "seat:mail-reading.unhandle":
f = s.Unhandle
case "seat:mail-reading.remind":
f = s.Remind
case "seat:mail-reading.record":
f = s.Record
default:
return nil, nil, false
}
raw, _ := json.Marshal(args)
s.mu.Lock()
s.calls = append(s.calls, askRecord{Key: key, Body: raw})
s.mu.Unlock()
if f == nil {
return nil, fmt.Errorf("the test's own stand-in for %s answers nothing", key), true
}
out, err := f(args)
return out, err, true
}
// Calls is every ask this stand-in has answered so far, a copy (1523/AC4).
func (s *standInMailReading) Calls() []askRecord {
s.mu.Lock()
defer s.mu.Unlock()
return append([]askRecord(nil), s.calls...)
}
// ---- 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/mesh-mail-web")
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
mailReading mailReadingFunc
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, mailReading mailReadingFunc, 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, mailReading: mailReading, 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, &params)
// 1523/AC4: a key of either stand-in is tried in turn; mail-reading's own verbs never reach mail-store's.
if p.mailStore != nil {
if result, err, known := p.mailStore(params.Key, params.Body); known {
if err != nil {
reply(nil, err.Error())
return
}
reply(result, "")
return
}
}
if p.mailReading != nil {
if result, err, known := p.mailReading(params.Key, params.Body); known {
if err != nil {
reply(nil, err.Error())
return
}
reply(result, "")
return
}
}
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)
}
// 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, &params)
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
mailReading *standInMailReading
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{}
mailReading := &standInMailReading{}
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, mailReading.answer, js, failer)
waitForHTTP(t, port)
return &startedMailWeb{
player: player, store: store, mailReading: mailReading, 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 "&amp;"); 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, "/all", 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)
}
}