Files
mesh-sdk/go/asks/asks_test.go
T
jschoubben 76902998cd
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery-group group feat/asks-answered-on-any-channel rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
Digest an ask and an act over named fields in a canonical encoding, not a language's encoding of a struct
The review of 2026-10-09: a digest over Go's JSON of a struct changes when a tag is renamed or a field
added, and another language cannot reproduce it. An act is now a flat map of named strings, and both
digests are over length-prefixed values in a fixed order, versioned; a test fails when a field is added
to Ask or Option without being added to the digest.
2026-10-09 11:59:28 +02:00

245 lines
9.7 KiB
Go

package asks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"reflect"
"strings"
"testing"
"time"
)
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
func held() Ask {
return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
Options: []Option{
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")},
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")},
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge},
}}
}
func bind(verb, what string) string {
d, err := ActDigest(Act{"verb": verb, "delivery": what})
if err != nil {
panic(err)
}
return d
}
func TestASoundAskPasses(t *testing.T) {
if err := held().Check(now); err != nil {
t.Fatal(err)
}
}
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
for want, change := range map[string]func(*Ask){
"is not letters": func(a *Ask) { a.ID = "a.b" },
"headline is empty": func(a *Ask) { a.Headline = "" },
"explains nothing": func(a *Ask) { a.Explanation = " " },
"only the operator answers": func(a *Ask) { a.Who = "agent" },
"offers 0 options": func(a *Ask) { a.Options = nil },
"offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
"is offered twice": func(a *Ask) { a.Options[1].ID = "release" },
"does not say what it does": func(a *Ask) { a.Options[0].Does = "" },
"none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" },
"expires before it is asked": func(a *Ask) { a.Expires = now },
"approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) },
"destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy },
"never defaults": func(a *Ask) { a.OnExpiry = "" },
"is a key, without spaces": func(a *Ask) { a.About = "a b" },
"does not bind what it": func(a *Ask) { a.Options[0].Binds = "" },
} {
a := held()
change(&a)
if err := a.Check(now); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("want a refusal saying %q, got %v", want, err)
}
}
ack := held()
ack.Options = ack.Options[2:]
ack.OnExpiry = ""
ack.Expires = now.Add(7 * 24 * time.Hour)
if err := ack.Check(now); err != nil {
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
}
}
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
AskDigest: held().Digest()}
if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" {
t.Fatalf("a sound warrant was refused: %v", err)
}
if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" {
t.Errorf("the warrant says %q", got)
}
for why, change := range map[string]func(*Warrant){
"another asker": func(w *Warrant) { w.Asker = "mesh-controller" },
"another ask": func(w *Warrant) { w.Ask = "d-2" },
"no choice": func(w *Warrant) { w.Outcome = OutcomeExpired },
"nobody": func(w *Warrant) { w.By = nil },
"not offered": func(w *Warrant) { w.Option = "delete" },
"a lower level": func(w *Warrant) { w.Level = Acknowledge },
"another role": func(w *Warrant) { w.By = &Person{Who: "agent"} },
"no digest": func(w *Warrant) { w.AskDigest = "" },
"another ask's digest": func(w *Warrant) {
other := held()
other.Options[0].Binds = bind("release", "another")
w.AskDigest = other.Digest()
},
} {
c := w
by := *w.By
c.By = &by
change(&c)
if _, err := c.For("mesh-delivery", held()); err == nil {
t.Errorf("a warrant with %s was acted on", why)
}
}
}
func TestTheSubjectsNameTheirPublisher(t *testing.T) {
for got, want := range map[string]string{
AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery",
DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery",
ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram",
IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop",
ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram",
} {
if got != want {
t.Errorf("%s, want %s", got, want)
}
}
}
// A warrant answers one ask as it was shown, and authorises the act its option bound and no other
// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments).
func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) {
a := held()
// The digest survives the trip through the bus: what the router decodes and digests is what was sent.
raw, err := json.Marshal(a)
if err != nil {
t.Fatal(err)
}
var decoded Ask
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Digest() != a.Digest() {
t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest())
}
local := a
local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600))
if local.Digest() != a.Digest() {
t.Error("the same instant in another zone digests differently")
}
// Changing anything the person was shown, or what an option binds, changes the digest.
for what, change := range map[string]func(*Ask){
"the headline": func(a *Ask) { a.Headline += "!" },
"the explanation": func(a *Ask) { a.Explanation += " " },
"a label": func(a *Ask) { a.Options[0].Label = "Go" },
"what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") },
"the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) },
"what it is about": func(a *Ask) { a.About = "delivery.other" },
} {
c := held()
change(&c)
if c.Digest() == a.Digest() {
t.Errorf("changing %s leaves the digest", what)
}
}
o, _ := a.Option("release")
if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil {
t.Errorf("the bound act was refused: %v", err)
}
if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil {
t.Error("an act the option did not bind was let through")
}
ack, _ := a.Option("silence")
if err := ack.Performs(Act{"anything": "x"}); err != nil {
t.Errorf("an acknowledgement that bound nothing: %v", err)
}
}
// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's
// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another
// order changes nothing it should not — and every field that is shown or bound is in it.
func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
b := Act{}
b["arg.delivery"] = "hq"
b["verb"] = "release"
a2, _ := ActDigest(b)
if a1 != a2 {
t.Error("the order an act was built in changed its digest")
}
// A value cannot be read as two: "a"+"b=c" is not "a=b"+"c".
x, _ := ActDigest(Act{"a": "b\n1:c"})
y, _ := ActDigest(Act{"a": "b", "c": ""})
if x == y {
t.Error("two different acts encode the same")
}
if _, err := ActDigest(Act{}); err == nil {
t.Error("an empty act was digested")
}
// The encoding, by hand: any language reproduces it.
sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n"))
if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) {
t.Errorf("the act's encoding is not the one written down: %s", got)
}
// Every exported field of Ask and Option is in the digest: changing any one changes it.
base := held()
mutations := map[string]func(*Ask){
"ID": func(a *Ask) { a.ID = "other" },
"Headline": func(a *Ask) { a.Headline += "!" },
"Explanation": func(a *Ask) { a.Explanation += "!" },
"Who": func(a *Ask) { a.Who = "someone" },
"Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) },
"OnExpiry": func(a *Ask) { a.OnExpiry += "!" },
"About": func(a *Ask) { a.About += "!" },
"Urgent": func(a *Ask) { a.Urgent = !a.Urgent },
"Options": func(a *Ask) { a.Options = a.Options[:1] },
"Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" },
"Option.Label": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Label += "!"
},
"Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" },
"Option.Level": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Level = Destroy
},
"Option.Binds": func(a *Ask) {
a.Options = append([]Option(nil), a.Options...)
a.Options[0].Binds = "sha256:00"
},
}
for name, mutate := range mutations {
c := base
mutate(&c)
if c.Digest() == base.Digest() {
t.Errorf("changing %s left the digest the same", name)
}
}
// A field added to Ask or Option without being added to Digest fails here.
if n := reflect.TypeOf(Ask{}).NumField(); n != 9 {
t.Errorf("Ask has %d fields, and Digest covers 9: add the new one to Digest and to this test", n)
}
if n := reflect.TypeOf(Option{}).NumField(); n != 5 {
t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n)
}
// The same instant in another zone is the same ask.
z := base
z.Expires = z.Expires.In(time.FixedZone("x", 3600))
if z.Digest() != base.Digest() {
t.Error("the expiry's zone changed the digest")
}
}