//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.). 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-" 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. 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.", stdio.go's own Ask) and its body, whole. type askRecord struct { Key string Body json.RawMessage } // mailStoreFunc answers one seat:mail-store. 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., 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. (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, "_" (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. (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-", 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.) — 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) // 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. (state.go's KeptState.ask, params {"state":".", ...}): // 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 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..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. 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) } }