mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group fix/383-a-proposal-reads-whole-on-the-phone ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The review of 2026-10-10: a mixed-case path with a digit ("/mnt/Photos_2024/Jochen") read as a
random string and was withheld from the whole words, the symptom again (outward.Secret now judges
a run with a slash piece by piece, as the messenger's CheckSecret does); and the Details line on
masking read, on the phone, as if something there were masked.
836 lines
36 KiB
Go
836 lines
36 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"os"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||
|
||
"github.com/novox/mesh-controller/internal/catalogue"
|
||
"github.com/novox/mesh-controller/internal/conditions"
|
||
"github.com/novox/mesh-controller/internal/outward"
|
||
)
|
||
|
||
// novox/hq ADR 0277: a trusted setting is proposed through a verb by anyone the bus admits, asked of the
|
||
// operator at the level approve with the exact change, and set only on the operator's warrant — once, for the
|
||
// exact values the option bound; declined, expired or refused, it is discarded; nothing is asked when nothing
|
||
// can carry the ask.
|
||
|
||
// aProposer is the propose path with its reaches faked: a layer as it stands, a judge that records what it
|
||
// judged, a memory store, and what was published.
|
||
type proposerRig struct {
|
||
pr proposer
|
||
store memAskedStore
|
||
sent []published
|
||
judged []map[string]any
|
||
before map[string]any
|
||
had bool
|
||
refusedBy string
|
||
now time.Time
|
||
}
|
||
|
||
func newProposerRig(t *testing.T) *proposerRig {
|
||
r := &proposerRig{store: memAskedStore{}, now: time.Date(2026, 10, 10, 14, 5, 0, 0, time.UTC)}
|
||
r.pr = proposer{
|
||
layer: func(_ context.Context, node, module string) (map[string]any, bool, error) {
|
||
return r.before, r.had, nil
|
||
},
|
||
judge: func(_ context.Context, node, module string, values map[string]any) error {
|
||
r.judged = append(r.judged, values)
|
||
if r.refusedBy != "" {
|
||
return errors.New(r.refusedBy)
|
||
}
|
||
return nil
|
||
},
|
||
machines: func(context.Context) ([]string, error) { return []string{"anchor", "laptop", "shanks"}, nil },
|
||
store: r.store,
|
||
publish: func(_ context.Context, subject string, body []byte, id string) error {
|
||
r.sent = append(r.sent, published{subject, id, body})
|
||
return nil
|
||
},
|
||
now: func() time.Time { return r.now },
|
||
caller: "g14/claude-code, through the mesh-controller seat",
|
||
waitFor: time.Millisecond,
|
||
waitEvery: time.Millisecond,
|
||
}
|
||
return r
|
||
}
|
||
|
||
func (r *proposerRig) askSent(t *testing.T) asks.Ask {
|
||
t.Helper()
|
||
if len(r.sent) != 1 || r.sent[0].subject != asks.AskSubject("mesh-controller") {
|
||
t.Fatalf("published %+v", r.sent)
|
||
}
|
||
var q asks.Ask
|
||
if err := json.Unmarshal(r.sent[0].body, &q); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return q
|
||
}
|
||
|
||
func (r *proposerRig) theProposal(t *testing.T) asked {
|
||
t.Helper()
|
||
for _, a := range r.store {
|
||
if a.Proposal != nil {
|
||
return a
|
||
}
|
||
}
|
||
t.Fatal("no proposal is kept")
|
||
return asked{}
|
||
}
|
||
|
||
var mountsSources = map[string]any{"sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", "shares": "library=/mnt/library"}
|
||
|
||
// The ask: at the level approve on both answers, each binding the proposal's act, with every key and its exact new
|
||
// value as far as the content rule lets it leave the mesh, the layer it was shown against, and nothing set.
|
||
func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
r.before, r.had = map[string]any{"shares": "none", "old": "x"}, true
|
||
words, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: mountsSources, replace: true})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := r.askSent(t)
|
||
if err := q.Check(r.now); err != nil {
|
||
t.Fatalf("the ask is refused: %v", err)
|
||
}
|
||
if q.Headline != "Set mounts on shanks?" || q.About != "settings.mounts.shanks" || q.Who != asks.Operator ||
|
||
!q.Expires.Equal(r.now.Add(askApproveFor)) {
|
||
t.Errorf("the ask: %+v", q)
|
||
}
|
||
if len(q.Options) != 2 {
|
||
t.Fatalf("options %+v", q.Options)
|
||
}
|
||
for _, o := range q.Options {
|
||
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
|
||
t.Errorf("the option %s is %s and binds %q", o.ID, o.Level, o.Binds)
|
||
}
|
||
}
|
||
if q.Options[0].Binds == q.Options[1].Binds {
|
||
t.Error("Approve and Decline bind the same act")
|
||
}
|
||
// The message's shape (issue 383): one line per key, then who proposed it and when — and nothing else: the
|
||
// fingerprint and the how-to are the Details answer's.
|
||
proposedBy := "Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") + "."
|
||
if q.Explanation != "+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)\n"+proposedBy {
|
||
t.Errorf("the explanation:\n%s", q.Explanation)
|
||
}
|
||
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
|
||
if strings.Contains(q.Explanation, leak) {
|
||
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
|
||
}
|
||
}
|
||
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
|
||
if q.Whole != "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)\n"+proposedBy {
|
||
t.Errorf("the whole words:\n%s", q.Whole)
|
||
}
|
||
for _, line := range []string{
|
||
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
|
||
"On a channel that does not prove who answers, these values are shown as ‹address›, ‹path› or ‹withheld›.",
|
||
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
|
||
"Approved, the layer is set at once and the machine takes it at its next push.",
|
||
} {
|
||
if !strings.Contains(q.Details, line) {
|
||
t.Errorf("Details lack %q:\n%s", line, q.Details)
|
||
}
|
||
}
|
||
for _, howTo := range []string{"ingerprint", "settings proposals", "mesh-controller.settings", "does not prove", "Approved,"} {
|
||
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
|
||
t.Errorf("the message carries the how-to %q", howTo)
|
||
}
|
||
}
|
||
all := []string{q.Headline, q.Explanation, q.OnExpiry}
|
||
for _, o := range q.Options {
|
||
all = append(all, o.Label, o.Does)
|
||
}
|
||
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
|
||
t.Errorf("the router would refuse the ask: %s", refusal)
|
||
}
|
||
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
|
||
t.Errorf("the router would refuse the whole words: %s", refusal)
|
||
}
|
||
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
|
||
kept := r.theProposal(t)
|
||
p := kept.Proposal
|
||
if kept.State != askOpen || kept.Ask.Digest() != q.Digest() || p.Module != "mounts" || p.Node != "shanks" ||
|
||
p.Digest != layerDigest(mountsSources) || p.BeforeDigest != layerDigest(r.before) || !p.Replace || !p.HadLayer ||
|
||
p.From != r.pr.caller || kept.ofACondition() {
|
||
t.Errorf("kept %+v / %+v", kept, p)
|
||
}
|
||
// Each option binds exactly the act the controller will perform (boundAct over the kept action).
|
||
for i, act := range kept.Actions {
|
||
binds, _ := asks.ActDigest(boundAct(act))
|
||
if q.Options[i].Binds != binds || act.Arguments["values"] != p.Digest || act.Arguments["before"] != p.BeforeDigest ||
|
||
act.Verb != proposalVerb || act.Level != conditions.LevelApprove {
|
||
t.Errorf("the option %s does not bind the kept act: %+v", q.Options[i].ID, act)
|
||
}
|
||
}
|
||
if len(r.judged) != 1 || r.judged[0]["sources"] != mountsSources["sources"] {
|
||
t.Errorf("judged %v", r.judged)
|
||
}
|
||
if !strings.Contains(words, "nothing changes") || !strings.Contains(words, kept.ID) {
|
||
t.Errorf("the caller is told: %s", words)
|
||
}
|
||
}
|
||
|
||
// A layer for the whole mesh is proposed too.
|
||
func TestAMeshWideLayerIsProposed(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", values: map[string]any{"x": "1"}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := r.askSent(t)
|
||
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
|
||
!strings.HasPrefix(q.Explanation, "+ x: 1\nProposed by ") || q.Whole != "" {
|
||
t.Errorf("%+v", q)
|
||
}
|
||
}
|
||
|
||
// kept is the one proposal the rig keeps.
|
||
func kept(r *proposerRig) asked {
|
||
for _, a := range r.store {
|
||
if a.Proposal != nil {
|
||
return a
|
||
}
|
||
}
|
||
return asked{}
|
||
}
|
||
|
||
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
|
||
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
|
||
// key still named; and a change no value of which is masked carries no whole words of its own.
|
||
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox",
|
||
"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----", "font": "Inter 13"}
|
||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := r.askSent(t)
|
||
masked, _ := kept(r).Proposal.change([]string{"anchor", "laptop", "shanks"}, false)
|
||
whole, _ := kept(r).Proposal.change([]string{"anchor", "laptop", "shanks"}, true)
|
||
if !strings.Contains(q.Explanation, masked) || !strings.Contains(q.Whole, whole) {
|
||
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
|
||
}
|
||
for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"} {
|
||
if !strings.Contains(masked, line) {
|
||
t.Errorf("masked, lacks %q:\n%s", line, masked)
|
||
}
|
||
}
|
||
for _, line := range []string{"+ cert: ‹withheld›", "+ font: Inter 13", "+ github: ‹withheld›", "+ shares: media=/storage/media",
|
||
"+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"} {
|
||
if !strings.Contains(whole, line) {
|
||
t.Errorf("whole, lacks %q:\n%s", line, whole)
|
||
}
|
||
}
|
||
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
|
||
if strings.Contains(q.Explanation, secret) || strings.Contains(q.Whole, secret) || strings.Contains(q.Details, secret) {
|
||
t.Errorf("the secret %q reaches the phone", secret)
|
||
}
|
||
}
|
||
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
|
||
t.Error("the router would refuse the whole words")
|
||
}
|
||
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
|
||
m, w := strings.Split(masked, "\n"), strings.Split(whole, "\n")
|
||
if len(m) != len(w) {
|
||
t.Fatalf("masked %d lines, whole %d", len(m), len(w))
|
||
}
|
||
differ := 0
|
||
for i := range m {
|
||
if m[i] != w[i] {
|
||
differ++
|
||
if !strings.Contains(m[i], "‹") || strings.Contains(w[i], "‹") {
|
||
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", m[i], w[i])
|
||
}
|
||
}
|
||
}
|
||
if differ != 2 {
|
||
t.Errorf("%d lines differ, and the mask covered 2", differ)
|
||
}
|
||
// No value masked: the message is the same everywhere, and the ask says no whole words.
|
||
r2 := newProposerRig(t)
|
||
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasPrefix(q2.Explanation, "+ font-size: 13\n+ width: 500\nProposed by ") ||
|
||
strings.Contains(q2.Details, "does not prove who answers") {
|
||
t.Errorf("%+v", q2)
|
||
}
|
||
}
|
||
|
||
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
|
||
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
|
||
r := newAskerRig(t)
|
||
calls := withSetLayer(r)
|
||
a := aProposalAsked(t, r, "s7", aProposal(r.now))
|
||
r.now = r.now.Add(askApproveFor + time.Minute)
|
||
if err := r.a.reconcile(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := r.store["s7"]; got.State != string(asks.OutcomeExpired) || !strings.HasPrefix(got.Acted, "nothing") {
|
||
t.Errorf("kept as %+v", got)
|
||
}
|
||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(-2*time.Minute)))
|
||
if len(*calls) != 0 {
|
||
t.Errorf("set after expiry: %+v", *calls)
|
||
}
|
||
}
|
||
|
||
// A clear is proposed too, and shows the layer it removes.
|
||
func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
r.before, r.had = map[string]any{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}}, true
|
||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", clear: true}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
q := r.askSent(t)
|
||
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
|
||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
|
||
!strings.Contains(q.Details, "Approved, the layer is removed at once") {
|
||
t.Errorf("%+v", q)
|
||
}
|
||
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
|
||
t.Errorf("a clear: %+v, judged %v", p, r.judged)
|
||
}
|
||
}
|
||
|
||
// What the phone is shown keeps a value's shape and passes the content rule: an address, a path, a secret's
|
||
// shape each replaced in place; a value every word of which may leave the mesh shown whole.
|
||
func TestAValueIsShownInItsShapeAndPassesTheContentRule(t *testing.T) {
|
||
for in, want := range map[any]string{
|
||
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro": "recalbox=‹address›@‹path›:ro",
|
||
"library=/mnt/library": "library=‹path›",
|
||
"none": "none",
|
||
"Inter 13": "Inter 13",
|
||
"10.77.0.9:53": "‹address›",
|
||
"jochen@example.com": "‹address›",
|
||
"nas.lan": "‹address›",
|
||
"anchor": "anchor",
|
||
"-----BEGIN CERTIFICATE-----": "‹withheld›",
|
||
42: "42",
|
||
true: "true",
|
||
} {
|
||
got, whole := sayableValue(in, []string{"anchor"})
|
||
if got != want {
|
||
t.Errorf("%v shown as %q, want %q", in, got, want)
|
||
}
|
||
if whole != (got == want && !strings.Contains(want, "‹")) {
|
||
t.Errorf("%v: whole %v", in, whole)
|
||
}
|
||
if _, ok := outward.Check(got, "anchor"); !ok {
|
||
t.Errorf("%v shown as %q, which the router refuses", in, got)
|
||
}
|
||
}
|
||
for _, v := range []any{[]any{"a", "/etc/x"}, map[string]any{"path": "/srv/x", "owner": "1001:1001"}} {
|
||
got, _ := sayableValue(v, nil)
|
||
if _, ok := outward.Check(got); !ok || strings.Contains(got, "/srv") || strings.Contains(got, "/etc") {
|
||
t.Errorf("%v shown as %q", v, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// A proposal that the mesh would refuse to set, or that would silently remove a key, is refused before anybody is
|
||
// asked (ADR 0217 holds for a proposal as for a set).
|
||
func TestAProposalTheMeshWouldRefuseIsNotAsked(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
r.before, r.had = map[string]any{"a": 1, "b": 2}, true
|
||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1}})
|
||
if err == nil || !strings.Contains(err.Error(), "would no longer set b") || !strings.Contains(err.Error(), "ADR 0217") {
|
||
t.Errorf("a silent removal: %v", err)
|
||
}
|
||
r.refusedBy = "refused: notes on laptop cannot compose"
|
||
_, err = r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1, "b": 3}})
|
||
if err == nil || !strings.Contains(err.Error(), "cannot compose") {
|
||
t.Errorf("a layer the mesh refuses: %v", err)
|
||
}
|
||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||
t.Errorf("asked anyway: %+v %+v", r.sent, r.store)
|
||
}
|
||
}
|
||
|
||
// Fail closed: no router, no grant, a publish that fails, or the router's refusal each leave nothing waiting for
|
||
// an answer that cannot come, and name the terminal's line.
|
||
func TestAProposalFailsClosedWhenNothingCanCarryIt(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||
r.pr.routerHere = func(context.Context) (bool, error) { return false, nil }
|
||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "no router") ||
|
||
!strings.Contains(err.Error(), "mesh-cli settings set mounts") {
|
||
t.Errorf("without a router: %v", err)
|
||
}
|
||
r.pr.routerHere = nil
|
||
r.pr.grantHeld = func(context.Context) (bool, string, error) { return false, "the bus's user list is behind", nil }
|
||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "user list is behind") {
|
||
t.Errorf("without the grant: %v", err)
|
||
}
|
||
if len(r.sent) != 0 || len(r.store) != 0 {
|
||
t.Fatalf("asked anyway: %+v %+v", r.sent, r.store)
|
||
}
|
||
r.pr.grantHeld = nil
|
||
r.pr.publish = func(context.Context, string, []byte, string) error { return errors.New("the bus is away") }
|
||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "the bus is away") ||
|
||
!strings.Contains(err.Error(), "never become active") {
|
||
t.Errorf("a publish that fails: %v", err)
|
||
}
|
||
if kept := r.theProposal(t); kept.State != askUnsent {
|
||
t.Errorf("an unpublished proposal is %s", kept.State)
|
||
}
|
||
// The router's refusal, heard by the serving controller and kept here, is said to the caller.
|
||
r = newProposerRig(t)
|
||
r.pr.publish = func(_ context.Context, _ string, _ []byte, id string) error {
|
||
ask := strings.TrimPrefix(id, "ask.")
|
||
r.store[ask] = func() asked {
|
||
a := r.store[ask]
|
||
a.State, a.Acted = string(asks.OutcomeRefused), "nothing: the ask refused: no channel can carry any of its answers now"
|
||
return a
|
||
}()
|
||
return nil
|
||
}
|
||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "did not ask the operator") ||
|
||
!strings.Contains(err.Error(), "no channel can carry") {
|
||
t.Errorf("the router's refusal: %v", err)
|
||
}
|
||
}
|
||
|
||
// The bounds: at most three asks open for the controller, and the same change not proposed twice.
|
||
func TestAProposalIsBounded(t *testing.T) {
|
||
r := newProposerRig(t)
|
||
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
|
||
if _, err := r.pr.propose(context.Background(), in); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "already proposed") {
|
||
t.Errorf("the same change twice: %v", err)
|
||
}
|
||
for _, node := range []string{"laptop", "anchor"} {
|
||
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: node, values: mountsSources}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"x": 1}})
|
||
if err == nil || !strings.Contains(err.Error(), "3 questions already wait") {
|
||
t.Errorf("a fourth: %v", err)
|
||
}
|
||
if len(r.sent) != 3 {
|
||
t.Errorf("published %d", len(r.sent))
|
||
}
|
||
}
|
||
|
||
// ---- the warrant ------------------------------------------------------------------------------------
|
||
|
||
// aProposalAsked keeps a proposal's ask in the asker rig's store, as propose keeps it.
|
||
func aProposalAsked(t *testing.T, r *askerRig, id string, p settingsProposal) asked {
|
||
t.Helper()
|
||
q, options := p.ask(id, nil)
|
||
if err := q.Check(r.now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
a := asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options, State: askOpen, Opened: r.now, Proposal: &p}
|
||
r.store[id] = a
|
||
return a
|
||
}
|
||
|
||
func aProposal(now time.Time) settingsProposal {
|
||
before := map[string]any{"shares": "none"}
|
||
return settingsProposal{Module: "mounts", Node: "shanks", Values: mountsSources, Replace: true, Before: before, HadLayer: true,
|
||
From: "g14/claude-code", At: now, Digest: layerDigest(mountsSources), BeforeDigest: layerDigest(before)}
|
||
}
|
||
|
||
// warrantOn is the router's warrant for a kept ask, choosing an option by id.
|
||
func warrantOn(a asked, option string, now time.Time) asks.Warrant {
|
||
o, _ := a.Ask.Option(option)
|
||
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: a.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID,
|
||
Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, AskDigest: a.Ask.Digest(),
|
||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
||
}
|
||
|
||
type setCall struct {
|
||
p settingsProposal
|
||
ask string
|
||
setBy string
|
||
}
|
||
|
||
func withSetLayer(r *askerRig) *[]setCall {
|
||
var calls []setCall
|
||
r.a.setLayer = func(_ context.Context, p settingsProposal, askID, setBy string) (string, error) {
|
||
calls = append(calls, setCall{p, askID, setBy})
|
||
return "+ sources, ~ shares", nil
|
||
}
|
||
return &calls
|
||
}
|
||
|
||
// On Approve the layer is set once, with who approved it and through which channel kept beside it, and the warrant
|
||
// is recorded as the operator's decision; heard again, nothing more happens.
|
||
func TestTheLayerIsSetOnceOnTheOperatorsApproval(t *testing.T) {
|
||
r := newAskerRig(t)
|
||
calls := withSetLayer(r)
|
||
a := aProposalAsked(t, r, "s1", aProposal(r.now))
|
||
if err := r.a.reconcile(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := r.store["s1"]; got.State != askOpen {
|
||
t.Fatalf("the reconciling of conditions ended the proposal: %+v", got)
|
||
}
|
||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
answerWith(t, r, w)
|
||
answerWith(t, r, w) // heard again, or replayed
|
||
if len(*calls) != 1 {
|
||
t.Fatalf("set %d time(s): %+v", len(*calls), *calls)
|
||
}
|
||
c := (*calls)[0]
|
||
if c.ask != "s1" || c.p.Digest != layerDigest(mountsSources) ||
|
||
!strings.HasPrefix(c.setBy, "approved by the operator, as telegram identity 42 via telegram (telegram), user id verified at ") ||
|
||
!strings.Contains(c.setBy, "(ask s1, proposed by g14/claude-code)") {
|
||
t.Errorf("set by %q for %+v", c.setBy, c.p)
|
||
}
|
||
if len(r.called)+len(r.silenced) != 0 {
|
||
t.Errorf("a verb was called: %v %v", r.called, r.silenced)
|
||
}
|
||
if len(r.acts) != 1 {
|
||
t.Fatalf("hand-acts %+v", r.acts)
|
||
}
|
||
act := r.acts[0]
|
||
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "s1" ||
|
||
!slices.Contains(act.Args, "answer=approve") || !slices.Contains(act.Args, "values="+layerDigest(mountsSources)) ||
|
||
!strings.HasPrefix(act.Outcome, "done") || !personsDecision(act) {
|
||
t.Errorf("the record: %+v", act)
|
||
}
|
||
if got := r.store["s1"]; got.State != string(asks.OutcomeChosen) || !strings.HasPrefix(got.Acted, "done") {
|
||
t.Errorf("kept as %+v", got)
|
||
}
|
||
}
|
||
|
||
// Decline, expiry, the router's refusal, a cancel: nothing is set, and the proposal is recorded as ended.
|
||
func TestDeclineExpiryAndRefusalDiscardAProposal(t *testing.T) {
|
||
for name, outcome := range map[string]asks.Outcome{"declined": asks.OutcomeChosen, "expired": asks.OutcomeExpired,
|
||
"refused": asks.OutcomeRefused, "cancelled": asks.OutcomeCancelled, "replaced": asks.OutcomeReplaced} {
|
||
t.Run(name, func(t *testing.T) {
|
||
r := newAskerRig(t)
|
||
calls := withSetLayer(r)
|
||
a := aProposalAsked(t, r, "s2", aProposal(r.now))
|
||
w := warrantOn(a, "decline", r.now.Add(time.Hour))
|
||
if outcome != asks.OutcomeChosen {
|
||
w = asks.Warrant{Ask: a.ID, Asker: "mesh-controller", Outcome: outcome, Words: "its time passed", At: r.now.Add(time.Hour)}
|
||
}
|
||
answerWith(t, r, w)
|
||
if len(*calls) != 0 {
|
||
t.Fatalf("set: %+v", *calls)
|
||
}
|
||
got := r.store["s2"]
|
||
if got.State != string(outcome) || got.Acted == "" || !strings.HasPrefix(got.Acted, "nothing") {
|
||
t.Errorf("kept as %+v", got)
|
||
}
|
||
if outcome == asks.OutcomeChosen && (len(r.acts) != 1 || !strings.Contains(r.acts[0].Outcome, "declined")) {
|
||
t.Errorf("a decline is a decision too: %+v", r.acts)
|
||
}
|
||
// An approval after it ended is refused.
|
||
answerWith(t, r, warrantOn(a, "approve", r.now.Add(2*time.Hour)))
|
||
if len(*calls) != 0 {
|
||
t.Fatalf("set after the end: %+v", *calls)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// The warrant binds the exact values: a record whose values changed after the ask, an action changed, a warrant for
|
||
// another ask's digest, at another level, or after expiry each set nothing.
|
||
func TestAWarrantSetsOnlyTheExactValuesTheOperatorWasShown(t *testing.T) {
|
||
cases := map[string]func(r *askerRig, a asked) asks.Warrant{
|
||
"the values changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Values = map[string]any{"sources": "recalbox=smb://evil/recalbox@/mnt/recalbox", "shares": "library=/mnt/library"}
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the layer it was shown against changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Before = map[string]any{"shares": "other"}
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the module changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Module = "sshd"
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the machine changed in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Node = "anchor"
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the removal became meant in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Replace = !a.Proposal.Replace
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the set became a clear in the record": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Proposal.Clear = true
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"the action's digest was changed": func(r *askerRig, a asked) asks.Warrant {
|
||
a.Actions[0].Arguments["values"] = layerDigest(map[string]any{"sources": "x"})
|
||
r.store[a.ID] = a
|
||
return warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
},
|
||
"another ask's digest": func(r *askerRig, a asked) asks.Warrant {
|
||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
w.AskDigest = "sha256:0000"
|
||
return w
|
||
},
|
||
"at the level acknowledge": func(r *askerRig, a asked) asks.Warrant {
|
||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
w.Level = asks.Acknowledge
|
||
return w
|
||
},
|
||
"after expiry": func(r *askerRig, a asked) asks.Warrant {
|
||
return warrantOn(a, "approve", r.now.Add(askApproveFor+time.Minute))
|
||
},
|
||
"nobody chose": func(r *askerRig, a asked) asks.Warrant {
|
||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
w.By = nil
|
||
return w
|
||
},
|
||
"another asker's": func(r *askerRig, a asked) asks.Warrant {
|
||
w := warrantOn(a, "approve", r.now.Add(time.Hour))
|
||
w.Asker = "claude-code"
|
||
return w
|
||
},
|
||
}
|
||
for name, tamper := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
r := newAskerRig(t)
|
||
calls := withSetLayer(r)
|
||
a := aProposalAsked(t, r, "s3", aProposal(r.now))
|
||
answerWith(t, r, tamper(r, a))
|
||
if len(*calls) != 0 {
|
||
t.Fatalf("set: %+v", *calls)
|
||
}
|
||
if got := r.store["s3"]; strings.HasPrefix(got.Acted, "done") {
|
||
t.Errorf("kept as done: %+v", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A proposal counts toward what the controller holds open, so a fourth ask is not attempted while three are.
|
||
func TestOpenProposalsCountTowardTheAsksHeldOpen(t *testing.T) {
|
||
r := newAskerRig(t)
|
||
for _, id := range []string{"s4", "s5", "s6"} {
|
||
aProposalAsked(t, r, id, aProposal(r.now))
|
||
}
|
||
r.open = []conditions.Condition{heldCondition()}
|
||
if err := r.a.reconcile(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(r.sent) != 0 {
|
||
t.Errorf("asked beyond the bound: %d", len(r.sent))
|
||
}
|
||
for _, id := range []string{"s4", "s5", "s6"} {
|
||
if r.store[id].State != askOpen {
|
||
t.Errorf("%s was ended by the reconciling of conditions: %+v", id, r.store[id])
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- the verb ---------------------------------------------------------------------------------------
|
||
|
||
func TestTheSettingsVerbComposesProposeAndProposals(t *testing.T) {
|
||
for name, c := range map[string]struct {
|
||
args map[string]any
|
||
want string
|
||
}{
|
||
"propose on a machine": {map[string]any{"module": "mounts", "node": "shanks", "values": `{"sources":"x"}`, "propose": "true"},
|
||
"settings propose mounts {\"sources\":\"x\"} --node shanks"},
|
||
"propose with replace": {map[string]any{"module": "mounts", "values": `{"a":1}`, "propose": "true", "replace": "true"},
|
||
"settings propose mounts {\"a\":1} --replace"},
|
||
"propose a clear": {map[string]any{"module": "mounts", "node": "shanks", "propose": "true", "clear": "true"},
|
||
"settings propose mounts --clear --node shanks"},
|
||
"the proposals": {map[string]any{"proposals": "true"}, "settings proposals"},
|
||
"one proposal": {map[string]any{"proposal": "s1"}, "settings proposals s1"},
|
||
} {
|
||
argv, err := argvFor("settings", c.args)
|
||
if err != nil || strings.Join(argv, " ") != c.want {
|
||
t.Errorf("%s: %v %v", name, argv, err)
|
||
}
|
||
}
|
||
for name, args := range map[string]map[string]any{
|
||
"propose without a module": {"values": `{"a":1}`, "propose": "true"},
|
||
"propose without values": {"module": "mounts", "propose": "true"},
|
||
"propose values and clear": {"module": "mounts", "values": `{"a":1}`, "clear": "true", "propose": "true"},
|
||
"proposals with a module": {"proposals": "true", "module": "mounts"},
|
||
"proposals and a proposal": {"proposals": "true", "proposal": "s1"},
|
||
"a proposal with values": {"proposal": "s1", "values": `{"a":1}`},
|
||
"proposals with a listing": {"proposals": "true", "list": "preferences"},
|
||
} {
|
||
if _, err := argvFor("settings", args); err == nil {
|
||
t.Errorf("%s was composed", name)
|
||
}
|
||
}
|
||
// The generic command verb proposes nothing (it is the settings verb's), and lists proposals (a read).
|
||
if err := refusedAsTheGenericCommand([]string{"settings", "propose", "mounts", "{}", "--node", "shanks"}); err == nil {
|
||
t.Error("the generic command proposed")
|
||
}
|
||
if err := refusedAsTheGenericCommand([]string{"settings", "proposals"}); err != nil {
|
||
t.Errorf("the generic command may not list proposals: %v", err)
|
||
}
|
||
}
|
||
|
||
// ---- on the real stores -----------------------------------------------------------------------------
|
||
|
||
// setLayerIn sets the layer as the terminal does — judged, the removal meant, the history kept — with who approved it
|
||
// beside it; and refuses once the layer is no longer the one the operator was shown the change against.
|
||
func TestSetOnAWarrantJudgesTheLayerAndKeepsWhoApprovedIt(t *testing.T) {
|
||
open := aMesh(t)
|
||
ctx := t.Context()
|
||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||
// y is a preference with a default, so a layer may leave it out; x is the operator's own (ADR 0262).
|
||
Settings: map[string]catalogue.SettingDeclaration{"y": {Kind: "preference", Default: "10", Why: "a size"}},
|
||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
|
||
// A trusted file (unmarked), so its keys are the terminal's — and now the warrant's (novox/hq ADR 0277).
|
||
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\ny = ${setting:y}\n"}}})
|
||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
set := setLayerIn(open)
|
||
now := time.Now()
|
||
first := map[string]any{"x": "1", "y": "2"}
|
||
p := settingsProposal{Module: "notes", Node: "laptop", Values: first, From: "g14/claude-code", At: now,
|
||
Digest: layerDigest(first), BeforeDigest: layerDigest(nil)}
|
||
changed, err := set(ctx, p, "s9", "approved by the operator, as telegram identity 42 via telegram at 14:05 (ask s9)")
|
||
if err != nil || !strings.Contains(changed, "+ x") {
|
||
t.Fatalf("%q %v", changed, err)
|
||
}
|
||
layer, has, err := open.inventory.Layer(ctx, "laptop", "notes")
|
||
if err != nil || !has || layer["x"] != "1" {
|
||
t.Fatalf("the layer: %v %v %v", layer, has, err)
|
||
}
|
||
setBy, _, has, err := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||
if err != nil || !has || !strings.HasPrefix(setBy, "approved by the operator, as telegram identity 42 via telegram") {
|
||
t.Fatalf("who set it: %q %v", setBy, err)
|
||
}
|
||
// Shown by `settings show`, at the terminal and through the verb.
|
||
out := captureStdout(t, func() {
|
||
if err := atTheTerminal(t, "settings", "show", "notes", "--node", "laptop"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
})
|
||
if !strings.Contains(out, "approved by the operator, as telegram identity 42 via telegram") {
|
||
t.Errorf("settings show says:\n%s", out)
|
||
}
|
||
// The same proposal again: the layer is no longer the one the operator was shown it against.
|
||
if _, err := set(ctx, p, "s9", "approved …"); err == nil || !strings.Contains(err.Error(), "changed since the operator was shown") {
|
||
t.Errorf("a stale proposal: %v", err)
|
||
}
|
||
// A removal not meant is refused; one meant is taken, and the history says who had set the layer.
|
||
second := map[string]any{"x": "1"}
|
||
q := settingsProposal{Module: "notes", Node: "laptop", Values: second, Before: first, HadLayer: true,
|
||
From: "g14/claude-code", At: now, Digest: layerDigest(second), BeforeDigest: layerDigest(first)}
|
||
if _, err := set(ctx, q, "s10", "approved …"); err == nil || !strings.Contains(err.Error(), "removal was not meant") {
|
||
t.Errorf("a silent removal: %v", err)
|
||
}
|
||
// A layer the mesh refuses is refused here too, with the same words as at the terminal.
|
||
bad := q
|
||
bad.Values, bad.Digest = map[string]any{"x": "a\nb", "y": "2"}, layerDigest(map[string]any{"x": "a\nb", "y": "2"})
|
||
if _, err := set(ctx, bad, "s11", "approved …"); err == nil || !strings.Contains(err.Error(), "line break") {
|
||
t.Errorf("a line break on a warrant: %v", err)
|
||
}
|
||
if layer, _, _ := open.inventory.Layer(ctx, "laptop", "notes"); layer["y"] != "2" {
|
||
t.Fatalf("a refused act changed the layer: %v", layer)
|
||
}
|
||
q.Replace = true
|
||
if _, err := set(ctx, q, "s10", "approved later"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
past, err := open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||
if err != nil || len(past) != 1 || !strings.HasPrefix(past[0].SetBy, "approved by the operator") {
|
||
t.Errorf("the history: %+v %v", past, err)
|
||
}
|
||
// And a clear, keeping who cleared it with the copy.
|
||
c := settingsProposal{Module: "notes", Node: "laptop", Clear: true, Before: second, HadLayer: true, From: "x", At: now,
|
||
Digest: layerDigest(second), BeforeDigest: layerDigest(second)}
|
||
if _, err := set(ctx, c, "s12", "approved by the operator at 15:00"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||
t.Error("the layer was not cleared")
|
||
}
|
||
past, _ = open.inventory.SettingsHistory(ctx, "laptop", "notes")
|
||
if len(past) != 2 || !strings.Contains(past[0].SetBy, "cleared approved by the operator at 15:00") {
|
||
t.Errorf("the history after a clear: %+v", past)
|
||
}
|
||
}
|
||
|
||
// A layer set at the terminal says so, and one set through a verb names the verb (novox/hq ADR 0277).
|
||
func TestALayerSaysWhoSetIt(t *testing.T) {
|
||
open := aMesh(t)
|
||
ctx := t.Context()
|
||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
|
||
"content": "x = ${setting:x}\n"}}})
|
||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"1"}`, "--node", "laptop"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
setBy, _, _, _ := open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||
if !strings.HasPrefix(setBy, "set at the controller's terminal by ") {
|
||
t.Errorf("at the terminal: %q", setBy)
|
||
}
|
||
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "values": `{"x":"2"}`}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
setBy, _, _, _ = open.inventory.LayerOrigin(ctx, "laptop", "notes")
|
||
if !strings.HasPrefix(setBy, "set by ") || !strings.Contains(setBy, "through settings") {
|
||
t.Errorf("through the verb: %q", setBy)
|
||
}
|
||
}
|
||
|
||
// A trusted setting is still refused through the settings verb (novox/hq issue 339), and the refusal now names the
|
||
// proposal as the way.
|
||
func TestATrustedSettingThroughAVerbNamesTheProposal(t *testing.T) {
|
||
open := aMesh(t)
|
||
ctx := t.Context()
|
||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"}}})
|
||
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
|
||
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}}}`})
|
||
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "propose") {
|
||
t.Errorf("%v", err)
|
||
}
|
||
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
|
||
t.Error("a layer was kept")
|
||
}
|
||
}
|
||
|
||
// captureStdout runs f and answers what it printed to standard output.
|
||
func captureStdout(t *testing.T, f func()) string {
|
||
t.Helper()
|
||
before := os.Stdout
|
||
r, w, err := os.Pipe()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
os.Stdout = w
|
||
done := make(chan string)
|
||
go func() {
|
||
var b strings.Builder
|
||
_, _ = io.Copy(&b, r)
|
||
done <- b.String()
|
||
}()
|
||
f()
|
||
os.Stdout = before
|
||
_ = w.Close()
|
||
return <-done
|
||
}
|