389 lines
16 KiB
Go
389 lines
16 KiB
Go
package operatorchannelclient
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
seat "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
|
)
|
|
|
|
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
|
|
func held() seat.Ask {
|
|
return seat.Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
|
|
Who: Operator, Expires: FormatTime(now.Add(time.Hour)), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
|
|
Options: []seat.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 := Check(held(), now); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
|
|
for want, change := range map[string]func(*seat.Ask){
|
|
"is not letters": func(a *seat.Ask) { a.ID = "a.b" },
|
|
"headline is empty": func(a *seat.Ask) { a.Headline = "" },
|
|
"explains nothing": func(a *seat.Ask) { a.Explanation = " " },
|
|
"only the operator answers": func(a *seat.Ask) { a.Who = "agent" },
|
|
"offers 0 options": func(a *seat.Ask) { a.Options = nil },
|
|
"offers 5 options": func(a *seat.Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
|
|
"is offered twice": func(a *seat.Ask) { a.Options[1].ID = "release" },
|
|
"does not say what it does": func(a *seat.Ask) { a.Options[0].Does = "" },
|
|
"none of acknowledge": func(a *seat.Ask) { a.Options[0].Level = "admin" },
|
|
"expires before it is asked": func(a *seat.Ask) { a.Expires = FormatTime(now) },
|
|
"approves lasts at most": func(a *seat.Ask) { a.Expires = FormatTime(now.Add(25 * time.Hour)) },
|
|
"destroys lasts at most": func(a *seat.Ask) { a.Options[0].Level = Destroy },
|
|
"never defaults": func(a *seat.Ask) { a.OnExpiry = "" },
|
|
"is a key, without spaces": func(a *seat.Ask) { a.About = "a b" },
|
|
"does not bind what it": func(a *seat.Ask) { a.Options[0].Binds = "" },
|
|
} {
|
|
a := held()
|
|
change(&a)
|
|
if err := Check(a, 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 = FormatTime(now.Add(7 * 24 * time.Hour))
|
|
if err := Check(ack, now); err != nil {
|
|
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
|
|
}
|
|
// Carried: an ask read back from a keeper's value is its own id's, and offers an option.
|
|
raw, _ := json.Marshal(held())
|
|
if a, err := Carried(raw, "d-1"); err != nil || !reflect.DeepEqual(a, held()) {
|
|
t.Errorf("a carried sound ask reads back whole: %v %+v", err, a)
|
|
}
|
|
none := held()
|
|
none.Options = nil
|
|
noOptions, _ := json.Marshal(none)
|
|
for want, c := range map[string]struct {
|
|
raw []byte
|
|
id string
|
|
}{
|
|
"carries no operator-channel ask": {[]byte("[1]"), "d-1"},
|
|
`is not "d-2"`: {raw, "d-2"},
|
|
"offers no option": {noOptions, "d-1"},
|
|
} {
|
|
if _, err := Carried(c.raw, c.id); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("Carried: want a refusal saying %q, got %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
|
|
w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
|
AskDigest: Digest(held()), At: FormatTime(now.Add(time.Minute))}
|
|
if o, err := For(w, "an-asker", held()); err != nil || o.ID != "release" {
|
|
t.Fatalf("a sound warrant was refused: %v", err)
|
|
}
|
|
if got := Says(w); got != "the operator, via telegram (user id verified), chose Release" {
|
|
t.Errorf("the warrant says %q", got)
|
|
}
|
|
for why, change := range map[string]func(*seat.Warrant){
|
|
"another asker": func(w *seat.Warrant) { w.Asker = "mesh-controller" },
|
|
"another ask": func(w *seat.Warrant) { w.Ask = "d-2" },
|
|
"no choice": func(w *seat.Warrant) { w.Outcome = OutcomeExpired },
|
|
"nobody": func(w *seat.Warrant) { w.By = nil },
|
|
"not offered": func(w *seat.Warrant) { w.Option = "delete" },
|
|
"a lower level": func(w *seat.Warrant) { w.Level = Acknowledge },
|
|
"another role": func(w *seat.Warrant) { w.By = &seat.Person{Who: "agent"} },
|
|
"no digest": func(w *seat.Warrant) { w.AskDigest = "" },
|
|
"no time given": func(w *seat.Warrant) { w.At = "" },
|
|
"after expiry": func(w *seat.Warrant) { w.At = FormatTime(now.Add(2 * time.Hour)) },
|
|
"another ask's digest": func(w *seat.Warrant) {
|
|
other := held()
|
|
other.Options[0].Binds = bind("release", "another")
|
|
w.AskDigest = Digest(other)
|
|
},
|
|
} {
|
|
c := w
|
|
by := *w.By
|
|
c.By = &by
|
|
change(&c)
|
|
if _, err := For(c, "an-asker", held()); err == nil {
|
|
t.Errorf("a warrant with %s was acted on", why)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 seat.Ask
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if Digest(decoded) != Digest(a) {
|
|
t.Fatalf("the ask's digest changed on the way: %s, %s", Digest(decoded), Digest(a))
|
|
}
|
|
local := a
|
|
local.Expires = inZone(a.Expires, time.FixedZone("CEST", 2*3600))
|
|
if Digest(local) != Digest(a) {
|
|
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(*seat.Ask){
|
|
"the headline": func(a *seat.Ask) { a.Headline += "!" },
|
|
"the explanation": func(a *seat.Ask) { a.Explanation += " " },
|
|
"a label": func(a *seat.Ask) { a.Options[0].Label = "Go" },
|
|
"what it binds": func(a *seat.Ask) { a.Options[0].Binds = bind("release", "another") },
|
|
"the expiry": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Second) },
|
|
"what it is about": func(a *seat.Ask) { a.About = "delivery.other" },
|
|
} {
|
|
c := held()
|
|
change(&c)
|
|
if Digest(c) == Digest(a) {
|
|
t.Errorf("changing %s leaves the digest", what)
|
|
}
|
|
}
|
|
o, _ := OptionOf(a, "release")
|
|
if err := Performs(o, Act{"verb": "release", "delivery": "openrazer"}); err != nil {
|
|
t.Errorf("the bound act was refused: %v", err)
|
|
}
|
|
if err := Performs(o, Act{"verb": "release", "delivery": "another"}); err == nil {
|
|
t.Error("an act the option did not bind was let through")
|
|
}
|
|
ack, _ := OptionOf(a, "silence")
|
|
if err := Performs(ack, 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) {
|
|
// The digests are those mesh-sdk's asks gave before the move to the contract's types (ADR 0627 §1): a router and
|
|
// an asker of either build agree on every ask.
|
|
gold := held()
|
|
gold.Whole, gold.Details, gold.WholeDetails = "w", "d", "wd"
|
|
if Digest(held()) != "sha256:088e7e69e1783b78c1b7e8310e0a6d0504a88a37982b293bc1e667da4565bb0f" ||
|
|
Digest(gold) != "sha256:997af29a80663c5eb7d9dd38fc029c4a15fd726220b25e3c7cdc54d5a60bbbae" {
|
|
t.Errorf("the digests changed with the move: %s, %s", Digest(held()), Digest(gold))
|
|
}
|
|
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(*seat.Ask){
|
|
"ID": func(a *seat.Ask) { a.ID = "other" },
|
|
"Headline": func(a *seat.Ask) { a.Headline += "!" },
|
|
"Explanation": func(a *seat.Ask) { a.Explanation += "!" },
|
|
"Who": func(a *seat.Ask) { a.Who = "someone" },
|
|
"Expires": func(a *seat.Ask) { a.Expires = shift(a.Expires, time.Nanosecond) },
|
|
"OnExpiry": func(a *seat.Ask) { a.OnExpiry += "!" },
|
|
"About": func(a *seat.Ask) { a.About += "!" },
|
|
"Urgent": func(a *seat.Ask) { a.Urgent = !a.Urgent },
|
|
"Options": func(a *seat.Ask) { a.Options = a.Options[:1] },
|
|
"Option.ID": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].ID = "x" },
|
|
"Option.Label": func(a *seat.Ask) {
|
|
a.Options = append([]seat.Option(nil), a.Options...)
|
|
a.Options[0].Label += "!"
|
|
},
|
|
"Option.Does": func(a *seat.Ask) { a.Options = append([]seat.Option(nil), a.Options...); a.Options[0].Does += "!" },
|
|
"Option.Level": func(a *seat.Ask) {
|
|
a.Options = append([]seat.Option(nil), a.Options...)
|
|
a.Options[0].Level = Destroy
|
|
},
|
|
"Option.Binds": func(a *seat.Ask) {
|
|
a.Options = append([]seat.Option(nil), a.Options...)
|
|
a.Options[0].Binds = "sha256:00"
|
|
},
|
|
}
|
|
for name, mutate := range mutations {
|
|
c := base
|
|
mutate(&c)
|
|
if Digest(c) == Digest(base) {
|
|
t.Errorf("changing %s left the digest the same", name)
|
|
}
|
|
}
|
|
// Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests
|
|
// exactly as before they existed — the encoding by hand, so no build can drift from it unseen.
|
|
plain := seat.Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: FormatTime(now), OnExpiry: "o", About: "a",
|
|
Options: []seat.Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}}
|
|
byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" +
|
|
"2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n"))
|
|
if Digest(plain) != "sha256:"+hex.EncodeToString(byHand[:]) {
|
|
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", Digest(plain))
|
|
}
|
|
withWhole, withDetails := plain, plain
|
|
withWhole.Whole = "e, whole"
|
|
withDetails.Details = "fingerprint 1234"
|
|
if Digest(withWhole) == Digest(plain) || Digest(withDetails) == Digest(plain) || Digest(withWhole) == Digest(withDetails) {
|
|
t.Error("Whole or Details left the digest the same")
|
|
}
|
|
both := withWhole
|
|
both.Details = "fingerprint 1234"
|
|
changed := both
|
|
changed.Whole += "!"
|
|
if Digest(changed) == Digest(both) {
|
|
t.Error("changing Whole beside Details left the digest the same")
|
|
}
|
|
// A field added to Ask or Option without being added to Digest fails here.
|
|
if n := reflect.TypeOf(seat.Ask{}).NumField(); n != 12 {
|
|
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
|
|
}
|
|
if n := reflect.TypeOf(seat.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 = inZone(z.Expires, time.FixedZone("x", 3600))
|
|
if Digest(z) != Digest(base) {
|
|
t.Error("the expiry's zone changed the digest")
|
|
}
|
|
}
|
|
|
|
// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could
|
|
// show the answer was in time.
|
|
func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
|
|
a := held()
|
|
a.Expires = ""
|
|
w := seat.Warrant{Ask: "d-1", Asker: "an-asker", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
Level: Approve, By: &seat.Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: Digest(a),
|
|
At: FormatTime(now.Add(time.Minute))}
|
|
if _, err := For(w, "an-asker", a); err == nil {
|
|
t.Error("a warrant for an ask with no expiry was taken")
|
|
}
|
|
}
|
|
|
|
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
|
|
func TestAnExplanationReadsAtAGlance(t *testing.T) {
|
|
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
|
|
t.Fatalf("a summary and two points: %v", err)
|
|
}
|
|
var wall strings.Builder
|
|
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
|
|
for i := 1; i <= 13; i++ {
|
|
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
|
|
}
|
|
err := Glance(wall.String())
|
|
if err == nil || !strings.Contains(err.Error(), "13 points") {
|
|
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
|
|
}
|
|
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
|
|
t.Fatalf("a long summary passes: %v", err)
|
|
}
|
|
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
|
|
t.Fatalf("a long point passes: %v", err)
|
|
}
|
|
if err := Glance(" \n"); err == nil {
|
|
t.Fatal("an empty explanation passes")
|
|
}
|
|
}
|
|
|
|
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
|
|
// of different builds still agree on every such ask.
|
|
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
|
|
a := seat.Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
|
|
before := Digest(a)
|
|
a.WholeDetails = "+ shares: media=/storage/media"
|
|
if Digest(a) == before {
|
|
t.Fatal("the whole details are not in the digest")
|
|
}
|
|
b := a
|
|
b.WholeDetails = "+ shares: media=/storage/other"
|
|
if Digest(a) == Digest(b) {
|
|
t.Fatal("two whole details digest the same")
|
|
}
|
|
a.WholeDetails = ""
|
|
if Digest(a) != before {
|
|
t.Fatal("an ask without whole details digests otherwise than before")
|
|
}
|
|
}
|
|
|
|
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
|
|
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
|
|
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
|
|
base := seat.Ask{ID: "a", Headline: "h", Explanation: "e"}
|
|
shapes := []seat.Ask{}
|
|
for _, f := range []struct{ whole, details, wholeDetails string }{
|
|
{"", "", "+ x: 1"},
|
|
{"whole-details", "+ x: 1", ""},
|
|
{"", "+ x: 1", ""},
|
|
{"+ x: 1", "", ""},
|
|
{"", "", "whole-details"},
|
|
{"", "+ x: 1", "+ x: 1"},
|
|
{"+ x: 1", "+ x: 1", ""},
|
|
{"", "", ""},
|
|
} {
|
|
a := base
|
|
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
|
|
shapes = append(shapes, a)
|
|
}
|
|
seen := map[string]int{}
|
|
for i, a := range shapes {
|
|
if j, ok := seen[Digest(a)]; ok {
|
|
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
|
|
}
|
|
seen[Digest(a)] = i
|
|
}
|
|
}
|
|
|
|
// shift is an RFC 3339 time of the contract moved by d.
|
|
func shift(s string, d time.Duration) string {
|
|
t, err := Time(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return t.Add(d).Format(time.RFC3339Nano)
|
|
}
|
|
|
|
// inZone is an RFC 3339 time of the contract written in another zone: the same instant.
|
|
func inZone(s string, z *time.Location) string {
|
|
t, err := Time(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return t.In(z).Format(time.RFC3339Nano)
|
|
}
|