Files
mesh-catalog/modules/mesh-cli/cmd/mesh-cli/main_test.go
T
jschoubben 067f2f4d61 Record no hand act for a cleanup that deletes nothing
nox cleanup delete without --confirm deletes nothing; the vault refuses it
for its missing confirm alone and judges only a confirmed delete, and nox
records the warranting act only for that one (issue 1126 review 2).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 12:29:56 +01:00

320 lines
17 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal.
func init() { standardInput = func() (io.Reader, bool) { return nil, true } }
// asked is one call the stand-in console took: the console's tool, and for mesh_call the address and its arguments.
type asked struct {
Tool string
Address string
Args string
}
// console is a stand-in for this machine's console: it answers MCP tools/call over HTTP as node-tools does, with the
// answer the test gives per address (an error when it starts with "!"), and records what it was asked.
func console(t *testing.T, answers map[string]string) *[]asked {
t.Helper()
var got []asked
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/mcp" || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
var req struct {
Method string `json:"method"`
Params struct {
Name string `json:"name"`
Arguments struct {
Address string `json:"address"`
Arguments map[string]any `json:"arguments"`
Query string `json:"query"`
} `json:"arguments"`
} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Method != "tools/call" {
t.Errorf("the console was asked %v %v", req.Method, err)
}
a := req.Params.Arguments
args, _ := json.Marshal(a.Arguments)
got = append(got, asked{req.Params.Name, a.Address, string(args)})
key := a.Address
if req.Params.Name != "mesh_call" {
key = req.Params.Name
}
text, ok := answers[key]
if !ok {
text = `{"ok":true}`
}
isError := strings.HasPrefix(text, "!")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{
"content": []any{map[string]any{"type": "text", "text": strings.TrimPrefix(text, "!")},
map[string]any{"type": "text", "text": "answered by ace"}}, "isError": isError}})
}))
t.Cleanup(srv.Close)
t.Setenv("MESH_CONSOLE", srv.URL+"/mcp")
t.Setenv("MESH_VERB", "")
return &got
}
func runLine(args ...string) (int, string, string) {
var out, errOut bytes.Buffer
code := run(context.Background(), args, &out, &errOut)
return code, out.String(), errOut.String()
}
// Every owned line calls the seat verb or module tool that owns its job, with the arguments the owner reads
// (novox/hq issue 1126; hq .work/1126/nox-map.md), and prints its answer.
func TestEachLineCallsItsOwner(t *testing.T) {
now = func() time.Time { return time.Date(2026, 11, 2, 10, 0, 0, 0, time.UTC) }
t.Cleanup(func() { now = time.Now })
for _, c := range []struct {
line string
address string
args string
}{
{"node show ace", "mesh-placement.placement", `{"node":"ace"}`},
{"node public-domain ace", "mesh-placement.placement", `{"node":"ace"}`},
{"node public-domain ace example.org --why moved", "mesh-placement.propose", `{"changes":[{"field":"public-domain","node":"ace","op":"decide","value":"example.org"}],"why":"moved"}`},
{"plan ace", "mesh-delivery-view.assigner_wants", `{"node":"ace"}`},
{"haves", "mesh-delivery-view.node_haves", `{}`},
{"composition ace plex", "ace/node-engine.composition", `{"module":"plex"}`},
{"seats", "mesh-placement.placement", `{"seats":"true"}`},
{"assign ace plex sonarr --why=media", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"place"},{"module":"sonarr","node":"ace","op":"place"}],"why":"media"}`},
{"unassign ace plex --why gone", "mesh-placement.propose", `{"changes":[{"module":"plex","node":"ace","op":"take-off"}],"why":"gone"}`},
{"pin ace postgres-database novox postgres --why one", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"pin","provider":{"module":"postgres","node":"novox"},"provision":"postgres-database"}],"why":"one"}`},
{"unpin ace postgres-database --why back", "mesh-placement.propose", `{"changes":[{"node":"ace","op":"unpin","provision":"postgres-database"}],"why":"back"}`},
{"deliveries --state merged --json", "mesh-delivery.deliveries", `{"state":"merged"}`},
{"delivery novox/mesh-sdk@dff0ce2abb21", "mesh-delivery.show", `{"id":"novox/mesh-sdk@dff0ce2abb21"}`},
{"checks novox/mesh-catalog --number 591", "mesh-delivery.checks", `{"number":"591","repository":"novox/mesh-catalog"}`},
{"try novox/mesh-catalog abc123 --level unit", "mesh-delivery-checker.try", `{"head":"abc123","level":"unit","repository":"novox/mesh-catalog"}`},
{"builds", "mesh-delivery-builder.builder_builds_since", `{"since":"2026-11-01T10:00:00Z"}`},
{"upgrade nats --policy record --why bus", "mesh-wants.upgrade", `{"module":"nats","policy":"record","why":"bus"}`},
{"bus", "mesh-wants.bus", `{}`},
{"conditions --severity urgent", "mesh-conditions.conditions", `{"severity":"urgent"}`},
{"conditions show machine.g14.root-not-free", "mesh-conditions.conditions", `{"key":"machine.g14.root-not-free"}`},
{"conditions silence k1 --for 4h --why known", "mesh-conditions.silence", `{"for":"4h","key":"k1","why":"known"}`},
{"conditions history --days 3", "mesh-conditions.conditions", `{"days":"3","history":"true"}`},
{"conditions history --key k1", "mesh-conditions.conditions_history", `{"key":"k1"}`},
{"doctor", "mesh-self-check.doctor", `{}`},
{"doctor run", "mesh-self-check.doctor", `{"run":true}`},
{"doctor probe D6", "mesh-self-check.doctor", `{"probe":"D6"}`},
{"healers --days 2", "mesh-healers.healers", `{"days":2}`},
{"hand-act record restarted the view --why stuck --cause view", "mesh-hand-acts.hand-act", `{"cause":"view","what":"restarted the view","why":"stuck"}`},
{"hand-acts --days 3", "mesh-hand-acts.hand-acts", `{"days":3}`},
{"settings show zsh --node g14", "mesh-config.config", `{"action":"list","module":"zsh","node":"g14"}`},
{"settings set zsh execute withhold --node g14 --why test", "mesh-config.config", `{"action":"set","key":"execute","module":"zsh","node":"g14","value":"withhold","why":"test"}`},
{"settings clear zsh execute --node g14 --why test", "mesh-config.config", `{"action":"clear","key":"execute","module":"zsh","node":"g14","why":"test"}`},
{"config history --key k --limit 5", "mesh-config.config", `{"action":"history","key":"k","limit":5}`},
{"operator key show", "mesh-vault.vault_status", `{}`},
// A cleanup without --confirm deletes nothing, so no hand act is recorded for it (issue 1126 review 2).
{"cleanup delete ace.plex --why dry", "mesh-vault.provisioner_delete", `{"consumer":"ace.plex","why":"dry"}`},
{"collect --confirm --why space", "mesh-artifact-sweep.collect", `{"confirm":"true","why":"space"}`},
{"images ace", "mesh-artifact-sweep.images", `{"node":"ace"}`},
{"licence use g14 work", "anthropic-licence-manager.switch", `{"consumer":"g14","licence":"work"}`},
{"module show plex", "mesh-delivery-view.indexer_module", `{"module":"plex"}`},
{"issues apostrophe bug --status open --limit 5", "issue-tracker.list", `{"limit":5,"search":"apostrophe bug","status":"open"}`},
{"issue 1126 --brief", "issue-tracker.get", `{"brief":true,"number":1126}`},
{"kb ask what holds about the merge window", "knowledge-base.ask", `{"question":"what holds about the merge window"}`},
{"kb get 587 --reasoning", "knowledge-base.get", `{"number":587,"reasoning":true}`},
{"progress 47", "knowledge-base.progress", `{"of":"design 47"}`},
{"enrol issue new1 --why laptop --expires 2h", "mesh-enrolment.issue", `{"expires":"2h","node":"new1","why":"laptop"}`},
{"token issue --new new1 --why laptop", "mesh-enrolment.issue", `{"node":"new1","why":"laptop"}`},
{"enrol tokens", "mesh-enrolment.tokens", `{}`},
{"call ace/node-service-manager.status {\"unit\":\"plex\"}", "ace/node-service-manager.status", `{"unit":"plex"}`},
{"ask mesh-catalog catalog_modules", "mesh-catalog.catalog_modules", `{}`},
} {
got := console(t, nil)
args := strings.Fields(c.line)
if strings.HasPrefix(c.line, "call ") {
args = []string{"call", "ace/node-service-manager.status", `{"unit":"plex"}`}
}
code, out, errOut := runLine(args...)
if code != 0 || len(*got) != 1 || (*got)[0].Tool != "mesh_call" || (*got)[0].Address != c.address || (*got)[0].Args != c.args {
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want %s %s", c.line, code, *got, errOut, c.address, c.args)
continue
}
if !strings.Contains(out, `"ok":true`) {
t.Errorf("nox %s printed %q, not the owner's answer", c.line, out)
}
}
}
// A line the controller ran that no seat owns says so and what to use, exits 1 and calls nothing; a line nox cannot
// read says how it is written, exits 2 and calls nothing.
func TestALineWithNoOwnerOrNotReadCallsNothingAndSaysWhy(t *testing.T) {
for _, c := range []struct {
line string
code int
err string
}{
{"push ace", 1, "mesh-delivery-assigner.assigner_put"},
{"migrate", 1, "no seat or module does this now"},
{"secret accept ace plex token", 1, "mesh-vault.secret-ask"},
{"node add new1", 1, "nox enrol issue"},
{"pause", 1, "node-build-go.pause"},
{"operator key make", 1, "operator key show"},
{"assign ace plex", 2, "needs --why"},
{"issue twelve", 2, "not an issue's number"},
{"conditions --bogus x", 2, "--bogus is not a flag"},
{"settings set zsh {\"execute\":\"withhold\"} --node g14 --why x", 2, "set each key"},
{"healers --days soon", 2, "takes a number"},
{"frobnicate", 2, "is not a command"},
} {
got := console(t, nil)
args := strings.Fields(c.line)
code, _, errOut := runLine(args...)
if code != c.code || len(*got) != 0 || !strings.Contains(errOut, c.err) {
t.Errorf("nox %s: exit %d, asked %+v, stderr %q; want exit %d saying %q", c.line, code, *got, errOut, c.code, c.err)
}
if c.code == 1 && !strings.Contains(errOut, "issue 1126") {
t.Errorf("nox %s does not say the controller is unassigned: %q", c.line, errOut)
}
}
}
// What the console says is printed as said: an answer it marks an error on standard error with exit 1, a console that
// does not answer as nothing called, a verb's process refused before anything is called; nodes are one line each.
func TestAnswersRefusalsAndSilenceAreSaid(t *testing.T) {
got := console(t, map[string]string{"mesh-enrolment.tokens": "!nothing serves seat:mesh-enrolment.tokens"})
if code, out, errOut := runLine("enrol", "tokens"); code != 1 || out != "" || !strings.Contains(errOut, "nothing serves") {
t.Errorf("an error answer: exit %d, stdout %q, stderr %q", code, out, errOut)
}
placement := `{"keys":{"novox":{"placement":{"decided":{"address":"10.10.0.1","site":"hub"},"modules":[{},{}]}},` +
`"ace":{"placement":{"decided":{"address":"10.10.0.2"},"modules":[{}]}}}}`
console(t, map[string]string{"mesh-placement.placement": placement})
if code, out, _ := runLine("nodes"); code != 0 || !strings.HasPrefix(out, "ace ") ||
!strings.Contains(out, "10.10.0.1") || !strings.Contains(out, "2 modules") || !strings.HasSuffix(out, "answered by ace\n") {
t.Errorf("nodes: exit %d, %q", code, out)
}
got = console(t, map[string]string{"mesh_search": `{"matches":[]}`})
if code, out, _ := runLine("search", "restart", "a", "unit"); code != 0 || (*got)[0].Tool != "mesh_search" || !strings.Contains(out, "matches") {
t.Errorf("search: exit %d, asked %+v, %q", code, *got, out)
}
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:1/mcp")
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "nothing was called") {
t.Errorf("no console: exit %d, %q", code, errOut)
}
t.Setenv("MESH_VERB", "some.verb")
if code, _, errOut := runLine("conditions"); code != 1 || !strings.Contains(errOut, "a verb is never the operator") {
t.Errorf("a verb's process: exit %d, %q", code, errOut)
}
}
// The status is the delivery view's. When the view does not answer, that is said first, the conditions and the
// deliveries are shown under their own addresses, and the exit is 1: they are never passed off as the status.
func TestStatusIsTheViewsAndItsSilenceIsSaid(t *testing.T) {
got := console(t, map[string]string{"mesh-delivery-view.view_status": `{"well":true}`})
if code, out, _ := runLine("status"); code != 0 || len(*got) != 1 || !strings.Contains(out, `"well":true`) {
t.Errorf("status: exit %d, asked %+v, %q", code, *got, out)
}
got = console(t, map[string]string{"mesh-delivery-view.view_status": "!mesh-delivery-view.view_status did not answer within 30s",
"mesh-conditions.conditions": `{"conditions":[]}`, "mesh-delivery.deliveries": `{"deliveries":[]}`})
code, out, errOut := runLine("status")
if code != 1 || len(*got) != 3 || !strings.Contains(errOut, "did not answer") || !strings.Contains(errOut, "not the status") ||
!strings.Contains(out, "== mesh-conditions.conditions") || !strings.Contains(out, "== mesh-delivery.deliveries") {
t.Errorf("status, the view silent: exit %d, asked %+v, stdout %q, stderr %q", code, *got, out, errOut)
}
}
// The console's address comes from the file the module writes from its mcp-endpoint; MESH_CONSOLE overrides it; with
// neither, the port node-tools declares. An answer given as a server-sent event is read like one given as JSON.
func TestTheConsoleIsFoundAndItsAnswerRead(t *testing.T) {
t.Setenv("MESH_CONSOLE", "")
dir := t.TempDir()
before := consoleFile
consoleFile = filepath.Join(dir, "console")
t.Cleanup(func() { consoleFile = before })
if url, _ := consoleAddress(); url != defaultConsole {
t.Errorf("no file: %s", url)
}
if err := os.WriteFile(consoleFile, []byte("http://127.0.0.1:4999/mcp\n"), 0o644); err != nil {
t.Fatal(err)
}
if url, from := consoleAddress(); url != "http://127.0.0.1:4999/mcp" || from != consoleFile {
t.Errorf("the file: %s from %s", url, from)
}
t.Setenv("MESH_CONSOLE", "http://127.0.0.1:5000/mcp")
if url, _ := consoleAddress(); url != "http://127.0.0.1:5000/mcp" {
t.Errorf("the variable: %s", url)
}
a, err := decodeAnswer([]byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}],\"isError\":true}}\n\n"))
if err != nil || a.Text != "hi" || !a.IsError {
t.Errorf("an event: %+v %v", a, err)
}
if _, err := decodeAnswer([]byte("<html>")); err == nil {
t.Error("a page that is no answer was read as one")
}
}
// Every command's line is in the help, and none is defined twice.
func TestEveryLineIsInTheHelpOnce(t *testing.T) {
seen := map[string]bool{}
help := usage()
for _, c := range commands {
w := strings.Join(c.words, " ")
if seen[w] {
t.Errorf("nox %s is defined twice", w)
}
seen[w] = true
if c.build != nil && !strings.Contains(help, "nox "+w+" ") {
t.Errorf("nox %s is not in the help", w)
}
if c.build == nil && c.instead == "" {
t.Errorf("nox %s has neither an owner nor an instead", w)
}
if _, dup := unowned[c.words[0]]; dup && len(c.words) == 1 {
t.Errorf("nox %s is both owned and unowned", w)
}
}
}
// ADR 0590 §8 (issue 1126 review 1): each sensitive vault verb judges its own caller, and nox reaches the vault as its
// machine's runtime, so nox first records the hand act that warrants the line through mesh-hand-acts (the vault verb as
// its verb, what it acts on as its what) and names it as `act`; a recording refused calls the vault not at all.
func TestASensitiveLineRecordsItsHandActAndNamesIt(t *testing.T) {
recorded := `{"act":{"id":"act-7","verb":"mesh-vault.x"},"said":"recorded"}`
for _, c := range []struct{ line, address, verb, what, args string }{
{"secret export", "mesh-vault.secret_vault_export", "mesh-vault.secret_vault_export", "the vault's sealed copies", `{"act":"act-7"}`},
{"rotate --node ace --module plex --name token --why leak", "mesh-vault.rotate", "mesh-vault.rotate", "own/ace/plex/token",
`{"act":"act-7","module":"plex","name":"token","node":"ace","why":"leak"}`},
{"rotate --provider novox --provision postgres --why yearly", "mesh-vault.rotate", "mesh-vault.rotate", "provision/novox/postgres",
`{"act":"act-7","provider":"novox","provision":"postgres","why":"yearly"}`},
{"retire approve c1 c2 --why done", "mesh-vault.provisioner_retire_approve", "mesh-vault.provisioner_retire_approve", "c1 c2",
`{"act":"act-7","consumers":["c1","c2"],"why":"done"}`},
{"cleanup delete ace.plex --why done --confirm", "mesh-vault.provisioner_delete", "mesh-vault.provisioner_delete", "ace.plex",
`{"act":"act-7","confirm":"ace.plex","consumer":"ace.plex","why":"done"}`},
} {
got := console(t, map[string]string{"mesh-hand-acts.hand-act": recorded})
code, _, errOut := runLine(strings.Fields(c.line)...)
if code != 0 || len(*got) != 2 || (*got)[0].Address != "mesh-hand-acts.hand-act" || (*got)[1].Address != c.address ||
(*got)[1].Args != c.args {
t.Errorf("nox %s: exit %d, asked %+v, stderr %q", c.line, code, *got, errOut)
continue
}
var act map[string]any
_ = json.Unmarshal([]byte((*got)[0].Args), &act)
if act["verb"] != c.verb || act["what"] != c.what || act["why"] == "" || act["cause"] == "" {
t.Errorf("nox %s recorded %v", c.line, act)
}
}
got := console(t, map[string]string{"mesh-hand-acts.hand-act": "!nothing serves seat:mesh-hand-acts.hand-act"})
if code, _, errOut := runLine("secret", "export"); code != 1 || len(*got) != 1 || !strings.Contains(errOut, "nothing serves") {
t.Errorf("a refused recording: exit %d, asked %+v, %q", code, *got, errOut)
}
}