Files
mesh-lab/asks/asker_test.go
T
jschoubben 3a486014fc
mesh/merge-gate pass: the change touches no module of the mesh's graph
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request
Prove the operator's answers end to end before anybody uses them (hq ADR 0259)
The bus the controller composes and raises, at its commit, for one machine; the mesh's runtime serving
the router and the Telegram channel on their own credentials; a fake Bot API as the phone. It links the
operator's account with the code shown on the desk, refuses an approval in the hour after a link,
performs an approval exactly once with who and how in the warrant and the router's record, refuses a
replayed tap and a redelivered warrant, tells the operator when another account answers, drops a tap from
a group, does nothing on Decline and on expiry, offers no approval while an agent can become root where
the channel runs, and has the bus refuse every forged warrant, ask and tap. live-acceptance.sh is the same
after rollout, with a drill the operator approves on the phone.
2026-10-09 11:08:42 +02:00

208 lines
5.3 KiB
Go

package asks
import (
"encoding/json"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/nats-io/nats.go"
)
// labAsker is the module `lab-asker` asking the operator, as any module does: the SDK's asker client
// (mesh-sdk go/asker) over the lab's bus, on that module's own composed credential. What it performs on a
// warrant is one switch flipped, bound when it asked, and counted: the proof that an approval does exactly
// that one thing, once.
type labAsker struct {
t *testing.T
client *asker.Client
nc *nats.Conn
mu sync.Mutex
warrants []asks.Warrant
handled []asker.Handled
acts []map[string]string // what was performed
}
// flip is the act an approve option binds.
func flip(which string) map[string]string {
return map[string]string{"verb": "lab.flip", "switch": which}
}
func newLabAsker(t *testing.T, l *lab) *labAsker {
nc, _ := l.as(machine + ".lab-asker")
js, err := nc.JetStream()
if err != nil {
t.Fatal(err)
}
a := &labAsker{t: t, nc: nc}
a.client = &asker.Client{Module: "lab-asker", State: &memState{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}},
Now: time.Now,
Publish: func(subject string, body any, id string) error {
raw, err := json.Marshal(body)
if err != nil {
return err
}
_, err = js.Publish(subject, raw, nats.MsgId("lab-asker."+id))
return err
}}
// Its warrants, on the one subject the bus lets it hear them on.
sub, err := nc.Subscribe(asks.DecidedSubject("lab-asker"), func(m *nats.Msg) {
var w asks.Warrant
if err := json.Unmarshal(m.Data, &w); err != nil {
t.Errorf("a word on the asker's decided subject is not a warrant: %v", err)
return
}
a.take(w)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
return a
}
// take hands one warrant to the SDK's Take, which performs the option once, and only the act it bound.
func (a *labAsker) take(w asks.Warrant) asker.Handled {
h, err := a.client.Take(w, func(o asks.Option, w asks.Warrant) error {
if o.Level == asks.Acknowledge {
return nil // decline: nothing is performed
}
act := flip(o.ID)
if err := o.Performs(act); err != nil {
return err
}
a.mu.Lock()
a.acts = append(a.acts, act)
a.mu.Unlock()
return nil
})
a.mu.Lock()
a.warrants = append(a.warrants, w)
a.handled = append(a.handled, h)
a.mu.Unlock()
if err != nil {
a.t.Logf("the asker refused a warrant for %s: %v", w.Ask, err)
}
return h
}
// ask asks the operator: one approve option binding a switch, one decline that acknowledges.
func (a *labAsker) ask(id, headline string, expires time.Duration) asks.Ask {
a.t.Helper()
binds, err := asks.ActDigest(flip("approve"))
if err != nil {
a.t.Fatal(err)
}
q := asks.Ask{ID: id, Headline: headline,
Explanation: "Needs you: approve or decline. The lab asks whether to flip one switch.",
Who: asks.Operator, Expires: time.Now().Add(expires), OnExpiry: "nothing is flipped",
Options: []asks.Option{
{ID: "approve", Label: "Approve", Does: "the switch is flipped, once", Level: asks.Approve, Binds: binds},
{ID: "decline", Label: "Decline", Does: "nothing is done", Level: asks.Acknowledge},
}}
if err := a.client.Ask(q); err != nil {
a.t.Fatalf("the asker could not ask %s: %v", id, err)
}
return q
}
// warrantFor waits for the router's word on one ask.
func (a *labAsker) warrantFor(id string, within time.Duration) asks.Warrant {
a.t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
a.mu.Lock()
for _, w := range a.warrants {
if w.Ask == id {
a.mu.Unlock()
return w
}
}
a.mu.Unlock()
time.Sleep(100 * time.Millisecond)
}
a.t.Fatalf("no word on the ask %s within %s", id, within)
return asks.Warrant{}
}
// noWordOn says the router said nothing on an ask for a while.
func (a *labAsker) noWordOn(id string, wait time.Duration) bool {
time.Sleep(wait)
a.mu.Lock()
defer a.mu.Unlock()
for _, w := range a.warrants {
if w.Ask == id {
return false
}
}
return true
}
func (a *labAsker) performed() []map[string]string {
a.mu.Lock()
defer a.mu.Unlock()
return append([]map[string]string(nil), a.acts...)
}
func (a *labAsker) wordsOn(id string) int {
a.mu.Lock()
defer a.mu.Unlock()
n := 0
for _, w := range a.warrants {
if w.Ask == id {
n++
}
}
return n
}
// memState is the asker's own record, kept in memory: the lab's asker restarts never.
type memState struct {
mu sync.Mutex
vals map[string]json.RawMessage
revs map[string]uint64
n uint64
}
func (m *memState) Get(k string) (*stdio.StateEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.vals[k]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
}
func (m *memState) Create(k string, v any) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.vals[k]; ok {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *memState) Update(k string, v any, rev uint64) (uint64, error) {
m.mu.Lock()
defer m.mu.Unlock()
if rev == 0 || m.revs[k] != rev {
return 0, stdio.ErrChanged
}
return m.put(k, v)
}
func (m *memState) put(k string, v any) (uint64, error) {
raw, err := json.Marshal(v)
if err != nil {
return 0, err
}
m.n++
m.vals[k], m.revs[k] = raw, m.n
return m.n, nil
}