diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go new file mode 100644 index 00000000..b5ec4cce --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/runtime_integration_test.go @@ -0,0 +1,797 @@ +//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.). 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" + "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. +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, "_" (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) ------------------------------------------------------------ + +// 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 { + t *testing.T + 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). +func startRuntimePlayer(t *testing.T, binary string, env []string, mailStore mailStoreFunc, js jetstream.JetStream) *runtimePlayer { + t.Helper() + 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{t: t, 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-", 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) { + 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.t.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)) { + var params struct { + Key string `json:"key"` + Body map[string]any `json:"body"` + } + _ = json.Unmarshal(m.Params, ¶ms) + if p.mailStore == nil { + p.t.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.t.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. (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)) { + 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.t.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 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. +func startMailWeb(t *testing.T, accounts map[string]accountBucketValue, operators []string) *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) + 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..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") +} + +// 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, reads the state the 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. +func signIn(t *testing.T, mw *startedMailWeb, username, path string) *http.Cookie { + t.Helper() + req, err := http.NewRequest(http.MethodGet, "http://"+mw.internalHost+path, nil) + if err != nil { + t.Fatal(err) + } + req.Host = mw.internalHost + resp, err := mw.client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + 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 +} + +// 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. +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) + } + return resp +} + +// ---- 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, "/", 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) + } + } +} + +// 1285/AC4: the bundle asking something the helper does not know fails the test, naming the ask — checked in +// a subtest, so this test can read whether it failed (t.Run answers false on a failure) without failing +// itself. +func TestHarnessFailsTheTestWhenTheBundleAsksSomethingUnknown(t *testing.T) { + passed := t.Run("deliberately unanswered ask", func(t *testing.T) { + mw := startMailWeb(t, nil, nil) + // 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, and the player must fail this subtest, + // naming the ask, rather than only shaping a protocol error (issue 1285/AC4). + if _, _, err := mw.player.ToolsCall(StatusTool, map[string]any{}); err != nil { + t.Logf("tools/call itself answered an error, which is fine here: %v", err) + } + }) + if passed { + t.Fatal("the subtest did not fail despite an ask the stand-in did not know (issue 1285/AC4)") + } +} diff --git a/modules/mesh-mail-web/go.mod b/modules/mesh-mail-web/go.mod index 9664b173..cb3bc2cb 100644 --- a/modules/mesh-mail-web/go.mod +++ b/modules/mesh-mail-web/go.mod @@ -5,7 +5,21 @@ go 1.26.0 require ( git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c github.com/coreos/go-oidc/v3 v3.21.0 + github.com/nats-io/nats-server/v2 v2.11.17 + github.com/nats-io/nats.go v1.54.0 golang.org/x/oauth2 v0.37.0 ) -require github.com/go-jose/go-jose/v4 v4.1.4 // indirect +require ( + github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/klauspost/compress v1.20.0 // indirect + github.com/minio/highwayhash v1.0.4 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.16 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect +) diff --git a/modules/mesh-mail-web/go.sum b/modules/mesh-mail-web/go.sum index f405e4dd..b39b27e2 100644 --- a/modules/mesh-mail-web/go.sum +++ b/modules/mesh-mail-web/go.sum @@ -1,8 +1,33 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= +github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= +github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I= +github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24= +github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA= +github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k= +github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg= +github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=